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