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,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'));
}
}