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,721 @@
<?php
declare(strict_types=1);
// Load the API plugin's autoloader so its controller classes are available
require_once '/Users/rhuk/Projects/grav/grav-plugin-api/vendor/autoload.php';
use Codeception\Util\Fixtures;
use Grav\Common\Grav;
use Grav\Common\Page\Page;
use Grav\Common\Page\Pages;
use Grav\Common\Config\Config;
use Grav\Common\Data\Data;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Controllers\PagesController;
use Grav\Plugin\Api\Controllers\MediaController;
use Grav\Plugin\Api\Controllers\UsersController;
use Grav\Plugin\Api\Controllers\ConfigController;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
/**
* Integration tests verifying that API controllers fire admin-compatible events.
*
* These tests use the real Grav framework to ensure events fire through the
* actual event dispatcher and that third-party plugins subscribing to
* onAdmin* events would be triggered correctly.
*/
class AdminEventsTest extends \PHPUnit\Framework\TestCase
{
protected Grav $grav;
protected Pages $pages;
protected string $tempDir;
/** @var array<int, array{name: string, event: Event}> */
protected array $capturedEvents = [];
protected function setUp(): void
{
parent::setUp();
$grav = Fixtures::get('grav');
$this->grav = $grav();
$this->tempDir = sys_get_temp_dir() . '/grav_api_events_test_' . uniqid();
@mkdir($this->tempDir . '/pages', 0775, true);
@mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true);
@mkdir($this->tempDir . '/user/config', 0775, true);
$this->pages = $this->grav['pages'];
/** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
$locator->addPath('page', '', $this->tempDir . '/pages', false);
$locator->addPath('cache', '', $this->tempDir . '/cache', false);
// Set up API plugin config
$this->grav['config']->set('plugins.api.route', '/api');
$this->grav['config']->set('plugins.api.version_prefix', 'v1');
$this->grav['config']->set('plugins.api.pagination.default_per_page', 20);
$this->grav['config']->set('plugins.api.pagination.max_per_page', 100);
$this->grav['config']->set('plugins.api.batch.max_items', 50);
$this->capturedEvents = [];
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
parent::tearDown();
}
// -------------------------------------------------------------------------
// PagesController: create
// -------------------------------------------------------------------------
public function testCreatePageFiresOnAdminCreatePageFrontmatter(): void
{
$captured = [];
$this->grav['events']->addListener('onAdminCreatePageFrontmatter', function (Event $event) use (&$captured) {
$captured[] = $event;
});
$controller = $this->createPagesController();
$request = $this->makeRequest('POST', '/api/v1/pages', [
'route' => '/test-create',
'title' => 'Test Create',
'content' => 'Hello world',
]);
$response = $controller->create($request);
self::assertSame(201, $response->getStatusCode());
self::assertCount(1, $captured, 'onAdminCreatePageFrontmatter should fire once');
self::assertArrayHasKey('header', $captured[0]->toArray());
self::assertArrayHasKey('data', $captured[0]->toArray());
}
public function testCreatePageFiresOnAdminSaveBeforeSave(): void
{
$saveOrder = [];
$this->grav['events']->addListener('onAdminSave', function (Event $event) use (&$saveOrder) {
$saveOrder[] = 'onAdminSave';
// Verify object is a Page
self::assertInstanceOf(Page::class, $event['object']);
self::assertArrayHasKey('page', $event->toArray());
});
$controller = $this->createPagesController();
$request = $this->makeRequest('POST', '/api/v1/pages', [
'route' => '/test-save-order',
'title' => 'Save Order Test',
]);
$controller->create($request);
self::assertContains('onAdminSave', $saveOrder);
}
public function testCreatePageFiresOnAdminAfterSave(): void
{
$captured = [];
$this->grav['events']->addListener('onAdminAfterSave', function (Event $event) use (&$captured) {
$captured[] = $event;
});
$controller = $this->createPagesController();
$request = $this->makeRequest('POST', '/api/v1/pages', [
'route' => '/test-after-save',
'title' => 'After Save Test',
]);
$controller->create($request);
self::assertCount(1, $captured, 'onAdminAfterSave should fire once');
self::assertArrayHasKey('object', $captured[0]->toArray());
self::assertArrayHasKey('page', $captured[0]->toArray());
}
public function testCreatePageFrontmatterEventCanModifyHeader(): void
{
// The real Grav Event uses ArrayAccess. To modify a referenced array
// inside an event, the plugin pattern is to read, modify, write back.
$this->grav['events']->addListener('onAdminCreatePageFrontmatter', function (Event $event) {
$header = $event['header'];
$header['injected_by_plugin'] = true;
$event['header'] = $header;
});
$controller = $this->createPagesController();
$request = $this->makeRequest('POST', '/api/v1/pages', [
'route' => '/test-inject',
'title' => 'Inject Test',
]);
$controller->create($request);
// Verify the injected field made it into the saved page
$this->pages->reset();
$this->pages->init();
$page = $this->pages->find('/test-inject');
self::assertNotNull($page, 'Page should exist after creation');
self::assertTrue(
property_exists($page->header(), 'injected_by_plugin') && $page->header()->injected_by_plugin === true,
'onAdminCreatePageFrontmatter should be able to inject fields into the header'
);
}
// -------------------------------------------------------------------------
// PagesController: update
// -------------------------------------------------------------------------
public function testUpdatePageFiresOnAdminSaveAndAfterSave(): void
{
// Create a page first
$this->createTestPage('/update-test', 'Update Test');
$saveEvents = [];
$afterSaveEvents = [];
$this->grav['events']->addListener('onAdminSave', function (Event $event) use (&$saveEvents) {
$saveEvents[] = $event;
});
$this->grav['events']->addListener('onAdminAfterSave', function (Event $event) use (&$afterSaveEvents) {
$afterSaveEvents[] = $event;
});
$controller = $this->createPagesController();
$request = $this->makeRequest('PATCH', '/api/v1/pages/update-test', [
'title' => 'Updated Title',
], ['route' => 'update-test']);
$response = $controller->update($request);
self::assertSame(200, $response->getStatusCode());
self::assertCount(1, $saveEvents, 'onAdminSave should fire once on update');
self::assertCount(1, $afterSaveEvents, 'onAdminAfterSave should fire once on update');
// Both events should have 'object' and 'page' keys
self::assertArrayHasKey('page', $saveEvents[0]->toArray());
self::assertArrayHasKey('page', $afterSaveEvents[0]->toArray());
}
public function testUpdatePageOnAdminSaveCanModifyPage(): void
{
$this->createTestPage('/modify-test', 'Modify Test');
$this->grav['events']->addListener('onAdminSave', function (Event $event) {
$page = $event['object'];
$header = (array) $page->header();
$header['modified_by_plugin'] = 'seo-magic';
$page->header((object) $header);
});
$controller = $this->createPagesController();
$request = $this->makeRequest('PATCH', '/api/v1/pages/modify-test', [
'title' => 'Modified',
], ['route' => 'modify-test']);
$controller->update($request);
// Re-read the page to verify
$this->pages->reset();
$this->pages->init();
$page = $this->pages->find('/modify-test');
self::assertNotNull($page);
self::assertSame('seo-magic', $page->header()->modified_by_plugin ?? null);
}
// -------------------------------------------------------------------------
// PagesController: delete
// -------------------------------------------------------------------------
public function testDeletePageFiresOnAdminAfterDelete(): void
{
$this->createTestPage('/delete-test', 'Delete Test');
$captured = [];
$this->grav['events']->addListener('onAdminAfterDelete', function (Event $event) use (&$captured) {
$captured[] = $event;
});
$controller = $this->createPagesController();
$request = $this->makeRequest('DELETE', '/api/v1/pages/delete-test', [], ['route' => 'delete-test']);
$response = $controller->delete($request);
self::assertSame(204, $response->getStatusCode());
self::assertCount(1, $captured, 'onAdminAfterDelete should fire once');
self::assertArrayHasKey('object', $captured[0]->toArray());
self::assertArrayHasKey('page', $captured[0]->toArray());
}
// -------------------------------------------------------------------------
// PagesController: move
// -------------------------------------------------------------------------
public function testMovePageFiresOnAdminAfterSaveAs(): void
{
$this->createTestPage('/move-source', 'Move Source');
$this->createTestPage('/move-target', 'Move Target');
$captured = [];
$this->grav['events']->addListener('onAdminAfterSaveAs', function (Event $event) use (&$captured) {
$captured[] = $event;
});
$controller = $this->createPagesController();
$request = $this->makeRequest('POST', '/api/v1/pages/move-source/move', [
'parent' => '/move-target',
], ['route' => 'move-source']);
$controller->move($request);
self::assertCount(1, $captured, 'onAdminAfterSaveAs should fire once');
self::assertArrayHasKey('path', $captured[0]->toArray());
self::assertStringContainsString('move-source', $captured[0]['path']);
}
// -------------------------------------------------------------------------
// MediaController: upload & delete
// -------------------------------------------------------------------------
public function testMediaUploadFiresOnAdminAfterAddMedia(): void
{
$this->createTestPage('/media-test', 'Media Test');
$captured = [];
$this->grav['events']->addListener('onAdminAfterAddMedia', function (Event $event) use (&$captured) {
$captured[] = $event;
});
$controller = $this->createMediaController();
// Create a temp file to upload
$tmpFile = $this->tempDir . '/upload.txt';
file_put_contents($tmpFile, 'test content');
$uploadedFile = $this->createUploadedFile($tmpFile, 'test-upload.txt', 'text/plain');
$request = $this->makeRequest('POST', '/api/v1/pages/media-test/media', [], ['route' => 'media-test']);
$request = $request->withUploadedFiles(['file' => $uploadedFile]);
$response = $controller->uploadPageMedia($request);
self::assertSame(201, $response->getStatusCode());
self::assertCount(1, $captured, 'onAdminAfterAddMedia should fire once');
self::assertArrayHasKey('object', $captured[0]->toArray());
self::assertArrayHasKey('page', $captured[0]->toArray());
}
public function testMediaDeleteFiresOnAdminAfterDelMedia(): void
{
$this->createTestPage('/media-del-test', 'Media Del Test');
// Put a file in the page directory
$pagePath = $this->tempDir . '/pages/media-del-test';
file_put_contents($pagePath . '/photo.txt', 'test');
$captured = [];
$this->grav['events']->addListener('onAdminAfterDelMedia', function (Event $event) use (&$captured) {
$captured[] = $event;
});
$controller = $this->createMediaController();
$request = $this->makeRequest('DELETE', '/api/v1/pages/media-del-test/media/photo.txt', [], [
'route' => 'media-del-test',
'filename' => 'photo.txt',
]);
$response = $controller->deletePageMedia($request);
self::assertSame(204, $response->getStatusCode());
self::assertCount(1, $captured, 'onAdminAfterDelMedia should fire once');
self::assertArrayHasKey('object', $captured[0]->toArray());
self::assertArrayHasKey('page', $captured[0]->toArray());
self::assertArrayHasKey('filename', $captured[0]->toArray());
self::assertSame('photo.txt', $captured[0]['filename']);
}
// -------------------------------------------------------------------------
// UsersController: create & update
// -------------------------------------------------------------------------
public function testUserCreateFiresOnAdminSaveAndAfterSave(): void
{
$saveEvents = [];
$afterSaveEvents = [];
$this->grav['events']->addListener('onAdminSave', function (Event $event) use (&$saveEvents) {
$saveEvents[] = $event;
});
$this->grav['events']->addListener('onAdminAfterSave', function (Event $event) use (&$afterSaveEvents) {
$afterSaveEvents[] = $event;
});
$controller = $this->createUsersController();
$request = $this->makeRequest('POST', '/api/v1/users', [
'username' => 'testuser_' . uniqid(),
'password' => 'TestPass123!',
'email' => 'test@example.com',
]);
$response = $controller->create($request);
self::assertSame(201, $response->getStatusCode());
self::assertCount(1, $saveEvents, 'onAdminSave should fire once for user create');
self::assertCount(1, $afterSaveEvents, 'onAdminAfterSave should fire once for user create');
self::assertArrayHasKey('object', $saveEvents[0]->toArray());
}
public function testUserUpdateFiresOnAdminSaveAndAfterSave(): void
{
// Create user first
$username = 'updateuser_' . uniqid();
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
$user->set('email', 'before@example.com');
$user->set('fullname', 'Before');
$user->set('state', 'enabled');
$user->set('hashed_password', password_hash('test', PASSWORD_DEFAULT));
$user->save();
$saveEvents = [];
$afterSaveEvents = [];
$this->grav['events']->addListener('onAdminSave', function (Event $event) use (&$saveEvents) {
$saveEvents[] = $event;
});
$this->grav['events']->addListener('onAdminAfterSave', function (Event $event) use (&$afterSaveEvents) {
$afterSaveEvents[] = $event;
});
$controller = $this->createUsersController();
$request = $this->makeRequest('PATCH', '/api/v1/users/' . $username, [
'fullname' => 'After',
], ['username' => $username]);
$response = $controller->update($request);
self::assertSame(200, $response->getStatusCode());
self::assertCount(1, $saveEvents, 'onAdminSave should fire once for user update');
self::assertCount(1, $afterSaveEvents, 'onAdminAfterSave should fire once for user update');
}
// -------------------------------------------------------------------------
// ConfigController: update
// -------------------------------------------------------------------------
public function testConfigUpdateFiresOnAdminSaveAndAfterSave(): void
{
$saveEvents = [];
$afterSaveEvents = [];
$this->grav['events']->addListener('onAdminSave', function (Event $event) use (&$saveEvents) {
$saveEvents[] = $event;
});
$this->grav['events']->addListener('onAdminAfterSave', function (Event $event) use (&$afterSaveEvents) {
$afterSaveEvents[] = $event;
});
$controller = $this->createConfigController();
$request = $this->makeRequest('PATCH', '/api/v1/config/site', [
'title' => 'Updated Site Title',
], ['scope' => 'site']);
$response = $controller->update($request);
self::assertSame(200, $response->getStatusCode());
self::assertCount(1, $saveEvents, 'onAdminSave should fire once for config update');
self::assertCount(1, $afterSaveEvents, 'onAdminAfterSave should fire once for config update');
// Config saves wrap in Data object
self::assertInstanceOf(Data::class, $saveEvents[0]['object']);
}
public function testConfigOnAdminSaveCanModifyData(): void
{
// Plugins modify the Data object in-place via set().
// Since objects are passed by identity, changes are visible to the caller.
$this->grav['events']->addListener('onAdminSave', function (Event $event) {
$obj = $event['object'];
if ($obj instanceof Data) {
$obj->set('injected', 'by-plugin');
}
});
$controller = $this->createConfigController();
$request = $this->makeRequest('PATCH', '/api/v1/config/site', [
'title' => 'Config Modify Test',
], ['scope' => 'site']);
$response = $controller->update($request);
$body = json_decode((string) $response->getBody(), true);
self::assertSame(200, $response->getStatusCode());
// The Data object was modified in-place by the listener, and the
// controller calls $obj->toArray() after the event, so the injected
// value should appear in the response (inside the 'data' wrapper).
$data = $body['data'] ?? $body;
self::assertSame('by-plugin', $data['injected'] ?? null, 'Plugin should be able to inject config values via onAdminSave');
}
// -------------------------------------------------------------------------
// Event ordering: admin events fire before API events
// -------------------------------------------------------------------------
public function testAdminEventsFireBeforeApiEvents(): void
{
$order = [];
$this->grav['events']->addListener('onAdminSave', function () use (&$order) {
$order[] = 'onAdminSave';
});
$this->grav['events']->addListener('onAdminAfterSave', function () use (&$order) {
$order[] = 'onAdminAfterSave';
});
$this->grav['events']->addListener('onApiPageCreated', function () use (&$order) {
$order[] = 'onApiPageCreated';
});
$this->grav['events']->addListener('onApiBeforePageCreate', function () use (&$order) {
$order[] = 'onApiBeforePageCreate';
});
$this->grav['events']->addListener('onAdminCreatePageFrontmatter', function () use (&$order) {
$order[] = 'onAdminCreatePageFrontmatter';
});
$controller = $this->createPagesController();
$request = $this->makeRequest('POST', '/api/v1/pages', [
'route' => '/order-test',
'title' => 'Order Test',
]);
$controller->create($request);
// Expected order:
// 1. onApiBeforePageCreate (API before event)
// 2. onAdminCreatePageFrontmatter (admin frontmatter injection)
// 3. onAdminSave (admin pre-save)
// 4. onAdminAfterSave (admin post-save)
// 5. onApiPageCreated (API after event)
self::assertSame([
'onApiBeforePageCreate',
'onAdminCreatePageFrontmatter',
'onAdminSave',
'onAdminAfterSave',
'onApiPageCreated',
], $order, 'Events should fire in the correct order');
}
// =========================================================================
// Helper methods
// =========================================================================
private function createPagesController(): PagesController
{
return new PagesController($this->grav, $this->grav['config']);
}
private function createMediaController(): MediaController
{
return new MediaController($this->grav, $this->grav['config']);
}
private function createUsersController(): UsersController
{
return new UsersController($this->grav, $this->grav['config']);
}
private function createConfigController(): ConfigController
{
return new ConfigController($this->grav, $this->grav['config']);
}
private function createTestPage(string $route, string $title, string $content = ''): void
{
$slug = ltrim($route, '/');
$dir = $this->tempDir . '/pages/' . $slug;
@mkdir($dir, 0775, true);
file_put_contents($dir . '/default.md', "---\ntitle: {$title}\n---\n{$content}");
$this->pages->reset();
$this->pages->init();
}
private function makeRequest(
string $method,
string $path,
array $body = [],
array $routeParams = [],
): \Psr\Http\Message\ServerRequestInterface {
$superAdmin = $this->createSuperAdmin();
return new class ($method, $path, $body, $routeParams, $superAdmin) implements \Psr\Http\Message\ServerRequestInterface {
private array $attributes;
private array $uploadedFiles = [];
public function __construct(
private readonly string $method,
private readonly string $path,
private readonly array $body,
array $routeParams,
object $user,
) {
$this->attributes = [
'api_user' => $user,
'json_body' => $body,
'route_params' => $routeParams,
];
}
public function getMethod(): string { return $this->method; }
public function getQueryParams(): array { return []; }
public function getServerParams(): array { return []; }
public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; }
public function getHeaderLine(string $name): string { return ''; }
public function getHeader(string $name): array { return []; }
public function hasHeader(string $name): bool { return false; }
public function getHeaders(): array { return []; }
public function getParsedBody(): mixed { return $this->body; }
public function getUploadedFiles(): array { return $this->uploadedFiles; }
public function withUploadedFiles(array $uploadedFiles): static {
$clone = clone $this;
$clone->uploadedFiles = $uploadedFiles;
return $clone;
}
public function withAttribute(string $name, mixed $value): static {
$clone = clone $this;
$clone->attributes[$name] = $value;
return $clone;
}
// Stubs for remaining PSR-7 methods
public function getUri(): \Psr\Http\Message\UriInterface {
$path = $this->path;
return new class($path) implements \Psr\Http\Message\UriInterface {
public function __construct(private string $p) {}
public function getScheme(): string { return 'https'; }
public function getAuthority(): string { return ''; }
public function getUserInfo(): string { return ''; }
public function getHost(): string { return 'localhost'; }
public function getPort(): ?int { return null; }
public function getPath(): string { return $this->p; }
public function getQuery(): string { return ''; }
public function getFragment(): string { return ''; }
public function withScheme(string $scheme): static { return clone $this; }
public function withUserInfo(string $user, ?string $password = null): static { return clone $this; }
public function withHost(string $host): static { return clone $this; }
public function withPort(?int $port): static { return clone $this; }
public function withPath(string $path): static { return clone $this; }
public function withQuery(string $query): static { return clone $this; }
public function withFragment(string $fragment): static { return clone $this; }
public function __toString(): string { return $this->p; }
};
}
public function getBody(): \Psr\Http\Message\StreamInterface {
$c = json_encode($this->body);
return new class($c) implements \Psr\Http\Message\StreamInterface {
public function __construct(private string $c) {}
public function __toString(): string { return $this->c; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->c); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->c; }
public function getContents(): string { return $this->c; }
public function getMetadata(?string $key = null): mixed { return null; }
};
}
public function getRequestTarget(): string { return $this->path; }
public function withRequestTarget(string $requestTarget): static { return clone $this; }
public function withMethod(string $method): static { return clone $this; }
public function withUri(\Psr\Http\Message\UriInterface $uri, bool $preserveHost = false): static { return clone $this; }
public function getProtocolVersion(): string { return '1.1'; }
public function withProtocolVersion(string $version): static { return clone $this; }
public function withHeader(string $name, $value): static { return clone $this; }
public function withAddedHeader(string $name, $value): static { return clone $this; }
public function withoutHeader(string $name): static { return clone $this; }
public function withBody(\Psr\Http\Message\StreamInterface $body): static { return clone $this; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $cookies): static { return clone $this; }
public function withQueryParams(array $query): static { return clone $this; }
public function withParsedBody($data): static { return clone $this; }
public function getAttributes(): array { return $this->attributes; }
public function withoutAttribute(string $name): static {
$clone = clone $this;
unset($clone->attributes[$name]);
return $clone;
}
};
}
private function createSuperAdmin(): UserInterface
{
/** @var \Grav\Common\User\Interfaces\UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load('admin');
// If user doesn't exist, create it
if (!$user->exists()) {
$user->set('email', 'admin@test.com');
$user->set('fullname', 'Test Admin');
$user->set('state', 'enabled');
$user->set('access', [
'admin' => ['super' => true, 'login' => true],
'api' => ['access' => true, 'pages' => ['read' => true, 'write' => true], 'media' => ['read' => true, 'write' => true], 'users' => ['read' => true, 'write' => true], 'config' => ['read' => true, 'write' => true]],
]);
$user->save();
}
return $user;
}
private function createUploadedFile(string $tmpPath, string $clientName, string $mediaType): \Psr\Http\Message\UploadedFileInterface
{
$size = filesize($tmpPath);
return new class($tmpPath, $clientName, $mediaType, $size) implements \Psr\Http\Message\UploadedFileInterface {
private bool $moved = false;
public function __construct(
private readonly string $tmpPath,
private readonly string $clientName,
private readonly string $mediaType,
private readonly int $fileSize,
) {}
public function getStream(): \Psr\Http\Message\StreamInterface { throw new \RuntimeException('Not implemented'); }
public function moveTo(string $targetPath): void { copy($this->tmpPath, $targetPath); $this->moved = true; }
public function getSize(): int { return $this->fileSize; }
public function getError(): int { return UPLOAD_ERR_OK; }
public function getClientFilename(): string { return $this->clientName; }
public function getClientMediaType(): string { return $this->mediaType; }
};
}
private static function assertStringContains(string $needle, string $haystack, string $message = ''): void
{
self::assertStringContainsString($needle, $haystack, $message);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
// Load the API plugin's autoloader so its controller classes are available.
require_once '/Users/rhuk/Projects/grav/grav-plugin-api/vendor/autoload.php';
use Codeception\Util\Fixtures;
use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Blueprints;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Controllers\ConfigController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration coverage for server-side blueprint validation on save
* (getgrav/grav-plugin-admin2#30).
*
* The API validates only the fields a request actually changes — NOT the whole
* merged object — because stock Grav config doesn't pass a whole-object
* `$blueprint->validate()`: `system.errors.display` is a bool against a
* `type: int` rule, and Grav's `list` validator rejects complete
* security/backups/scheduler list items. These tests pin both halves: required
* /invalid submitted values ARE rejected, and editing an unrelated field does
* NOT trip those stock-config landmines.
*
* Requires a booted Grav (Validation translates messages), so it lives in the
* integration group.
*/
#[Group('integration')]
class BlueprintValidationTest extends TestCase
{
private object $controller;
private \ReflectionMethod $validate;
protected function setUp(): void
{
parent::setUp();
// Boot the real Grav framework via the shared Codeception fixture, the
// same way the other integration tests do — Validation needs the
// language service to translate messages.
$grav = Fixtures::get('grav');
$grav();
$this->controller = (new \ReflectionClass(ConfigController::class))->newInstanceWithoutConstructor();
$this->validate = (new \ReflectionClass(AbstractApiController::class))->getMethod('validateChangedFields');
}
private function blueprint(array $items): Blueprint
{
$bp = new Blueprint('test', $items);
$bp->init();
return $bp;
}
/** @return string[] field names that failed, empty if validation passed */
private function failingFields(array $changes, ?Blueprint $blueprint): array
{
try {
$this->validate->invoke($this->controller, $changes, $blueprint);
return [];
} catch (ValidationException $e) {
return array_map(static fn(array $err) => $err['field'], $e->getValidationErrors());
}
}
#[Test]
public function required_field_submitted_empty_is_rejected(): void
{
$bp = $this->blueprint(['form' => ['fields' => [
'api_key' => ['type' => 'text', 'label' => 'API Key', 'validate' => ['required' => true]],
]]]);
$this->assertSame(['api_key'], $this->failingFields(['api_key' => ''], $bp));
$this->assertSame([], $this->failingFields(['api_key' => 'abc'], $bp));
}
#[Test]
public function untouched_required_field_does_not_block_unrelated_edit(): void
{
$bp = $this->blueprint(['form' => ['fields' => [
'api_key' => ['type' => 'text', 'validate' => ['required' => true]],
'timeout' => ['type' => 'number', 'validate' => ['type' => 'int', 'min' => 1, 'max' => 60]],
]]]);
// api_key is required but not part of this change — must not be flagged.
$this->assertSame([], $this->failingFields(['timeout' => 30], $bp));
$this->assertSame(['timeout'], $this->failingFields(['timeout' => 999], $bp));
}
#[Test]
public function int_typed_field_accepts_boolean_via_coercion(): void
{
// Mirrors system.errors.display: declared type:int, but Grav's runtime
// accepts bool (true === 1). Both must validate.
$bp = $this->blueprint(['form' => ['fields' => [
'errors.display' => ['type' => 'select', 'validate' => ['type' => 'int']],
]]]);
$this->assertSame([], $this->failingFields(['errors' => ['display' => 1]], $bp));
$this->assertSame([], $this->failingFields(['errors' => ['display' => true]], $bp));
$this->assertSame([], $this->failingFields(['errors' => ['display' => false]], $bp));
}
#[Test]
public function real_system_blueprint_does_not_false_positive_on_unrelated_edit(): void
{
$system = (new Blueprints('blueprints://config'))->get('system');
// Stock system config fails a whole-object validate on errors.display;
// a delta that doesn't touch it must still save cleanly.
$this->assertSame([], $this->failingFields(['timezone' => 'UTC'], $system));
$this->assertSame([], $this->failingFields(['errors' => ['display' => true]], $system));
}
#[Test]
public function real_security_blueprint_does_not_trip_list_validation_bug(): void
{
$security = (new Blueprints('blueprints://config'))->get('security');
// security.twig_sandbox.allowed_methods is a list whose per-item
// required `.class` field trips a core validation bug on a whole-object
// validate. Editing an unrelated scalar must not surface it.
$this->assertSame([], $this->failingFields(['xss_enabled' => true], $security));
}
#[Test]
public function real_account_blueprint_validates_submitted_fields(): void
{
$account = (new Blueprints('blueprints://user'))->get('account');
$this->assertSame(['email'], $this->failingFields(['email' => 'not-an-email'], $account));
$this->assertSame([], $this->failingFields(['email' => 'joe@example.com'], $account));
$this->assertSame(['fullname'], $this->failingFields(['fullname' => ''], $account));
// Dynamic data-options@ select must not false-positive (options unresolved).
$this->assertSame([], $this->failingFields(['language' => 'fr'], $account));
}
/**
* GHSA-5wc5-7v9g-f7v6 / CVE-2026-11982 regression: the partial-validation
* path must run the XSS safety check, not just type/required validation.
*
* A non-superadmin page editor previously stored an event-handler payload
* in page Markdown through PATCH /pages because validateChangedFields()
* called Validation::validate() but never Validation::checkSafety() — the
* method that invokes Security::detectXss(). The full blueprint validator
* (classic admin) runs checkSafety() per field; this path now matches it.
*/
#[Test]
public function stored_xss_payload_in_content_is_rejected(): void
{
// Mirrors the page blueprint's content field (type markdown, validated
// as textarea), which is the field the advisory's PoC abused.
$bp = $this->blueprint(['form' => ['fields' => [
'content' => ['type' => 'markdown', 'label' => 'Content', 'validate' => ['type' => 'textarea']],
]]]);
// The advisory's payload: an unquoted on* event handler in raw HTML.
$payload = "### XSS PoC\n<img src=x onerror=alert(1)>\n";
$this->assertSame(['content'], $this->failingFields(['content' => $payload], $bp));
// Benign Markdown must still save cleanly — the gate only blocks XSS.
$this->assertSame([], $this->failingFields(['content' => "### Hello\nJust *normal* content.\n"], $bp));
}
#[Test]
public function field_opting_out_of_xss_check_still_allows_html(): void
{
// A field that explicitly sets `xss_check: false` must behave exactly
// like the classic admin, which skips checkSafety() for it. This keeps
// the fix from over-blocking fields a publisher is trusted to author.
$bp = $this->blueprint(['form' => ['fields' => [
'content' => ['type' => 'markdown', 'xss_check' => false, 'validate' => ['type' => 'textarea']],
]]]);
$this->assertSame([], $this->failingFields(['content' => '<img src=x onerror=alert(1)>'], $bp));
}
}
@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
// Load the API plugin's autoloader so its controller classes are available
require_once '/Users/rhuk/Projects/grav/grav-plugin-api/vendor/autoload.php';
use Codeception\Util\Fixtures;
use Grav\Common\Grav;
use Grav\Common\Language\Language;
use Grav\Common\Page\Pages;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Controllers\PagesController;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
/**
* Integration tests pinning multilingual page resolution for GET/PATCH/DELETE.
*
* Regression coverage for getgrav/grav-plugin-api#6: a PATCH carrying
* ?lang=<secondary> used to clobber the default-language file (and a DELETE
* 404'd) because Grav builds and caches the pages index for whichever language
* is active at init time. The controller set the active language but never
* forced the index to rebuild, so find()/save() resolved to the wrong
* translation file. The fix rebuilds pages whenever applyLanguage() changes the
* active language.
*/
class PagesControllerLanguageTest extends \PHPUnit\Framework\TestCase
{
protected Grav $grav;
protected Pages $pages;
protected string $tempDir;
protected function setUp(): void
{
parent::setUp();
$grav = Fixtures::get('grav');
$this->grav = $grav();
$this->tempDir = sys_get_temp_dir() . '/grav_api_lang_test_' . uniqid();
@mkdir($this->tempDir . '/pages', 0775, true);
@mkdir($this->tempDir . '/cache', 0775, true);
/** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
$locator->addPath('page', '', $this->tempDir . '/pages', false);
$locator->addPath('cache', '', $this->tempDir . '/cache', false);
// API config
$this->grav['config']->set('plugins.api.route', '/api');
$this->grav['config']->set('plugins.api.version_prefix', 'v1');
// Turn this install into a multilingual site: en (default) + fr, with
// the default language stored in a suffix-less file (default.md).
$this->grav['config']->set('system.languages.supported', ['en', 'fr']);
$this->grav['config']->set('system.languages.default_lang', 'en');
$this->grav['config']->set('system.languages.include_default_lang', false);
// Disable the on-disk pages cache so each rebuild reads the fixtures we
// just wrote rather than a stale per-language snapshot.
$this->grav['config']->set('system.cache.enabled', false);
// The Language service reads system.languages.supported in its
// constructor, so rebuild it now that the config is multilingual.
unset($this->grav['language']);
/** @var Language $language */
$language = $this->grav['language'];
self::assertTrue($language->enabled(), 'Multi-language should be enabled for these tests');
$this->pages = $this->grav['pages'];
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
parent::tearDown();
}
public function testPatchWithLangUpdatesTheTargetTranslationNotTheDefault(): void
{
$this->createMultilangPage('contact', [
'en' => "title: Contact\n",
'fr' => "title: Contactez\n",
], [
'en' => 'English body',
'fr' => 'Corps francais',
]);
$controller = $this->createPagesController();
$request = $this->makeRequest(
'PATCH',
'/api/v1/pages/contact',
['content' => 'Nouveau corps francais'],
['route' => 'contact'],
['lang' => 'fr'],
);
$response = $controller->update($request);
self::assertSame(200, $response->getStatusCode());
$frFile = $this->tempDir . '/pages/contact/default.fr.md';
$enFile = $this->tempDir . '/pages/contact/default.md';
self::assertStringContainsString('Nouveau corps francais', file_get_contents($frFile), 'French file should be updated');
self::assertStringContainsString('English body', file_get_contents($enFile), 'Default (English) file must be untouched');
self::assertStringNotContainsString('Nouveau corps francais', file_get_contents($enFile), 'Default file must not receive the French payload');
}
public function testPatchWithoutLangUpdatesTheDefaultTranslation(): void
{
$this->createMultilangPage('about', [
'en' => "title: About\n",
'fr' => "title: A propos\n",
], [
'en' => 'English body',
'fr' => 'Corps francais',
]);
$controller = $this->createPagesController();
$request = $this->makeRequest(
'PATCH',
'/api/v1/pages/about',
['content' => 'Updated english body'],
['route' => 'about'],
);
$response = $controller->update($request);
self::assertSame(200, $response->getStatusCode());
$enFile = $this->tempDir . '/pages/about/default.md';
$frFile = $this->tempDir . '/pages/about/default.fr.md';
self::assertStringContainsString('Updated english body', file_get_contents($enFile), 'Default file should be updated');
self::assertStringContainsString('Corps francais', file_get_contents($frFile), 'French file must be untouched');
}
public function testDeleteWithLangRemovesOnlyThatTranslation(): void
{
$this->createMultilangPage('news', [
'en' => "title: News\n",
'fr' => "title: Nouvelles\n",
], [
'en' => 'English body',
'fr' => 'Corps francais',
]);
$controller = $this->createPagesController();
$request = $this->makeRequest(
'DELETE',
'/api/v1/pages/news',
[],
['route' => 'news'],
['lang' => 'fr'],
);
$response = $controller->delete($request);
self::assertSame(204, $response->getStatusCode());
self::assertFileDoesNotExist($this->tempDir . '/pages/news/default.fr.md', 'French translation should be deleted');
self::assertFileExists($this->tempDir . '/pages/news/default.md', 'Default translation must survive');
}
// =========================================================================
// Helper methods
// =========================================================================
private function createPagesController(): PagesController
{
return new PagesController($this->grav, $this->grav['config']);
}
/**
* Write a page folder with a suffix-less default.md for the default
* language and default.<lang>.md for every secondary language.
*
* @param array<string, string> $headers lang => frontmatter body (YAML)
* @param array<string, string> $contents lang => markdown body
*/
private function createMultilangPage(string $slug, array $headers, array $contents): void
{
$dir = $this->tempDir . '/pages/' . $slug;
@mkdir($dir, 0775, true);
$default = $this->grav['language']->getDefault();
foreach ($headers as $lang => $frontmatter) {
$name = $lang === $default ? 'default.md' : "default.{$lang}.md";
file_put_contents($dir . '/' . $name, "---\n{$frontmatter}---\n" . ($contents[$lang] ?? ''));
}
$this->pages->reset();
$this->pages->init();
}
private function makeRequest(
string $method,
string $path,
array $body = [],
array $routeParams = [],
array $query = [],
): \Psr\Http\Message\ServerRequestInterface {
$superAdmin = $this->createSuperAdmin();
return new class ($method, $path, $body, $routeParams, $query, $superAdmin) implements \Psr\Http\Message\ServerRequestInterface {
private array $attributes;
public function __construct(
private readonly string $method,
private readonly string $path,
private readonly array $body,
array $routeParams,
private readonly array $query,
object $user,
) {
$this->attributes = [
'api_user' => $user,
'json_body' => $body,
'route_params' => $routeParams,
];
}
public function getMethod(): string { return $this->method; }
public function getQueryParams(): array { return $this->query; }
public function getServerParams(): array { return []; }
public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; }
public function getHeaderLine(string $name): string { return ''; }
public function getHeader(string $name): array { return []; }
public function hasHeader(string $name): bool { return false; }
public function getHeaders(): array { return []; }
public function getParsedBody(): mixed { return $this->body; }
public function getUploadedFiles(): array { return []; }
public function withAttribute(string $name, mixed $value): static {
$clone = clone $this;
$clone->attributes[$name] = $value;
return $clone;
}
// Stubs for remaining PSR-7 methods
public function getUri(): \Psr\Http\Message\UriInterface {
$path = $this->path;
return new class($path) implements \Psr\Http\Message\UriInterface {
public function __construct(private string $p) {}
public function getScheme(): string { return 'https'; }
public function getAuthority(): string { return ''; }
public function getUserInfo(): string { return ''; }
public function getHost(): string { return 'localhost'; }
public function getPort(): ?int { return null; }
public function getPath(): string { return $this->p; }
public function getQuery(): string { return ''; }
public function getFragment(): string { return ''; }
public function withScheme(string $scheme): static { return clone $this; }
public function withUserInfo(string $user, ?string $password = null): static { return clone $this; }
public function withHost(string $host): static { return clone $this; }
public function withPort(?int $port): static { return clone $this; }
public function withPath(string $path): static { return clone $this; }
public function withQuery(string $query): static { return clone $this; }
public function withFragment(string $fragment): static { return clone $this; }
public function __toString(): string { return $this->p; }
};
}
public function getBody(): \Psr\Http\Message\StreamInterface {
$c = json_encode($this->body);
return new class($c) implements \Psr\Http\Message\StreamInterface {
public function __construct(private string $c) {}
public function __toString(): string { return $this->c; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->c); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->c; }
public function getContents(): string { return $this->c; }
public function getMetadata(?string $key = null): mixed { return null; }
};
}
public function getRequestTarget(): string { return $this->path; }
public function withRequestTarget(string $requestTarget): static { return clone $this; }
public function withMethod(string $method): static { return clone $this; }
public function withUri(\Psr\Http\Message\UriInterface $uri, bool $preserveHost = false): static { return clone $this; }
public function getProtocolVersion(): string { return '1.1'; }
public function withProtocolVersion(string $version): static { return clone $this; }
public function withHeader(string $name, $value): static { return clone $this; }
public function withAddedHeader(string $name, $value): static { return clone $this; }
public function withoutHeader(string $name): static { return clone $this; }
public function withBody(\Psr\Http\Message\StreamInterface $body): static { return clone $this; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $cookies): static { return clone $this; }
public function withQueryParams(array $query): static { return clone $this; }
public function withParsedBody($data): static { return clone $this; }
public function getAttributes(): array { return $this->attributes; }
public function withUploadedFiles(array $uploadedFiles): static { return clone $this; }
public function withoutAttribute(string $name): static {
$clone = clone $this;
unset($clone->attributes[$name]);
return $clone;
}
};
}
private function createSuperAdmin(): UserInterface
{
/** @var \Grav\Common\User\Interfaces\UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load('admin');
if (!$user->exists()) {
$user->set('email', 'admin@test.com');
$user->set('fullname', 'Test Admin');
$user->set('state', 'enabled');
$user->set('access', [
'admin' => ['super' => true, 'login' => true],
'api' => ['access' => true, 'pages' => ['read' => true, 'write' => true]],
]);
$user->save();
}
return $user;
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
}
+672
View File
@@ -0,0 +1,672 @@
<?php
/**
* Minimal stub classes for Grav CMS types used in the API plugin.
*
* These are loaded ONLY when the real Grav classes are not available
* (i.e., when the plugin is tested outside of a Grav installation).
* They provide just enough structure for the plugin classes to be
* instantiated and tested in isolation.
*/
// Only declare stubs if the real classes don't exist.
// This file is loaded by the bootstrap's fallback autoloader.
namespace Grav\Common\Config {
if (!class_exists(\Grav\Common\Config\Config::class, false)) {
class Config
{
public function __construct(protected array $items = []) {}
public function get(string $key, mixed $default = null): mixed
{
$segments = explode('.', $key);
$current = $this->items;
foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return $default;
}
$current = $current[$segment];
}
return $current;
}
public function set(string $key, mixed $value): void
{
$segments = explode('.', $key);
$current = &$this->items;
foreach ($segments as $i => $segment) {
if ($i === count($segments) - 1) {
$current[$segment] = $value;
} else {
if (!isset($current[$segment]) || !is_array($current[$segment])) {
$current[$segment] = [];
}
$current = &$current[$segment];
}
}
}
}
}
}
namespace Grav\Common {
if (!class_exists(\Grav\Common\Yaml::class, false)) {
// Thin shim over symfony/yaml so tests that need YAML parsing run
// without the full Grav core on the classpath.
abstract class Yaml
{
public static function parse(string $data): array
{
$parsed = \Symfony\Component\Yaml\Yaml::parse($data);
return is_array($parsed) ? $parsed : [];
}
public static function dump(mixed $data, ?int $inline = null, ?int $indent = null): string
{
return \Symfony\Component\Yaml\Yaml::dump($data, $inline ?? 5, $indent ?? 2);
}
}
}
if (!class_exists(\Grav\Common\Grav::class, false)) {
class Grav implements \ArrayAccess
{
private static ?self $instance = null;
private array $services = [];
/** @var array<int, array{name: string, event: object}> Recorded event firings for test assertions. */
private array $firedEvents = [];
public static function instance(): static
{
if (self::$instance === null) {
self::$instance = new static();
}
return self::$instance;
}
/** Reset the singleton (useful between tests). */
public static function resetInstance(): void
{
self::$instance = null;
}
/**
* Fire a Grav event (stub implementation).
* Records the event for later assertion in tests.
*/
public function fireEvent(string $name, ?object $event = null): object
{
$event = $event ?? new \stdClass();
$this->firedEvents[] = ['name' => $name, 'event' => $event];
return $event;
}
/**
* Get all recorded fired events (for test assertions).
* @return array<int, array{name: string, event: object}>
*/
public function getFiredEvents(): array
{
return $this->firedEvents;
}
/** Clear the recorded events list. */
public function clearFiredEvents(): void
{
$this->firedEvents = [];
}
public function offsetExists(mixed $offset): bool
{
return isset($this->services[$offset]);
}
public function offsetGet(mixed $offset): mixed
{
return $this->services[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->services[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->services[$offset]);
}
}
}
}
namespace Grav\Common\User {
if (!class_exists(\Grav\Common\User\Authentication::class, false)) {
/**
* Minimal Authentication stub mirroring Grav's password hashing helper.
* create() returns a bcrypt hash; verify() returns an int (1 = match,
* 0 = no match) matching the contract ApiKeyManager::verifyKey() relies on.
*/
abstract class Authentication
{
public static function create(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT);
}
public static function verify(string $password, string $hash): int
{
return password_verify($password, $hash) ? 1 : 0;
}
}
}
}
namespace Grav\Common\User\Interfaces {
if (!interface_exists(\Grav\Common\User\Interfaces\UserInterface::class, false)) {
interface UserInterface
{
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value): void;
public function save(): void;
public function exists(): bool;
}
}
if (!interface_exists(\Grav\Common\User\Interfaces\UserCollectionInterface::class, false)) {
interface UserCollectionInterface extends \Traversable
{
public function load(string $username): UserInterface;
}
}
}
namespace Grav\Common\Page\Interfaces {
if (!interface_exists(\Grav\Common\Page\Interfaces\PageInterface::class, false)) {
interface PageInterface
{
public function route($var = null): ?string;
public function slug($var = null): string;
public function order($var = null): ?int;
public function path($var = null): ?string;
public function title($var = null): string;
public function isModule(): bool;
public function children(): \Traversable;
}
}
}
namespace Grav\Common\Filesystem {
if (!class_exists(\Grav\Common\Filesystem\Folder::class, false)) {
class Folder
{
public static function move(string $source, string $target): void
{
if (is_dir($source)) {
rename($source, $target);
}
}
}
}
}
namespace Grav\Framework\Psr7 {
if (!class_exists(\Grav\Framework\Psr7\Response::class, false)) {
/**
* Minimal PSR-7 Response implementation for testing.
*/
class Response implements \Psr\Http\Message\ResponseInterface
{
/** @var array<string, string[]> */
private array $headerValues = [];
/** @var string */
private string $body;
public function __construct(
private int $statusCode = 200,
array $headers = [],
string $body = '',
private string $protocolVersion = '1.1',
private string $reasonPhrase = '',
) {
foreach ($headers as $name => $value) {
$this->headerValues[strtolower($name)] = [
'original' => $name,
'values' => is_array($value) ? $value : [$value],
];
}
$this->body = $body;
}
public function getStatusCode(): int { return $this->statusCode; }
public function withStatus(int $code, string $reasonPhrase = ''): static
{
$clone = clone $this;
$clone->statusCode = $code;
$clone->reasonPhrase = $reasonPhrase;
return $clone;
}
public function getReasonPhrase(): string { return $this->reasonPhrase; }
public function getProtocolVersion(): string { return $this->protocolVersion; }
public function withProtocolVersion(string $version): static
{
$clone = clone $this;
$clone->protocolVersion = $version;
return $clone;
}
public function getHeaders(): array
{
$result = [];
foreach ($this->headerValues as $info) {
$result[$info['original']] = $info['values'];
}
return $result;
}
public function hasHeader(string $name): bool
{
return isset($this->headerValues[strtolower($name)]);
}
public function getHeader(string $name): array
{
return $this->headerValues[strtolower($name)]['values'] ?? [];
}
public function getHeaderLine(string $name): string
{
return implode(', ', $this->getHeader($name));
}
public function withHeader(string $name, $value): static
{
$clone = clone $this;
$clone->headerValues[strtolower($name)] = [
'original' => $name,
'values' => is_array($value) ? $value : [$value],
];
return $clone;
}
public function withAddedHeader(string $name, $value): static
{
$clone = clone $this;
$lower = strtolower($name);
$existing = $clone->headerValues[$lower]['values'] ?? [];
$clone->headerValues[$lower] = [
'original' => $clone->headerValues[$lower]['original'] ?? $name,
'values' => array_merge($existing, is_array($value) ? $value : [$value]),
];
return $clone;
}
public function withoutHeader(string $name): static
{
$clone = clone $this;
unset($clone->headerValues[strtolower($name)]);
return $clone;
}
public function getBody(): \Psr\Http\Message\StreamInterface
{
$content = $this->body;
return new class ($content) implements \Psr\Http\Message\StreamInterface {
public function __construct(private readonly string $content) {}
public function __toString(): string { return $this->content; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->content); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->content; }
public function getContents(): string { return $this->content; }
public function getMetadata(?string $key = null): mixed { return null; }
};
}
public function withBody(\Psr\Http\Message\StreamInterface $body): static
{
$clone = clone $this;
$clone->body = (string) $body;
return $clone;
}
}
}
}
namespace RocketTheme\Toolbox\Event {
if (!class_exists(\RocketTheme\Toolbox\Event\Event::class, false)) {
/**
* Minimal Event stub that supports array access for event data.
*/
class Event implements \ArrayAccess
{
private array $data;
public function __construct(array $data = [])
{
$this->data = $data;
}
public function offsetExists(mixed $offset): bool
{
return array_key_exists($offset, $this->data);
}
public function &offsetGet(mixed $offset): mixed
{
return $this->data[$offset];
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->data[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->data[$offset]);
}
public function toArray(): array
{
return $this->data;
}
}
}
}
namespace Grav\Common\Page {
if (!class_exists(\Grav\Common\Page\Page::class, false)) {
/**
* Minimal Page stub for testing controllers that instantiate Page directly.
*/
class Page
{
private ?string $filePath = null;
private object $header;
private string $rawMarkdown = '';
private string $template = 'default';
private string $name = 'default.md';
private ?string $path = null;
private ?string $route = null;
private ?string $slug = null;
private ?int $order = null;
private ?string $lang = null;
public function __construct()
{
$this->header = new \stdClass();
}
public function filePath(?string $path = null): ?string
{
if ($path !== null) {
$this->filePath = $path;
// Derive path (directory) from filePath
$this->path = dirname($path);
}
return $this->filePath;
}
public function header($var = null)
{
if ($var !== null) {
$this->header = is_array($var) ? (object) $var : $var;
}
return $this->header;
}
public function rawMarkdown(?string $var = null): string
{
if ($var !== null) {
$this->rawMarkdown = $var;
}
return $this->rawMarkdown;
}
public function template(?string $var = null): string
{
if ($var !== null) {
$this->template = $var;
}
return $this->template;
}
public function name(?string $var = null): string
{
if ($var !== null) {
$this->name = $var;
}
return $this->name;
}
public function path(?string $var = null): ?string
{
if ($var !== null) {
$this->path = $var;
}
return $this->path;
}
public function route($var = null): ?string
{
if ($var !== null) {
$this->route = $var;
}
return $this->route;
}
public function slug($var = null): ?string
{
if ($var !== null) {
$this->slug = $var;
}
return $this->slug;
}
public function order($var = null): ?int
{
if ($var !== null) {
$this->order = $var;
}
return $this->order;
}
public function language(?string $var = null): ?string
{
if ($var !== null) {
$this->lang = $var;
}
return $this->lang;
}
public function title($var = null): string
{
return $this->header->title ?? '';
}
public function save($reorder = true): void
{
// No-op in tests — the actual file writing is not needed
}
public function isModule(): bool
{
return false;
}
public function children(): \Traversable
{
return new \ArrayIterator([]);
}
public function translatedLanguages(): array
{
return [];
}
public function file(): ?object
{
return null;
}
public function content($var = null): string
{
return $this->rawMarkdown;
}
}
}
if (!class_exists(\Grav\Common\Page\Media::class, false)) {
/**
* Minimal Media stub.
*/
class Media
{
public function __construct(private readonly ?string $path = null) {}
public function all(): array
{
return [];
}
}
}
}
namespace Grav\Common\GPM {
if (!class_exists(\Grav\Common\GPM\GPM::class, false)) {
/**
* Minimal GPM stub. Methods are intentionally non-final and present
* here only so PHPUnit's createMock() can produce a mock subclass.
* Behavior is supplied per-test via mock expectations.
*/
class GPM
{
public function __construct(bool $refresh = false, $callback = null) {}
public function getUpdatable(): array { return []; }
public function isUpdatable(string $slug): bool { return false; }
public function checkPackagesCanBeInstalled(array $slugs): void {}
public function getDependencies(array $slugs): array { return []; }
}
}
}
namespace Grav\Common {
if (!class_exists(\Grav\Common\Utils::class, false)) {
/**
* Minimal Utils stub. Exercised by unit tests via PermissionResolver
* (arrayFlattenDotNotation) and UsersController's permission filtering
* (isPositive).
*/
class Utils
{
public static function arrayFlattenDotNotation(array $array, string $prepend = ''): array
{
$results = [];
foreach ($array as $key => $value) {
if (is_array($value) && !empty($value)) {
$results = array_merge($results, self::arrayFlattenDotNotation($value, $prepend . $key . '.'));
} else {
$results[$prepend . $key] = $value;
}
}
return $results;
}
public static function isPositive($value): bool
{
return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true);
}
/**
* Exercised by UploadFieldSettings (random_name). The real Utils
* draws from a larger alphabet; a deterministic-length lowercase
* alnum string is enough for the upload-pipeline tests.
*/
public static function generateRandomString($length = 5): string
{
$alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
$out = '';
for ($i = 0; $i < $length; $i++) {
$out .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return $out;
}
/**
* Exercised by UploadFieldSettings (accept allowlist). Maps the few
* extensions the tests rely on; everything else is octet-stream,
* matching the real Utils' fallback when no media type is found.
*/
public static function getMimeByFilename($filename, $default = 'application/octet-stream'): string
{
$ext = strtolower(pathinfo((string) $filename, PATHINFO_EXTENSION));
return match ($ext) {
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'pdf' => 'application/pdf',
'txt' => 'text/plain',
default => $default,
};
}
}
}
}
namespace Grav\Framework\Acl {
if (!class_exists(\Grav\Framework\Acl\Permissions::class, false)) {
/**
* Minimal Permissions stub so PermissionResolver can be constructed.
* Only resolvedMap() touches getInstances(); resolve() reads only the
* user's access array, so most unit tests get away with an empty stub.
*/
class Permissions
{
/** @return array<string, object> */
public function getInstances(): array { return []; }
}
}
}
namespace Grav\Common\Data {
if (!class_exists(\Grav\Common\Data\Data::class, false)) {
/**
* Minimal Data stub for config wrapping.
*/
class Data
{
public function __construct(private array $items = []) {}
public function toArray(): array
{
return $this->items;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->items[$key] ?? $default;
}
public function set(string $key, mixed $value): void
{
$this->items[$key] = $value;
}
}
}
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Auth;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Auth\ApiKeyAuthenticator;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ApiKeyAuthenticator.
*
* API keys live in the central user/data/api-keys.yaml store (keyed by id,
* each entry carrying its owning `username`). The authenticator looks a raw
* key up in that store, then loads the matching account. Tests seed the store
* via a temp-dir `user://data` locator and provide a matching accounts mock.
*/
#[CoversClass(ApiKeyAuthenticator::class)]
class ApiKeyAuthenticatorTest extends TestCase
{
private const RAW_KEY = 'grav_test_api_key_raw_value_1234';
private string $dataDir;
protected function setUp(): void
{
$this->dataDir = sys_get_temp_dir() . '/grav_api_authn_test_' . uniqid();
@mkdir($this->dataDir, 0775, true);
$this->resetKeysCache();
}
protected function tearDown(): void
{
$this->resetKeysCache();
$this->rmrf($this->dataDir);
}
private function resetKeysCache(): void
{
(new \ReflectionProperty(ApiKeyManager::class, 'keysCache'))->setValue(null, null);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
/**
* Build an authenticator with the central key store seeded and the given
* accounts available.
*
* @param array<string, array> $keys central store entries keyed by id
* @param array<string, object> $users accounts keyed by username
*/
private function buildAuthenticator(array $keys, array $users = []): ApiKeyAuthenticator
{
$dataDir = $this->dataDir;
$locator = new class ($dataDir) {
public function __construct(private string $dir) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string
{
return $this->dir;
}
};
$accounts = TestHelper::createMockAccounts($users);
$grav = TestHelper::createMockGrav(['accounts' => $accounts, 'locator' => $locator]);
// Seed the central store after the Grav container exists.
file_put_contents($this->dataDir . '/api-keys.yaml', Yaml::dump($keys));
$this->resetKeysCache();
return new ApiKeyAuthenticator($grav);
}
#[Test]
public function returns_null_when_no_api_key_present(): void
{
$authenticator = $this->buildAuthenticator([]);
$request = TestHelper::createMockRequest();
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function authenticates_via_header(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'alice',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
'expires' => null,
],
], ['alice' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function authenticates_via_query_param(): void
{
$user = TestHelper::createMockUser('bob');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'bob',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
'expires' => null,
],
], ['bob' => $user]);
$request = TestHelper::createMockRequest(
queryParams: ['api_key' => self::RAW_KEY],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('bob', $result->username);
}
#[Test]
public function returns_null_for_invalid_key(): void
{
$user = TestHelper::createMockUser('carol');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'carol',
'hash' => hash('sha256', 'some_other_key'),
'active' => true,
],
], ['carol' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => 'grav_wrong_key_value'],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_for_inactive_key(): void
{
$user = TestHelper::createMockUser('dave');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'dave',
'hash' => hash('sha256', self::RAW_KEY),
'active' => false,
],
], ['dave' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_for_expired_key(): void
{
$user = TestHelper::createMockUser('eve');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'eve',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
'expires' => time() - 3600, // expired an hour ago
],
], ['eve' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_when_account_does_not_exist(): void
{
// Key matches, but no account exists for its username.
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'ghost',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
],
], []);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function header_takes_precedence_over_query_param(): void
{
$headerKey = 'grav_header_key_value_123456789';
$queryKey = 'grav_query_key_value_987654321';
$user = TestHelper::createMockUser('frank');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'frank',
'hash' => hash('sha256', $headerKey),
'active' => true,
],
], ['frank' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => $headerKey],
queryParams: ['api_key' => $queryKey],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('frank', $result->username);
}
}
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Auth;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ApiKeyManager.
*
* Keys are stored centrally in user/data/api-keys.yaml (keyed by id, each
* entry carrying its owning `username`), so the tests seed and inspect that
* central store via a temp-dir `user://data` locator rather than the user
* object itself.
*/
#[CoversClass(ApiKeyManager::class)]
class ApiKeyManagerTest extends TestCase
{
private ApiKeyManager $manager;
private string $dataDir;
protected function setUp(): void
{
$this->dataDir = sys_get_temp_dir() . '/grav_api_keys_test_' . uniqid();
@mkdir($this->dataDir, 0775, true);
$dataDir = $this->dataDir;
$locator = new class ($dataDir) {
public function __construct(private string $dir) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string
{
return $this->dir;
}
};
TestHelper::createMockGrav(['locator' => $locator]);
$this->resetKeysCache();
$this->manager = new ApiKeyManager();
}
protected function tearDown(): void
{
$this->resetKeysCache();
$this->rmrf($this->dataDir);
}
/** Clear the ApiKeyManager static cache so each test starts clean. */
private function resetKeysCache(): void
{
(new \ReflectionProperty(ApiKeyManager::class, 'keysCache'))->setValue(null, null);
}
/** Seed the central api-keys.yaml store with the given entries. */
private function seedKeys(array $keys): void
{
file_put_contents($this->dataDir . '/api-keys.yaml', Yaml::dump($keys));
$this->resetKeysCache();
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
#[Test]
public function generate_key_returns_key_and_id(): void
{
$user = TestHelper::createMockUser('alice');
$result = $this->manager->generateKey($user);
self::assertArrayHasKey('key', $result);
self::assertArrayHasKey('id', $result);
self::assertNotEmpty($result['key']);
self::assertNotEmpty($result['id']);
}
#[Test]
public function generated_key_starts_with_grav_prefix(): void
{
$user = TestHelper::createMockUser('bob');
$result = $this->manager->generateKey($user);
self::assertStringStartsWith('grav_', $result['key']);
}
#[Test]
public function generated_key_is_stored_centrally(): void
{
$user = TestHelper::createMockUser('carol');
$result = $this->manager->generateKey($user, 'My Key', ['read', 'write']);
$keys = $this->manager->loadKeys();
self::assertArrayHasKey($result['id'], $keys);
$stored = $keys[$result['id']];
self::assertSame($result['id'], $stored['id']);
self::assertSame('carol', $stored['username']);
self::assertSame('My Key', $stored['name']);
// The raw key verifies against the stored hash (bcrypt, not reversible).
self::assertTrue(ApiKeyManager::verifyKey($result['key'], $stored['hash']));
self::assertSame(['read', 'write'], $stored['scopes']);
self::assertTrue($stored['active']);
self::assertNotNull($stored['created']);
self::assertNull($stored['last_used']);
self::assertNull($stored['expires']);
}
#[Test]
public function generated_key_stores_prefix(): void
{
$user = TestHelper::createMockUser('dave');
$result = $this->manager->generateKey($user);
$stored = $this->manager->loadKeys()[$result['id']];
// Prefix should be first 12 chars of the raw key followed by '...'
$expectedPrefix = substr($result['key'], 0, 12) . '...';
self::assertSame($expectedPrefix, $stored['prefix']);
}
#[Test]
public function default_key_name_is_api_key(): void
{
$user = TestHelper::createMockUser('eve');
$result = $this->manager->generateKey($user);
self::assertSame('API Key', $this->manager->loadKeys()[$result['id']]['name']);
}
#[Test]
public function list_keys_excludes_hashes(): void
{
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'frank',
'name' => 'Production',
'hash' => 'abc123secrethash',
'prefix' => 'grav_abc123...',
'scopes' => ['read'],
'active' => true,
'created' => 1700000000,
'last_used' => null,
'expires' => null,
],
'k2' => [
'id' => 'k2',
'username' => 'frank',
'name' => 'Staging',
'hash' => 'def456secrethash',
'prefix' => 'grav_def456...',
'scopes' => [],
'active' => false,
'created' => 1700001000,
'last_used' => 1700002000,
'expires' => 1700100000,
],
]);
$user = TestHelper::createMockUser('frank');
$list = $this->manager->listKeys($user);
self::assertCount(2, $list);
// Verify no hash field is present in the output
foreach ($list as $item) {
self::assertArrayNotHasKey('hash', $item);
self::assertArrayHasKey('id', $item);
self::assertArrayHasKey('name', $item);
self::assertArrayHasKey('prefix', $item);
self::assertArrayHasKey('scopes', $item);
self::assertArrayHasKey('active', $item);
self::assertArrayHasKey('created', $item);
self::assertArrayHasKey('last_used', $item);
self::assertArrayHasKey('expires', $item);
}
self::assertSame('Production', $list[0]['name']);
self::assertSame('Staging', $list[1]['name']);
}
#[Test]
public function list_keys_only_returns_keys_for_the_given_user(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'frank', 'name' => 'Mine', 'hash' => 'h'],
'k2' => ['id' => 'k2', 'username' => 'someone_else', 'name' => 'Theirs', 'hash' => 'h'],
]);
$list = $this->manager->listKeys(TestHelper::createMockUser('frank'));
self::assertCount(1, $list);
self::assertSame('Mine', $list[0]['name']);
}
#[Test]
public function list_keys_skips_non_array_entries(): void
{
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'grace',
'name' => 'Valid Key',
'hash' => 'somehash',
'prefix' => 'grav_aaa...',
'scopes' => [],
'active' => true,
'created' => 1700000000,
'last_used' => null,
'expires' => null,
],
'corrupted' => 'not_an_array',
]);
$list = $this->manager->listKeys(TestHelper::createMockUser('grace'));
self::assertCount(1, $list);
self::assertSame('Valid Key', $list[0]['name']);
}
#[Test]
public function revoke_key_removes_it(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'heidi', 'name' => 'To be revoked', 'hash' => 'somehash'],
'k2' => ['id' => 'k2', 'username' => 'heidi', 'name' => 'Keeper', 'hash' => 'otherhash'],
]);
$result = $this->manager->revokeKey(TestHelper::createMockUser('heidi'), 'k1');
self::assertTrue($result);
$keys = $this->manager->loadKeys();
self::assertArrayNotHasKey('k1', $keys);
self::assertArrayHasKey('k2', $keys);
}
#[Test]
public function revoke_nonexistent_key_returns_false(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'ivan', 'name' => 'Existing', 'hash' => 'h'],
]);
$result = $this->manager->revokeKey(TestHelper::createMockUser('ivan'), 'nonexistent');
self::assertFalse($result);
// The existing key should remain untouched
self::assertArrayHasKey('k1', $this->manager->loadKeys());
}
#[Test]
public function revoke_does_not_remove_another_users_key(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'owner', 'name' => 'Owned', 'hash' => 'h'],
]);
// A different user attempting to revoke a key they don't own fails.
$result = $this->manager->revokeKey(TestHelper::createMockUser('attacker'), 'k1');
self::assertFalse($result);
self::assertArrayHasKey('k1', $this->manager->loadKeys());
}
#[Test]
public function multiple_keys_can_be_generated_for_same_user(): void
{
$user = TestHelper::createMockUser('judy');
$first = $this->manager->generateKey($user, 'First Key');
$second = $this->manager->generateKey($user, 'Second Key');
self::assertNotSame($first['key'], $second['key']);
self::assertNotSame($first['id'], $second['id']);
$keys = $this->manager->loadKeys();
self::assertCount(2, $keys);
self::assertSame('judy', $keys[$first['id']]['username']);
self::assertSame('judy', $keys[$second['id']]['username']);
}
#[Test]
public function touch_key_updates_last_used(): void
{
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'kate',
'name' => 'Touch Test',
'hash' => 'somehash',
'last_used' => null,
],
]);
$this->manager->touchKey('k1');
$keys = $this->manager->loadKeys();
self::assertNotNull($keys['k1']['last_used']);
self::assertEqualsWithDelta(time(), $keys['k1']['last_used'], 2);
}
#[Test]
public function find_key_matches_raw_key_against_central_store(): void
{
$rawKey = 'grav_find_me_raw_value_0123456789';
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'leo',
'name' => 'Findable',
'hash' => hash('sha256', $rawKey),
],
]);
$match = $this->manager->findKey($rawKey);
self::assertNotNull($match);
self::assertSame('k1', $match['key_id']);
self::assertSame('leo', $match['username']);
self::assertNull($this->manager->findKey('grav_not_a_real_key'));
}
}
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Auth;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Tests for the JwtAuthenticator.
*
* We subclass JwtAuthenticator to override getSecret() and getRevokedTokensFile()
* so the tests run without a full Grav file system.
*/
#[CoversClass(JwtAuthenticator::class)]
class JwtAuthenticatorTest extends TestCase
{
private const SECRET = 'test-jwt-secret-key-at-least-32-chars-long';
private const ALGORITHM = 'HS256';
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_jwt_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 returns_null_when_no_bearer_token(): void
{
$authenticator = $this->buildAuthenticator([]);
$request = TestHelper::createMockRequest();
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_with_non_bearer_authorization(): void
{
$authenticator = $this->buildAuthenticator([]);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Basic dXNlcjpwYXNz'],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function authenticates_valid_access_token(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator(['alice' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'alice',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function authenticates_via_x_api_token_bare_jwt(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator(['alice' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'alice',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['X-API-Token' => $token],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function authenticates_via_x_api_token_with_bearer_prefix(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator(['alice' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'alice',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['X-API-Token' => 'Bearer ' . $token],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function x_api_token_takes_precedence_over_authorization(): void
{
$alice = TestHelper::createMockUser('alice');
$bob = TestHelper::createMockUser('bob');
$authenticator = $this->buildAuthenticator(['alice' => $alice, 'bob' => $bob]);
$aliceToken = JWT::encode([
'iss' => 'grav-api', 'sub' => 'alice', 'iat' => time(),
'exp' => time() + 3600, 'type' => 'access',
], self::SECRET, self::ALGORITHM);
$bobToken = JWT::encode([
'iss' => 'grav-api', 'sub' => 'bob', 'iat' => time(),
'exp' => time() + 3600, 'type' => 'access',
], self::SECRET, self::ALGORITHM);
// X-API-Token carries Alice's JWT; Authorization carries Bob's.
// Custom header wins (FPM-stripping hosts may drop Authorization
// silently, so we want the survivable channel to be authoritative).
$request = TestHelper::createMockRequest(
headers: [
'X-API-Token' => $aliceToken,
'Authorization' => 'Bearer ' . $bobToken,
],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function rejects_expired_token(): void
{
$user = TestHelper::createMockUser('bob');
$authenticator = $this->buildAuthenticator(['bob' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'bob',
'iat' => time() - 7200,
'exp' => time() - 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function rejects_refresh_token_as_access(): void
{
$user = TestHelper::createMockUser('carol');
$authenticator = $this->buildAuthenticator(['carol' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'carol',
'iat' => time(),
'exp' => time() + 604800,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)),
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
self::assertNull($authenticator->authenticate($request), 'Refresh tokens must not authenticate as access tokens');
}
#[Test]
public function rejects_nonexistent_user(): void
{
$authenticator = $this->buildAuthenticator([]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'ghost',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function generate_access_token_is_valid(): void
{
$user = TestHelper::createMockUser('dave');
$authenticator = $this->buildAuthenticator(['dave' => $user]);
$token = $authenticator->generateAccessToken($user);
self::assertNotEmpty($token);
$decoded = JWT::decode($token, new Key(self::SECRET, self::ALGORITHM));
self::assertSame('grav-api', $decoded->iss);
self::assertSame('dave', $decoded->sub);
self::assertSame('access', $decoded->type);
self::assertGreaterThan(time(), $decoded->exp);
}
#[Test]
public function generate_refresh_token_is_valid(): void
{
$user = TestHelper::createMockUser('eve');
$authenticator = $this->buildAuthenticator(['eve' => $user]);
$token = $authenticator->generateRefreshToken($user);
self::assertNotEmpty($token);
$decoded = JWT::decode($token, new Key(self::SECRET, self::ALGORITHM));
self::assertSame('grav-api', $decoded->iss);
self::assertSame('eve', $decoded->sub);
self::assertSame('refresh', $decoded->type);
self::assertNotEmpty($decoded->jti);
self::assertGreaterThan(time(), $decoded->exp);
}
#[Test]
public function refresh_token_validation(): void
{
$user = TestHelper::createMockUser('frank');
$authenticator = $this->buildAuthenticator(['frank' => $user]);
$refreshToken = $authenticator->generateRefreshToken($user);
$result = $authenticator->validateRefreshToken($refreshToken);
self::assertNotNull($result);
self::assertSame('frank', $result->username);
}
#[Test]
public function refresh_token_validation_rejects_access_token(): void
{
$user = TestHelper::createMockUser('grace');
$authenticator = $this->buildAuthenticator(['grace' => $user]);
$accessToken = $authenticator->generateAccessToken($user);
$result = $authenticator->validateRefreshToken($accessToken);
self::assertNull($result, 'Access tokens must not be accepted as refresh tokens');
}
#[Test]
public function revoke_token(): void
{
$user = TestHelper::createMockUser('heidi');
$authenticator = $this->buildAuthenticator(['heidi' => $user]);
$refreshToken = $authenticator->generateRefreshToken($user);
// Token should be valid before revocation
self::assertNotNull($authenticator->validateRefreshToken($refreshToken));
// Revoke it
$revoked = $authenticator->revokeToken($refreshToken);
self::assertTrue($revoked);
// Token should be rejected after revocation
self::assertNull($authenticator->validateRefreshToken($refreshToken));
}
#[Test]
public function revoke_access_token_returns_false(): void
{
$user = TestHelper::createMockUser('ivan');
$authenticator = $this->buildAuthenticator(['ivan' => $user]);
$accessToken = $authenticator->generateAccessToken($user);
// Access tokens have no jti, so revocation should return false
$result = $authenticator->revokeToken($accessToken);
self::assertFalse($result);
}
/**
* Build a testable JwtAuthenticator subclass that doesn't depend on the Grav locator.
*/
private function buildAuthenticator(array $users): JwtAuthenticator
{
$accounts = TestHelper::createMockAccounts($users);
$grav = TestHelper::createMockGrav(['accounts' => $accounts]);
$config = TestHelper::createMockConfig([
'plugins' => ['api' => ['auth' => [
'jwt_secret' => self::SECRET,
'jwt_algorithm' => self::ALGORITHM,
'jwt_expiry' => 3600,
'jwt_refresh_expiry' => 604800,
]]],
]);
$tempDir = $this->tempDir;
return new class ($grav, $config, $tempDir) extends JwtAuthenticator {
public function __construct(
Grav $grav,
Config $config,
private readonly string $dir,
) {
parent::__construct($grav, $config);
}
protected function getSecret(): string
{
return $this->config->get('plugins.api.auth.jwt_secret');
}
protected function getRevokedTokensFile(): string
{
return $this->dir . '/revoked_tokens.json';
}
};
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Plugin\Api\Controllers\BlueprintController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Unit coverage for BlueprintController::serializeFields() option normalization.
*
* With strict YAML (system.strict_mode.yaml_compat: false) Grav uses the native
* YAML 1.1 parser, which reads unquoted Yes/No/On/Off option labels as booleans.
* Those booleans must come back to the client as readable Yes/No so blueprints
* authored for Grav 1.7's compat parser keep rendering correctly without the
* author quoting every label. See getgrav/grav-plugin-admin2#36.
*/
#[CoversClass(BlueprintController::class)]
class BlueprintBooleanOptionsTest extends TestCase
{
/**
* @param array<string, mixed> $fields
* @return array<int, array<string, mixed>>
*/
private function serialize(array $fields): array
{
$ref = new ReflectionClass(BlueprintController::class);
$instance = $ref->newInstanceWithoutConstructor();
return $ref->getMethod('serializeFields')->invoke($instance, $fields);
}
#[Test]
public function boolean_option_labels_become_yes_no(): void
{
// What native YAML 1.1 produces from `'0': No` / `'1': Yes`.
$fields = $this->serialize([
'chromeless.enabled' => [
'type' => 'toggle',
'options' => ['0' => false, '1' => true],
'validate' => ['type' => 'bool'],
],
]);
$options = $fields[0]['options'];
$this->assertSame([
['value' => '0', 'label' => 'No'],
['value' => '1', 'label' => 'Yes'],
], $options);
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Controllers\ConfigController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Unit coverage for AbstractApiController::coerceForValidation() — the bool↔int
* leniency that lets blueprint validation accept Grav's own loosely-typed
* values (e.g. system.errors.display ships as a bool against a `type: int`
* rule; Grav's runtime treats true/false as 1/0). See getgrav/grav-plugin-admin2#30.
*/
#[CoversClass(AbstractApiController::class)]
class BlueprintCoercionTest extends TestCase
{
private function coerce(mixed $value, array $field): mixed
{
$ref = new ReflectionClass(AbstractApiController::class);
$instance = (new ReflectionClass(ConfigController::class))->newInstanceWithoutConstructor();
return $ref->getMethod('coerceForValidation')->invoke($instance, $value, $field);
}
public static function intTypedFields(): array
{
return [
'validate.type int' => [['type' => 'select', 'validate' => ['type' => 'int']]],
'validate.type number' => [['type' => 'text', 'validate' => ['type' => 'number']]],
'field type number' => [['type' => 'number']],
];
}
#[Test]
#[DataProvider('intTypedFields')]
public function booleans_become_ints_for_int_typed_fields(array $field): void
{
$this->assertSame(1, $this->coerce(true, $field));
$this->assertSame(0, $this->coerce(false, $field));
}
#[Test]
public function int_typed_field_leaves_non_booleans_untouched(): void
{
$field = ['type' => 'select', 'validate' => ['type' => 'int']];
$this->assertSame(1, $this->coerce(1, $field));
$this->assertSame('1', $this->coerce('1', $field));
$this->assertSame(-1, $this->coerce(-1, $field));
}
#[Test]
public function non_int_fields_leave_booleans_untouched(): void
{
// A genuine boolean field (toggle/bool) must keep its bool — only
// int/number-typed fields get the leniency.
$this->assertTrue($this->coerce(true, ['type' => 'toggle', 'validate' => ['type' => 'bool']]));
$this->assertSame('text', $this->coerce('text', ['type' => 'text']));
$this->assertTrue($this->coerce(true, ['type' => 'text']));
}
}
@@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Framework\Acl\Permissions;
use Grav\Plugin\Api\Controllers\BlueprintFilesController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
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\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* Coverage for the read-only blueprint-files browse endpoint. Mirrors the
* stream-resolution and security guarantees of BlueprintUploadController but
* for `folder:` (read) rather than `destination:` (write).
*/
#[CoversClass(BlueprintFilesController::class)]
class BlueprintFilesControllerTest extends TestCase
{
private string $tempDir;
private Config $config;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_blueprint_files_' . uniqid();
mkdir($this->tempDir . '/accounts', 0775, true);
mkdir($this->tempDir . '/media', 0775, true);
mkdir($this->tempDir . '/cache', 0775, true);
mkdir($this->tempDir . '/plugins/api', 0775, true);
mkdir($this->tempDir . '/themes/quark/images', 0775, true);
$this->config = new Config([
'system' => ['pages' => ['theme' => 'quark']],
'plugins' => ['api' => ['route' => '/api', 'version_prefix' => 'v1']],
]);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
/**
* Minimal real PNG bytes so mime_content_type() reports image/png.
*/
private const PNG_BYTES = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82";
#[Test]
public function user_media_stream_resolves_and_lists_files(): void
{
file_put_contents($this->tempDir . '/media/cover.png', self::PNG_BYTES);
file_put_contents($this->tempDir . '/media/doc.pdf', '%PDF-1.4');
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'user://media', ''));
self::assertSame(200, $response->getStatusCode());
$body = $this->jsonBody($response);
$names = array_column($body['data'], 'filename');
sort($names);
self::assertSame(['cover.png', 'doc.pdf'], $names);
self::assertTrue($body['meta']['exists']);
}
#[Test]
public function theme_stream_resolves(): void
{
file_put_contents($this->tempDir . '/themes/quark/images/logo.png', "\x89PNG");
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'theme://images', 'themes/quark'));
self::assertSame(200, $response->getStatusCode());
$body = $this->jsonBody($response);
self::assertSame(['logo.png'], array_column($body['data'], 'filename'));
}
#[Test]
public function self_scope_resolves_for_plugin(): void
{
mkdir($this->tempDir . '/plugins/api/assets', 0775, true);
file_put_contents($this->tempDir . '/plugins/api/assets/icon.svg', '<svg/>');
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'self@:assets', 'plugins/api'));
self::assertSame(200, $response->getStatusCode());
$body = $this->jsonBody($response);
self::assertSame(['icon.svg'], array_column($body['data'], 'filename'));
}
#[Test]
public function self_literal_returns_page_media_sentinel(): void
{
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', '@self', ''));
self::assertSame(422, $response->getStatusCode());
$body = $this->jsonBody($response);
self::assertSame('PAGE_MEDIA_ONLY', $body['data']['error']);
}
#[Test]
public function self_at_literal_returns_page_media_sentinel(): void
{
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'self@', ''));
self::assertSame(422, $response->getStatusCode());
$body = $this->jsonBody($response);
self::assertSame('PAGE_MEDIA_ONLY', $body['data']['error']);
}
#[Test]
public function traversal_in_relative_path_is_rejected(): void
{
$controller = $this->buildController('alice');
$this->expectException(ValidationException::class);
$controller->list($this->listRequest('alice', '../etc', ''));
}
#[Test]
public function traversal_in_stream_path_is_rejected(): void
{
$controller = $this->buildController('alice');
$this->expectException(ValidationException::class);
$controller->list($this->listRequest('alice', 'user://media/../../etc', ''));
}
#[Test]
public function missing_folder_param_is_rejected(): void
{
$controller = $this->buildController('alice');
$this->expectException(ValidationException::class);
$controller->list($this->listRequest('alice', '', ''));
}
#[Test]
public function non_existent_folder_returns_empty_with_exists_false(): void
{
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'user://does-not-exist', ''));
self::assertSame(200, $response->getStatusCode());
$body = $this->jsonBody($response);
self::assertSame([], $body['data']);
self::assertFalse($body['meta']['exists']);
}
#[Test]
public function accept_extension_filter_applies(): void
{
file_put_contents($this->tempDir . '/media/a.png', self::PNG_BYTES);
file_put_contents($this->tempDir . '/media/b.pdf', '%PDF');
file_put_contents($this->tempDir . '/media/c.zip', 'PK');
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'user://media', '', '.png,.pdf'));
$names = array_column($this->jsonBody($response)['data'], 'filename');
sort($names);
self::assertSame(['a.png', 'b.pdf'], $names);
}
#[Test]
public function accept_mime_wildcard_filter_applies(): void
{
file_put_contents($this->tempDir . '/media/a.png', self::PNG_BYTES);
file_put_contents($this->tempDir . '/media/b.pdf', '%PDF');
$controller = $this->buildController('alice');
$response = $controller->list($this->listRequest('alice', 'user://media', '', 'image/*'));
$names = array_column($this->jsonBody($response)['data'], 'filename');
self::assertSame(['a.png'], $names);
}
#[Test]
public function users_scope_cross_user_is_forbidden_for_low_priv_caller(): void
{
$controller = $this->buildController('alice');
$this->expectException(ForbiddenException::class);
$controller->list($this->listRequest('alice', 'self@:', 'users/bob'));
}
private function buildController(string $username): BlueprintFilesController
{
$user = TestHelper::createMockUser($username, [
'access' => ['api' => ['access' => true, 'media' => ['read' => true]]],
]);
TestHelper::createMockGrav([
'config' => $this->config,
'locator' => new BlueprintFilesTestLocator($this->tempDir),
'uri' => new class {
public function rootUrl(): string { return 'https://example.test'; }
},
'permissions' => new Permissions(),
'accounts' => TestHelper::createMockAccounts([$username => $user]),
]);
// Subclass that swaps the Grav Media iterator for a filesystem scan
// returning duck-typed Medium objects. Real Grav Media needs streams,
// image cache, taxonomies, and other framework state we don't want
// to spin up for a unit test.
return new class (\Grav\Common\Grav::instance(), $this->config) extends BlueprintFilesController {
protected function iterateMedia(string $absoluteDir): iterable
{
foreach (scandir($absoluteDir) ?: [] as $name) {
if ($name === '.' || $name === '..') continue;
$full = $absoluteDir . '/' . $name;
if (!is_file($full)) continue;
yield $name => new BlueprintFilesTestMedium($name, $full);
}
}
};
}
private function listRequest(
string $username,
string $folder,
string $scope,
string $accept = '',
): ServerRequestInterface {
$user = TestHelper::createMockUser($username, [
'access' => ['api' => ['access' => true, 'media' => ['read' => true]]],
]);
return new BlueprintFilesTestRequest(
['folder' => $folder, 'scope' => $scope, 'accept' => $accept],
['api_user' => $user],
);
}
private function jsonBody(ResponseInterface $r): array
{
return json_decode((string) $r->getBody(), true) ?? [];
}
private function rmrf(string $path): void
{
if (is_link($path) || is_file($path)) {
unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $item) {
if ($item === '.' || $item === '..') continue;
$this->rmrf($path . '/' . $item);
}
rmdir($path);
}
}
final class BlueprintFilesTestMedium
{
public function __construct(
public readonly string $filename,
private readonly string $absolutePath,
) {}
public function get(string $key, mixed $default = null): mixed
{
if ($key === 'mime') return mime_content_type($this->absolutePath) ?: 'application/octet-stream';
if ($key === 'size') return filesize($this->absolutePath) ?: 0;
return $default;
}
public function url(): string { return 'file://' . $this->absolutePath; }
public function path(): string { return $this->absolutePath; }
public function modified(): int { return filemtime($this->absolutePath) ?: 0; }
}
final class BlueprintFilesTestLocator
{
public function __construct(private readonly string $base) {}
public function isStream(string $path): bool
{
return preg_match('#^[A-Za-z][A-Za-z0-9+.-]*://#', $path) === 1;
}
public function findResource(string $uri, bool $absolute = false, bool $first = false): string|false
{
// Modeled on Grav's UniformResourceLocator: the third arg is `$first`
// (return first match), not "create dir". Read-only browse must not
// silently mkdir non-existent destinations.
$map = [
'user://' => $this->base,
'cache://' => $this->base . '/cache',
'account://' => $this->base . '/accounts',
'plugins://' => $this->base . '/plugins',
'themes://' => $this->base . '/themes',
'theme://' => $this->base . '/themes/quark',
'image://' => $this->base . '/images',
'asset://' => $this->base . '/assets',
'page://' => $this->base . '/pages',
];
foreach ($map as $prefix => $root) {
if (str_starts_with($uri, $prefix)) {
return rtrim($root . '/' . ltrim(substr($uri, strlen($prefix)), '/'), '/');
}
}
return false;
}
}
final class BlueprintFilesTestRequest implements ServerRequestInterface
{
public function __construct(
private readonly array $queryParams,
private array $attributes,
) {}
public function getQueryParams(): array { return $this->queryParams; }
public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; }
public function withAttribute(string $name, mixed $value): static { $clone = clone $this; $clone->attributes[$name] = $value; return $clone; }
public function withoutAttribute(string $name): static { $clone = clone $this; unset($clone->attributes[$name]); return $clone; }
public function getAttributes(): array { return $this->attributes; }
public function getMethod(): string { return 'GET'; }
public function withMethod(string $method): static { return clone $this; }
public function getParsedBody(): mixed { return null; }
public function getUploadedFiles(): array { return []; }
public function getBody(): StreamInterface { return new BlueprintFilesTestStream(''); }
public function getHeaderLine(string $name): string { return ''; }
public function getHeader(string $name): array { return []; }
public function getHeaders(): array { return []; }
public function hasHeader(string $name): bool { return false; }
public function getRequestTarget(): string { return '/api/v1/blueprint-files'; }
public function withRequestTarget(string $requestTarget): static { return clone $this; }
public function getUri(): UriInterface { return new BlueprintFilesTestUri(); }
public function withUri(UriInterface $uri, bool $preserveHost = false): static { return clone $this; }
public function getProtocolVersion(): string { return '1.1'; }
public function withProtocolVersion(string $version): static { return clone $this; }
public function withHeader(string $name, $value): static { return clone $this; }
public function withAddedHeader(string $name, $value): static { return clone $this; }
public function withoutHeader(string $name): static { return clone $this; }
public function withBody(StreamInterface $body): static { return clone $this; }
public function getServerParams(): array { return []; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $cookies): static { return clone $this; }
public function withQueryParams(array $query): static { return clone $this; }
public function withUploadedFiles(array $uploadedFiles): static { return clone $this; }
public function withParsedBody($data): static { return clone $this; }
}
final class BlueprintFilesTestStream implements StreamInterface
{
public function __construct(private readonly string $contents) {}
public function __toString(): string { return $this->contents; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->contents); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->contents; }
public function getContents(): string { return $this->contents; }
public function getMetadata(?string $key = null): mixed { return null; }
}
final class BlueprintFilesTestUri implements UriInterface
{
public function getScheme(): string { return 'https'; }
public function getAuthority(): string { return 'example.test'; }
public function getUserInfo(): string { return ''; }
public function getHost(): string { return 'example.test'; }
public function getPort(): ?int { return null; }
public function getPath(): string { return '/api/v1/blueprint-files'; }
public function getQuery(): string { return ''; }
public function getFragment(): string { return ''; }
public function withScheme(string $scheme): static { return clone $this; }
public function withUserInfo(string $user, ?string $password = null): static { return clone $this; }
public function withHost(string $host): static { return clone $this; }
public function withPort(?int $port): static { return clone $this; }
public function withPath(string $path): static { return clone $this; }
public function withQuery(string $query): static { return clone $this; }
public function withFragment(string $fragment): static { return clone $this; }
public function __toString(): string { return 'https://example.test/api/v1/blueprint-files'; }
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Plugin\Api\Controllers\BlueprintController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Unit coverage for BlueprintController::serializeFields() leading-dot
* resolution. A child keyed `.optionA` uses relative naming: it binds under
* its container's own name rather than the (transparent) layout prefix, so a
* field inside a section named `header.sectionName` resolves to the full
* dotted name `header.sectionName.optionA` and saves nested. This restores the
* Grav 1.x admin-classic behaviour that broke in Admin 2.0. See getgrav/grav#4120.
*/
#[CoversClass(BlueprintController::class)]
class BlueprintLeadingDotTest extends TestCase
{
/**
* @param array<string, mixed> $fields
* @return array<int, array<string, mixed>>
*/
private function serialize(array $fields): array
{
$ref = new ReflectionClass(BlueprintController::class);
$instance = $ref->newInstanceWithoutConstructor();
return $ref->getMethod('serializeFields')->invoke($instance, $fields);
}
#[Test]
public function leading_dot_child_resolves_against_its_section_name(): void
{
$fields = $this->serialize([
'header.sectionName' => [
'type' => 'section',
'fields' => [
'.optionA' => ['type' => 'text'],
'.optionB' => ['type' => 'text'],
],
],
]);
$children = $fields[0]['fields'];
$this->assertSame('header.sectionName.optionA', $children[0]['name']);
$this->assertSame('header.sectionName.optionB', $children[1]['name']);
}
#[Test]
public function plain_child_of_a_section_stays_transparent(): void
{
// A section without dotted naming is purely visual: a plain child binds
// to the top level, NOT namespaced under the section.
$fields = $this->serialize([
'mysection' => [
'type' => 'section',
'fields' => [
'optionA' => ['type' => 'text'],
],
],
]);
$this->assertSame('optionA', $fields[0]['fields'][0]['name']);
}
#[Test]
public function nested_sections_chain_the_resolved_name(): void
{
$fields = $this->serialize([
'header.outer' => [
'type' => 'section',
'fields' => [
'.inner' => [
'type' => 'section',
'fields' => [
'.optionA' => ['type' => 'text'],
],
],
],
],
]);
$inner = $fields[0]['fields'][0];
$this->assertSame('header.outer.inner', $inner['name']);
$this->assertSame('header.outer.inner.optionA', $inner['fields'][0]['name']);
}
#[Test]
public function non_layout_container_prefixes_children_with_its_path(): void
{
// Regression guard: a non-layout container with nested fields keeps the
// existing behaviour of prefixing plain children with its own path.
$fields = $this->serialize([
'parent' => [
'type' => 'list',
'fields' => [
'child' => ['type' => 'text'],
],
],
]);
$this->assertSame('parent.child', $fields[0]['fields'][0]['name']);
}
#[Test]
public function leading_dot_without_a_container_drops_the_dot(): void
{
// A leading-dot field with no parent context falls back to the plain
// name (mirrors the form plugin's plain_name behaviour).
$fields = $this->serialize([
'.orphan' => ['type' => 'text'],
]);
$this->assertSame('orphan', $fields[0]['name']);
}
}
@@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Framework\Acl\Permissions;
use Grav\Plugin\Api\Controllers\BlueprintUploadController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
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\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\UriInterface;
/**
* Regression coverage for GHSA-6xx2-m8wv-756h and adjacent file-write risks.
*/
#[CoversClass(BlueprintUploadController::class)]
class BlueprintUploadControllerSecurityTest extends TestCase
{
private string $tempDir;
private Config $config;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_blueprint_upload_' . uniqid();
mkdir($this->tempDir . '/accounts', 0775, true);
mkdir($this->tempDir . '/config', 0775, true);
mkdir($this->tempDir . '/media', 0775, true);
mkdir($this->tempDir . '/plugins/api', 0775, true);
mkdir($this->tempDir . '/themes/quark', 0775, true);
$this->config = new Config([
'system' => ['pages' => ['theme' => 'quark']],
'security' => ['uploads_dangerous_extensions' => ['php', 'phtml', 'phar', 'js', 'html']],
'plugins' => ['api' => ['route' => '/api', 'version_prefix' => 'v1']],
]);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
#[Test]
public function account_yaml_upload_is_rejected_for_media_write_user(): void
{
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$request = $this->uploadRequest('alice', 'self@:', 'users/alice', 'evil.yaml', "access:\n api:\n super: true\n");
$this->expectException(ValidationException::class);
try {
$controller->upload($request);
} finally {
self::assertFileDoesNotExist($this->tempDir . '/accounts/evil.yaml');
}
}
#[Test]
public function account_scope_accepts_avatar_image_for_self(): void
{
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$request = $this->uploadRequest('alice', 'self@:', 'users/alice', 'avatar.png', 'png');
$response = $controller->upload($request);
self::assertSame(201, $response->getStatusCode());
self::assertFileExists($this->tempDir . '/accounts/avatar.png');
$payload = json_decode((string) $response->getBody(), true);
self::assertSame('user/accounts/avatar.png', $payload['data'][0]['path'] ?? null);
}
#[Test]
public function account_scope_rejects_cross_user_upload_without_users_write(): void
{
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$request = $this->uploadRequest('alice', 'self@:', 'users/bob', 'avatar.png', 'png');
$this->expectException(ForbiddenException::class);
$controller->upload($request);
}
#[Test]
public function config_directory_upload_is_rejected_even_for_images(): void
{
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$request = $this->uploadRequest('alice', 'user://config/images', 'plugins/api', 'logo.png', 'png');
$this->expectException(ForbiddenException::class);
try {
$controller->upload($request);
} finally {
self::assertFileDoesNotExist($this->tempDir . '/config/images/logo.png');
}
}
#[Test]
public function config_bearing_extension_is_rejected_outside_config_directories(): void
{
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$request = $this->uploadRequest('alice', 'user://media', 'plugins/api', 'settings.yaml', 'enabled: true');
$this->expectException(ValidationException::class);
try {
$controller->upload($request);
} finally {
self::assertFileDoesNotExist($this->tempDir . '/media/settings.yaml');
}
}
#[Test]
public function delete_rejects_account_yaml_and_leaves_file_intact(): void
{
file_put_contents($this->tempDir . '/accounts/admin.yaml', "access:\n api:\n super: true\n");
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$this->expectException(ForbiddenException::class);
try {
$controller->delete($this->deleteRequest('alice', 'user/accounts/admin.yaml'));
} finally {
self::assertFileExists($this->tempDir . '/accounts/admin.yaml');
}
}
#[Test]
public function delete_rejects_config_bearing_extension_outside_accounts(): void
{
file_put_contents($this->tempDir . '/plugins/api/blueprints.yaml', 'name: API');
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$this->expectException(ValidationException::class);
try {
$controller->delete($this->deleteRequest('alice', 'user/plugins/api/blueprints.yaml'));
} finally {
self::assertFileExists($this->tempDir . '/plugins/api/blueprints.yaml');
}
}
#[Test]
public function symlinked_theme_upload_remains_allowed_for_safe_image(): void
{
$external = $this->tempDir . '-theme';
mkdir($external . '/images', 0775, true);
$this->rmrf($this->tempDir . '/themes/quark');
symlink($external, $this->tempDir . '/themes/quark');
$controller = $this->buildController('alice', ['media' => ['write' => true]]);
$response = $controller->upload(
$this->uploadRequest('alice', 'themes://quark/images', 'themes/quark', 'logo.png', 'png')
);
self::assertSame(201, $response->getStatusCode());
self::assertFileExists($external . '/images/logo.png');
$this->rmrf($external);
}
private function buildController(string $username, array $apiAccess): BlueprintUploadController
{
$user = TestHelper::createMockUser($username, [
'access' => ['api' => ['access' => true] + $apiAccess],
]);
TestHelper::createMockGrav([
'config' => $this->config,
'locator' => new BlueprintUploadTestLocator($this->tempDir),
'uri' => new class {
public function rootUrl(): string { return 'https://example.test'; }
},
'permissions' => new Permissions(),
'accounts' => TestHelper::createMockAccounts([$username => $user]),
]);
return new BlueprintUploadController(\Grav\Common\Grav::instance(), $this->config);
}
private function uploadRequest(
string $username,
string $destination,
string $scope,
string $filename,
string $contents,
): ServerRequestInterface {
$user = TestHelper::createMockUser($username, [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
return new BlueprintUploadTestRequest(
'POST',
['destination' => $destination, 'scope' => $scope],
['file' => new BlueprintUploadTestFile($filename, $contents)],
['api_user' => $user],
);
}
private function deleteRequest(string $username, string $path): ServerRequestInterface
{
$user = TestHelper::createMockUser($username, [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
return new BlueprintUploadTestRequest(
'DELETE',
['path' => $path],
[],
['api_user' => $user, 'json_body' => ['path' => $path]],
);
}
private function rmrf(string $path): void
{
if (is_link($path) || is_file($path)) {
unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $item) {
if ($item === '.' || $item === '..') continue;
$this->rmrf($path . '/' . $item);
}
rmdir($path);
}
}
final class BlueprintUploadTestLocator
{
public function __construct(private readonly string $base) {}
public function isStream(string $path): bool
{
return preg_match('#^[A-Za-z][A-Za-z0-9+.-]*://#', $path) === 1;
}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): string|false
{
$map = [
'user://' => $this->base,
'account://' => $this->base . '/accounts',
'plugins://' => $this->base . '/plugins',
'themes://' => $this->base . '/themes',
'theme://' => $this->base . '/themes/quark',
'image://' => $this->base . '/images',
'asset://' => $this->base . '/assets',
'page://' => $this->base . '/pages',
];
foreach ($map as $prefix => $root) {
if (str_starts_with($uri, $prefix)) {
$path = rtrim($root . '/' . ltrim(substr($uri, strlen($prefix)), '/'), '/');
if ($createDir && !is_dir($path)) {
mkdir($path, 0775, true);
}
return $path;
}
}
return false;
}
}
final class BlueprintUploadTestFile implements UploadedFileInterface
{
private readonly string $source;
private bool $moved = false;
public function __construct(
private readonly string $filename,
string $contents,
) {
$this->source = tempnam(sys_get_temp_dir(), 'grav_api_upload_') ?: '';
file_put_contents($this->source, $contents);
}
public function getStream(): StreamInterface { throw new \RuntimeException('Not needed in tests.'); }
public function moveTo(string $targetPath): void
{
if ($this->moved) {
throw new \RuntimeException('File already moved.');
}
$dir = dirname($targetPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
rename($this->source, $targetPath);
$this->moved = true;
}
public function getSize(): ?int { return file_exists($this->source) ? filesize($this->source) : null; }
public function getError(): int { return UPLOAD_ERR_OK; }
public function getClientFilename(): ?string { return $this->filename; }
public function getClientMediaType(): ?string { return 'application/octet-stream'; }
}
final class BlueprintUploadTestRequest implements ServerRequestInterface
{
public function __construct(
private readonly string $method,
private readonly array $parsedBody,
private readonly array $uploadedFiles,
private array $attributes,
) {}
public function getParsedBody(): mixed { return $this->parsedBody; }
public function getUploadedFiles(): array { return $this->uploadedFiles; }
public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; }
public function withAttribute(string $name, mixed $value): static { $clone = clone $this; $clone->attributes[$name] = $value; return $clone; }
public function withoutAttribute(string $name): static { $clone = clone $this; unset($clone->attributes[$name]); return $clone; }
public function getAttributes(): array { return $this->attributes; }
public function getMethod(): string { return $this->method; }
public function withMethod(string $method): static { return clone $this; }
public function getQueryParams(): array { return []; }
public function getBody(): StreamInterface { return new BlueprintUploadTestStream(json_encode($this->parsedBody)); }
public function getHeaderLine(string $name): string { return ''; }
public function getHeader(string $name): array { return []; }
public function getHeaders(): array { return []; }
public function hasHeader(string $name): bool { return false; }
public function getRequestTarget(): string { return '/api/v1/blueprint-upload'; }
public function withRequestTarget(string $requestTarget): static { return clone $this; }
public function getUri(): UriInterface { return new BlueprintUploadTestUri(); }
public function withUri(UriInterface $uri, bool $preserveHost = false): static { return clone $this; }
public function getProtocolVersion(): string { return '1.1'; }
public function withProtocolVersion(string $version): static { return clone $this; }
public function withHeader(string $name, $value): static { return clone $this; }
public function withAddedHeader(string $name, $value): static { return clone $this; }
public function withoutHeader(string $name): static { return clone $this; }
public function withBody(StreamInterface $body): static { return clone $this; }
public function getServerParams(): array { return []; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $cookies): static { return clone $this; }
public function withQueryParams(array $query): static { return clone $this; }
public function withUploadedFiles(array $uploadedFiles): static { return clone $this; }
public function withParsedBody($data): static { return clone $this; }
}
final class BlueprintUploadTestStream implements StreamInterface
{
public function __construct(private readonly string $contents) {}
public function __toString(): string { return $this->contents; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->contents); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->contents; }
public function getContents(): string { return $this->contents; }
public function getMetadata(?string $key = null): mixed { return null; }
}
final class BlueprintUploadTestUri implements UriInterface
{
public function getScheme(): string { return 'https'; }
public function getAuthority(): string { return 'example.test'; }
public function getUserInfo(): string { return ''; }
public function getHost(): string { return 'example.test'; }
public function getPort(): ?int { return null; }
public function getPath(): string { return '/api/v1/blueprint-upload'; }
public function getQuery(): string { return ''; }
public function getFragment(): string { return ''; }
public function withScheme(string $scheme): static { return clone $this; }
public function withUserInfo(string $user, ?string $password = null): static { return clone $this; }
public function withHost(string $host): static { return clone $this; }
public function withPort(?int $port): static { return clone $this; }
public function withPath(string $path): static { return clone $this; }
public function withQuery(string $query): static { return clone $this; }
public function withFragment(string $fragment): static { return clone $this; }
public function __toString(): string { return 'https://example.test/api/v1/blueprint-upload'; }
}
@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Framework\Acl\Permissions;
use Grav\Plugin\Api\Controllers\BlueprintUploadController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
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;
use Psr\Http\Message\UploadedFileInterface;
/**
* Regression tests for GHSA-6xx2-m8wv-756h.
*
* The advisory describes a privilege escalation where a low-priv API user
* holding `api.media.write` could POST to /blueprint-upload with
* `destination=self@:` + `scope=users/anything` + a YAML file, drop
* `pwned.yaml` straight into `user/accounts/`, and log in as a brand-new
* super-admin. These tests pin the four layers that close the chain:
*
* 1. The `users/<x>` scope is gated to self-or-admin (no cross-user writes).
* 2. `user/accounts/` accepts image extensions only (avatars).
* 3. `user/config/` and `user/env/` reject every blueprint upload.
* 4. YAML/JSON/Twig and similar config formats are denied at any target.
*/
#[CoversClass(BlueprintUploadController::class)]
class BlueprintUploadPrivescTest extends TestCase
{
private string $tempDir;
private string $userRoot;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_blueprint_upload_test_' . uniqid();
$this->userRoot = $this->tempDir . '/user';
@mkdir($this->userRoot . '/accounts', 0775, true);
@mkdir($this->userRoot . '/config', 0775, true);
@mkdir($this->userRoot . '/env/dev/config', 0775, true);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
private function buildController(): BlueprintUploadController
{
$userRoot = $this->userRoot;
$config = new Config([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
'pagination' => ['default_per_page' => 20, 'max_per_page' => 100],
]],
'security' => [
// A representative subset of Grav's default dangerous list. The
// GHSA-6xx2-m8wv-756h PoC relies on `.yaml` *not* being here —
// tests pin the per-endpoint denylist that closes that gap.
'uploads_dangerous_extensions' => ['php', 'phar', 'phtml', 'js', 'exe', 'html', 'htm'],
],
]);
$locator = new class ($userRoot) {
public function __construct(private string $userRoot) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): mixed
{
return match (true) {
$uri === 'user://' => $this->userRoot,
$uri === 'account://' => $this->userRoot . '/accounts',
str_starts_with($uri, 'user://') => $this->userRoot . '/' . substr($uri, 7),
default => false,
};
}
public function isStream(string $uri): bool
{
return (bool) preg_match('#^[a-z][a-z0-9+.\-]*://#i', $uri);
}
};
TestHelper::createMockGrav([
'config' => $config,
'locator' => $locator,
'permissions' => new Permissions(),
'uri' => new class { public function rootUrl(): string { return ''; } },
]);
return new BlueprintUploadController(\Grav\Common\Grav::instance(), $config);
}
/**
* @param array<string, string> $body
* @param array<string, UploadedFileInterface> $files
*/
private function makeRequest(
UserInterface $caller,
array $body,
array $files = [],
): ServerRequestInterface {
$request = TestHelper::createMockRequest(
method: 'POST',
path: '/api/v1/blueprint-upload',
attributes: ['api_user' => $caller, 'json_body' => $body],
);
// PSR-7 ServerRequestInterface expects getParsedBody() and
// getUploadedFiles() to drive the upload path; the TestHelper
// request stub returns null/[] for these. Wrap it.
return new class ($request, $body, $files) implements ServerRequestInterface {
public function __construct(
private readonly ServerRequestInterface $inner,
private readonly array $parsedBody,
private readonly array $uploadedFiles,
) {}
public function getParsedBody(): array { return $this->parsedBody; }
public function getUploadedFiles(): array { return $this->uploadedFiles; }
public function getMethod(): string { return $this->inner->getMethod(); }
public function getUri(): \Psr\Http\Message\UriInterface { return $this->inner->getUri(); }
public function getBody(): \Psr\Http\Message\StreamInterface { return $this->inner->getBody(); }
public function getQueryParams(): array { return $this->inner->getQueryParams(); }
public function getServerParams(): array { return $this->inner->getServerParams(); }
public function getHeaderLine(string $n): string { return $this->inner->getHeaderLine($n); }
public function getHeader(string $n): array { return $this->inner->getHeader($n); }
public function hasHeader(string $n): bool { return $this->inner->hasHeader($n); }
public function getHeaders(): array { return $this->inner->getHeaders(); }
public function getAttribute(string $n, mixed $d = null): mixed { return $this->inner->getAttribute($n, $d); }
public function withAttribute(string $n, mixed $v): static { return clone $this; }
public function getRequestTarget(): string { return $this->inner->getRequestTarget(); }
public function withRequestTarget(string $r): static { return clone $this; }
public function withMethod(string $m): static { return clone $this; }
public function withUri(\Psr\Http\Message\UriInterface $u, bool $p = false): static { return clone $this; }
public function getProtocolVersion(): string { return $this->inner->getProtocolVersion(); }
public function withProtocolVersion(string $v): static { return clone $this; }
public function withHeader(string $n, $v): static { return clone $this; }
public function withAddedHeader(string $n, $v): static { return clone $this; }
public function withoutHeader(string $n): static { return clone $this; }
public function withBody(\Psr\Http\Message\StreamInterface $b): static { return clone $this; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $c): static { return clone $this; }
public function withQueryParams(array $q): static { return clone $this; }
public function withUploadedFiles(array $u): static { return clone $this; }
public function withParsedBody($d): static { return clone $this; }
public function getAttributes(): array { return $this->inner->getAttributes(); }
public function withoutAttribute(string $n): static { return clone $this; }
};
}
/**
* Build a stub UploadedFile that records moveTo() destinations to a sink
* we can inspect from tests.
*/
private function makeUpload(string $clientFilename, string $content = 'fake'): UploadedFileInterface
{
return new class ($clientFilename, $content) implements UploadedFileInterface {
public ?string $movedTo = null;
public function __construct(
private readonly string $name,
private readonly string $body,
) {}
public function getStream(): \Psr\Http\Message\StreamInterface
{ throw new \RuntimeException('not used'); }
public function moveTo(string $targetPath): void
{
$this->movedTo = $targetPath;
file_put_contents($targetPath, $this->body);
}
public function getSize(): ?int { return strlen($this->body); }
public function getError(): int { return UPLOAD_ERR_OK; }
public function getClientFilename(): ?string { return $this->name; }
public function getClientMediaType(): ?string { return 'application/octet-stream'; }
};
}
// ------------------------------------------------------------------
// Layer 1: `users/<x>` scope is gated to self-or-admin.
// ------------------------------------------------------------------
#[Test]
public function ghsa_6xx2_self_at_users_other_is_forbidden_for_low_priv_caller(): void
{
// The exact PoC shape: caller has only api.media.write, and aims a
// `self@:` write at `users/<arbitrary>` to land in user/accounts/.
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'self@:',
'scope' => 'users/anything',
], ['file' => $this->makeUpload('pwned.yaml', "password: hunter2\naccess:\n api:\n super: true\n")]);
$this->expectException(ForbiddenException::class);
$this->expectExceptionMessageMatches('/users\/anything.*api\.users\.write/i');
$controller->upload($request);
}
#[Test]
public function users_scope_succeeds_for_caller_targeting_own_account(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'self@:',
'scope' => 'users/uploader',
], ['file' => $this->makeUpload('avatar.png', "\x89PNG\r\n\x1a\n")]);
$response = $controller->upload($request);
$this->assertSame(201, $response->getStatusCode());
$this->assertFileExists($this->userRoot . '/accounts/avatar.png');
}
#[Test]
public function users_scope_allows_admin_to_target_other_user(): void
{
$admin = TestHelper::createMockUser('admin', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true], 'users' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($admin, [
'destination' => 'self@:',
'scope' => 'users/someone-else',
], ['file' => $this->makeUpload('avatar.png', "\x89PNG\r\n\x1a\n")]);
$response = $controller->upload($request);
$this->assertSame(201, $response->getStatusCode());
}
// ------------------------------------------------------------------
// Layer 2: `user/accounts/` accepts image extensions only.
// ------------------------------------------------------------------
#[Test]
public function ghsa_6xx2_yaml_into_accounts_via_account_stream_is_rejected(): void
{
// Bypasses the scope check by using `account://` directly. Must still
// be blocked by the per-endpoint extension policy.
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'account://',
'scope' => '',
], ['file' => $this->makeUpload('pwned.yaml', "access:\n api:\n super: true\n")]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/(\.yaml.*not allowed|user\/accounts)/i');
$controller->upload($request);
$this->assertFileDoesNotExist($this->userRoot . '/accounts/pwned.yaml');
}
#[Test]
public function image_into_accounts_via_account_stream_is_allowed(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'account://',
'scope' => '',
], ['file' => $this->makeUpload('avatar.png', "\x89PNG\r\n\x1a\n")]);
$response = $controller->upload($request);
$this->assertSame(201, $response->getStatusCode());
}
// ------------------------------------------------------------------
// Layer 3: `user/config/` and `user/env/` reject every upload.
// ------------------------------------------------------------------
#[Test]
public function uploads_into_user_config_are_rejected(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'config',
'scope' => '',
], ['file' => $this->makeUpload('site.png', 'png-bytes')]);
$this->expectException(ForbiddenException::class);
$this->expectExceptionMessageMatches('/config.*not allowed/i');
$controller->upload($request);
}
#[Test]
public function uploads_into_user_env_are_rejected(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'env/dev/config',
'scope' => '',
], ['file' => $this->makeUpload('logo.png', 'png-bytes')]);
$this->expectException(ForbiddenException::class);
$this->expectExceptionMessageMatches('/env.*not allowed/i');
$controller->upload($request);
}
// ------------------------------------------------------------------
// Layer 4: yaml/json/twig denied even outside user/accounts/.
// ------------------------------------------------------------------
#[Test]
public function yaml_into_arbitrary_dir_is_rejected_by_endpoint_denylist(): void
{
// Even outside user/accounts/, .yaml has no legitimate use as a
// blueprint media upload — the per-endpoint denylist guards against
// future scope/locator edge cases that bypass layer 2.
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
@mkdir($this->userRoot . '/data', 0775, true);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'data',
'scope' => '',
], ['file' => $this->makeUpload('payload.yaml', "x: 1\n")]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/\.yaml.*not allowed/i');
$controller->upload($request);
}
// ------------------------------------------------------------------
// Delete-side coverage: deleting an account YAML via the same endpoint.
// ------------------------------------------------------------------
#[Test]
public function delete_of_account_yaml_is_rejected(): void
{
// Place a real account YAML so the endpoint can't claim "already
// gone" — the rejection must fire on the path classification, not
// the file-existence shortcut.
file_put_contents($this->userRoot . '/accounts/admin.yaml', "fullname: Admin\n");
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'path' => 'accounts/admin.yaml',
]);
$threw = false;
try {
$controller->delete($request);
} catch (ForbiddenException $e) {
$threw = true;
$this->assertMatchesRegularExpression('/avatar image|user\/accounts/i', $e->getMessage());
}
$this->assertTrue($threw, 'Delete must be rejected by Forbidden, not silently succeed.');
$this->assertFileExists($this->userRoot . '/accounts/admin.yaml', 'YAML must not be unlinked.');
}
#[Test]
public function delete_into_user_config_is_rejected(): void
{
file_put_contents($this->userRoot . '/config/system.yaml', "site:\n title: x\n");
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, ['path' => 'config/system.yaml']);
$this->expectException(ForbiddenException::class);
$controller->delete($request);
$this->assertFileExists($this->userRoot . '/config/system.yaml');
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Controllers\ConfigController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Regression test for getgrav/grav-plugin-admin2#28.
*
* Toggling a checkbox whose sibling sits at its default value (e.g. unchecking
* `system.pages.events.page` while `events.twig` stays at its default `true`)
* used to 409 on the *second* save. The ETag was hashed from the full merged
* config: right after a save the in-memory config still carries the
* default-equal `twig: true`, but writeConfigFile() persists only the delta,
* so on the next request the reloaded config no longer reports it and the hash
* shifts under the client's stored If-Match.
*
* The fix keys the ETag off the persisted delta instead. The delta is
* invariant across the save→reload round-trip — a default-equal value is
* stripped on both sides — so the basis (and therefore the ETag) is stable.
*/
#[CoversClass(ConfigController::class)]
class ConfigControllerEtagBasisTest extends TestCase
{
private ?string $tmp = null;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-etagbasis-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/system/config', 0777, true);
mkdir($this->tmp . '/user/config', 0777, true);
// Grav core defaults: both event flags default to true.
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"pages:\n events:\n page: true\n twig: true\n",
);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function etag_basis_is_stable_across_the_save_reload_round_trip(): void
{
// Right after saving page:false — config->set() left the full merged
// value in memory, default-equal twig:true included.
$postSave = $this->etagBasis([
'pages' => ['events' => ['page' => false, 'twig' => true]],
]);
// Next request — config reloaded from the persisted delta, which never
// stored the default-equal twig:true.
$reloaded = $this->etagBasis([
'pages' => ['events' => ['page' => false]],
]);
// Both collapse to the same persisted delta, so the ETag is unchanged
// and the client's If-Match still validates.
$this->assertSame(['pages' => ['events' => ['page' => false]]], $postSave);
$this->assertSame($postSave, $reloaded);
}
#[Test]
public function etag_basis_ignores_key_order(): void
{
$a = $this->etagBasis(['pages' => ['events' => ['page' => false, 'twig' => true]]]);
// Same logical config, different key insertion order.
$b = $this->etagBasis(['pages' => ['events' => ['twig' => true, 'page' => false]]]);
$this->assertSame($a, $b);
}
/**
* Drive the private configEtagBasis() for the system scope with a given
* in-memory config snapshot.
*
* @param array<mixed> $systemConfig
* @return array<mixed>
*/
private function etagBasis(array $systemConfig): array
{
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new EtagFakeLocator($this->tmp);
// effectiveConfig() resolves from the persisted YAML files (not the live
// in-memory snapshot), so it stays target-exact behind a reverse proxy
// where the booted config env and $uri->environment() disagree. Persist
// the base override the way writeConfigFile() would, then read it back —
// which is exactly the save→reload round-trip this regression guards.
file_put_contents(
$this->tmp . '/user/config/system.yaml',
\Grav\Common\Yaml::dump($systemConfig),
);
$controller = new ConfigController($grav, new Config());
$ref = new \ReflectionMethod($controller, 'configEtagBasis');
return $ref->invoke($controller, 'system', null);
}
private function rrmdir(string $path): void
{
if (!is_dir($path)) return;
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
) as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($path);
}
}
/**
* Minimal locator resolving the streams ConfigDiffer::parent() touches.
*/
class EtagFakeLocator
{
public function __construct(private string $root) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false
{
$map = [
'user://' => $this->root . '/user',
'user://config' => $this->root . '/user/config',
'system://config' => $this->root . '/system/config',
];
foreach ($map as $prefix => $base) {
if ($prefix === $uri) {
return file_exists($base) || $first ? $base : false;
}
if (str_starts_with($uri, $prefix)) {
$sub = substr($uri, strlen($prefix));
$full = $base . ($sub !== '' ? '/' . $sub : '');
return file_exists($full) ? $full : false;
}
}
return false;
}
}
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Controllers\ConfigController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* GHSA-wx62 regression: the scheduler config scope feeds custom_jobs[].command
* into a Symfony Process, so writing it through the generic config endpoint
* must require API super authority — a non-super holder of api.config.write
* must not reach it. We drive assertScopeAllowed() directly via reflection
* rather than a full update() round-trip; the guard is the whole security
* boundary, so testing it in isolation is the right altitude.
*/
#[CoversClass(ConfigController::class)]
class ConfigControllerPrivilegedScopeTest extends TestCase
{
protected function tearDown(): void
{
Grav::resetInstance();
}
#[Test]
public function non_super_is_blocked_from_scheduler_scope(): void
{
$this->expectException(ForbiddenException::class);
$this->guard($this->nonSuper(), 'scheduler');
}
#[Test]
public function non_super_is_blocked_from_backups_scope(): void
{
$this->expectException(ForbiddenException::class);
$this->guard($this->nonSuper(), 'backups');
}
#[Test]
public function super_may_access_scheduler_scope(): void
{
$this->guard($this->super(), 'scheduler');
$this->addToAssertionCount(1); // no exception == pass
}
#[Test]
public function ordinary_scope_is_unaffected_for_non_super(): void
{
// The tool-managed gate (read+write) leaves ordinary scopes alone: a
// non-super configuration admin can still READ/list system config.
$this->guard($this->nonSuper(), 'system');
$this->addToAssertionCount(1);
}
// -- SUPER_WRITE_SCOPES: system/security stay readable but are super-only to
// write (GHSA-9wg2-prc3-vx89) --------------------------------------------
#[Test]
public function non_super_is_blocked_from_writing_system_scope(): void
{
$this->expectException(ForbiddenException::class);
$this->guardWrite($this->nonSuper(), 'system');
}
#[Test]
public function non_super_is_blocked_from_writing_security_scope(): void
{
$this->expectException(ForbiddenException::class);
$this->guardWrite($this->nonSuper(), 'security');
}
#[Test]
public function super_may_write_system_scope(): void
{
$this->guardWrite($this->super(), 'system');
$this->addToAssertionCount(1); // no exception == pass
}
#[Test]
public function non_super_may_still_read_security_scope(): void
{
// Write is gated, but the read/list gate must stay open for security.
$this->guard($this->nonSuper(), 'security');
$this->addToAssertionCount(1);
}
private function nonSuper(): UserInterface
{
return TestHelper::createMockUser('config-admin', [
'access' => ['api' => ['access' => true, 'config' => ['write' => true]]],
]);
}
private function super(): UserInterface
{
// createMockUser does flat-key lookup, and isSuperAdmin() reads the
// dotted 'access.api.super' path, so seed that literal key.
return TestHelper::createMockUser('root', ['access.api.super' => true]);
}
private function guard(UserInterface $user, string $scope): void
{
Grav::resetInstance();
$controller = new ConfigController(Grav::instance(), new Config());
$request = TestHelper::createMockRequest(
'PATCH',
"/config/{$scope}",
attributes: ['api_user' => $user],
);
$ref = new \ReflectionMethod($controller, 'assertScopeAllowed');
$ref->invoke($controller, $request, $scope);
}
private function guardWrite(UserInterface $user, string $scope): void
{
Grav::resetInstance();
$controller = new ConfigController(Grav::instance(), new Config());
$request = TestHelper::createMockRequest(
'PATCH',
"/config/{$scope}",
attributes: ['api_user' => $user],
);
$ref = new \ReflectionMethod($controller, 'assertScopeWritable');
$ref->invoke($controller, $request, $scope);
}
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Controllers\ConfigController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* resolveTargetEnv() decides where a config write lands. The interesting
* cases are the three header states, so we drive it directly via reflection
* rather than wiring a full update() round-trip.
*/
#[CoversClass(ConfigController::class)]
class ConfigControllerResolveTargetEnvTest extends TestCase
{
private ?string $tmp = null;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-cfgctl-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/user/config', 0777, true);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function missing_header_falls_back_to_active_environment(): void
{
// Simulates the bug we're fixing: hostname-derived env folder exists
// and shadows base config, but admin2 doesn't pass X-Config-Environment.
mkdir($this->tmp . '/user/localhost/config', 0777, true);
$controller = $this->buildController(activeEnv: 'localhost');
$request = TestHelper::createMockRequest('PATCH', '/config/system');
$this->assertSame('localhost', $this->invokeResolveTargetEnv($controller, $request));
}
#[Test]
public function missing_header_returns_null_when_no_active_env_overlay(): void
{
// Active env name is set but has no config dir — base writes are correct.
$controller = $this->buildController(activeEnv: 'production.example.com');
$request = TestHelper::createMockRequest('PATCH', '/config/system');
$this->assertNull($this->invokeResolveTargetEnv($controller, $request));
}
#[Test]
public function empty_header_is_explicit_base_write_and_skips_auto_detect(): void
{
// Caller wants to bypass auto-detection — they MUST be able to target
// base even when a Grav env is active. An explicitly-empty header is
// the opt-out lever.
mkdir($this->tmp . '/user/localhost/config', 0777, true);
$controller = $this->buildController(activeEnv: 'localhost');
$request = TestHelper::createMockRequest(
'PATCH',
'/config/system',
['X-Config-Environment' => ''],
);
$this->assertNull($this->invokeResolveTargetEnv($controller, $request));
}
#[Test]
public function default_sentinel_header_is_explicit_base_write(): void
{
// admin-next sends the reserved `default` sentinel for its base
// ("Default") selection — non-empty so proxies/FPM can't strip it.
// It must resolve to a base write even when a Grav env is active.
mkdir($this->tmp . '/user/localhost/config', 0777, true);
$controller = $this->buildController(activeEnv: 'localhost');
$request = TestHelper::createMockRequest(
'PATCH',
'/config/system',
['X-Config-Environment' => 'default'],
);
$this->assertNull($this->invokeResolveTargetEnv($controller, $request));
}
#[Test]
public function explicit_header_value_wins_over_active_env(): void
{
mkdir($this->tmp . '/user/localhost/config', 0777, true);
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
$controller = $this->buildController(activeEnv: 'localhost');
$request = TestHelper::createMockRequest(
'PATCH',
'/config/system',
['X-Config-Environment' => 'staging'],
);
$this->assertSame('staging', $this->invokeResolveTargetEnv($controller, $request));
}
#[Test]
public function invalid_header_value_throws(): void
{
$controller = $this->buildController(activeEnv: null);
$request = TestHelper::createMockRequest(
'PATCH',
'/config/system',
['X-Config-Environment' => '../etc'],
);
$this->expectException(ValidationException::class);
$this->invokeResolveTargetEnv($controller, $request);
}
private function buildController(?string $activeEnv): ConfigController
{
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new CfgCtlFakeLocator($this->tmp);
if ($activeEnv !== null) {
$grav['uri'] = new class ($activeEnv) {
public function __construct(private readonly string $env) {}
public function environment(): string { return $this->env; }
};
}
return new ConfigController($grav, new Config());
}
private function invokeResolveTargetEnv(ConfigController $controller, object $request): ?string
{
$ref = new \ReflectionMethod($controller, 'resolveTargetEnv');
return $ref->invoke($controller, $request);
}
private function rrmdir(string $path): void
{
if (!is_dir($path)) return;
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
) as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($path);
}
}
/**
* EnvironmentService only ever resolves user://, mirrored from the
* EnvironmentServiceTest fixture so this test is self-contained.
*/
class CfgCtlFakeLocator
{
public function __construct(private readonly string $root) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false
{
if ($uri === 'user://') {
return is_dir($this->root . '/user') ? $this->root . '/user' : false;
}
return false;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Controllers\ConfigController;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see \Grav\Plugin\Api\Controllers\AbstractApiController::normalizeEtag()} —
* a compressing front-end appends a transport suffix to the ETag and the client
* echoes it back verbatim in If-Match, so the suffix must be stripped before
* comparing against the stored hash. Missing `zstd` here surfaced as a false
* 409 "modified elsewhere" on mod_zstd servers (getgrav/grav-plugin-admin2#28).
*/
class EtagNormalizationTest extends TestCase
{
protected function tearDown(): void
{
Grav::resetInstance();
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function etagCases(): array
{
$hash = '8fe605d9c21bc107eeceba0c63c93baa';
return [
'bare' => ["\"{$hash}\"", $hash],
'gzip suffix' => ["\"{$hash}-gzip\"", $hash],
'gzip semicolon' => ["\"{$hash};gzip\"", $hash],
'brotli suffix' => ["\"{$hash}-br\"", $hash],
'deflate suffix' => ["\"{$hash}-deflate\"", $hash],
'zstd suffix' => ["\"{$hash}-zstd\"", $hash],
'weak marker' => ["W/\"{$hash}-zstd\"", $hash],
'no quotes' => ["{$hash}-zstd", $hash],
];
}
#[Test]
#[DataProvider('etagCases')]
public function strips_transport_suffixes_and_wrappers(string $input, string $expected): void
{
Grav::resetInstance();
$controller = new ConfigController(Grav::instance(), new Config());
$ref = new \ReflectionMethod($controller, 'normalizeEtag');
$this->assertSame($expected, $ref->invoke($controller, $input));
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Framework\Acl\Permissions;
use Grav\Plugin\Api\Controllers\GpmController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(GpmController::class)]
class GpmControllerSecurityTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_gpm_security_' . uniqid();
mkdir($this->tempDir . '/cache', 0775, true);
mkdir($this->tempDir . '/plugins/api', 0775, true);
file_put_contents($this->tempDir . '/README.md', 'root readme must not be exposed');
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
#[Test]
public function readme_rejects_dot_dot_package_slug(): void
{
$user = TestHelper::createMockUser('auditor', [
'access' => ['api' => ['access' => true, 'gpm' => ['read' => true]]],
]);
$config = new Config(['plugins' => ['api' => ['route' => '/api', 'version_prefix' => 'v1']]]);
TestHelper::createMockGrav([
'config' => $config,
'locator' => new GpmSecurityTestLocator($this->tempDir),
'permissions' => new Permissions(),
]);
$controller = new GpmController(\Grav\Common\Grav::instance(), $config);
$request = TestHelper::createMockRequest(
method: 'GET',
path: '/api/v1/gpm/plugins/../readme',
attributes: [
'api_user' => $user,
'route_params' => ['slug' => '..'],
],
);
$this->expectException(ValidationException::class);
$controller->readme($request);
}
private function rmrf(string $path): void
{
if (is_file($path) || is_link($path)) {
unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $item) {
if ($item === '.' || $item === '..') continue;
$this->rmrf($path . '/' . $item);
}
rmdir($path);
}
}
final class GpmSecurityTestLocator
{
public function __construct(private readonly string $base) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): string|false
{
if (str_starts_with($uri, 'cache://')) {
return $this->base . '/cache';
}
if (str_starts_with($uri, 'user://')) {
return rtrim($this->base . '/' . ltrim(substr($uri, strlen('user://')), '/'), '/');
}
return false;
}
}
@@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\GPM\GPM;
use Grav\Plugin\Api\Controllers\GpmController;
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;
/**
* Unit tests for GpmController::updateAll().
*
* These tests focus on the dependency-resolution and ordering behavior added
* to the bulk-update flow. The real GPM and GpmService both touch the
* filesystem and remote GPM repository; the controller exposes
* getGpm() / installPackage() / updatePackage() as protected methods so a
* test subclass can inject mocks for these collaborators.
*
* Each test uses a fresh GPM mock per call to getGpm(); cascade-skip
* behavior is asserted via the controller's internal cascadedDeps tracking,
* not via per-iteration changes to GPM::isUpdatable() (Grav core's
* Remote\Packages static cache makes that mutate-and-recheck unreliable).
*/
#[CoversClass(GpmController::class)]
class GpmControllerUpdateAllTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_updateall_test_' . uniqid();
@mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
private function makeRequest(): ServerRequestInterface
{
$superAdmin = TestHelper::createMockUser('admin', [
'access.api.super' => true,
]);
return TestHelper::createMockRequest(
method: 'POST',
path: '/api/v1/gpm/update-all',
headers: ['Content-Type' => 'application/json'],
body: '{}',
attributes: [
'api_user' => $superAdmin,
'json_body' => [],
],
);
}
/**
* Build a controller whose getGpm/installPackage/updatePackage are
* driven by the supplied callables.
*
* @param callable():GPM $gpmFactory Returns a GPM mock per call (allows per-iteration state)
* @param callable(string,array):(string|bool) $installer Records and returns install results
* @param callable(string,array):(string|bool) $updater Records and returns update results
*/
private function createController(
callable $gpmFactory,
callable $installer,
callable $updater,
): GpmController {
$tempDir = $this->tempDir;
$config = new Config([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
]],
]);
$locator = new class ($tempDir) {
public function __construct(private string $base) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): ?string
{
if (str_starts_with($uri, 'cache://')) {
return $this->base . '/cache';
}
return $this->base;
}
};
$grav = TestHelper::createMockGrav([
'config' => $config,
'locator' => $locator,
'permissions' => new \stdClass(), // unused: super-admin shortcut bypasses resolver
]);
return new class ($grav, $config, $gpmFactory, $installer, $updater) extends GpmController {
/** @var callable():GPM */
private $gpmFactory;
/** @var callable(string,array):(string|bool) */
private $installer;
/** @var callable(string,array):(string|bool) */
private $updater;
public function __construct($grav, $config, callable $gpmFactory, callable $installer, callable $updater)
{
parent::__construct($grav, $config);
$this->gpmFactory = $gpmFactory;
$this->installer = $installer;
$this->updater = $updater;
}
protected function getGpm(bool $refresh = false): GPM
{
return ($this->gpmFactory)();
}
protected function installPackage(string $slug, array $options): string|bool
{
return ($this->installer)($slug, $options);
}
protected function updatePackage(string $slug, array $options): string|bool
{
return ($this->updater)($slug, $options);
}
};
}
/**
* Build a GPM mock with canned answers for the methods updateAll calls.
*
* @param array{plugins?: array<string,object>, themes?: array<string,object>} $updatable
* @param array<string,bool> $isUpdatable map of slug -> bool
* @param array<string,array<string,string>>|array<string,\Throwable> $depsBySlug
*/
private function makeGpmMock(array $updatable, array $isUpdatable, array $depsBySlug): GPM
{
$gpm = $this->createMock(GPM::class);
$gpm->method('getUpdatable')->willReturn($updatable);
$gpm->method('isUpdatable')->willReturnCallback(
fn (string $slug) => $isUpdatable[$slug] ?? false
);
$gpm->method('checkPackagesCanBeInstalled')->willReturnCallback(
function (array $slugs) use ($depsBySlug): void {
foreach ($slugs as $slug) {
if (($depsBySlug[$slug] ?? null) instanceof \Throwable) {
throw $depsBySlug[$slug];
}
}
}
);
$gpm->method('getDependencies')->willReturnCallback(
function (array $slugs) use ($depsBySlug): array {
$result = [];
foreach ($slugs as $slug) {
$deps = $depsBySlug[$slug] ?? [];
if ($deps instanceof \Throwable) {
throw $deps;
}
foreach ($deps as $depSlug => $action) {
$result[$depSlug] = $action;
}
}
return $result;
}
);
return $gpm;
}
private function decode(\Psr\Http\Message\ResponseInterface $response): array
{
// ApiResponse::create wraps the payload in a `data` envelope.
$body = json_decode((string) $response->getBody(), true);
return $body['data'] ?? $body;
}
// -------------------------------------------------------
// Tests
// -------------------------------------------------------
#[Test]
public function fails_package_when_grav_dep_not_satisfied(): void
{
// Two plugins updatable; "needy" requires a newer Grav, "ok" has no deps.
$gravError = new \RuntimeException(
'<red>One of the packages require Grav >=2.0.0-beta.2. Please update Grav to the latest release.'
);
$depsBySlug = [
'needy' => $gravError, // throws when getDependencies(['needy']) is called
'ok' => [],
];
$factory = function () use ($depsBySlug): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['needy' => (object) [], 'ok' => (object) []]],
isUpdatable: ['needy' => true, 'ok' => true],
depsBySlug: $depsBySlug,
);
};
$installCalls = [];
$updateCalls = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: function (string $slug, array $opts) use (&$installCalls): bool {
$installCalls[] = [$slug, $opts];
return true;
},
updater: function (string $slug, array $opts) use (&$updateCalls): bool {
$updateCalls[] = [$slug, $opts];
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
// 'needy' must NOT have been updated.
$updatedSlugs = array_column($updateCalls, 0);
$this->assertNotContains('needy', $updatedSlugs);
$this->assertContains('ok', $updatedSlugs);
$this->assertSame(['ok'], $body['updated']);
$this->assertCount(1, $body['failed']);
$this->assertSame('needy', $body['failed'][0]['package']);
// Color tags should be stripped; Grav-required language preserved.
$this->assertStringNotContainsString('<red>', $body['failed'][0]['error']);
$this->assertStringContainsString('Grav >=2.0.0-beta.2', $body['failed'][0]['error']);
}
#[Test]
public function cascades_dependency_update_before_target(): void
{
// 'parent' depends on 'child' needing an update.
// Both appear in the initial updatable list. Processing parent first
// cascade-installs child; when the loop reaches child it is found in
// the cascadedDeps set and reported as skipped.
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['parent' => (object) [], 'child' => (object) []]],
isUpdatable: ['parent' => true, 'child' => true],
depsBySlug: [
'parent' => ['child' => 'update'],
'child' => [],
],
);
};
$callOrder = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: function (string $slug) use (&$callOrder): bool {
$callOrder[] = "install:$slug";
return true;
},
updater: function (string $slug) use (&$callOrder): bool {
$callOrder[] = "update:$slug";
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
// child must be installed BEFORE parent is updated.
$this->assertSame(['install:child', 'update:parent'], $callOrder);
$this->assertSame(['parent'], $body['updated']);
$this->assertSame(['child'], $body['cascaded_dependencies']);
// child appears in the original updatable list, but on its iteration
// the cascadedDeps set causes it to be skipped, not updated again.
$this->assertCount(1, $body['skipped']);
$this->assertSame('child', $body['skipped'][0]['package']);
$this->assertSame([], $body['failed']);
}
#[Test]
public function fails_package_when_dependency_install_throws(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['parent' => (object) []]],
isUpdatable: ['parent' => true],
depsBySlug: ['parent' => ['child' => 'install']],
);
};
$updateCalls = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: function (string $slug): never {
throw new \RuntimeException("network error fetching $slug");
},
updater: function (string $slug, array $opts) use (&$updateCalls): bool {
$updateCalls[] = $slug;
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
// updatePackage must NOT be invoked when a dep install fails.
$this->assertSame([], $updateCalls);
$this->assertSame([], $body['updated']);
$this->assertCount(1, $body['failed']);
$this->assertSame('parent', $body['failed'][0]['package']);
$this->assertStringContainsString("Failed to install dependency 'child'", $body['failed'][0]['error']);
$this->assertStringContainsString('network error fetching child', $body['failed'][0]['error']);
}
#[Test]
public function passes_isTheme_option_for_theme_packages(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: [
'plugins' => ['p1' => (object) []],
'themes' => ['t1' => (object) []],
],
isUpdatable: ['p1' => true, 't1' => true],
depsBySlug: ['p1' => [], 't1' => []],
);
};
$updateCalls = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: fn () => true,
updater: function (string $slug, array $opts) use (&$updateCalls): bool {
$updateCalls[$slug] = $opts;
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
$this->assertSame(['p1', 't1'], $body['updated']);
$this->assertFalse($updateCalls['p1']['theme']);
$this->assertTrue($updateCalls['t1']['theme']);
// install_deps must be false: deps already resolved by the controller.
$this->assertFalse($updateCalls['p1']['install_deps']);
$this->assertFalse($updateCalls['t1']['install_deps']);
}
#[Test]
public function reports_update_failure_when_service_returns_non_success(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['boom' => (object) []]],
isUpdatable: ['boom' => true],
depsBySlug: ['boom' => []],
);
};
$controller = $this->createController(
gpmFactory: $factory,
installer: fn () => true,
updater: fn () => false, // service signaled failure (neither true nor string)
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
$this->assertSame([], $body['updated']);
$this->assertCount(1, $body['failed']);
$this->assertSame('boom', $body['failed'][0]['package']);
$this->assertStringContainsString("Failed to update 'boom'", $body['failed'][0]['error']);
}
#[Test]
public function returns_empty_buckets_when_nothing_updatable(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => [], 'themes' => []],
isUpdatable: [],
depsBySlug: [],
);
};
$controller = $this->createController(
gpmFactory: $factory,
installer: fn () => true,
updater: fn () => true,
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
$this->assertSame([], $body['updated']);
$this->assertSame([], $body['failed']);
$this->assertSame([], $body['skipped']);
$this->assertSame([], $body['cascaded_dependencies']);
}
}
@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Plugin\Api\Controllers\MediaController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use ReflectionMethod;
/**
* Unit coverage for the shared upload pipeline (HandlesMediaUploads). The trait
* is the security-critical, storage-agnostic core reused by both page media and
* flex-object media, so it carries the validation guarantees that matter:
* dangerous extensions are blocked, traversal filenames are rejected, the size
* cap is enforced, and a clean file lands in the target folder.
*
* Exercised through MediaController since it uses the trait; the methods under
* test are storage-agnostic, so the same behavior applies to FlexApiController.
*/
#[CoversTrait(\Grav\Plugin\Api\Controllers\HandlesMediaUploads::class)]
class HandlesMediaUploadsTest extends TestCase
{
private string $tempDir;
private MediaController $controller;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_media_trait_' . uniqid();
mkdir($this->tempDir, 0775, true);
$config = new Config([
'security' => ['uploads_dangerous_extensions' => ['php', 'phtml', 'phar', 'js', 'html']],
]);
// createMockGrav installs the Grav singleton the base controller reads.
$grav = TestHelper::createMockGrav(['config' => $config]);
$this->controller = new MediaController($grav, $config);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
#[Test]
public function a_clean_file_lands_in_the_target_folder(): void
{
$file = new TraitTestUploadedFile('avatar.png', 'binary-png-data');
$name = $this->invoke('processUploadedFile', $file, $this->tempDir);
self::assertSame('avatar.png', $name);
self::assertFileExists($this->tempDir . '/avatar.png');
self::assertSame('binary-png-data', file_get_contents($this->tempDir . '/avatar.png'));
}
#[Test]
public function dangerous_extension_is_rejected_and_no_file_is_written(): void
{
$file = new TraitTestUploadedFile('shell.php', '<?php evil();');
try {
$this->invoke('processUploadedFile', $file, $this->tempDir);
self::fail('Expected ValidationException for a .php upload.');
} catch (ValidationException) {
self::assertFileDoesNotExist($this->tempDir . '/shell.php');
}
}
#[Test]
public function extensionless_file_is_rejected(): void
{
$file = new TraitTestUploadedFile('README', 'no extension');
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir);
}
#[Test]
public function traversal_filename_is_rejected(): void
{
$file = new TraitTestUploadedFile('../../evil.png', 'png');
// basename() strips the path, but the resulting name still must not be
// a dotfile or contain traversal markers — assert nothing escapes.
try {
$this->invoke('processUploadedFile', $file, $this->tempDir);
} catch (ValidationException) {
// acceptable — rejected outright
}
self::assertFileDoesNotExist(dirname($this->tempDir) . '/evil.png');
}
#[Test]
public function dotfile_is_rejected(): void
{
$file = new TraitTestUploadedFile('.htaccess', 'deny');
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir);
}
#[Test]
public function oversize_file_is_rejected(): void
{
// Reports a size beyond the 64 MB cap without writing 64 MB to disk.
$file = new TraitTestUploadedFile('big.png', 'x', 67_108_864 + 1);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir);
}
#[Test]
public function random_name_replaces_the_filename_but_keeps_the_extension(): void
{
$file = new TraitTestUploadedFile('My Photo.PNG', 'png-bytes');
$settings = UploadFieldSettings::fromParams(['random_name' => '1']);
$name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
self::assertNotSame('My Photo.PNG', $name);
self::assertMatchesRegularExpression('/^[a-z0-9]{15}\.png$/', $name);
self::assertFileExists($this->tempDir . '/' . $name);
self::assertFileDoesNotExist($this->tempDir . '/My Photo.PNG');
}
#[Test]
public function avoid_overwriting_prefixes_a_conflicting_filename(): void
{
// Pre-seed a colliding file so the conflict branch fires.
file_put_contents($this->tempDir . '/logo.png', 'existing');
$file = new TraitTestUploadedFile('logo.png', 'new-bytes');
$settings = UploadFieldSettings::fromParams(['avoid_overwriting' => true]);
$name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
self::assertMatchesRegularExpression('/^\d{14}-logo\.png$/', $name);
self::assertSame('existing', file_get_contents($this->tempDir . '/logo.png'));
self::assertSame('new-bytes', file_get_contents($this->tempDir . '/' . $name));
}
#[Test]
public function accept_allowlist_rejects_a_non_matching_file(): void
{
$file = new TraitTestUploadedFile('notes.txt', 'text');
$settings = UploadFieldSettings::fromParams(['accept' => 'image/*']);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
}
#[Test]
public function accept_allowlist_admits_a_matching_extension(): void
{
$file = new TraitTestUploadedFile('photo.png', 'png');
$settings = UploadFieldSettings::fromParams(['accept' => '.png,.jpg']);
$name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
self::assertSame('photo.png', $name);
self::assertFileExists($this->tempDir . '/photo.png');
}
#[Test]
public function per_field_filesize_limit_is_enforced_under_the_hard_cap(): void
{
// 2 MB file against a 1 MB per-field limit — well under the 64 MB cap.
$file = new TraitTestUploadedFile('big.png', 'x', 2 * 1_048_576);
$settings = UploadFieldSettings::fromParams(['filesize' => 1]);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
}
#[Test]
public function random_name_cannot_smuggle_a_dangerous_extension(): void
{
// The extension floor runs before random_name, which preserves the
// extension — so a .php upload is still rejected even with randomizing.
$file = new TraitTestUploadedFile('shell.php', '<?php evil();');
$settings = UploadFieldSettings::fromParams(['random_name' => '1']);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
}
#[Test]
public function nested_uploaded_files_are_flattened(): void
{
$a = new TraitTestUploadedFile('a.png', 'a');
$b = new TraitTestUploadedFile('b.png', 'b');
$c = new TraitTestUploadedFile('c.png', 'c');
// Mirrors PSR-7 nesting: files[gallery][] alongside files[avatar].
$flat = $this->invoke('flattenUploadedFiles', ['avatar' => $a, 'gallery' => [$b, $c]]);
self::assertCount(3, $flat);
self::assertContainsOnlyInstancesOf(UploadedFileInterface::class, $flat);
}
private function invoke(string $method, mixed ...$args): mixed
{
$ref = new ReflectionMethod($this->controller, $method);
return $ref->invoke($this->controller, ...$args);
}
private function rmrf(string $path): void
{
if (is_file($path) || is_link($path)) {
unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$this->rmrf($path . '/' . $item);
}
rmdir($path);
}
}
final class TraitTestUploadedFile implements UploadedFileInterface
{
private readonly string $source;
private bool $moved = false;
public function __construct(
private readonly string $filename,
string $contents,
private readonly ?int $reportedSize = null,
) {
$this->source = tempnam(sys_get_temp_dir(), 'grav_api_trait_upload_') ?: '';
file_put_contents($this->source, $contents);
}
public function getStream(): StreamInterface
{
throw new \RuntimeException('Not needed in tests.');
}
public function moveTo(string $targetPath): void
{
if ($this->moved) {
throw new \RuntimeException('File already moved.');
}
$dir = dirname($targetPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
rename($this->source, $targetPath);
$this->moved = true;
}
public function getSize(): ?int
{
return $this->reportedSize ?? (file_exists($this->source) ? filesize($this->source) : null);
}
public function getError(): int { return UPLOAD_ERR_OK; }
public function getClientFilename(): ?string { return $this->filename; }
public function getClientMediaType(): ?string { return 'application/octet-stream'; }
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Page\Header;
use Grav\Plugin\Api\Controllers\PagesController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Yaml\Yaml;
/**
* Regression coverage for getgrav/grav-plugin-admin2#31 — frontmatter polluted
* with NUL-prefixed keys after saving from Admin 2.0 (Expert mode).
*
* Flex pages (Grav's default since 1.7) return a Header/Data object from
* header(), not a stdClass. The update path used to read the current header
* with `(array) $page->header()`, which leaks the object's protected
* properties as NUL-prefixed keys ("\0*\0items", "\0*\0nestedSeparator").
* Those keys were then merged with the incoming edit and persisted, so every
* save nested the real fields one level deeper inside a growing "\0*\0items"
* wrapper. headerToArray() routes through jsonSerialize() instead and keeps the
* keys clean.
*/
#[CoversClass(PagesController::class)]
class PagesControllerHeaderToArrayTest extends TestCase
{
private function headerToArray($header): array
{
$ref = new ReflectionClass(PagesController::class);
$instance = $ref->newInstanceWithoutConstructor();
return $ref->getMethod('headerToArray')->invoke($instance, $header);
}
#[Test]
public function flex_header_object_yields_clean_keys(): void
{
$header = new Header([
'title' => 'page to filter out',
'access' => ['site.restricted' => true],
]);
$result = $this->headerToArray($header);
$this->assertSame(['title', 'access'], array_keys($result));
foreach (array_keys($result) as $key) {
$this->assertStringNotContainsString("\0", (string) $key, 'No NUL-prefixed keys may leak');
}
$this->assertStringNotContainsString('items', Yaml::dump($result));
$this->assertStringNotContainsString('nestedSeparator', Yaml::dump($result));
}
#[Test]
public function stdclass_header_from_legacy_pages_round_trips(): void
{
$header = (object) ['title' => 'Hello', 'taxonomy' => (object) ['tag' => ['a', 'b']]];
$result = $this->headerToArray($header);
$this->assertSame('Hello', $result['title']);
$this->assertSame(['a', 'b'], $result['taxonomy']['tag']);
}
#[Test]
public function null_and_array_headers_are_passed_through(): void
{
$this->assertSame([], $this->headerToArray(null));
$this->assertSame(['title' => 'x'], $this->headerToArray(['title' => 'x']));
}
#[Test]
public function repeated_saves_do_not_compound_pollution(): void
{
// Simulate the read-merge-write loop the update endpoint runs: read the
// current header off the page object, merge an incoming Expert-mode
// payload, then store it back. Three iterations must stay clean — the
// bug nested the real fields one level deeper on every save.
$stored = ['title' => 'Original', 'access' => ['site.restricted' => true]];
for ($i = 0; $i < 3; $i++) {
$headerObject = new Header($stored);
$existing = $this->headerToArray($headerObject);
$incoming = ['title' => 'Edited ' . $i, 'access' => ['site.restricted' => true]];
// mirrors mergePatch + (object) cast + Header re-wrap on save
$merged = array_replace($existing, $incoming);
$stored = $merged;
$dump = Yaml::dump($merged, 10, 2);
$this->assertStringNotContainsString("\0", $dump);
$this->assertStringNotContainsString('*0items', $dump);
$this->assertSame(['title', 'access'], array_keys($merged));
}
$this->assertSame('Edited 2', $stored['title']);
}
}
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Controllers\PagesController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Yaml\Yaml;
/**
* Unit tests for AbstractApiController::mergePatch(), exercised through
* PagesController (the page-update endpoint is where the regression bit).
*
* Regression coverage for getgrav/grav-theme-quark2#8 — saving a page whose
* frontmatter contains a YAML list (e.g. `form.fields`, `form.process`) used to
* grow the file on every save: array_replace_recursive merged the existing
* integer-keyed list with the incoming name-keyed map, leaving both keysets in
* the result and forcing Symfony YAML to dump the list as a quoted '0','1','2'
* map next to the new named entries.
*/
#[CoversClass(AbstractApiController::class)]
#[CoversClass(PagesController::class)]
class PagesControllerMergeHeaderTest extends TestCase
{
private function invoke(array $existing, array $incoming): array
{
$ref = new ReflectionClass(PagesController::class);
$instance = $ref->newInstanceWithoutConstructor();
return $ref->getMethod('mergePatch')->invoke($instance, $existing, $incoming);
}
#[Test]
public function list_in_existing_is_replaced_when_incoming_sends_a_map(): void
{
$existing = [
'form' => [
'fields' => [
['name' => 'name', 'label' => 'Name'],
['name' => 'email', 'label' => 'Email'],
['name' => 'message', 'label' => 'Message'],
],
],
];
$incoming = [
'form' => [
'fields' => [
'name' => ['label' => 'Name'],
'email' => ['label' => 'Email'],
],
],
];
$merged = $this->invoke($existing, $incoming);
$this->assertSame(['name', 'email'], array_keys($merged['form']['fields']));
$yaml = Yaml::dump($merged, 10, 2);
$this->assertStringNotContainsString("'0':", $yaml, 'No quoted integer keys should leak through');
$this->assertStringNotContainsString("'1':", $yaml);
$this->assertStringNotContainsString("'2':", $yaml);
}
#[Test]
public function list_in_incoming_replaces_existing_map(): void
{
$existing = [
'form' => [
'fields' => [
'name' => ['label' => 'Name'],
'email' => ['label' => 'Email'],
],
],
];
$incoming = [
'form' => [
'fields' => [
['name' => 'subject', 'label' => 'Subject'],
],
],
];
$merged = $this->invoke($existing, $incoming);
$this->assertTrue(array_is_list($merged['form']['fields']));
$this->assertCount(1, $merged['form']['fields']);
$this->assertSame('subject', $merged['form']['fields'][0]['name']);
}
#[Test]
public function maps_still_recurse_so_partial_header_updates_keep_working(): void
{
$existing = [
'title' => 'Old',
'metadata' => [
'description' => 'desc',
'author' => 'andy',
],
'published' => true,
];
$incoming = [
'metadata' => [
'author' => 'someone',
],
];
$merged = $this->invoke($existing, $incoming);
$this->assertSame('Old', $merged['title']);
$this->assertSame('desc', $merged['metadata']['description']);
$this->assertSame('someone', $merged['metadata']['author']);
$this->assertTrue($merged['published']);
}
#[Test]
public function nested_list_under_a_map_is_replaced_not_index_merged(): void
{
$existing = ['taxonomy' => ['tag' => ['a', 'b', 'c', 'd']]];
$incoming = ['taxonomy' => ['tag' => ['x', 'y']]];
$merged = $this->invoke($existing, $incoming);
$this->assertSame(['x', 'y'], $merged['taxonomy']['tag']);
}
#[Test]
public function quark2_issue_8_full_payload_round_trip(): void
{
$existing = [
'title' => 'Contact',
'form' => [
'name' => 'contact-form',
'fields' => [
['name' => 'name', 'label' => 'Name', 'type' => 'text'],
['name' => 'email', 'label' => 'Email', 'type' => 'email'],
['name' => 'message', 'label' => 'Message', 'type' => 'textarea'],
],
'buttons' => [
['type' => 'submit', 'value' => 'Submit'],
['type' => 'reset', 'value' => 'Reset'],
],
'process' => [
['email' => ['from' => 'x', 'subject' => 'y']],
['save' => ['fileprefix' => 'feedback-']],
['message' => 'Thank you'],
['display' => '/contact'],
],
],
];
$incoming = [
'form' => [
'fields' => [
'name' => ['label' => 'Name', 'type' => 'text'],
'email' => ['label' => 'Email', 'type' => 'email'],
],
'buttons' => [
'submit' => ['type' => 'submit', 'value' => 'Submit'],
],
'process' => [
'email' => ['from' => 'x', 'subject' => 'y'],
'message' => 'Thank you',
],
],
];
$merged = $this->invoke($existing, $incoming);
$this->assertSame('Contact', $merged['title']);
$this->assertSame('contact-form', $merged['form']['name']);
$this->assertSame(['name', 'email'], array_keys($merged['form']['fields']));
$this->assertSame(['submit'], array_keys($merged['form']['buttons']));
$this->assertSame(['email', 'message'], array_keys($merged['form']['process']));
$yaml = Yaml::dump($merged, 10, 2);
$this->assertStringNotContainsString("'0':", $yaml);
$this->assertStringNotContainsString("'1':", $yaml);
$this->assertStringNotContainsString("'2':", $yaml);
$this->assertStringNotContainsString("'3':", $yaml);
}
}
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Plugin\Api\Controllers\PagesController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the PagesController::reorganize() validation logic.
*
* Tests exercise the validation phase without performing filesystem operations.
*/
#[CoversClass(PagesController::class)]
class PagesControllerReorganizeTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_reorg_test_' . uniqid();
@mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true);
@mkdir($this->tempDir . '/pages', 0775, true);
}
protected function tearDown(): void
{
// Clean up temp dirs
$this->rmrf($this->tempDir);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
private function createController(array $knownPages = [], array $configOverrides = []): PagesController
{
$pagesService = $this->createPagesService($knownPages);
$tempDir = $this->tempDir;
$config = new Config(array_merge([
'plugins' => ['api' => [
'batch' => ['max_items' => 50],
'route' => '/api',
'version_prefix' => 'v1',
]],
], $configOverrides));
$locator = new class ($tempDir) {
public function __construct(private string $base) {}
public function findResource(string $uri, bool $absolute = false): ?string
{
return match (true) {
str_starts_with($uri, 'cache://') => $this->base . '/cache',
str_starts_with($uri, 'page://') => $this->base . '/pages',
default => $this->base,
};
}
};
$grav = TestHelper::createMockGrav([
'pages' => $pagesService,
'config' => $config,
'locator' => $locator,
]);
return new PagesController($grav, $config);
}
private function createPagesService(array $knownPages): object
{
return new class ($knownPages) {
private array $pages;
public function __construct(array $pages) { $this->pages = $pages; }
public function enablePages(): void {}
public function reset(): void {}
public function find(string $route): ?object
{
return $this->pages[$route] ?? null;
}
};
}
private function createMockPage(string $route, string $slug, ?int $order = null): object
{
$pagesDir = $this->tempDir . '/pages';
$path = $pagesDir . '/' . ($order !== null ? str_pad((string)$order, 2, '0', STR_PAD_LEFT) . '.' . $slug : $slug);
return new class ($route, $slug, $order, $path) {
public function __construct(
private string $route,
private string $slug,
private ?int $order,
private string $path,
) {}
public function route($var = null): ?string { return $this->route; }
public function slug($var = null): string { return $this->slug; }
public function order($var = null): ?int { return $this->order; }
public function path($var = null): ?string { return $this->path; }
public function title($var = null): string { return ucfirst($this->slug); }
public function isModule(): bool { return false; }
public function children(): \Traversable { return new \ArrayIterator([]); }
};
}
private function makeRequest(array $body): \Psr\Http\Message\ServerRequestInterface
{
// API authority is scoped to access.api.super (admin-classic's legacy
// access.admin.super is intentionally NOT honored by the API — see
// AbstractApiController::isSuperAdmin()).
$superAdmin = TestHelper::createMockUser('admin', [
'access.api.super' => true,
]);
return TestHelper::createMockRequest(
method: 'POST',
path: '/api/v1/pages/reorganize',
headers: ['Content-Type' => 'application/json'],
body: json_encode($body),
attributes: [
'api_user' => $superAdmin,
'json_body' => $body,
],
);
}
// -------------------------------------------------------
// Validation tests
// -------------------------------------------------------
#[Test]
public function reorganize_requires_operations_field(): void
{
$controller = $this->createController();
$this->expectException(ValidationException::class);
$controller->reorganize($this->makeRequest([]));
}
#[Test]
public function reorganize_rejects_empty_operations_array(): void
{
$controller = $this->createController();
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('non-empty array');
$controller->reorganize($this->makeRequest(['operations' => []]));
}
#[Test]
public function reorganize_rejects_operation_missing_route(): void
{
$controller = $this->createController();
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('route');
$controller->reorganize($this->makeRequest([
'operations' => [
['position' => 1],
],
]));
}
#[Test]
public function reorganize_rejects_duplicate_routes(): void
{
$controller = $this->createController([
'/blog/post-a' => $this->createMockPage('/blog/post-a', 'post-a'),
'/blog' => $this->createMockPage('/blog', 'blog'),
]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Duplicate');
$controller->reorganize($this->makeRequest([
'operations' => [
['route' => '/blog/post-a', 'position' => 1],
['route' => '/blog/post-a', 'position' => 2],
],
]));
}
#[Test]
public function reorganize_rejects_nonexistent_page(): void
{
$controller = $this->createController();
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('not found');
$controller->reorganize($this->makeRequest([
'operations' => [
['route' => '/does-not-exist', 'position' => 1],
],
]));
}
#[Test]
public function reorganize_rejects_nonexistent_parent(): void
{
$controller = $this->createController([
'/blog/post-a' => $this->createMockPage('/blog/post-a', 'post-a'),
'/blog' => $this->createMockPage('/blog', 'blog'),
]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Destination parent not found');
$controller->reorganize($this->makeRequest([
'operations' => [
['route' => '/blog/post-a', 'parent' => '/nonexistent', 'position' => 1],
],
]));
}
#[Test]
public function reorganize_rejects_move_into_own_subtree(): void
{
$controller = $this->createController([
'/blog' => $this->createMockPage('/blog', 'blog'),
'/blog/child' => $this->createMockPage('/blog/child', 'child'),
]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('own subtree');
$controller->reorganize($this->makeRequest([
'operations' => [
['route' => '/blog', 'parent' => '/blog/child'],
],
]));
}
#[Test]
public function reorganize_rejects_position_conflict(): void
{
$controller = $this->createController([
'/blog/post-a' => $this->createMockPage('/blog/post-a', 'post-a'),
'/blog/post-b' => $this->createMockPage('/blog/post-b', 'post-b'),
'/blog' => $this->createMockPage('/blog', 'blog'),
]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Position conflict');
$controller->reorganize($this->makeRequest([
'operations' => [
['route' => '/blog/post-a', 'position' => 1],
['route' => '/blog/post-b', 'position' => 1],
],
]));
}
#[Test]
public function reorganize_rejects_exceeding_batch_limit(): void
{
$pages = [];
$ops = [];
for ($i = 0; $i < 51; $i++) {
$route = "/page-{$i}";
$pages[$route] = $this->createMockPage($route, "page-{$i}");
$ops[] = ['route' => $route, 'position' => $i + 1];
}
$controller = $this->createController($pages);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('limited to');
$controller->reorganize($this->makeRequest(['operations' => $ops]));
}
}
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Framework\Acl\Permissions;
use Grav\Plugin\Api\Controllers\UsersController;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
/**
* Covers the server-side permission/group filtering added to GET /users so the
* admin UI can answer "find all admins". Exercises the private matcher helpers
* directly — the index() entry points just iterate a user list and delegate to
* these, which carry all of the non-trivial logic (parent-key inheritance,
* group-inherited access, the super-admin shortcut).
*/
#[CoversClass(UsersController::class)]
class UsersControllerFilterTest extends TestCase
{
private function controller(array $configData = []): UsersController
{
$config = new Config(array_merge_recursive([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
'pagination' => ['default_per_page' => 20, 'max_per_page' => 100],
]],
], $configData));
TestHelper::createMockGrav([
'config' => $config,
'permissions' => new Permissions(),
]);
return new UsersController(\Grav\Common\Grav::instance(), $config);
}
private function matchesFilters(UsersController $c, UserInterface $user, array $filters): bool
{
$m = new ReflectionMethod($c, 'userMatchesFilters');
$m->setAccessible(true);
return $m->invoke($c, $user, $filters);
}
private function filters(string $access = '', string $group = ''): array
{
return ['access' => $access, 'group' => $group];
}
#[Test]
public function direct_permission_matches(): void
{
$c = $this->controller();
$user = TestHelper::createMockUser('editor', [
'access' => ['admin' => ['login' => true], 'site' => ['login' => true]],
]);
$this->assertTrue($this->matchesFilters($c, $user, $this->filters('admin.login')));
$this->assertFalse($this->matchesFilters($c, $user, $this->filters('api.users.write')));
}
#[Test]
public function parent_key_inheritance_grants_child_permission(): void
{
$c = $this->controller();
$user = TestHelper::createMockUser('pm', [
'access' => ['api' => ['pages' => true]],
]);
// api.pages covers api.pages.read via walk-up.
$this->assertTrue($this->matchesFilters($c, $user, $this->filters('api.pages.read')));
}
#[Test]
public function explicit_false_is_not_overridden_by_parent(): void
{
$c = $this->controller();
$user = TestHelper::createMockUser('limited', [
'access' => ['api' => ['pages' => true, 'pages.delete' => false]],
]);
$this->assertTrue($this->matchesFilters($c, $user, $this->filters('api.pages.read')));
$this->assertFalse($this->matchesFilters($c, $user, $this->filters('api.pages.delete')));
}
#[Test]
public function super_admin_matches_any_permission_filter(): void
{
$c = $this->controller();
$apiSuper = TestHelper::createMockUser('root', ['access' => ['api' => ['super' => true]]]);
$adminSuper = TestHelper::createMockUser('classic', ['access' => ['admin' => ['super' => true]]]);
foreach (['admin.login', 'api.users.write', 'api.pages.read'] as $perm) {
$this->assertTrue($this->matchesFilters($c, $apiSuper, $this->filters($perm)), "api.super should match $perm");
$this->assertTrue($this->matchesFilters($c, $adminSuper, $this->filters($perm)), "admin.super should match $perm");
}
}
#[Test]
public function group_inherited_access_matches(): void
{
$c = $this->controller([
'groups' => ['editors' => ['access' => ['admin' => ['login' => true]]]],
]);
$user = TestHelper::createMockUser('member', [
'access' => ['site' => ['login' => true]],
'groups' => ['editors'],
]);
// Granted only via the group, not directly.
$this->assertTrue($this->matchesFilters($c, $user, $this->filters('admin.login')));
}
#[Test]
public function own_access_overrides_group_access(): void
{
$c = $this->controller([
'groups' => ['editors' => ['access' => ['admin' => ['login' => true]]]],
]);
$user = TestHelper::createMockUser('revoked', [
'access' => ['admin' => ['login' => false]],
'groups' => ['editors'],
]);
$this->assertFalse($this->matchesFilters($c, $user, $this->filters('admin.login')));
}
#[Test]
public function group_filter_checks_membership(): void
{
$c = $this->controller();
$user = TestHelper::createMockUser('member', ['groups' => ['editors', 'authors']]);
$this->assertTrue($this->matchesFilters($c, $user, $this->filters('', 'authors')));
$this->assertFalse($this->matchesFilters($c, $user, $this->filters('', 'admins')));
}
#[Test]
public function access_and_group_filters_combine(): void
{
$c = $this->controller();
$user = TestHelper::createMockUser('member', [
'access' => ['admin' => ['login' => true]],
'groups' => ['editors'],
]);
$this->assertTrue($this->matchesFilters($c, $user, $this->filters('admin.login', 'editors')));
// Right permission, wrong group.
$this->assertFalse($this->matchesFilters($c, $user, $this->filters('admin.login', 'admins')));
}
#[Test]
public function empty_filters_match_everyone(): void
{
$c = $this->controller();
$user = TestHelper::createMockUser('anyone', []);
$this->assertTrue($this->matchesFilters($c, $user, $this->filters()));
}
#[Test]
public function get_list_filters_reads_access_permission_alias_and_group(): void
{
$c = $this->controller();
$m = new ReflectionMethod($c, 'getListFilters');
$m->setAccessible(true);
$withAccess = TestHelper::createMockRequest(
method: 'GET',
path: '/api/v1/users',
queryParams: ['access' => ' admin.login ', 'group' => 'editors'],
);
$this->assertSame(['access' => 'admin.login', 'group' => 'editors'], $m->invoke($c, $withAccess));
// `permission` is accepted as an alias for `access`.
$withAlias = TestHelper::createMockRequest(
method: 'GET',
path: '/api/v1/users',
queryParams: ['permission' => 'api.super'],
);
$this->assertSame(['access' => 'api.super', 'group' => ''], $m->invoke($c, $withAlias));
}
}
@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Framework\Acl\Permissions;
use Grav\Plugin\Api\Controllers\UsersController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
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;
/**
* Regression tests for GHSA-r945-h4vm-h736.
*
* The advisory describes a self-edit IDOR where any user holding `api.access`
* could PATCH /users/{self} with an `access` payload and self-promote to
* super-admin. The fix gates `state` and `access` on `api.users.write`; these
* tests pin that boundary.
*/
#[CoversClass(UsersController::class)]
class UsersControllerUpdatePrivescTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_users_privesc_test_' . uniqid();
@mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
private function buildController(UserInterface $targetUser): UsersController
{
$tempDir = $this->tempDir;
$config = new Config([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
'pagination' => ['default_per_page' => 20, 'max_per_page' => 100],
], 'login' => ['twofa_enabled' => false]],
]);
$locator = new class ($tempDir) {
public function __construct(private string $base) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): ?string
{
if (str_starts_with($uri, 'cache://')) {
return $this->base . '/cache';
}
return $this->base;
}
};
$accounts = TestHelper::createMockAccounts([$targetUser->username => $targetUser]);
TestHelper::createMockGrav([
'config' => $config,
'locator' => $locator,
'accounts' => $accounts,
// PermissionResolver::resolve() reads only from $user->get('access'),
// so an empty Permissions() instance is enough to satisfy the type.
'permissions' => new Permissions(),
]);
return new UsersController(\Grav\Common\Grav::instance(), $config);
}
/** @param array<string, mixed> $body */
private function makeRequest(UserInterface $caller, string $targetUsername, array $body): ServerRequestInterface
{
return TestHelper::createMockRequest(
method: 'PATCH',
path: '/api/v1/users/' . $targetUsername,
headers: ['Content-Type' => 'application/json'],
body: json_encode($body),
attributes: [
'api_user' => $caller,
'json_body' => $body,
'route_params' => ['username' => $targetUsername],
],
);
}
// -------------------------------------------------------------------
// GHSA-r945-h4vm-h736 — privilege escalation via self-edit `access`
// -------------------------------------------------------------------
#[Test]
public function self_edit_with_access_payload_is_rejected_for_low_priv_user(): void
{
$user = TestHelper::createMockUser('user1', [
'access' => ['api' => ['access' => true], 'site' => ['login' => true]],
'email' => 'user1@example.com',
]);
$controller = $this->buildController($user);
$payload = [
'access' => [
'admin' => ['login' => true, 'super' => true],
'api' => ['access' => true, 'super' => true],
'site' => ['login' => true],
],
];
$threw = false;
try {
$controller->update($this->makeRequest($user, 'user1', $payload));
} catch (ForbiddenException $e) {
$threw = true;
$this->assertStringContainsString("'access'", $e->getMessage());
$this->assertStringContainsString('api.users.write', $e->getMessage());
}
$this->assertTrue($threw, 'Self-edit with access payload must throw ForbiddenException.');
// Defense in depth: even if the exception path were skipped, the user's
// access map must not have been mutated to grant super-admin.
$access = $user->get('access');
$this->assertArrayNotHasKey('admin', $access ?? [], 'admin.* must not be added by the rejected request');
$this->assertArrayNotHasKey('super', ($access['api'] ?? []), 'api.super must not be added by the rejected request');
}
#[Test]
public function self_edit_with_state_payload_is_rejected_for_low_priv_user(): void
{
$user = TestHelper::createMockUser('user1', [
'access' => ['api' => ['access' => true]],
'state' => 'enabled',
]);
$controller = $this->buildController($user);
$this->expectException(ForbiddenException::class);
$controller->update($this->makeRequest($user, 'user1', ['state' => 'disabled']));
}
#[Test]
public function self_edit_of_profile_fields_succeeds_for_low_priv_user(): void
{
$user = TestHelper::createMockUser('user1', [
'access' => ['api' => ['access' => true]],
'email' => 'old@example.com',
'fullname' => 'Old Name',
]);
$controller = $this->buildController($user);
$controller->update($this->makeRequest($user, 'user1', [
'email' => 'new@example.com',
'fullname' => 'New Name',
'title' => 'Editor',
'language' => 'en',
]));
$this->assertSame('new@example.com', $user->get('email'));
$this->assertSame('New Name', $user->get('fullname'));
$this->assertSame('Editor', $user->get('title'));
$this->assertSame('en', $user->get('language'));
}
#[Test]
public function admin_can_update_access_field_on_other_user(): void
{
// Nested access map so PermissionResolver sees api.users.write as
// granted (the test mock's get() doesn't traverse dot notation, so
// the flat-key shortcut wouldn't work for the resolver).
$admin = TestHelper::createMockUser('admin', [
'access' => ['api' => ['access' => true, 'users' => ['write' => true]]],
]);
$target = TestHelper::createMockUser('user1', [
'access' => ['api' => ['access' => true]],
]);
$config = new Config([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
'pagination' => ['default_per_page' => 20, 'max_per_page' => 100],
], 'login' => ['twofa_enabled' => false]],
]);
$tempDir = $this->tempDir;
$locator = new class ($tempDir) {
public function __construct(private string $base) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): ?string
{
if (str_starts_with($uri, 'cache://')) return $this->base . '/cache';
return $this->base;
}
};
TestHelper::createMockGrav([
'config' => $config,
'locator' => $locator,
'accounts' => TestHelper::createMockAccounts(['admin' => $admin, 'user1' => $target]),
'permissions' => new Permissions(),
]);
$controller = new UsersController(\Grav\Common\Grav::instance(), $config);
$newAccess = ['api' => ['access' => true, 'users' => ['read' => true]]];
$controller->update($this->makeRequest($admin, 'user1', ['access' => $newAccess]));
$this->assertSame($newAccess, $target->get('access'));
}
#[Test]
public function user_with_users_write_can_self_edit_access_field(): void
{
// A user-manager editing their own profile is allowed to touch `access`
// because they already hold api.users.write.
$manager = TestHelper::createMockUser('manager', [
'access' => ['api' => ['access' => true, 'users' => ['write' => true]]],
]);
$controller = $this->buildController($manager);
$newAccess = ['api' => ['access' => true, 'users' => ['write' => true, 'read' => true]]];
$controller->update($this->makeRequest($manager, 'manager', ['access' => $newAccess]));
$this->assertSame($newAccess, $manager->get('access'));
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Exceptions;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(ApiException::class)]
#[CoversClass(NotFoundException::class)]
#[CoversClass(ForbiddenException::class)]
#[CoversClass(UnauthorizedException::class)]
#[CoversClass(ValidationException::class)]
#[CoversClass(ConflictException::class)]
class ExceptionTest extends TestCase
{
#[Test]
public function api_exception_properties(): void
{
$exception = new ApiException(
statusCode: 418,
errorTitle: "I'm a Teapot",
detail: 'Short and stout.',
headers: ['X-Teapot' => 'yes'],
);
self::assertSame(418, $exception->getStatusCode());
self::assertSame("I'm a Teapot", $exception->getErrorTitle());
self::assertSame('Short and stout.', $exception->getMessage());
self::assertSame(['X-Teapot' => 'yes'], $exception->getHeaders());
// The code property of RuntimeException should be the HTTP status
self::assertSame(418, $exception->getCode());
}
#[Test]
public function api_exception_with_previous(): void
{
$previous = new \RuntimeException('Root cause');
$exception = new ApiException(500, 'Internal Server Error', 'Something broke.', previous: $previous);
self::assertSame($previous, $exception->getPrevious());
}
#[Test]
public function not_found_exception_defaults(): void
{
$exception = new NotFoundException();
self::assertSame(404, $exception->getStatusCode());
self::assertSame('Not Found', $exception->getErrorTitle());
self::assertSame('The requested resource was not found.', $exception->getMessage());
self::assertSame([], $exception->getHeaders());
}
#[Test]
public function not_found_exception_custom_detail(): void
{
$exception = new NotFoundException('Page /blog/missing was not found.');
self::assertSame(404, $exception->getStatusCode());
self::assertSame('Page /blog/missing was not found.', $exception->getMessage());
}
#[Test]
public function forbidden_exception_defaults(): void
{
$exception = new ForbiddenException();
self::assertSame(403, $exception->getStatusCode());
self::assertSame('Forbidden', $exception->getErrorTitle());
self::assertSame('You do not have permission to perform this action.', $exception->getMessage());
self::assertSame([], $exception->getHeaders());
}
#[Test]
public function unauthorized_exception_has_www_authenticate_header(): void
{
$exception = new UnauthorizedException();
self::assertSame(401, $exception->getStatusCode());
self::assertSame('Unauthorized', $exception->getErrorTitle());
self::assertSame('Authentication is required.', $exception->getMessage());
$headers = $exception->getHeaders();
self::assertArrayHasKey('WWW-Authenticate', $headers);
self::assertSame('Bearer', $headers['WWW-Authenticate']);
}
#[Test]
public function unauthorized_exception_custom_detail(): void
{
$exception = new UnauthorizedException('Token has expired.');
self::assertSame(401, $exception->getStatusCode());
self::assertSame('Token has expired.', $exception->getMessage());
self::assertSame('Bearer', $exception->getHeaders()['WWW-Authenticate']);
}
#[Test]
public function validation_exception_includes_errors(): void
{
$errors = [
['field' => 'email', 'message' => 'Email is required.'],
['field' => 'name', 'message' => 'Name must not be empty.'],
];
$exception = new ValidationException('Validation failed.', $errors);
self::assertSame(422, $exception->getStatusCode());
self::assertSame('Unprocessable Entity', $exception->getErrorTitle());
self::assertSame('Validation failed.', $exception->getMessage());
self::assertSame($errors, $exception->getValidationErrors());
}
#[Test]
public function validation_exception_defaults(): void
{
$exception = new ValidationException();
self::assertSame(422, $exception->getStatusCode());
self::assertSame('The request data is invalid.', $exception->getMessage());
self::assertSame([], $exception->getValidationErrors());
}
#[Test]
public function conflict_exception_defaults(): void
{
$exception = new ConflictException();
self::assertSame(409, $exception->getStatusCode());
self::assertSame('Conflict', $exception->getErrorTitle());
self::assertSame('The resource has been modified. Refresh and try again.', $exception->getMessage());
self::assertSame([], $exception->getHeaders());
}
#[Test]
public function conflict_exception_custom_detail(): void
{
$exception = new ConflictException('ETag mismatch for page /about.');
self::assertSame(409, $exception->getStatusCode());
self::assertSame('ETag mismatch for page /about.', $exception->getMessage());
}
#[Test]
public function all_exceptions_extend_api_exception(): void
{
self::assertInstanceOf(ApiException::class, new NotFoundException());
self::assertInstanceOf(ApiException::class, new ForbiddenException());
self::assertInstanceOf(ApiException::class, new UnauthorizedException());
self::assertInstanceOf(ApiException::class, new ValidationException());
self::assertInstanceOf(ApiException::class, new ConflictException());
}
#[Test]
public function api_exception_extends_runtime_exception(): void
{
self::assertInstanceOf(\RuntimeException::class, new ApiException(500, 'Error', 'test'));
}
}
@@ -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;
}
};
}
}
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Response;
use Grav\Plugin\Api\Response\ApiResponse;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
#[CoversClass(ApiResponse::class)]
class ApiResponseTest extends TestCase
{
#[Test]
public function create_returns_json_response(): void
{
$response = ApiResponse::create(['name' => 'test']);
self::assertInstanceOf(ResponseInterface::class, $response);
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('application/json', $response->getHeaderLine('Content-Type'));
$body = json_decode((string) $response->getBody(), true);
self::assertIsArray($body);
self::assertArrayHasKey('data', $body);
self::assertSame(['name' => 'test'], $body['data']);
}
#[Test]
public function create_with_custom_status(): void
{
$response = ApiResponse::create(['ok' => true], 202);
self::assertSame(202, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
self::assertSame(['ok' => true], $body['data']);
}
#[Test]
public function create_includes_cache_control_header(): void
{
$response = ApiResponse::create(['x' => 1]);
self::assertSame('no-store, max-age=0', $response->getHeaderLine('Cache-Control'));
}
#[Test]
public function paginated_response_structure(): void
{
$items = [['id' => 1], ['id' => 2]];
$response = ApiResponse::paginated(
data: $items,
total: 50,
page: 2,
perPage: 10,
baseUrl: '/api/items',
);
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
self::assertArrayHasKey('data', $body);
self::assertSame($items, $body['data']);
self::assertArrayHasKey('meta', $body);
self::assertArrayHasKey('pagination', $body['meta']);
$pagination = $body['meta']['pagination'];
self::assertSame(2, $pagination['page']);
self::assertSame(10, $pagination['per_page']);
self::assertSame(50, $pagination['total']);
self::assertSame(5, $pagination['total_pages']);
self::assertArrayHasKey('links', $body);
self::assertArrayHasKey('self', $body['links']);
}
#[Test]
public function paginated_response_links(): void
{
$response = ApiResponse::paginated(
data: [],
total: 50,
page: 3,
perPage: 10,
baseUrl: '/api/items',
);
$body = json_decode((string) $response->getBody(), true);
$links = $body['links'];
self::assertStringContainsString('page=3', $links['self']);
self::assertStringContainsString('per_page=10', $links['self']);
self::assertArrayHasKey('first', $links);
self::assertStringContainsString('page=1', $links['first']);
self::assertArrayHasKey('prev', $links);
self::assertStringContainsString('page=2', $links['prev']);
self::assertArrayHasKey('next', $links);
self::assertStringContainsString('page=4', $links['next']);
self::assertArrayHasKey('last', $links);
self::assertStringContainsString('page=5', $links['last']);
}
#[Test]
public function paginated_first_page_no_prev_link(): void
{
$response = ApiResponse::paginated(
data: [],
total: 30,
page: 1,
perPage: 10,
baseUrl: '/api/items',
);
$body = json_decode((string) $response->getBody(), true);
$links = $body['links'];
self::assertArrayNotHasKey('first', $links, 'First page should not have a "first" link');
self::assertArrayNotHasKey('prev', $links, 'First page should not have a "prev" link');
self::assertArrayHasKey('next', $links);
self::assertArrayHasKey('last', $links);
}
#[Test]
public function paginated_last_page_no_next_link(): void
{
$response = ApiResponse::paginated(
data: [],
total: 30,
page: 3,
perPage: 10,
baseUrl: '/api/items',
);
$body = json_decode((string) $response->getBody(), true);
$links = $body['links'];
self::assertArrayNotHasKey('next', $links, 'Last page should not have a "next" link');
self::assertArrayNotHasKey('last', $links, 'Last page should not have a "last" link');
self::assertArrayHasKey('first', $links);
self::assertArrayHasKey('prev', $links);
}
#[Test]
public function created_response(): void
{
$response = ApiResponse::created(['id' => 42], '/api/items/42');
self::assertSame(201, $response->getStatusCode());
self::assertSame('/api/items/42', $response->getHeaderLine('Location'));
$body = json_decode((string) $response->getBody(), true);
self::assertSame(['id' => 42], $body['data']);
}
#[Test]
public function no_content_response(): void
{
$response = ApiResponse::noContent();
self::assertSame(204, $response->getStatusCode());
$body = (string) $response->getBody();
self::assertEmpty($body);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Response;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ErrorResponse;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
#[CoversClass(ErrorResponse::class)]
class ErrorResponseTest extends TestCase
{
#[Test]
public function create_returns_problem_json(): void
{
$response = ErrorResponse::create(400, 'Bad Request', 'Something was wrong.');
self::assertInstanceOf(ResponseInterface::class, $response);
self::assertStringContainsString(
'application/problem+json',
$response->getHeaderLine('Content-Type'),
);
}
#[Test]
public function create_includes_status_title_detail(): void
{
$response = ErrorResponse::create(422, 'Unprocessable Entity', 'Name is required.');
self::assertSame(422, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
self::assertSame(422, $body['status']);
self::assertSame('Unprocessable Entity', $body['title']);
self::assertSame('Name is required.', $body['detail']);
}
#[Test]
public function create_with_custom_headers(): void
{
$response = ErrorResponse::create(
429,
'Too Many Requests',
'Rate limit exceeded.',
['Retry-After' => '60'],
);
self::assertSame(429, $response->getStatusCode());
self::assertSame('60', $response->getHeaderLine('Retry-After'));
}
#[Test]
public function create_includes_cache_control(): void
{
$response = ErrorResponse::create(500, 'Error', 'Oops.');
self::assertSame('no-store, max-age=0', $response->getHeaderLine('Cache-Control'));
}
#[Test]
public function from_exception(): void
{
$exception = new ApiException(
statusCode: 404,
errorTitle: 'Not Found',
detail: 'Page /missing not found.',
);
$response = ErrorResponse::fromException($exception);
self::assertSame(404, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
self::assertSame(404, $body['status']);
self::assertSame('Not Found', $body['title']);
self::assertSame('Page /missing not found.', $body['detail']);
}
#[Test]
public function from_validation_exception_includes_errors(): void
{
$errors = [
['field' => 'title', 'message' => 'Title is required.'],
['field' => 'slug', 'message' => 'Slug must be unique.'],
];
$exception = new ValidationException(
detail: 'Validation failed.',
errors: $errors,
);
$response = ErrorResponse::fromException($exception);
self::assertSame(422, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
self::assertArrayHasKey('errors', $body);
self::assertCount(2, $body['errors']);
self::assertSame('Title is required.', $body['errors'][0]['message']);
self::assertSame('slug', $body['errors'][1]['field']);
}
#[Test]
public function from_exception_with_no_validation_errors_has_no_errors_key(): void
{
$exception = new ApiException(
statusCode: 403,
errorTitle: 'Forbidden',
detail: 'Access denied.',
);
$response = ErrorResponse::fromException($exception);
$body = json_decode((string) $response->getBody(), true);
self::assertArrayNotHasKey('errors', $body);
}
#[Test]
public function from_exception_preserves_custom_headers(): void
{
$exception = new ApiException(
statusCode: 401,
errorTitle: 'Unauthorized',
detail: 'Token expired.',
headers: ['WWW-Authenticate' => 'Bearer'],
);
$response = ErrorResponse::fromException($exception);
self::assertSame('Bearer', $response->getHeaderLine('WWW-Authenticate'));
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Router;
use FastRoute\DataGenerator;
use FastRoute\RouteCollector;
use FastRoute\RouteParser;
use Grav\Plugin\Api\ApiRouteCollector;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(ApiRouteCollector::class)]
class ApiRouteCollectorTest extends TestCase
{
#[Test]
public function get_registers_get_route(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with('GET', '/items', ['ItemController', 'index']);
$api = new ApiRouteCollector($collector);
$result = $api->get('/items', ['ItemController', 'index']);
self::assertSame($api, $result, 'get() should return $this for fluent chaining');
}
#[Test]
public function post_registers_post_route(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with('POST', '/items', ['ItemController', 'create']);
$api = new ApiRouteCollector($collector);
$api->post('/items', ['ItemController', 'create']);
}
#[Test]
public function patch_registers_patch_route(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with('PATCH', '/items/{id}', ['ItemController', 'update']);
$api = new ApiRouteCollector($collector);
$api->patch('/items/{id}', ['ItemController', 'update']);
}
#[Test]
public function delete_registers_delete_route(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with('DELETE', '/items/{id}', ['ItemController', 'destroy']);
$api = new ApiRouteCollector($collector);
$api->delete('/items/{id}', ['ItemController', 'destroy']);
}
#[Test]
public function put_registers_put_route(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with('PUT', '/items/{id}', ['ItemController', 'replace']);
$api = new ApiRouteCollector($collector);
$api->put('/items/{id}', ['ItemController', 'replace']);
}
#[Test]
public function add_route_with_multiple_methods(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with(['GET', 'POST'], '/multi', ['MultiController', 'handle']);
$api = new ApiRouteCollector($collector);
$api->addRoute(['GET', 'POST'], '/multi', ['MultiController', 'handle']);
}
#[Test]
public function group_prefixes_routes(): void
{
$collector = $this->createMock(RouteCollector::class);
$matcher = self::exactly(2);
$collector->expects($matcher)
->method('addRoute')
->willReturnCallback(function (string $method, string $route, array $handler) use ($matcher): void {
match ($matcher->numberOfInvocations()) {
1 => self::assertSame([
'GET', '/admin/users', ['UserController', 'index'],
], [$method, $route, $handler]),
2 => self::assertSame([
'POST', '/admin/users', ['UserController', 'create'],
], [$method, $route, $handler]),
};
});
$api = new ApiRouteCollector($collector);
$api->group('/admin', function (ApiRouteCollector $group): void {
$group->get('/users', ['UserController', 'index']);
$group->post('/users', ['UserController', 'create']);
});
}
#[Test]
public function nested_groups(): void
{
$collector = $this->createMock(RouteCollector::class);
$collector->expects(self::once())
->method('addRoute')
->with('GET', '/api/v2/pages', ['PageController', 'index']);
$api = new ApiRouteCollector($collector);
$api->group('/api', function (ApiRouteCollector $group): void {
$group->group('/v2', function (ApiRouteCollector $inner): void {
$inner->get('/pages', ['PageController', 'index']);
});
});
}
#[Test]
public function group_returns_self_for_chaining(): void
{
$collector = $this->createMock(RouteCollector::class);
$api = new ApiRouteCollector($collector);
$result = $api->group('/prefix', function (ApiRouteCollector $group): void {
// no-op
});
self::assertSame($api, $result, 'group() should return the outer collector for chaining');
}
}
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see ConfigDiffer::effective()} — the per-environment, file-based config
* resolution the admin reads and edits. This is what makes base/"Default"
* show base config while a hostname overlay is active, instead of leaking the
* overlay into both modes.
*
* Mirrors the real bug: kimi.api_key is `sk-A7…` in user/config but `32433…`
* in user/env/localhost/config — "Default" must read the former, "localhost"
* the latter.
*
* Needs Grav core on the classpath for the YAML parser. Run inside a Grav
* install or with GRAV_ROOT set (e.g. `GRAV_ROOT=/path/to/grav composer test`).
*/
class ConfigDifferEffectiveTest extends TestCase
{
private ?string $tmp = null;
private ConfigDiffer $differ;
private const SCOPE = 'plugins/translation-service';
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-effective-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/user/config/plugins', 0777, true);
mkdir($this->tmp . '/user/plugins/translation-service', 0777, true);
mkdir($this->tmp . '/user/env/localhost/config/plugins', 0777, true);
// Plugin's own defaults: kimi key empty by default.
file_put_contents(
$this->tmp . '/user/plugins/translation-service/translation-service.yaml',
"enabled: true\nkimi:\n model_bulk: kimi-k2.6\n api_key: ''\n",
);
// Base user/config: real base key.
file_put_contents(
$this->tmp . '/user/config/plugins/translation-service.yaml',
"kimi:\n api_key: 'sk-A7base'\n",
);
// localhost env overlay: different key.
file_put_contents(
$this->tmp . '/user/env/localhost/config/plugins/translation-service.yaml',
"kimi:\n api_key: '32433overlay'\n",
);
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new EffectiveFakeLocator($this->tmp);
$this->differ = new ConfigDiffer($grav);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function base_target_reads_user_config_not_the_env_overlay(): void
{
$effective = $this->differ->effective(self::SCOPE, null);
$this->assertSame('sk-A7base', $effective['kimi']['api_key']);
// Default from the plugin yaml survives where neither layer overrides it.
$this->assertSame('kimi-k2.6', $effective['kimi']['model_bulk']);
$this->assertTrue($effective['enabled']);
}
#[Test]
public function env_target_reads_the_overlay_on_top_of_base(): void
{
$effective = $this->differ->effective(self::SCOPE, 'localhost');
$this->assertSame('32433overlay', $effective['kimi']['api_key']);
$this->assertSame('kimi-k2.6', $effective['kimi']['model_bulk']);
}
#[Test]
public function unknown_env_target_falls_back_to_base(): void
{
// No user/env/staging folder → overlay layer contributes nothing.
$effective = $this->differ->effective(self::SCOPE, 'staging');
$this->assertSame('sk-A7base', $effective['kimi']['api_key']);
}
#[Test]
public function effective_is_empty_when_scope_has_no_files(): void
{
$this->assertSame([], $this->differ->effective('plugins/ghost', null));
$this->assertSame([], $this->differ->effective('plugins/ghost', 'localhost'));
}
private function rrmdir(string $path): void
{
if (!is_dir($path)) return;
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
) as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($path);
}
}
/**
* Locator covering the stream prefixes ConfigDiffer::effective() and
* EnvironmentService touch (user://, user://config, plugins://).
*/
class EffectiveFakeLocator
{
public function __construct(private string $root) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false
{
$map = [
'user://' => $this->root . '/user',
'user://config' => $this->root . '/user/config',
'system://config' => $this->root . '/system/config',
'plugins://' => $this->root . '/user/plugins',
'themes://' => $this->root . '/user/themes',
];
foreach ($map as $prefix => $base) {
if ($prefix === $uri) {
return file_exists($base) || $first ? $base : false;
}
if (str_starts_with($uri, $prefix)) {
$sub = substr($uri, strlen($prefix));
$full = $base . ($sub !== '' ? '/' . $sub : '');
return file_exists($full) ? $full : false;
}
}
return false;
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ConfigDiffer's GRAV_CONFIG__* environment-override handling.
*
* These mirror Grav core's InitializeProcessor resolution: values injected via
* env vars (typically from a .env file) win at runtime and must never be
* written back to a YAML config file on save.
*/
class ConfigDifferEnvOverrideTest extends TestCase
{
private ConfigDiffer $differ;
/** @var list<string> */
private array $touched = [];
protected function setUp(): void
{
Grav::resetInstance();
$this->differ = new ConfigDiffer(Grav::instance());
$this->clearVar('GRAV_CONFIG');
}
protected function tearDown(): void
{
foreach ($this->touched as $name) {
$this->clearVar($name);
}
$this->clearVar('GRAV_CONFIG');
}
// ---------- environmentOverrideKeys() ----------
#[Test]
public function override_keys_are_empty_when_switch_is_off(): void
{
$this->setVar('GRAV_CONFIG__plugins__email__enabled', 'true');
$this->assertSame([], $this->differ->environmentOverrideKeys());
}
#[Test]
public function override_keys_resolve_double_underscores_to_dots(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__plugins__email__mailer__smtp__password', 'secret');
$this->assertSame(
['plugins.email.mailer.smtp.password'],
$this->differ->environmentOverrideKeys(),
);
}
#[Test]
public function override_keys_apply_aliases_for_hyphenated_paths(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG_ALIAS__TRANSLATIONSERVICE', 'plugins.translation-service');
$this->setVar('GRAV_CONFIG__TRANSLATIONSERVICE__anthropic__api_key', 'sk-ant-123');
$this->assertSame(
['plugins.translation-service.anthropic.api_key'],
$this->differ->environmentOverrideKeys(),
);
}
// ---------- stripEnvironmentOverrides() ----------
#[Test]
public function strip_removes_a_scoped_env_key_and_prunes_empty_parents(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG_ALIAS__TRANSLATIONSERVICE', 'plugins.translation-service');
$this->setVar('GRAV_CONFIG__TRANSLATIONSERVICE__anthropic__api_key', 'sk-ant-123');
$data = [
'enabled' => true,
'anthropic' => ['api_key' => 'sk-ant-123', 'model' => 'opus'],
];
// api_key is dropped; model (and its parent) survive.
$this->assertSame(
['enabled' => true, 'anthropic' => ['model' => 'opus']],
$this->differ->stripEnvironmentOverrides($data, 'plugins/translation-service'),
);
}
#[Test]
public function strip_prunes_a_subtree_that_empties_out(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG_ALIAS__TRANSLATIONSERVICE', 'plugins.translation-service');
$this->setVar('GRAV_CONFIG__TRANSLATIONSERVICE__anthropic__api_key', 'sk-ant-123');
$data = ['enabled' => true, 'anthropic' => ['api_key' => 'sk-ant-123']];
$this->assertSame(
['enabled' => true],
$this->differ->stripEnvironmentOverrides($data, 'plugins/translation-service'),
);
}
#[Test]
public function strip_is_a_noop_when_switch_is_off(): void
{
$this->setVar('GRAV_CONFIG__plugins__email__mailer__smtp__password', 'secret');
$data = ['mailer' => ['smtp' => ['password' => 'secret']]];
$this->assertSame($data, $this->differ->stripEnvironmentOverrides($data, 'plugins/email'));
}
#[Test]
public function strip_ignores_keys_outside_the_scope(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__plugins__other__token', 'xyz');
$data = ['token' => 'mine'];
// The env key targets plugins.other, not plugins.email — leave it alone.
$this->assertSame($data, $this->differ->stripEnvironmentOverrides($data, 'plugins/email'));
}
#[Test]
public function strip_returns_empty_when_the_whole_scope_is_env_provided(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__system', 'whatever');
$this->assertSame([], $this->differ->stripEnvironmentOverrides(['x' => 1], 'system'));
}
#[Test]
public function strip_is_scope_agnostic_for_core_and_flex_scopes(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__system__cache__enabled', 'false');
$this->setVar('GRAV_CONFIG__flex__accounts__timeout', '60');
// Core 'system' scope: cache.enabled is stripped, the emptied cache
// subtree is pruned, pages survives.
$this->assertSame(
['pages' => ['theme' => 'quark']],
$this->differ->stripEnvironmentOverrides(
['pages' => ['theme' => 'quark'], 'cache' => ['enabled' => false]],
'system',
),
);
// 'flex/accounts' scope maps to the flex.accounts config key.
$this->assertSame(
['name' => 'flex'],
$this->differ->stripEnvironmentOverrides(
['name' => 'flex', 'timeout' => '60'],
'flex/accounts',
),
);
}
private function setVar(string $name, string $value): void
{
$this->touched[] = $name;
putenv("$name=$value");
$_ENV[$name] = $_SERVER[$name] = $value;
}
private function clearVar(string $name): void
{
putenv($name);
unset($_ENV[$name], $_SERVER[$name]);
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* The override-map primitives behind the per-field override indicators and the
* revert endpoint (see docs/config-overrides-revert): flatten a persisted delta
* to dotted leaf paths, read the fallback value out of the parent layer, and
* remove a key from the active layer's file.
*/
class ConfigDifferOverrideTest extends TestCase
{
protected function tearDown(): void
{
Grav::resetInstance();
}
#[Test]
public function flatten_leaves_recurses_maps_but_keeps_lists_atomic(): void
{
$delta = [
'pages' => ['theme' => 'quark2', 'events' => ['page' => false]],
'types' => ['html', 'htm', 'xml'], // sequential list — atomic
'debugger' => ['enabled' => true],
];
$this->assertSame(
['pages.theme', 'pages.events.page', 'types', 'debugger.enabled'],
ConfigDiffer::flattenLeaves($delta),
);
}
#[Test]
public function flatten_leaves_is_empty_for_no_overrides(): void
{
$this->assertSame([], ConfigDiffer::flattenLeaves([]));
}
#[Test]
public function value_at_path_digs_nested_keys(): void
{
$parent = ['github' => ['app_id' => '3771292'], 'kimi' => ['api_key' => 'sk-A7']];
$this->assertSame('3771292', ConfigDiffer::valueAtPath($parent, 'github.app_id'));
$this->assertSame('sk-A7', ConfigDiffer::valueAtPath($parent, 'kimi.api_key'));
}
#[Test]
public function value_at_path_returns_null_when_absent(): void
{
// A key the active layer adds but the parent never had reverts to the
// blueprint default / unset, which the client renders as "empty".
$parent = ['github' => ['app_id' => '3771292']];
$this->assertNull(ConfigDiffer::valueAtPath($parent, 'github.missing'));
$this->assertNull(ConfigDiffer::valueAtPath($parent, 'nope.at.all'));
}
#[Test]
public function unset_dot_path_removes_a_key_and_prunes_empty_parents(): void
{
$differ = new ConfigDiffer(Grav::instance());
$delta = ['github' => ['app_id' => '3726627', 'secret' => 'x'], 'kimi' => ['api_key' => '123']];
// Removing one of two siblings keeps the parent.
$this->assertSame(
['github' => ['secret' => 'x'], 'kimi' => ['api_key' => '123']],
$differ->unsetDotPath($delta, 'github.app_id'),
);
// Removing the last child prunes the now-empty parent map entirely.
$this->assertSame(
['github' => ['app_id' => '3726627', 'secret' => 'x']],
$differ->unsetDotPath($delta, 'kimi.api_key'),
);
}
#[Test]
public function unset_dot_path_is_a_noop_for_absent_keys(): void
{
$differ = new ConfigDiffer(Grav::instance());
$delta = ['github' => ['app_id' => '3726627']];
$this->assertSame($delta, $differ->unsetDotPath($delta, 'kimi.api_key'));
$this->assertSame($delta, $differ->unsetDotPath($delta, 'github.nope'));
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration-ish tests for {@see ConfigDiffer::parent()} — the only method
* that actually touches the filesystem. We spin up a temp directory laid
* out like a Grav install and wire a fake locator into the Grav stub.
*
* Skipped when Grav core isn't on the classpath (stubs have no Yaml parser).
*/
class ConfigDifferParentTest extends TestCase
{
private ?string $tmp = null;
private ConfigDiffer $differ;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-differ-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/system/config', 0777, true);
mkdir($this->tmp . '/user/config', 0777, true);
mkdir($this->tmp . '/user/plugins/form', 0777, true);
mkdir($this->tmp . '/user/themes/quark', 0777, true);
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new FakeLocator($this->tmp);
$this->differ = new ConfigDiffer($grav);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
}
#[Test]
public function parent_for_system_uses_system_config_defaults(): void
{
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\ntimezone: UTC\n",
);
$parent = $this->differ->parent('system', null);
$this->assertSame(['force_ssl' => false, 'timezone' => 'UTC'], $parent);
}
#[Test]
public function parent_for_plugin_uses_plugin_own_yaml(): void
{
file_put_contents(
$this->tmp . '/user/plugins/form/form.yaml',
"enabled: true\nfiles:\n fields: true\n",
);
$parent = $this->differ->parent('plugins/form', null);
$this->assertSame(['enabled' => true, 'files' => ['fields' => true]], $parent);
}
#[Test]
public function parent_for_theme_uses_theme_own_yaml(): void
{
file_put_contents(
$this->tmp . '/user/themes/quark/quark.yaml',
"enabled: true\ndropdown:\n enabled: false\n",
);
$parent = $this->differ->parent('themes/quark', null);
$this->assertSame(['enabled' => true, 'dropdown' => ['enabled' => false]], $parent);
}
#[Test]
public function env_parent_merges_defaults_with_user_config_base(): void
{
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\ntimezone: UTC\npages:\n theme: quark\n",
);
file_put_contents(
$this->tmp . '/user/config/system.yaml',
"force_ssl: true\npages:\n theme: quark2\n",
);
$parent = $this->differ->parent('system', 'staging.foo.com');
// force_ssl + pages.theme overridden by user/config; timezone stays at default.
$this->assertSame(
['force_ssl' => true, 'timezone' => 'UTC', 'pages' => ['theme' => 'quark2']],
$parent,
);
}
#[Test]
public function env_parent_falls_back_to_defaults_when_no_base_file(): void
{
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\n",
);
$parent = $this->differ->parent('system', 'staging');
$this->assertSame(['force_ssl' => false], $parent);
}
#[Test]
public function parent_is_empty_array_when_no_defaults_exist(): void
{
// Theme with no defaults file — parent should be [].
$this->assertSame([], $this->differ->parent('themes/ghost', null));
}
#[Test]
public function parent_diff_round_trip_system_config(): void
{
// Put some defaults and user-layer overrides on disk, then verify the
// full pipeline: compute env parent, diff the desired effective state
// against it, and confirm we only persist the env-specific deltas.
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\ntimezone: UTC\nlanguages:\n supported: [en, fr, de]\n",
);
file_put_contents(
$this->tmp . '/user/config/system.yaml',
"force_ssl: true\n",
);
$desiredEffective = [
'force_ssl' => true, // same as user/config base
'timezone' => 'America/Denver', // env-specific
'languages' => ['supported' => ['en', 'fr']], // shortened list
];
$parent = $this->differ->parent('system', 'staging');
$delta = $this->differ->diff($desiredEffective, $parent);
$this->assertSame(
[
'timezone' => 'America/Denver',
'languages' => ['supported' => ['en', 'fr']],
],
$delta,
);
}
private function rrmdir(string $path): void
{
if (!is_dir($path)) return;
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
) as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($path);
}
}
/**
* Minimal locator mimicking UniformResourceLocator::findResource() for the
* stream prefixes we use.
*/
class FakeLocator
{
public function __construct(private string $root) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false
{
$map = [
'user://' => $this->root . '/user',
'user://config' => $this->root . '/user/config',
'system://config' => $this->root . '/system/config',
'plugins://' => $this->root . '/user/plugins',
'themes://' => $this->root . '/user/themes',
];
foreach ($map as $prefix => $base) {
if ($prefix === $uri) {
return file_exists($base) || $first ? $base : false;
}
if (str_starts_with($uri, $prefix)) {
$sub = substr($uri, strlen($prefix));
$full = $base . ($sub !== '' ? '/' . $sub : '');
return file_exists($full) ? $full : false;
}
}
return false;
}
}
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ConfigDiffer.
*
* The {@see ConfigDiffer::diff()} and {@see ConfigDiffer::deepMergeAssoc()}
* methods are pure and don't touch Grav services — we pass a throwaway Grav
* instance just to satisfy the constructor. {@see ConfigDiffer::parent()}
* covers filesystem resolution in its own test with a real tempdir.
*/
class ConfigDifferTest extends TestCase
{
private ConfigDiffer $differ;
protected function setUp(): void
{
Grav::resetInstance();
$this->differ = new ConfigDiffer(Grav::instance());
}
// ---------- diff() ----------
#[Test]
public function diff_returns_empty_when_current_matches_parent(): void
{
$parent = ['force_ssl' => false, 'languages' => ['supported' => ['en', 'fr']]];
$current = ['force_ssl' => false, 'languages' => ['supported' => ['en', 'fr']]];
$this->assertSame([], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_includes_scalar_overrides(): void
{
$parent = ['force_ssl' => false, 'timezone' => null];
$current = ['force_ssl' => true, 'timezone' => null];
$this->assertSame(['force_ssl' => true], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_recurses_into_associative_arrays(): void
{
$parent = [
'pages' => ['theme' => 'quark', 'markdown' => ['extra' => false]],
];
$current = [
'pages' => ['theme' => 'quark2', 'markdown' => ['extra' => false]],
];
$this->assertSame(
['pages' => ['theme' => 'quark2']],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_treats_sequential_arrays_as_atomic(): void
{
// The classic "shortened list" scenario: user removes one language from
// the list. We must emit the whole shortened list, not a key-diff that
// would silently merge the removed entry back in when Grav re-loads.
$parent = ['languages' => ['supported' => ['en', 'fr', 'de']]];
$current = ['languages' => ['supported' => ['en', 'fr']]];
$this->assertSame(
['languages' => ['supported' => ['en', 'fr']]],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_treats_reordered_sequential_arrays_as_different(): void
{
$parent = ['types' => ['htm', 'html']];
$current = ['types' => ['html', 'htm']];
$this->assertSame(
['types' => ['html', 'htm']],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_null_override_is_retained(): void
{
// Setting a field explicitly to null should override a non-null default.
$parent = ['timezone' => 'UTC'];
$current = ['timezone' => null];
$this->assertSame(['timezone' => null], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_key_absent_from_parent_is_always_kept(): void
{
$parent = ['a' => 1];
$current = ['a' => 1, 'b' => 2];
$this->assertSame(['b' => 2], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_type_change_from_assoc_to_list_replaces_whole_value(): void
{
$parent = ['http_x_forwarded' => ['protocol' => true]];
$current = ['http_x_forwarded' => ['a', 'b']];
$this->assertSame(
['http_x_forwarded' => ['a', 'b']],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_ignores_key_order_differences(): void
{
// Yaml parsers or API clients can legitimately reorder keys. Parent has
// the same content, different insertion order — should diff to empty.
$parent = ['pages' => ['theme' => 'quark', 'markdown' => ['extra' => false]]];
$current = ['pages' => ['markdown' => ['extra' => false], 'theme' => 'quark']];
$this->assertSame([], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_drops_subtree_when_no_inner_differences(): void
{
$parent = ['a' => ['b' => 1, 'c' => 2]];
$current = ['a' => ['b' => 1, 'c' => 2], 'd' => 3];
$this->assertSame(['d' => 3], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_deeply_nested_override(): void
{
$parent = ['a' => ['b' => ['c' => ['d' => 1, 'e' => 2]]]];
$current = ['a' => ['b' => ['c' => ['d' => 1, 'e' => 99]]]];
$this->assertSame(
['a' => ['b' => ['c' => ['e' => 99]]]],
$this->differ->diff($current, $parent),
);
}
// ---------- deepMergeAssoc() ----------
#[Test]
public function deep_merge_overrides_scalar(): void
{
$this->assertSame(
['a' => 2, 'b' => 3],
$this->differ->deepMergeAssoc(['a' => 1, 'b' => 3], ['a' => 2]),
);
}
#[Test]
public function deep_merge_recurses_into_assoc(): void
{
$result = $this->differ->deepMergeAssoc(
['x' => ['a' => 1, 'b' => 2]],
['x' => ['b' => 20, 'c' => 30]],
);
$this->assertSame(['x' => ['a' => 1, 'b' => 20, 'c' => 30]], $result);
}
#[Test]
public function deep_merge_replaces_sequential_arrays(): void
{
$result = $this->differ->deepMergeAssoc(
['tags' => ['a', 'b', 'c']],
['tags' => ['x']],
);
$this->assertSame(['tags' => ['x']], $result);
}
// ---------- valuesEqual() / isAssoc() ----------
#[Test]
public function values_equal_treats_assoc_key_order_as_irrelevant(): void
{
$this->assertTrue(ConfigDiffer::valuesEqual(
['a' => 1, 'b' => 2],
['b' => 2, 'a' => 1],
));
}
#[Test]
public function values_equal_respects_sequential_order(): void
{
$this->assertFalse(ConfigDiffer::valuesEqual([1, 2], [2, 1]));
}
#[Test]
public function is_assoc_is_false_for_empty_and_lists(): void
{
$this->assertFalse(ConfigDiffer::isAssoc([]));
$this->assertFalse(ConfigDiffer::isAssoc(['a', 'b']));
$this->assertTrue(ConfigDiffer::isAssoc(['x' => 1]));
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigScopes;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see ConfigScopes::isCustom()} — the gate that lets the cookbook "add a
* custom yaml file" recipe surface as a config tab in admin2 while keeping
* core/system blueprints (and path traversal) out.
*
* A custom scope is valid only when a user:// or environment:// blueprint
* exists for it; core scopes and system-shipped blueprints (e.g. streams) are
* rejected so the generic api.config.write permission can't reach them.
*/
class ConfigScopesTest extends TestCase
{
private ?string $tmp = null;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-scopes-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/user/blueprints/config', 0777, true);
// A site-authored top-level config blueprint (the recipe).
file_put_contents(
$this->tmp . '/user/blueprints/config/custom.yaml',
"title: Custom Settings\nform:\n fields:\n my_text:\n type: text\n",
);
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new ScopesFakeLocator($this->tmp);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function user_authored_blueprint_is_a_custom_scope(): void
{
$this->assertTrue(ConfigScopes::isCustom(Grav::instance(), 'custom'));
}
#[Test]
public function core_scopes_are_not_custom(): void
{
foreach (ConfigScopes::CORE as $scope) {
$this->assertFalse(
ConfigScopes::isCustom(Grav::instance(), $scope),
"{$scope} is a core scope and must not be treated as custom",
);
}
}
#[Test]
public function scope_without_a_user_blueprint_is_rejected(): void
{
// `streams` ships a system blueprint, not a user one — the FakeLocator
// only resolves user://, so this stands in for "core blueprint exists
// but not under user://".
$this->assertFalse(ConfigScopes::isCustom(Grav::instance(), 'streams'));
$this->assertFalse(ConfigScopes::isCustom(Grav::instance(), 'nope'));
}
#[Test]
public function unsafe_scope_names_are_rejected_before_any_lookup(): void
{
foreach (['../etc/passwd', 'a/b', 'a.b', 'Custom', '-leading', '', 'a b'] as $scope) {
$this->assertFalse(
ConfigScopes::isCustom(Grav::instance(), $scope),
"unsafe scope '{$scope}' must be rejected",
);
}
$this->assertFalse(ConfigScopes::isCustom(Grav::instance(), null));
}
private function rrmdir(string $path): void
{
if (!is_dir($path)) return;
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
) as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($path);
}
}
/**
* Minimal locator resolving only the user:// blueprints stream ConfigScopes
* checks. environment:// is intentionally absent (never set in this fixture)
* so it resolves to false, exercising the user-only path.
*/
class ScopesFakeLocator
{
public function __construct(private string $root) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false
{
$prefix = 'user://';
if (!str_starts_with($uri, $prefix)) {
return false;
}
$full = $this->root . '/user/' . substr($uri, strlen($prefix));
return file_exists($full) ? $full : false;
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Config\Setup;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\EnvironmentService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(EnvironmentService::class)]
class EnvironmentServiceTest extends TestCase
{
private ?string $tmp = null;
private ?string $savedSetupEnv = null;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-envsvc-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/user/config', 0777, true);
// activeEnvironment() now trusts Setup::$environment (the booted env)
// before the Uri. Null it so the Uri-only tests are deterministic; the
// booted-env tests set it explicitly. Restore the global afterwards.
$this->savedSetupEnv = Setup::$environment;
Setup::$environment = null;
}
protected function tearDown(): void
{
Setup::$environment = $this->savedSetupEnv;
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function active_environment_returns_null_when_no_uri_service(): void
{
$svc = $this->buildService(uri: null);
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_returns_null_when_env_name_empty(): void
{
$svc = $this->buildService(uri: $this->fakeUri(''));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_returns_null_when_env_name_invalid(): void
{
// Path traversal must never be honored as an env.
$svc = $this->buildService(uri: $this->fakeUri('../etc'));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_returns_null_when_env_config_dir_missing(): void
{
// Hostname-derived env that doesn't have a config folder on disk —
// base writes are correct in this case, no overlay to worry about.
$svc = $this->buildService(uri: $this->fakeUri('production.example.com'));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_resolves_legacy_user_host_config_layout(): void
{
mkdir($this->tmp . '/user/localhost/config', 0777, true);
$svc = $this->buildService(uri: $this->fakeUri('localhost'));
$this->assertSame('localhost', $svc->activeEnvironment());
}
#[Test]
public function active_environment_resolves_modern_user_env_layout(): void
{
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
$svc = $this->buildService(uri: $this->fakeUri('staging'));
$this->assertSame('staging', $svc->activeEnvironment());
}
#[Test]
public function active_environment_prefers_booted_env_behind_reverse_proxy(): void
{
// Reverse proxy: Grav booted its overlay from the real host (localhost,
// which has a config dir), while the Uri reports the forwarded host
// (translations.rhuk.net, no dir). The loaded overlay is localhost, so
// that's what must be reported — not null, and not the forwarded host.
mkdir($this->tmp . '/user/env/localhost/config', 0777, true);
Setup::$environment = 'localhost';
$svc = $this->buildService(uri: $this->fakeUri('translations.rhuk.net'));
$this->assertSame('localhost', $svc->activeEnvironment());
}
#[Test]
public function active_environment_is_null_when_booted_env_has_no_config_dir(): void
{
// The booted env is authoritative: if IT has no overlay on disk, no
// overlay is loaded and base is correct — we must NOT fall through to a
// forwarded-host env that happens to have a dir but was never loaded.
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
Setup::$environment = 'localhost'; // booted host, no config dir
$svc = $this->buildService(uri: $this->fakeUri('staging'));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_ignores_malformed_booted_env_and_falls_back_to_uri(): void
{
// A malformed Setup::$environment is treated as "unknown", so the Uri
// fallback still applies.
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
Setup::$environment = '../etc';
$svc = $this->buildService(uri: $this->fakeUri('staging'));
$this->assertSame('staging', $svc->activeEnvironment());
}
#[Test]
public function active_environment_ignores_uri_without_environment_method(): void
{
// Some frameworks ship a Uri-ish object — only Grav's exposes environment().
$svc = $this->buildService(uri: new \stdClass());
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function is_reserved_name_matches_base_sentinels_case_insensitively(): void
{
$this->assertTrue(EnvironmentService::isReservedName('default'));
$this->assertTrue(EnvironmentService::isReservedName('Default'));
$this->assertTrue(EnvironmentService::isReservedName('base'));
$this->assertFalse(EnvironmentService::isReservedName('localhost'));
$this->assertFalse(EnvironmentService::isReservedName('staging'));
}
#[Test]
public function create_environment_rejects_reserved_base_sentinel(): void
{
// `default` must never become an env folder, otherwise its overlay would
// shadow the admin's base-only ("Default") view.
$svc = $this->buildService(uri: null);
$this->expectException(\InvalidArgumentException::class);
$svc->createEnvironment('default');
}
#[Test]
public function create_environment_creates_modern_env_dir(): void
{
$svc = $this->buildService(uri: null);
$dir = $svc->createEnvironment('staging');
$this->assertSame($this->tmp . '/user/env/staging/config', $dir);
$this->assertDirectoryExists($dir);
}
private function buildService(mixed $uri): EnvironmentService
{
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new EnvSvcFakeLocator($this->tmp);
if ($uri !== null) {
$grav['uri'] = $uri;
}
return new EnvironmentService($grav);
}
private function fakeUri(string $env): object
{
return new class ($env) {
public function __construct(private readonly string $env) {}
public function environment(): string { return $this->env; }
};
}
private function rrmdir(string $path): void
{
if (!is_dir($path)) return;
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
) as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($path);
}
}
/**
* EnvironmentService only ever resolves `user://`, so a one-prefix locator
* is enough — and intentionally minimal so each test reflects exactly what
* the service walks.
*/
class EnvSvcFakeLocator
{
public function __construct(private readonly string $root) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false
{
if ($uri === 'user://') {
return is_dir($this->root . '/user') ? $this->root . '/user' : false;
}
return false;
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see UploadFieldSettings} — the per-field upload settings (random_name,
* avoid_overwriting, accept, filesize) that bring admin-next file fields to
* parity with admin-classic. Parsing tolerates both JSON-body and multipart-
* meta shapes; enforcement only ever tightens the controllers' security floor.
*/
#[CoversClass(UploadFieldSettings::class)]
class UploadFieldSettingsTest extends TestCase
{
#[Test]
public function absent_params_produce_an_inert_object(): void
{
$settings = UploadFieldSettings::fromParams([]);
self::assertTrue($settings->isEmpty());
self::assertFalse($settings->randomName);
self::assertFalse($settings->avoidOverwriting);
self::assertSame([], $settings->accept);
self::assertNull($settings->filesizeMb);
}
#[Test]
public function booleans_parse_from_multipart_string_truthies(): void
{
self::assertTrue(UploadFieldSettings::fromParams(['random_name' => '1'])->randomName);
self::assertTrue(UploadFieldSettings::fromParams(['random_name' => 'true'])->randomName);
self::assertTrue(UploadFieldSettings::fromParams(['avoid_overwriting' => 'on'])->avoidOverwriting);
self::assertFalse(UploadFieldSettings::fromParams(['random_name' => '0'])->randomName);
self::assertFalse(UploadFieldSettings::fromParams(['random_name' => 'false'])->randomName);
}
#[Test]
public function accept_parses_from_both_csv_string_and_array(): void
{
self::assertSame(
['image/*', '.pdf'],
UploadFieldSettings::fromParams(['accept' => 'image/*, .pdf'])->accept
);
self::assertSame(
['image/png', '.jpg'],
UploadFieldSettings::fromParams(['accept' => ['image/png', ' .jpg ', '']])->accept
);
}
#[Test]
public function filesize_parses_only_positive_numbers(): void
{
self::assertSame(5.0, UploadFieldSettings::fromParams(['filesize' => '5'])->filesizeMb);
self::assertSame(2.5, UploadFieldSettings::fromParams(['filesize' => 2.5])->filesizeMb);
self::assertNull(UploadFieldSettings::fromParams(['filesize' => '0'])->filesizeMb);
self::assertNull(UploadFieldSettings::fromParams(['filesize' => 'huge'])->filesizeMb);
}
#[Test]
public function assert_filesize_enforces_the_per_field_limit(): void
{
$settings = UploadFieldSettings::fromParams(['filesize' => 1]);
// 1 MB exactly is fine; one byte over the limit is rejected.
$settings->assertFilesize(1_048_576);
$this->expectException(ValidationException::class);
$settings->assertFilesize(1_048_577);
}
#[Test]
public function assert_filesize_is_a_noop_without_a_limit_or_known_size(): void
{
UploadFieldSettings::none()->assertFilesize(999_999_999);
UploadFieldSettings::fromParams(['filesize' => 1])->assertFilesize(null);
// No exception thrown.
$this->addToAssertionCount(2);
}
#[Test]
public function assert_accepted_matches_extensions_and_mime_globs(): void
{
UploadFieldSettings::fromParams(['accept' => '.png'])->assertAccepted('photo.png');
UploadFieldSettings::fromParams(['accept' => 'image/*'])->assertAccepted('photo.png');
UploadFieldSettings::fromParams(['accept' => '*'])->assertAccepted('anything.bin');
$this->addToAssertionCount(3);
}
#[Test]
public function assert_accepted_rejects_a_non_matching_type(): void
{
$this->expectException(ValidationException::class);
UploadFieldSettings::fromParams(['accept' => 'image/*'])->assertAccepted('notes.txt');
}
#[Test]
public function resolve_filename_randomizes_and_lowercases_keeping_the_extension(): void
{
$name = UploadFieldSettings::fromParams(['random_name' => '1'])
->resolveFilename('My Photo.PNG', sys_get_temp_dir());
self::assertMatchesRegularExpression('/^[a-z0-9]{15}\.png$/', $name);
}
#[Test]
public function resolve_filename_only_prefixes_on_an_actual_conflict(): void
{
$dir = sys_get_temp_dir() . '/grav_api_ufs_' . uniqid();
mkdir($dir, 0775, true);
try {
$settings = UploadFieldSettings::fromParams(['avoid_overwriting' => true]);
// No conflict — name is untouched.
self::assertSame('logo.png', $settings->resolveFilename('logo.png', $dir));
// Conflict — datetime-prefixed.
file_put_contents($dir . '/logo.png', 'x');
self::assertMatchesRegularExpression(
'/^\d{14}-logo\.png$/',
$settings->resolveFilename('logo.png', $dir)
);
} finally {
@unlink($dir . '/logo.png');
@rmdir($dir);
}
}
}
+271
View File
@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* Static helper methods for building lightweight stubs used across the test suite.
*
* All request stubs are anonymous class implementations of PSR interfaces.
* Config/Grav/User stubs come from our test Stubs/GravStubs.php and are
* real instances of the Grav types (either the genuine classes or our
* minimal stubs depending on the environment).
*/
final class TestHelper
{
/**
* Create a stub PSR-7 ServerRequest.
*/
public static function createMockRequest(
string $method = 'GET',
string $path = '/',
array $headers = [],
string $body = '',
array $queryParams = [],
array $serverParams = [],
array $attributes = [],
): ServerRequestInterface {
return new class ($method, $path, $headers, $body, $queryParams, $serverParams, $attributes) implements ServerRequestInterface {
public function __construct(
private readonly string $method,
private readonly string $path,
private readonly array $headers,
private readonly string $body,
private readonly array $queryParams,
private readonly array $serverParams,
private array $attributes,
) {}
public function getMethod(): string { return $this->method; }
public function getUri(): UriInterface
{
$path = $this->path;
return new class ($path) implements UriInterface {
public function __construct(private readonly string $path) {}
public function getScheme(): string { return 'https'; }
public function getAuthority(): string { return ''; }
public function getUserInfo(): string { return ''; }
public function getHost(): string { return 'localhost'; }
public function getPort(): ?int { return null; }
public function getPath(): string { return $this->path; }
public function getQuery(): string { return ''; }
public function getFragment(): string { return ''; }
public function withScheme(string $scheme): static { return clone $this; }
public function withUserInfo(string $user, ?string $password = null): static { return clone $this; }
public function withHost(string $host): static { return clone $this; }
public function withPort(?int $port): static { return clone $this; }
public function withPath(string $path): static { return clone $this; }
public function withQuery(string $query): static { return clone $this; }
public function withFragment(string $fragment): static { return clone $this; }
public function __toString(): string { return $this->path; }
};
}
public function getBody(): StreamInterface
{
$body = $this->body;
return new class ($body) implements StreamInterface {
public function __construct(private readonly string $content) {}
public function __toString(): string { return $this->content; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->content); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->content; }
public function getContents(): string { return $this->content; }
public function getMetadata(?string $key = null): mixed { return null; }
};
}
public function getQueryParams(): array { return $this->queryParams; }
public function getServerParams(): array { return $this->serverParams; }
public function getHeaderLine(string $name): string
{
foreach ($this->headers as $key => $value) {
if (strcasecmp($key, $name) === 0) {
return $value;
}
}
return '';
}
public function getHeader(string $name): array
{
foreach ($this->headers as $key => $value) {
if (strcasecmp($key, $name) === 0) {
return [$value];
}
}
return [];
}
public function hasHeader(string $name): bool
{
foreach ($this->headers as $key => $value) {
if (strcasecmp($key, $name) === 0) {
return true;
}
}
return false;
}
public function getHeaders(): array { return $this->headers; }
public function getAttribute(string $name, mixed $default = null): mixed
{
return $this->attributes[$name] ?? $default;
}
public function withAttribute(string $name, mixed $value): static
{
$clone = clone $this;
$clone->attributes[$name] = $value;
return $clone;
}
public function getRequestTarget(): string { return $this->path; }
public function withRequestTarget(string $requestTarget): static { return clone $this; }
public function withMethod(string $method): static { return clone $this; }
public function withUri(UriInterface $uri, bool $preserveHost = false): static { return clone $this; }
public function getProtocolVersion(): string { return '1.1'; }
public function withProtocolVersion(string $version): static { return clone $this; }
public function withHeader(string $name, $value): static { return clone $this; }
public function withAddedHeader(string $name, $value): static { return clone $this; }
public function withoutHeader(string $name): static { return clone $this; }
public function withBody(StreamInterface $body): static { return clone $this; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $cookies): static { return clone $this; }
public function withQueryParams(array $query): static { return clone $this; }
public function getUploadedFiles(): array { return []; }
public function withUploadedFiles(array $uploadedFiles): static { return clone $this; }
public function getParsedBody(): mixed { return null; }
public function withParsedBody($data): static { return clone $this; }
public function getAttributes(): array { return $this->attributes; }
public function withoutAttribute(string $name): static {
$clone = clone $this;
unset($clone->attributes[$name]);
return $clone;
}
};
}
/**
* Create a Config instance from a nested data array.
*
* Returns a real Grav\Common\Config\Config (or our stub equivalent).
*/
public static function createMockConfig(array $data = []): Config
{
return new Config($data);
}
/**
* Create a mock user that duck-types Grav\Common\User\Interfaces\UserInterface.
*
* The returned object has a public $username property and supports
* get(), set(), save(), and exists().
*/
public static function createMockUser(
string $username = 'testuser',
array $data = [],
bool $exists = true,
): UserInterface {
return new class ($username, $data, $exists) implements UserInterface {
public readonly string $username;
public function __construct(
string $username,
private array $data,
private readonly bool $existsFlag,
) {
$this->username = $username;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function save(): void
{
// no-op in tests
}
public function exists(): bool
{
return $this->existsFlag;
}
};
}
/**
* Create a Grav container instance with given services.
*
* Returns the Grav singleton (reset between calls).
*/
public static function createMockGrav(array $services = []): Grav
{
Grav::resetInstance();
$grav = Grav::instance();
foreach ($services as $key => $value) {
$grav[$key] = $value;
}
return $grav;
}
/**
* Create a mock accounts collection that is iterable and supports load().
*/
public static function createMockAccounts(array $users = []): object
{
return new class ($users) implements \IteratorAggregate {
/** @param array<string, object> $users keyed by username */
public function __construct(private readonly array $users) {}
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->users);
}
public function load(string $username): object
{
return $this->users[$username] ?? self::nonExistentUser($username);
}
private static function nonExistentUser(string $username): object
{
return new class ($username) {
public readonly string $username;
public function __construct(string $username)
{
$this->username = $username;
}
public function exists(): bool { return false; }
public function get(string $key, mixed $default = null): mixed { return $default; }
};
}
};
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
// Plugin's own autoloader (includes PHPUnit, firebase/php-jwt, fast-route)
require_once __DIR__ . '/../vendor/autoload.php';
// Load Grav core's autoloader so tests can reach the framework — and the
// symfony/yaml that the plugin no longer bundles (it relies on Grav's copy; see
// the "replace" entry in composer.json). The relative path resolves when the
// plugin runs as user/plugins/api inside a Grav install. Because a development
// clone is usually symlinked in, __DIR__ resolves to the source checkout and
// that relative path misses — so we also walk up from the shell's working
// directory (which preserves the symlinked path) to find the hosting Grav. As
// a last resort set GRAV_ROOT explicitly, e.g. `GRAV_ROOT=/path/to/grav composer test`.
$findGravAutoloader = static function (): ?string {
$gravRoot = getenv('GRAV_ROOT');
// Explicit override and the in-install relative path come first.
$direct = [
$gravRoot ? rtrim($gravRoot, '/') . '/vendor/autoload.php' : null,
__DIR__ . '/../../../../vendor/autoload.php',
];
foreach ($direct as $candidate) {
if ($candidate && is_file($candidate)) {
return $candidate;
}
}
// Walk up from the symlink-preserving shell CWD and the resolved CWD,
// looking for a directory that holds both a Composer autoloader and the
// Grav core marker (so we don't grab the plugin's own vendor).
$starts = array_filter([getenv('PWD') ?: null, getcwd() ?: null]);
foreach ($starts as $dir) {
$dir = rtrim($dir, '/');
while ($dir !== '' && $dir !== '/' && $dir !== '.') {
if (is_file($dir . '/vendor/autoload.php') && is_file($dir . '/system/defines.php')) {
return $dir . '/vendor/autoload.php';
}
$parent = \dirname($dir);
if ($parent === $dir) {
break;
}
$dir = $parent;
}
}
return null;
};
$gravAutoloader = $findGravAutoloader();
if ($gravAutoloader !== null) {
require_once $gravAutoloader;
}
// If Grav core (and thus symfony/yaml) is still unavailable — e.g. running fully
// standalone without GRAV_ROOT — load our minimal stub implementations so the
// plugin classes can still be instantiated and unit-tested.
if (!class_exists(\Grav\Common\Grav::class, false)) {
require_once __DIR__ . '/Stubs/GravStubs.php';
}
date_default_timezone_set('UTC');
+46
View File
@@ -0,0 +1,46 @@
{
"id": "grav-api-test-env",
"name": "Grav API - Local Test",
"values": [
{
"key": "base_url",
"value": "https://localhost/grav-api",
"enabled": true
},
{
"key": "api_prefix",
"value": "/api/v1",
"enabled": true
},
{
"key": "grav_environment",
"value": "localhost",
"enabled": true
},
{
"key": "username",
"value": "admin",
"enabled": true
},
{
"key": "password",
"value": "Password1",
"enabled": true
},
{
"key": "access_token",
"value": "",
"enabled": true
},
{
"key": "refresh_token",
"value": "",
"enabled": true
},
{
"key": "api_key",
"value": "",
"enabled": true
}
]
}
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
#
# Newman API Integration Test Runner
#
# Usage:
# ./tests/newman/run.sh [--base-url URL] [--user USER] [--password PASS] [--env ENV]
#
# Defaults to localhost with admin/Password1
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Defaults (reads from env vars set in ~/.zshrc if available)
BASE_URL="${GRAV_BASE_URL:-${BASE_URL:-https://localhost/grav-api}}"
API_PREFIX="${GRAV_API_PREFIX:-/api/v1}"
GRAV_ENV="${GRAV_ENVIRONMENT:-${GRAV_ENV:-localhost}}"
USERNAME="${USERNAME:-admin}"
PASSWORD="${PASSWORD:-Password1}"
STATIC_API_KEY="${GRAV_API_KEY:-}"
CURL_OPTS="-sk"
# Parse args
while [[ $# -gt 0 ]]; do
case $1 in
--base-url) BASE_URL="$2"; shift 2;;
--user) USERNAME="$2"; shift 2;;
--password) PASSWORD="$2"; shift 2;;
--env) GRAV_ENV="$2"; shift 2;;
*) echo "Unknown option: $1"; exit 1;;
esac
done
API_BASE="${BASE_URL}${API_PREFIX}"
echo "============================================"
echo " Grav API — Newman Integration Tests"
echo "============================================"
echo " Server: ${BASE_URL}"
echo " Environment: ${GRAV_ENV}"
echo " User: ${USERNAME}"
echo ""
# Step 1: Authenticate and get JWT token
echo "→ Authenticating..."
AUTH_RESPONSE=$(curl ${CURL_OPTS} "${API_BASE}/auth/token" \
-H "Content-Type: application/json" \
-H "X-Grav-Environment: ${GRAV_ENV}" \
-d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\"}")
ACCESS_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])" 2>/dev/null)
if [ -z "$ACCESS_TOKEN" ]; then
echo " ✗ Authentication failed"
echo " Response: $AUTH_RESPONSE"
exit 1
fi
echo " ✓ Got JWT token"
# Step 2: Use static API key from env, or create a temporary one
CLEANUP_KEY=false
if [ -n "$STATIC_API_KEY" ]; then
API_KEY="$STATIC_API_KEY"
echo "→ Using API key from environment: ${API_KEY:0:20}..."
else
echo "→ Creating test API key..."
KEY_RESPONSE=$(curl ${CURL_OPTS} "${API_BASE}/users/${USERNAME}/api-keys" \
-H "X-API-Token: ${ACCESS_TOKEN}" \
-H "X-Grav-Environment: ${GRAV_ENV}" \
-H "Content-Type: application/json" \
-d '{"name":"Newman Test Key","expires_in_days":1}')
API_KEY=$(echo "$KEY_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['api_key'])" 2>/dev/null)
KEY_ID=$(echo "$KEY_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])" 2>/dev/null)
if [ -z "$API_KEY" ]; then
echo " ✗ Failed to create API key"
echo " Response: $KEY_RESPONSE"
exit 1
fi
echo " ✓ Created API key: ${API_KEY:0:20}..."
CLEANUP_KEY=true
fi
# Step 3: Generate temporary environment file with real credentials
TEMP_ENV=$(mktemp)
cat > "$TEMP_ENV" << EOF
{
"id": "grav-api-newman-test",
"name": "Newman Test (auto-generated)",
"values": [
{"key": "base_url", "value": "${BASE_URL}", "enabled": true},
{"key": "api_prefix", "value": "${API_PREFIX}", "enabled": true},
{"key": "grav_environment", "value": "${GRAV_ENV}", "enabled": true},
{"key": "username", "value": "${USERNAME}", "enabled": true},
{"key": "password", "value": "${PASSWORD}", "enabled": true},
{"key": "api_key", "value": "${API_KEY}", "enabled": true},
{"key": "access_token", "value": "", "enabled": true},
{"key": "refresh_token", "value": "", "enabled": true},
{"key": "test_username", "value": "testuser", "enabled": true},
{"key": "test_api_key_id", "value": "", "enabled": true},
{"key": "page_route", "value": "blog", "enabled": true},
{"key": "lang", "value": "en", "enabled": true},
{"key": "package_slug", "value": "", "enabled": true},
{"key": "webhook_id", "value": "", "enabled": true},
{"key": "notification_id", "value": "", "enabled": true}
]
}
EOF
# Step 4: Run Newman
echo ""
echo "→ Running Newman tests..."
echo ""
NEWMAN_BIN="${PROJECT_DIR}/node_modules/.bin/newman"
${NEWMAN_BIN} run "${PROJECT_DIR}/grav-api.postman_collection.json" \
--environment "$TEMP_ENV" \
--env-var "api_key=${API_KEY}" \
--env-var "base_url=${BASE_URL}" \
--env-var "api_prefix=${API_PREFIX}" \
--env-var "grav_environment=${GRAV_ENV}" \
--env-var "username=${USERNAME}" \
--env-var "password=${PASSWORD}" \
--env-var "test_username=${GRAV_TEST_USERNAME:-test_username}" \
--env-var "page_route=${GRAV_PAGE_ROUTE:-typography}" \
--env-var "lang=${GRAV_LANG:-en}" \
--env-var "package_slug=${GRAV_PACKAGE_SLUG:-form}" \
--insecure \
--reporters cli \
--color on \
--timeout-request 10000 \
"$@"
NEWMAN_EXIT=$?
# Step 5: Cleanup — revoke the test API key (only if we created one)
if [ "$CLEANUP_KEY" = true ] && [ -n "$KEY_ID" ]; then
echo ""
echo "→ Cleaning up test API key..."
curl ${CURL_OPTS} -X DELETE "${API_BASE}/users/${USERNAME}/api-keys/${KEY_ID}" \
-H "X-API-Token: ${ACCESS_TOKEN}" \
-H "X-Grav-Environment: ${GRAV_ENV}" \
-o /dev/null -w " ✓ Revoked test key (HTTP %{http_code})\n"
fi
rm -f "$TEMP_ENV"
echo ""
if [ $NEWMAN_EXIT -eq 0 ]; then
echo "============================================"
echo " ✓ All tests passed!"
echo "============================================"
else
echo "============================================"
echo " ✗ Some tests failed (exit code: $NEWMAN_EXIT)"
echo "============================================"
fi
exit $NEWMAN_EXIT