feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see ConfigDiffer::effective()} — the per-environment, file-based config
* resolution the admin reads and edits. This is what makes base/"Default"
* show base config while a hostname overlay is active, instead of leaking the
* overlay into both modes.
*
* Mirrors the real bug: kimi.api_key is `sk-A7…` in user/config but `32433…`
* in user/env/localhost/config — "Default" must read the former, "localhost"
* the latter.
*
* Needs Grav core on the classpath for the YAML parser. Run inside a Grav
* install or with GRAV_ROOT set (e.g. `GRAV_ROOT=/path/to/grav composer test`).
*/
class ConfigDifferEffectiveTest extends TestCase
{
private ?string $tmp = null;
private ConfigDiffer $differ;
private const SCOPE = 'plugins/translation-service';
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-effective-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/user/config/plugins', 0777, true);
mkdir($this->tmp . '/user/plugins/translation-service', 0777, true);
mkdir($this->tmp . '/user/env/localhost/config/plugins', 0777, true);
// Plugin's own defaults: kimi key empty by default.
file_put_contents(
$this->tmp . '/user/plugins/translation-service/translation-service.yaml',
"enabled: true\nkimi:\n model_bulk: kimi-k2.6\n api_key: ''\n",
);
// Base user/config: real base key.
file_put_contents(
$this->tmp . '/user/config/plugins/translation-service.yaml',
"kimi:\n api_key: 'sk-A7base'\n",
);
// localhost env overlay: different key.
file_put_contents(
$this->tmp . '/user/env/localhost/config/plugins/translation-service.yaml',
"kimi:\n api_key: '32433overlay'\n",
);
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new EffectiveFakeLocator($this->tmp);
$this->differ = new ConfigDiffer($grav);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function base_target_reads_user_config_not_the_env_overlay(): void
{
$effective = $this->differ->effective(self::SCOPE, null);
$this->assertSame('sk-A7base', $effective['kimi']['api_key']);
// Default from the plugin yaml survives where neither layer overrides it.
$this->assertSame('kimi-k2.6', $effective['kimi']['model_bulk']);
$this->assertTrue($effective['enabled']);
}
#[Test]
public function env_target_reads_the_overlay_on_top_of_base(): void
{
$effective = $this->differ->effective(self::SCOPE, 'localhost');
$this->assertSame('32433overlay', $effective['kimi']['api_key']);
$this->assertSame('kimi-k2.6', $effective['kimi']['model_bulk']);
}
#[Test]
public function unknown_env_target_falls_back_to_base(): void
{
// No user/env/staging folder → overlay layer contributes nothing.
$effective = $this->differ->effective(self::SCOPE, 'staging');
$this->assertSame('sk-A7base', $effective['kimi']['api_key']);
}
#[Test]
public function effective_is_empty_when_scope_has_no_files(): void
{
$this->assertSame([], $this->differ->effective('plugins/ghost', null));
$this->assertSame([], $this->differ->effective('plugins/ghost', 'localhost'));
}
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);
}
}
/**
* Locator covering the stream prefixes ConfigDiffer::effective() and
* EnvironmentService touch (user://, user://config, plugins://).
*/
class EffectiveFakeLocator
{
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',
'plugins://' => $this->root . '/user/plugins',
'themes://' => $this->root . '/user/themes',
];
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,182 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ConfigDiffer's GRAV_CONFIG__* environment-override handling.
*
* These mirror Grav core's InitializeProcessor resolution: values injected via
* env vars (typically from a .env file) win at runtime and must never be
* written back to a YAML config file on save.
*/
class ConfigDifferEnvOverrideTest extends TestCase
{
private ConfigDiffer $differ;
/** @var list<string> */
private array $touched = [];
protected function setUp(): void
{
Grav::resetInstance();
$this->differ = new ConfigDiffer(Grav::instance());
$this->clearVar('GRAV_CONFIG');
}
protected function tearDown(): void
{
foreach ($this->touched as $name) {
$this->clearVar($name);
}
$this->clearVar('GRAV_CONFIG');
}
// ---------- environmentOverrideKeys() ----------
#[Test]
public function override_keys_are_empty_when_switch_is_off(): void
{
$this->setVar('GRAV_CONFIG__plugins__email__enabled', 'true');
$this->assertSame([], $this->differ->environmentOverrideKeys());
}
#[Test]
public function override_keys_resolve_double_underscores_to_dots(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__plugins__email__mailer__smtp__password', 'secret');
$this->assertSame(
['plugins.email.mailer.smtp.password'],
$this->differ->environmentOverrideKeys(),
);
}
#[Test]
public function override_keys_apply_aliases_for_hyphenated_paths(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG_ALIAS__TRANSLATIONSERVICE', 'plugins.translation-service');
$this->setVar('GRAV_CONFIG__TRANSLATIONSERVICE__anthropic__api_key', 'sk-ant-123');
$this->assertSame(
['plugins.translation-service.anthropic.api_key'],
$this->differ->environmentOverrideKeys(),
);
}
// ---------- stripEnvironmentOverrides() ----------
#[Test]
public function strip_removes_a_scoped_env_key_and_prunes_empty_parents(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG_ALIAS__TRANSLATIONSERVICE', 'plugins.translation-service');
$this->setVar('GRAV_CONFIG__TRANSLATIONSERVICE__anthropic__api_key', 'sk-ant-123');
$data = [
'enabled' => true,
'anthropic' => ['api_key' => 'sk-ant-123', 'model' => 'opus'],
];
// api_key is dropped; model (and its parent) survive.
$this->assertSame(
['enabled' => true, 'anthropic' => ['model' => 'opus']],
$this->differ->stripEnvironmentOverrides($data, 'plugins/translation-service'),
);
}
#[Test]
public function strip_prunes_a_subtree_that_empties_out(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG_ALIAS__TRANSLATIONSERVICE', 'plugins.translation-service');
$this->setVar('GRAV_CONFIG__TRANSLATIONSERVICE__anthropic__api_key', 'sk-ant-123');
$data = ['enabled' => true, 'anthropic' => ['api_key' => 'sk-ant-123']];
$this->assertSame(
['enabled' => true],
$this->differ->stripEnvironmentOverrides($data, 'plugins/translation-service'),
);
}
#[Test]
public function strip_is_a_noop_when_switch_is_off(): void
{
$this->setVar('GRAV_CONFIG__plugins__email__mailer__smtp__password', 'secret');
$data = ['mailer' => ['smtp' => ['password' => 'secret']]];
$this->assertSame($data, $this->differ->stripEnvironmentOverrides($data, 'plugins/email'));
}
#[Test]
public function strip_ignores_keys_outside_the_scope(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__plugins__other__token', 'xyz');
$data = ['token' => 'mine'];
// The env key targets plugins.other, not plugins.email — leave it alone.
$this->assertSame($data, $this->differ->stripEnvironmentOverrides($data, 'plugins/email'));
}
#[Test]
public function strip_returns_empty_when_the_whole_scope_is_env_provided(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__system', 'whatever');
$this->assertSame([], $this->differ->stripEnvironmentOverrides(['x' => 1], 'system'));
}
#[Test]
public function strip_is_scope_agnostic_for_core_and_flex_scopes(): void
{
$this->setVar('GRAV_CONFIG', 'true');
$this->setVar('GRAV_CONFIG__system__cache__enabled', 'false');
$this->setVar('GRAV_CONFIG__flex__accounts__timeout', '60');
// Core 'system' scope: cache.enabled is stripped, the emptied cache
// subtree is pruned, pages survives.
$this->assertSame(
['pages' => ['theme' => 'quark']],
$this->differ->stripEnvironmentOverrides(
['pages' => ['theme' => 'quark'], 'cache' => ['enabled' => false]],
'system',
),
);
// 'flex/accounts' scope maps to the flex.accounts config key.
$this->assertSame(
['name' => 'flex'],
$this->differ->stripEnvironmentOverrides(
['name' => 'flex', 'timeout' => '60'],
'flex/accounts',
),
);
}
private function setVar(string $name, string $value): void
{
$this->touched[] = $name;
putenv("$name=$value");
$_ENV[$name] = $_SERVER[$name] = $value;
}
private function clearVar(string $name): void
{
putenv($name);
unset($_ENV[$name], $_SERVER[$name]);
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* The override-map primitives behind the per-field override indicators and the
* revert endpoint (see docs/config-overrides-revert): flatten a persisted delta
* to dotted leaf paths, read the fallback value out of the parent layer, and
* remove a key from the active layer's file.
*/
class ConfigDifferOverrideTest extends TestCase
{
protected function tearDown(): void
{
Grav::resetInstance();
}
#[Test]
public function flatten_leaves_recurses_maps_but_keeps_lists_atomic(): void
{
$delta = [
'pages' => ['theme' => 'quark2', 'events' => ['page' => false]],
'types' => ['html', 'htm', 'xml'], // sequential list — atomic
'debugger' => ['enabled' => true],
];
$this->assertSame(
['pages.theme', 'pages.events.page', 'types', 'debugger.enabled'],
ConfigDiffer::flattenLeaves($delta),
);
}
#[Test]
public function flatten_leaves_is_empty_for_no_overrides(): void
{
$this->assertSame([], ConfigDiffer::flattenLeaves([]));
}
#[Test]
public function value_at_path_digs_nested_keys(): void
{
$parent = ['github' => ['app_id' => '3771292'], 'kimi' => ['api_key' => 'sk-A7']];
$this->assertSame('3771292', ConfigDiffer::valueAtPath($parent, 'github.app_id'));
$this->assertSame('sk-A7', ConfigDiffer::valueAtPath($parent, 'kimi.api_key'));
}
#[Test]
public function value_at_path_returns_null_when_absent(): void
{
// A key the active layer adds but the parent never had reverts to the
// blueprint default / unset, which the client renders as "empty".
$parent = ['github' => ['app_id' => '3771292']];
$this->assertNull(ConfigDiffer::valueAtPath($parent, 'github.missing'));
$this->assertNull(ConfigDiffer::valueAtPath($parent, 'nope.at.all'));
}
#[Test]
public function unset_dot_path_removes_a_key_and_prunes_empty_parents(): void
{
$differ = new ConfigDiffer(Grav::instance());
$delta = ['github' => ['app_id' => '3726627', 'secret' => 'x'], 'kimi' => ['api_key' => '123']];
// Removing one of two siblings keeps the parent.
$this->assertSame(
['github' => ['secret' => 'x'], 'kimi' => ['api_key' => '123']],
$differ->unsetDotPath($delta, 'github.app_id'),
);
// Removing the last child prunes the now-empty parent map entirely.
$this->assertSame(
['github' => ['app_id' => '3726627', 'secret' => 'x']],
$differ->unsetDotPath($delta, 'kimi.api_key'),
);
}
#[Test]
public function unset_dot_path_is_a_noop_for_absent_keys(): void
{
$differ = new ConfigDiffer(Grav::instance());
$delta = ['github' => ['app_id' => '3726627']];
$this->assertSame($delta, $differ->unsetDotPath($delta, 'kimi.api_key'));
$this->assertSame($delta, $differ->unsetDotPath($delta, 'github.nope'));
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration-ish tests for {@see ConfigDiffer::parent()} — the only method
* that actually touches the filesystem. We spin up a temp directory laid
* out like a Grav install and wire a fake locator into the Grav stub.
*
* Skipped when Grav core isn't on the classpath (stubs have no Yaml parser).
*/
class ConfigDifferParentTest extends TestCase
{
private ?string $tmp = null;
private ConfigDiffer $differ;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-differ-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/system/config', 0777, true);
mkdir($this->tmp . '/user/config', 0777, true);
mkdir($this->tmp . '/user/plugins/form', 0777, true);
mkdir($this->tmp . '/user/themes/quark', 0777, true);
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new FakeLocator($this->tmp);
$this->differ = new ConfigDiffer($grav);
}
protected function tearDown(): void
{
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
}
#[Test]
public function parent_for_system_uses_system_config_defaults(): void
{
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\ntimezone: UTC\n",
);
$parent = $this->differ->parent('system', null);
$this->assertSame(['force_ssl' => false, 'timezone' => 'UTC'], $parent);
}
#[Test]
public function parent_for_plugin_uses_plugin_own_yaml(): void
{
file_put_contents(
$this->tmp . '/user/plugins/form/form.yaml',
"enabled: true\nfiles:\n fields: true\n",
);
$parent = $this->differ->parent('plugins/form', null);
$this->assertSame(['enabled' => true, 'files' => ['fields' => true]], $parent);
}
#[Test]
public function parent_for_theme_uses_theme_own_yaml(): void
{
file_put_contents(
$this->tmp . '/user/themes/quark/quark.yaml',
"enabled: true\ndropdown:\n enabled: false\n",
);
$parent = $this->differ->parent('themes/quark', null);
$this->assertSame(['enabled' => true, 'dropdown' => ['enabled' => false]], $parent);
}
#[Test]
public function env_parent_merges_defaults_with_user_config_base(): void
{
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\ntimezone: UTC\npages:\n theme: quark\n",
);
file_put_contents(
$this->tmp . '/user/config/system.yaml',
"force_ssl: true\npages:\n theme: quark2\n",
);
$parent = $this->differ->parent('system', 'staging.foo.com');
// force_ssl + pages.theme overridden by user/config; timezone stays at default.
$this->assertSame(
['force_ssl' => true, 'timezone' => 'UTC', 'pages' => ['theme' => 'quark2']],
$parent,
);
}
#[Test]
public function env_parent_falls_back_to_defaults_when_no_base_file(): void
{
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\n",
);
$parent = $this->differ->parent('system', 'staging');
$this->assertSame(['force_ssl' => false], $parent);
}
#[Test]
public function parent_is_empty_array_when_no_defaults_exist(): void
{
// Theme with no defaults file — parent should be [].
$this->assertSame([], $this->differ->parent('themes/ghost', null));
}
#[Test]
public function parent_diff_round_trip_system_config(): void
{
// Put some defaults and user-layer overrides on disk, then verify the
// full pipeline: compute env parent, diff the desired effective state
// against it, and confirm we only persist the env-specific deltas.
file_put_contents(
$this->tmp . '/system/config/system.yaml',
"force_ssl: false\ntimezone: UTC\nlanguages:\n supported: [en, fr, de]\n",
);
file_put_contents(
$this->tmp . '/user/config/system.yaml',
"force_ssl: true\n",
);
$desiredEffective = [
'force_ssl' => true, // same as user/config base
'timezone' => 'America/Denver', // env-specific
'languages' => ['supported' => ['en', 'fr']], // shortened list
];
$parent = $this->differ->parent('system', 'staging');
$delta = $this->differ->diff($desiredEffective, $parent);
$this->assertSame(
[
'timezone' => 'America/Denver',
'languages' => ['supported' => ['en', 'fr']],
],
$delta,
);
}
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 mimicking UniformResourceLocator::findResource() for the
* stream prefixes we use.
*/
class FakeLocator
{
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',
'plugins://' => $this->root . '/user/plugins',
'themes://' => $this->root . '/user/themes',
];
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,213 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigDiffer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ConfigDiffer.
*
* The {@see ConfigDiffer::diff()} and {@see ConfigDiffer::deepMergeAssoc()}
* methods are pure and don't touch Grav services — we pass a throwaway Grav
* instance just to satisfy the constructor. {@see ConfigDiffer::parent()}
* covers filesystem resolution in its own test with a real tempdir.
*/
class ConfigDifferTest extends TestCase
{
private ConfigDiffer $differ;
protected function setUp(): void
{
Grav::resetInstance();
$this->differ = new ConfigDiffer(Grav::instance());
}
// ---------- diff() ----------
#[Test]
public function diff_returns_empty_when_current_matches_parent(): void
{
$parent = ['force_ssl' => false, 'languages' => ['supported' => ['en', 'fr']]];
$current = ['force_ssl' => false, 'languages' => ['supported' => ['en', 'fr']]];
$this->assertSame([], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_includes_scalar_overrides(): void
{
$parent = ['force_ssl' => false, 'timezone' => null];
$current = ['force_ssl' => true, 'timezone' => null];
$this->assertSame(['force_ssl' => true], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_recurses_into_associative_arrays(): void
{
$parent = [
'pages' => ['theme' => 'quark', 'markdown' => ['extra' => false]],
];
$current = [
'pages' => ['theme' => 'quark2', 'markdown' => ['extra' => false]],
];
$this->assertSame(
['pages' => ['theme' => 'quark2']],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_treats_sequential_arrays_as_atomic(): void
{
// The classic "shortened list" scenario: user removes one language from
// the list. We must emit the whole shortened list, not a key-diff that
// would silently merge the removed entry back in when Grav re-loads.
$parent = ['languages' => ['supported' => ['en', 'fr', 'de']]];
$current = ['languages' => ['supported' => ['en', 'fr']]];
$this->assertSame(
['languages' => ['supported' => ['en', 'fr']]],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_treats_reordered_sequential_arrays_as_different(): void
{
$parent = ['types' => ['htm', 'html']];
$current = ['types' => ['html', 'htm']];
$this->assertSame(
['types' => ['html', 'htm']],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_null_override_is_retained(): void
{
// Setting a field explicitly to null should override a non-null default.
$parent = ['timezone' => 'UTC'];
$current = ['timezone' => null];
$this->assertSame(['timezone' => null], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_key_absent_from_parent_is_always_kept(): void
{
$parent = ['a' => 1];
$current = ['a' => 1, 'b' => 2];
$this->assertSame(['b' => 2], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_type_change_from_assoc_to_list_replaces_whole_value(): void
{
$parent = ['http_x_forwarded' => ['protocol' => true]];
$current = ['http_x_forwarded' => ['a', 'b']];
$this->assertSame(
['http_x_forwarded' => ['a', 'b']],
$this->differ->diff($current, $parent),
);
}
#[Test]
public function diff_ignores_key_order_differences(): void
{
// Yaml parsers or API clients can legitimately reorder keys. Parent has
// the same content, different insertion order — should diff to empty.
$parent = ['pages' => ['theme' => 'quark', 'markdown' => ['extra' => false]]];
$current = ['pages' => ['markdown' => ['extra' => false], 'theme' => 'quark']];
$this->assertSame([], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_drops_subtree_when_no_inner_differences(): void
{
$parent = ['a' => ['b' => 1, 'c' => 2]];
$current = ['a' => ['b' => 1, 'c' => 2], 'd' => 3];
$this->assertSame(['d' => 3], $this->differ->diff($current, $parent));
}
#[Test]
public function diff_deeply_nested_override(): void
{
$parent = ['a' => ['b' => ['c' => ['d' => 1, 'e' => 2]]]];
$current = ['a' => ['b' => ['c' => ['d' => 1, 'e' => 99]]]];
$this->assertSame(
['a' => ['b' => ['c' => ['e' => 99]]]],
$this->differ->diff($current, $parent),
);
}
// ---------- deepMergeAssoc() ----------
#[Test]
public function deep_merge_overrides_scalar(): void
{
$this->assertSame(
['a' => 2, 'b' => 3],
$this->differ->deepMergeAssoc(['a' => 1, 'b' => 3], ['a' => 2]),
);
}
#[Test]
public function deep_merge_recurses_into_assoc(): void
{
$result = $this->differ->deepMergeAssoc(
['x' => ['a' => 1, 'b' => 2]],
['x' => ['b' => 20, 'c' => 30]],
);
$this->assertSame(['x' => ['a' => 1, 'b' => 20, 'c' => 30]], $result);
}
#[Test]
public function deep_merge_replaces_sequential_arrays(): void
{
$result = $this->differ->deepMergeAssoc(
['tags' => ['a', 'b', 'c']],
['tags' => ['x']],
);
$this->assertSame(['tags' => ['x']], $result);
}
// ---------- valuesEqual() / isAssoc() ----------
#[Test]
public function values_equal_treats_assoc_key_order_as_irrelevant(): void
{
$this->assertTrue(ConfigDiffer::valuesEqual(
['a' => 1, 'b' => 2],
['b' => 2, 'a' => 1],
));
}
#[Test]
public function values_equal_respects_sequential_order(): void
{
$this->assertFalse(ConfigDiffer::valuesEqual([1, 2], [2, 1]));
}
#[Test]
public function is_assoc_is_false_for_empty_and_lists(): void
{
$this->assertFalse(ConfigDiffer::isAssoc([]));
$this->assertFalse(ConfigDiffer::isAssoc(['a', 'b']));
$this->assertTrue(ConfigDiffer::isAssoc(['x' => 1]));
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\ConfigScopes;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see ConfigScopes::isCustom()} — the gate that lets the cookbook "add a
* custom yaml file" recipe surface as a config tab in admin2 while keeping
* core/system blueprints (and path traversal) out.
*
* A custom scope is valid only when a user:// or environment:// blueprint
* exists for it; core scopes and system-shipped blueprints (e.g. streams) are
* rejected so the generic api.config.write permission can't reach them.
*/
class ConfigScopesTest extends TestCase
{
private ?string $tmp = null;
protected function setUp(): void
{
$this->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;
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Common\Config\Setup;
use Grav\Common\Grav;
use Grav\Plugin\Api\Services\EnvironmentService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(EnvironmentService::class)]
class EnvironmentServiceTest extends TestCase
{
private ?string $tmp = null;
private ?string $savedSetupEnv = null;
protected function setUp(): void
{
$this->tmp = sys_get_temp_dir() . '/grav-envsvc-' . bin2hex(random_bytes(4));
mkdir($this->tmp . '/user/config', 0777, true);
// activeEnvironment() now trusts Setup::$environment (the booted env)
// before the Uri. Null it so the Uri-only tests are deterministic; the
// booted-env tests set it explicitly. Restore the global afterwards.
$this->savedSetupEnv = Setup::$environment;
Setup::$environment = null;
}
protected function tearDown(): void
{
Setup::$environment = $this->savedSetupEnv;
if ($this->tmp !== null) {
$this->rrmdir($this->tmp);
$this->tmp = null;
}
Grav::resetInstance();
}
#[Test]
public function active_environment_returns_null_when_no_uri_service(): void
{
$svc = $this->buildService(uri: null);
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_returns_null_when_env_name_empty(): void
{
$svc = $this->buildService(uri: $this->fakeUri(''));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_returns_null_when_env_name_invalid(): void
{
// Path traversal must never be honored as an env.
$svc = $this->buildService(uri: $this->fakeUri('../etc'));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_returns_null_when_env_config_dir_missing(): void
{
// Hostname-derived env that doesn't have a config folder on disk —
// base writes are correct in this case, no overlay to worry about.
$svc = $this->buildService(uri: $this->fakeUri('production.example.com'));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_resolves_legacy_user_host_config_layout(): void
{
mkdir($this->tmp . '/user/localhost/config', 0777, true);
$svc = $this->buildService(uri: $this->fakeUri('localhost'));
$this->assertSame('localhost', $svc->activeEnvironment());
}
#[Test]
public function active_environment_resolves_modern_user_env_layout(): void
{
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
$svc = $this->buildService(uri: $this->fakeUri('staging'));
$this->assertSame('staging', $svc->activeEnvironment());
}
#[Test]
public function active_environment_prefers_booted_env_behind_reverse_proxy(): void
{
// Reverse proxy: Grav booted its overlay from the real host (localhost,
// which has a config dir), while the Uri reports the forwarded host
// (translations.rhuk.net, no dir). The loaded overlay is localhost, so
// that's what must be reported — not null, and not the forwarded host.
mkdir($this->tmp . '/user/env/localhost/config', 0777, true);
Setup::$environment = 'localhost';
$svc = $this->buildService(uri: $this->fakeUri('translations.rhuk.net'));
$this->assertSame('localhost', $svc->activeEnvironment());
}
#[Test]
public function active_environment_is_null_when_booted_env_has_no_config_dir(): void
{
// The booted env is authoritative: if IT has no overlay on disk, no
// overlay is loaded and base is correct — we must NOT fall through to a
// forwarded-host env that happens to have a dir but was never loaded.
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
Setup::$environment = 'localhost'; // booted host, no config dir
$svc = $this->buildService(uri: $this->fakeUri('staging'));
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function active_environment_ignores_malformed_booted_env_and_falls_back_to_uri(): void
{
// A malformed Setup::$environment is treated as "unknown", so the Uri
// fallback still applies.
mkdir($this->tmp . '/user/env/staging/config', 0777, true);
Setup::$environment = '../etc';
$svc = $this->buildService(uri: $this->fakeUri('staging'));
$this->assertSame('staging', $svc->activeEnvironment());
}
#[Test]
public function active_environment_ignores_uri_without_environment_method(): void
{
// Some frameworks ship a Uri-ish object — only Grav's exposes environment().
$svc = $this->buildService(uri: new \stdClass());
$this->assertNull($svc->activeEnvironment());
}
#[Test]
public function is_reserved_name_matches_base_sentinels_case_insensitively(): void
{
$this->assertTrue(EnvironmentService::isReservedName('default'));
$this->assertTrue(EnvironmentService::isReservedName('Default'));
$this->assertTrue(EnvironmentService::isReservedName('base'));
$this->assertFalse(EnvironmentService::isReservedName('localhost'));
$this->assertFalse(EnvironmentService::isReservedName('staging'));
}
#[Test]
public function create_environment_rejects_reserved_base_sentinel(): void
{
// `default` must never become an env folder, otherwise its overlay would
// shadow the admin's base-only ("Default") view.
$svc = $this->buildService(uri: null);
$this->expectException(\InvalidArgumentException::class);
$svc->createEnvironment('default');
}
#[Test]
public function create_environment_creates_modern_env_dir(): void
{
$svc = $this->buildService(uri: null);
$dir = $svc->createEnvironment('staging');
$this->assertSame($this->tmp . '/user/env/staging/config', $dir);
$this->assertDirectoryExists($dir);
}
private function buildService(mixed $uri): EnvironmentService
{
Grav::resetInstance();
$grav = Grav::instance();
$grav['locator'] = new EnvSvcFakeLocator($this->tmp);
if ($uri !== null) {
$grav['uri'] = $uri;
}
return new EnvironmentService($grav);
}
private function fakeUri(string $env): object
{
return new class ($env) {
public function __construct(private readonly string $env) {}
public function environment(): string { return $this->env; }
};
}
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://`, so a one-prefix locator
* is enough — and intentionally minimal so each test reflects exactly what
* the service walks.
*/
class EnvSvcFakeLocator
{
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,135 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Services;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* {@see UploadFieldSettings} — the per-field upload settings (random_name,
* avoid_overwriting, accept, filesize) that bring admin-next file fields to
* parity with admin-classic. Parsing tolerates both JSON-body and multipart-
* meta shapes; enforcement only ever tightens the controllers' security floor.
*/
#[CoversClass(UploadFieldSettings::class)]
class UploadFieldSettingsTest extends TestCase
{
#[Test]
public function absent_params_produce_an_inert_object(): void
{
$settings = UploadFieldSettings::fromParams([]);
self::assertTrue($settings->isEmpty());
self::assertFalse($settings->randomName);
self::assertFalse($settings->avoidOverwriting);
self::assertSame([], $settings->accept);
self::assertNull($settings->filesizeMb);
}
#[Test]
public function booleans_parse_from_multipart_string_truthies(): void
{
self::assertTrue(UploadFieldSettings::fromParams(['random_name' => '1'])->randomName);
self::assertTrue(UploadFieldSettings::fromParams(['random_name' => 'true'])->randomName);
self::assertTrue(UploadFieldSettings::fromParams(['avoid_overwriting' => 'on'])->avoidOverwriting);
self::assertFalse(UploadFieldSettings::fromParams(['random_name' => '0'])->randomName);
self::assertFalse(UploadFieldSettings::fromParams(['random_name' => 'false'])->randomName);
}
#[Test]
public function accept_parses_from_both_csv_string_and_array(): void
{
self::assertSame(
['image/*', '.pdf'],
UploadFieldSettings::fromParams(['accept' => 'image/*, .pdf'])->accept
);
self::assertSame(
['image/png', '.jpg'],
UploadFieldSettings::fromParams(['accept' => ['image/png', ' .jpg ', '']])->accept
);
}
#[Test]
public function filesize_parses_only_positive_numbers(): void
{
self::assertSame(5.0, UploadFieldSettings::fromParams(['filesize' => '5'])->filesizeMb);
self::assertSame(2.5, UploadFieldSettings::fromParams(['filesize' => 2.5])->filesizeMb);
self::assertNull(UploadFieldSettings::fromParams(['filesize' => '0'])->filesizeMb);
self::assertNull(UploadFieldSettings::fromParams(['filesize' => 'huge'])->filesizeMb);
}
#[Test]
public function assert_filesize_enforces_the_per_field_limit(): void
{
$settings = UploadFieldSettings::fromParams(['filesize' => 1]);
// 1 MB exactly is fine; one byte over the limit is rejected.
$settings->assertFilesize(1_048_576);
$this->expectException(ValidationException::class);
$settings->assertFilesize(1_048_577);
}
#[Test]
public function assert_filesize_is_a_noop_without_a_limit_or_known_size(): void
{
UploadFieldSettings::none()->assertFilesize(999_999_999);
UploadFieldSettings::fromParams(['filesize' => 1])->assertFilesize(null);
// No exception thrown.
$this->addToAssertionCount(2);
}
#[Test]
public function assert_accepted_matches_extensions_and_mime_globs(): void
{
UploadFieldSettings::fromParams(['accept' => '.png'])->assertAccepted('photo.png');
UploadFieldSettings::fromParams(['accept' => 'image/*'])->assertAccepted('photo.png');
UploadFieldSettings::fromParams(['accept' => '*'])->assertAccepted('anything.bin');
$this->addToAssertionCount(3);
}
#[Test]
public function assert_accepted_rejects_a_non_matching_type(): void
{
$this->expectException(ValidationException::class);
UploadFieldSettings::fromParams(['accept' => 'image/*'])->assertAccepted('notes.txt');
}
#[Test]
public function resolve_filename_randomizes_and_lowercases_keeping_the_extension(): void
{
$name = UploadFieldSettings::fromParams(['random_name' => '1'])
->resolveFilename('My Photo.PNG', sys_get_temp_dir());
self::assertMatchesRegularExpression('/^[a-z0-9]{15}\.png$/', $name);
}
#[Test]
public function resolve_filename_only_prefixes_on_an_actual_conflict(): void
{
$dir = sys_get_temp_dir() . '/grav_api_ufs_' . uniqid();
mkdir($dir, 0775, true);
try {
$settings = UploadFieldSettings::fromParams(['avoid_overwriting' => true]);
// No conflict — name is untouched.
self::assertSame('logo.png', $settings->resolveFilename('logo.png', $dir));
// Conflict — datetime-prefixed.
file_put_contents($dir . '/logo.png', 'x');
self::assertMatchesRegularExpression(
'/^\d{14}-logo\.png$/',
$settings->resolveFilename('logo.png', $dir)
);
} finally {
@unlink($dir . '/logo.png');
@rmdir($dir);
}
}
}