feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user