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