tmp = sys_get_temp_dir() . '/grav-scopes-' . bin2hex(random_bytes(4)); mkdir($this->tmp . '/user/blueprints/config', 0777, true); // A site-authored top-level config blueprint (the recipe). file_put_contents( $this->tmp . '/user/blueprints/config/custom.yaml', "title: Custom Settings\nform:\n fields:\n my_text:\n type: text\n", ); Grav::resetInstance(); $grav = Grav::instance(); $grav['locator'] = new ScopesFakeLocator($this->tmp); } protected function tearDown(): void { if ($this->tmp !== null) { $this->rrmdir($this->tmp); $this->tmp = null; } Grav::resetInstance(); } #[Test] public function user_authored_blueprint_is_a_custom_scope(): void { $this->assertTrue(ConfigScopes::isCustom(Grav::instance(), 'custom')); } #[Test] public function core_scopes_are_not_custom(): void { foreach (ConfigScopes::CORE as $scope) { $this->assertFalse( ConfigScopes::isCustom(Grav::instance(), $scope), "{$scope} is a core scope and must not be treated as custom", ); } } #[Test] public function scope_without_a_user_blueprint_is_rejected(): void { // `streams` ships a system blueprint, not a user one — the FakeLocator // only resolves user://, so this stands in for "core blueprint exists // but not under user://". $this->assertFalse(ConfigScopes::isCustom(Grav::instance(), 'streams')); $this->assertFalse(ConfigScopes::isCustom(Grav::instance(), 'nope')); } #[Test] public function unsafe_scope_names_are_rejected_before_any_lookup(): void { foreach (['../etc/passwd', 'a/b', 'a.b', 'Custom', '-leading', '', 'a b'] as $scope) { $this->assertFalse( ConfigScopes::isCustom(Grav::instance(), $scope), "unsafe scope '{$scope}' must be rejected", ); } $this->assertFalse(ConfigScopes::isCustom(Grav::instance(), null)); } private function rrmdir(string $path): void { if (!is_dir($path)) return; foreach (new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST, ) as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } rmdir($path); } } /** * Minimal locator resolving only the user:// blueprints stream ConfigScopes * checks. environment:// is intentionally absent (never set in this fixture) * so it resolves to false, exercising the user-only path. */ class ScopesFakeLocator { public function __construct(private string $root) {} public function findResource(string $uri, bool $absolute = true, bool $first = false): string|false { $prefix = 'user://'; if (!str_starts_with($uri, $prefix)) { return false; } $full = $this->root . '/user/' . substr($uri, strlen($prefix)); return file_exists($full) ? $full : false; } }