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