Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
18860c799d Merge pull request 'Task: #53687 Support instance-specific cfg write targets and add coverage V2' (#4) from 53687/task-support-instance-specific-cfg-write-target-v2 into master
Reviewed-on: #4
Reviewed-by: norb <norbert.wagner@verua.ch>
2026-03-30 15:09:20 +00:00
5fdf1e2b7f
fix: remove Verua specific names using Settings specific 2026-03-30 17:04:33 +02:00
56872a04c4
style: fix indentation 2026-03-30 16:57:58 +02:00
f87ada6c15
fix: remove commented code 2026-03-30 16:56:40 +02:00
6f1f78fc33
feat: add shortOption to siteDir 2026-03-30 16:56:15 +02:00
1c851632c3 Task: #53687 Refactor CLI input validation into option validators, add validator coverage, and refactor cfg CLI tests 2026-03-30 13:57:29 +02:00
480d97c804 Task: #53687 Add minimal --verbose mode and replace legacy var_dump debug points 2026-03-27 08:55:53 +01:00
2a391f4e0a Task: #53687 Simplify cfg argument handling and keep --siteDir with JSON write input (-i) 2026-03-26 12:27:12 +01:00
29d1da4ac5 Task: #53687 Align cfg brace style to project conventions 2026-03-26 12:10:55 +01:00
227de9ac07 Task: #53687 Replace --site/--directory-name with --siteDir and add JSON batch input
- Changed site targeting from --site, --directory-name, --directoryName to --siteDir
- Added write -i <json> for multi-setting input
- Added support for inline JSON string values in write
- Updated help/examples to generic placeholders
- Extended tests for new arguments and validations (all passing)
2026-03-26 11:28:58 +01:00
3 changed files with 496 additions and 334 deletions

365
bin/cfg
View file

@ -22,59 +22,9 @@ foreach ($autoloadFiles as $autoloadFile) {
} }
} }
// Pre-parse site options so they can be passed after positional args. function verboseLog(bool $enabled, string $message): void
// The input helper library does not handle this case reliably. {
$preparsedSiteOptions = []; if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL);
$rawArgv = $_SERVER['argv'] ?? $GLOBALS['argv'] ?? null;
if (is_array($rawArgv) && !empty($rawArgv)) {
$filteredArgv = [$rawArgv[0]];
for ($idx = 1; $idx < count($rawArgv); $idx++) {
$arg = $rawArgv[$idx];
if ($arg === '-s' || $arg === '--site') {
$preparsedSiteOptions[] = [
'name' => 'site',
'value' => $rawArgv[$idx + 1] ?? '',
];
if (isset($rawArgv[$idx + 1])) $idx++;
continue;
}
if (str_starts_with($arg, '--site=')) {
$preparsedSiteOptions[] = [
'name' => 'site',
'value' => substr($arg, strlen('--site=')),
];
continue;
}
if ($arg === '--directory-name' || $arg === '--directoryName') {
$preparsedSiteOptions[] = [
'name' => 'directoryName',
'value' => $rawArgv[$idx + 1] ?? '',
];
if (isset($rawArgv[$idx + 1])) $idx++;
continue;
}
if (str_starts_with($arg, '--directory-name=')) {
$preparsedSiteOptions[] = [
'name' => 'directoryName',
'value' => substr($arg, strlen('--directory-name=')),
];
continue;
}
if (str_starts_with($arg, '--directoryName=')) {
$preparsedSiteOptions[] = [
'name' => 'directoryName',
'value' => substr($arg, strlen('--directoryName=')),
];
continue;
}
$filteredArgv[] = $arg;
}
$_SERVER['argv'] = $filteredArgv;
$GLOBALS['argv'] = $filteredArgv;
} }
$version = '0.4'; $version = '0.4';
@ -90,15 +40,55 @@ $collection = (new Input\InputCollection())
->description('Display help text') ->description('Display help text')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('verbose')->short('v') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL)
->description('Path to a json data file to to read') ->description('Print debug details to STDERR')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('out')->short('o') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a json file to to write to') ->description('Path to a JSON data file to read (for write)')
) // }}} ->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$path = $context->find('in');
if ($path === null || $path === '') {
return null;
}
if (!is_readable($path))
{
throw new \Exception("Input file is not readable: $path");
}
$content = file_get_contents($path);
if ($content === false)
{
throw new \Exception("Can not read input file: $path");
}
try
{
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $ex)
{
throw new \Exception(sprintf(
'Input JSON is invalid (%s): %s',
(string)$ex->getCode(),
$ex->getMessage()
));
}
if (!is_array($data))
{
throw new \Exception('Input JSON must decode to an object/array');
}
return $data;
}
))
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('mode')->short('m') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('mode')->short('m') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
@ -115,15 +105,59 @@ $collection = (new Input\InputCollection())
->description('Path where the config/ directory of the package conf files is located, defaults to the working dir') ->description('Path where the config/ directory of the package conf files is located, defaults to the working dir')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('site')->short('s') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('siteDir')->short('s') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Site/instance directory below config/ (target: config/<site>/<prefix>.conf.php), e.g. owner_xyz') ->description('Site/instance directory below config/. Accepts owner_xyz or config/owner_xyz')
) // }}} ->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$siteInput = $context->find('siteDir');
if ($siteInput === null || $siteInput === false) {
return null;
}
->add( Input\InputTypeFactory::build('LongOption')->name('directoryName') // {{{ $siteInput = trim((string)$siteInput);
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) $appPath = $context->find('appPath');
->description('Alias for --site. Accepts owner_xyz or config/owner_xyz (also supports --directory-name)') if (!$appPath) {
) // }}} $appPath = getcwd().'/';
}
$appPath = rtrim($appPath, '/').'/';
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key.
if (str_starts_with($siteInput, $appPath.'config/'))
{
$siteInput = substr($siteInput, strlen($appPath.'config/'));
}
elseif (str_starts_with($siteInput, 'config/'))
{
$siteInput = substr($siteInput, strlen('config/'));
}
$siteInput = trim($siteInput, '/');
if ($siteInput === '')
{
throw new \Exception('Option --siteDir is empty.');
}
// Block directory traversal and hidden-dot segments.
if (str_contains($siteInput, '..') || preg_match('~(^|/)\.(?:/|$)~', $siteInput))
{
throw new \Exception("Invalid directory in --siteDir: '$siteInput'.");
}
// Allow only predictable path segments for instance directories.
foreach (explode('/', $siteInput) as $part)
{
if ($part === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $part))
{
throw new \Exception("Invalid directory name segment '$part' in --siteDir.");
}
}
return $siteInput;
}
))
) // }}}
->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{ ->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{
->flags(AbstractInputType::FLAG_REQUIRED) ->flags(AbstractInputType::FLAG_REQUIRED)
@ -167,18 +201,30 @@ $collection = (new Input\InputCollection())
->description( ->description(
'the settings you want to work on. With action "write" you can pass the value to set as JSON after an equal sign.' 'the settings you want to work on. With action "write" you can pass the value to set as JSON after an equal sign.'
) )
->validator(new Input\Validator( ->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context) function (AbstractInputType $input, AbstractInputHandler $context)
{ {
$setting = $context->find('setting'); $setting = $context->find('setting');
$action = $context->find('action'); $action = $context->find('action');
$inputPayload = $context->find('in');
if ($action === 'write')
{
if (($setting !== null && $setting !== '') && $inputPayload) {
throw new \Exception('Please use either SETTING or --in, not both.');
}
}
if ($setting === null || $setting === '') {
return ['key' => '', 'value' => null];
}
$setting = explode('=', $setting); $setting = explode('=', $setting);
$settings['key'] = $setting[0]; $settings['key'] = $setting[0];
if ($action === 'write') if ($action === 'write')
{ {
$value = $setting[1]; $value = $setting[1] ?? null;
if (! (isset($value) || $context->find('data'))) { if (! (isset($value) || $context->find('in'))) {
throw new \Exception('You need a value to write'); throw new \Exception('You need a value to write');
} }
$specialValues = [ 'true', 'false', 'null' ]; $specialValues = [ 'true', 'false', 'null' ];
@ -204,28 +250,50 @@ $collection = (new Input\InputCollection())
$usage = Cli\manpage( basename(__FILE__), $version, $usage = Cli\manpage( basename(__FILE__), $version,
'read write settings', 'read write settings',
$collection, Colour::FG_GREEN, Colour::FG_WHITE, $collection, Colour::FG_GREEN, Colour::FG_WHITE,
[ [
'Examples' => 'Examples' =>
'cfg write myCEESV \'auth:projectId="218523"\'' 'Basic usage:'
.PHP_EOL .PHP_EOL
.'# writes local config: config/myCEESV.conf.php' .'cfg show VeruA db:host'
.PHP_EOL .PHP_EOL
.'cfg write myCEESV \'auth:projectId="218523"\' --directory-name=owner_xyz' .'cfg write VeruA \'db:host="newHost"\''
.PHP_EOL .PHP_EOL
.'# writes instance config: config/owner_xyz/myCEESV.conf.php' .PHP_EOL
.PHP_EOL .'Advance usage:'
.'cfg show myCEESV auth:projectId --site=owner_xyz' .PHP_EOL
.PHP_EOL .'cfg write prefix \'module:enabled=true\''
.'# reads merged config including config/owner_xyz/myCEESV.conf.php' .PHP_EOL
.PHP_EOL .PHP_EOL
] .'#Site specific write:'
.PHP_EOL
.'cfg write prefix \'module:enabled=true\' --siteDir=owner_xyz'
.PHP_EOL
.PHP_EOL
.'#Batch write from JSON file:'
.PHP_EOL
.'cfg write prefix --siteDir=owner_xyz -i /tmp/extension.json'
.PHP_EOL
.PHP_EOL
.'#Batch write from JSON string:'
.PHP_EOL
.'cfg write prefix \'module={"enabled":true,"timeout":30,"label":"example"}\' --siteDir=owner_xyz'
.PHP_EOL
.PHP_EOL
.'#Read merged value for a site:'
.PHP_EOL
.'cfg show prefix module:enabled --siteDir=owner_xyz'
.PHP_EOL
]
).PHP_EOL; ).PHP_EOL;
// Get the supplied input. Passing the collection will make the handler bind values // Get the supplied input. Passing the collection will make the handler bind values
// and validate the input according to our collection // and validate the input according to our collection
try { try
{
$argv = Input\InputHandlerFactory::build('Argv', $collection); $argv = Input\InputHandlerFactory::build('Argv', $collection);
} catch (\Exception $ex) { }
catch (\Exception $ex)
{
echo $usage; echo $usage;
if (isset($argv[1])) { if (isset($argv[1])) {
@ -247,27 +315,11 @@ if ($argv->find( 'help' ) || $argv->find('action') == 'help')
$prefix = $argv->find('prefix'); $prefix = $argv->find('prefix');
/* $verbose = (bool)$argv->find('verbose');
echo $argv->find('action').PHP_EOL;
echo ($prefix).PHP_EOL;
echo $argv->find('setting')['key'].PHP_EOL;
echo $argv->find('setting')['value'].PHP_EOL;
*/
// var_dump($cfg);
$appPath = $argv->find('appPath'); $appPath = $argv->find('appPath');
if (!$appPath) $appPath = getcwd().'/'; if (!$appPath) $appPath = getcwd().'/';
$appPath = rtrim($appPath, '/').'/'; $appPath = rtrim($appPath, '/').'/';
/* $it = new RecursiveDirectoryIterator($appPath);
foreach(new RecursiveIteratorIterator($it) as $file)
{
$configDir = $file->getPath();
if ($file->isDir() && $file->getFilename() == '.' && basename($configDir) == 'config') {
echo "found config dir: $configDir\n";
}
}
*/
$mode = ($argv->find('mode') == '') ? null : $argv->find('mode'); $mode = ($argv->find('mode') == '') ? null : $argv->find('mode');
$cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix); $cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix);
// pkgPath points to package defaults (e.g. <prefix>.default.conf.php) // pkgPath points to package defaults (e.g. <prefix>.default.conf.php)
@ -275,70 +327,29 @@ if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/');
$site = null; $site = null;
$siteFlag = 0x01; $siteFlag = 0x01;
// Only one site selector may be provided to avoid ambiguous targets. $site = $argv->find('siteDir');
$providedSiteOptions = []; if ($site !== null && $site !== false)
foreach ($preparsedSiteOptions as $option) { {
$providedSiteOptions[$option['name']][] = $option['value'] ?? ''; // Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
} $cfg->site($site);
foreach (['site', 'directoryName'] as $optionName) { verboseLog($verbose, "siteDir resolved to '$site'");
$parsedValue = $argv->find($optionName);
if ($parsedValue !== null && $parsedValue !== '' && $parsedValue !== false) {
$providedSiteOptions[$optionName][] = $parsedValue;
}
} }
if (count($providedSiteOptions) > 1) { try
fwrite(STDERR, 'Please use only one of --site or --directoryName (alias: --directory-name).'.PHP_EOL); {
exit(1);
}
if (!empty($providedSiteOptions)) {
$optionName = array_key_first($providedSiteOptions);
$siteInput = trim((string)end($providedSiteOptions[$optionName]));
if ($optionName === 'directoryName') {
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key.
if (str_starts_with($siteInput, $appPath.'config/')) {
$siteInput = substr($siteInput, strlen($appPath.'config/'));
} elseif (str_starts_with($siteInput, 'config/')) {
$siteInput = substr($siteInput, strlen('config/'));
}
}
$siteInput = trim($siteInput, '/');
if ($siteInput === '') {
fwrite(STDERR, "Option --$optionName is empty.".PHP_EOL);
exit(1);
}
// Block directory traversal and hidden-dot segments.
if (str_contains($siteInput, '..') || preg_match('~(^|/)\.(?:/|$)~', $siteInput)) {
fwrite(STDERR, "Invalid directory in --$optionName: '$siteInput'.".PHP_EOL);
exit(1);
}
// Allow only predictable path segments for instance directories.
foreach (explode('/', $siteInput) as $part) {
if ($part === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $part)) {
fwrite(STDERR, "Invalid directory name segment '$part' in --$optionName.".PHP_EOL);
exit(1);
}
}
$site = $siteInput;
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
}
try {
if (is_readable($cfg->buildFileName('default'))) { if (is_readable($cfg->buildFileName('default'))) {
$cfg->load(); $cfg->load();
} }
elseif (is_readable($cfgFile = $cfg->buildFileName())) { elseif (is_readable($cfgFile = $cfg->buildFileName())) {
$cfg->load(require($cfgFile)); $cfg->load(require($cfgFile));
} }
} catch (\Throwable $e) { }
catch (\Throwable $e)
{
fwrite(STDERR, "Error: ".$e->getMessage().PHP_EOL); fwrite(STDERR, "Error: ".$e->getMessage().PHP_EOL);
exit(1); exit(1);
} }
//var_dump($cfg); verboseLog($verbose, 'config bootstrap loaded');
$result = $cfg; $result = $cfg;
$settings = $argv->find('setting') ?? $settings; $settings = $argv->find('setting') ?? $settings;
@ -363,37 +374,57 @@ if ($result instanceof Settings) $result = $result->toArray();
switch ($argv->find('action')) switch ($argv->find('action'))
{ {
case 'show': case 'show':
verboseLog($verbose, "show action for prefix '$prefix'");
$out = (is_string($result)) ? $result : json_encode($result, JSON_PRETTY_PRINT); $out = (is_string($result)) ? $result : json_encode($result, JSON_PRETTY_PRINT);
echo $out.PHP_EOL; echo $out.PHP_EOL;
break; break;
case 'write': case 'write':
$inputPayload = $argv->find('in');
if (!$inputPayload && $settings['key'] === '')
{
fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
exit(1);
}
$path = ($settings['key'] !== '') ? explode(':', $settings['key']) : []; $path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING'));
$setting2write = $settings['value']; if ($inputPayload)
{
$setting2write = $inputPayload;
}
else
{
$setting2write = $settings['value'];
}
verboseLog($verbose, 'write path: '.json_encode($path));
while ( ! empty($path)) while ( ! empty($path))
{ {
$setting2write = [array_pop($path) => $setting2write]; $setting2write = [array_pop($path) => $setting2write];
} }
$writeType = ($site !== null) ? $siteFlag : null; $writeType = ($site !== null && $site !== false) ? $siteFlag : null;
$file = $cfg->buildFileName($writeType); $file = $cfg->buildFileName($writeType);
verboseLog($verbose, "write target: $file");
if (is_readable($file)) if (is_readable($file))
{ {
$setting2write = array_replace_recursive(require($file), $setting2write); $setting2write = array_replace_recursive(require($file), $setting2write);
copy($file, "$file.bak"); copy($file, "$file.bak");
verboseLog($verbose, "existing config merged from: $file");
} }
$targetDir = dirname($file); $targetDir = dirname($file);
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir))
{
fwrite(STDERR, "Can not create directory: $targetDir".PHP_EOL); fwrite(STDERR, "Can not create directory: $targetDir".PHP_EOL);
exit(1); exit(1);
} }
$writeCfg = $cfg->create($setting2write); $writeCfg = $cfg->create($setting2write);
// var_dump($writeCfg->toArray()); verboseLog($verbose, 'payload prepared for writer');
try { try
{
(new SettingsWriter($writeCfg, '', $writeType))->write(); (new SettingsWriter($writeCfg, '', $writeType))->write();
echo "Written modified settings to: $file".PHP_EOL; echo "Written modified settings to: $file".PHP_EOL;
} }

View file

@ -8,6 +8,7 @@ class CfgTest extends TestCase
{ {
private string $tmpDir; private string $tmpDir;
/* Lifecycle {{{ */
protected function setUp(): void protected function setUp(): void
{ {
$this->tmpDir = rtrim(sys_get_temp_dir(), '/').'/util-settings-cfg-'.bin2hex(random_bytes(8)); $this->tmpDir = rtrim(sys_get_temp_dir(), '/').'/util-settings-cfg-'.bin2hex(random_bytes(8));
@ -15,8 +16,8 @@ class CfgTest extends TestCase
mkdir($this->tmpDir.'/config', 0775, true); mkdir($this->tmpDir.'/config', 0775, true);
file_put_contents( file_put_contents(
$this->tmpDir.'/config/myCEESV.default.conf.php', $this->tmpDir.'/config/Extension.default.conf.php',
"<?php return [\n\t'mode' => 'prod',\n\t'auth' => [\n\t\t'projectId' => '',\n\t\t'clientId' => '',\n\t],\n];\n" "<?php return [\n\t'mode' => 'prod',\n\t'module' => [\n\t\t'code' => '',\n\t\t'label' => '',\n\t\t'flags' => [\n\t\t\t'enabled' => false,\n\t\t],\n\t],\n\t'feature' => [\n\t\t'endpoint' => '',\n\t],\n];\n"
); );
} }
@ -24,261 +25,390 @@ class CfgTest extends TestCase
{ {
$this->removeDir($this->tmpDir); $this->removeDir($this->tmpDir);
} }
/* }}} */
/* Write Flow {{{ */
public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$localFile = $this->tmpDir.'/config/myCEESV.conf.php'; $localFile = $this->tmpDir.'/config/Extension.conf.php';
$this->assertFileExists($localFile); $this->assertFileExists($localFile);
$cfg = require $localFile; $cfg = require $localFile;
$this->assertSame('218523', $cfg['auth']['projectId']); $this->assertSame('X100', $cfg['module']['code']);
} }
public function testWriteWithoutModeFallsBackToDefaultMode(): void public function testWriteWithoutModeFallsBackToDefaultMode(): void
{ {
file_put_contents( file_put_contents(
$this->tmpDir.'/config/myCEESV.default.conf.php', $this->tmpDir.'/config/Extension.default.conf.php',
"<?php return [\n\t'auth' => [\n\t\t'projectId' => '',\n\t],\n];\n" "<?php return [\n\t'module' => [\n\t\t'code' => '',\n\t],\n];\n"
); );
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/myCEESV.conf.php'); $this->assertFileExists($this->tmpDir.'/config/Extension.conf.php');
} }
public function testWriteWithDirectoryNameCreatesAndWritesSiteConfig(): void public function testWriteWithInputFileWritesMultipleSettings(): void
{ {
$result = $this->runCfg([ $inFile = $this->createJsonInputFile([
'-a', 'module' => [
$this->tmpDir, 'code' => 'X100',
'write', 'label' => 'demo-module',
'myCEESV', ],
'auth:projectId="218523"', 'feature' => [
'--directory-name', 'endpoint' => 'https://example.invalid/v1/resource',
'owner_xyz', ],
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $result = $this->runWrite([
'--siteDir=owner_xyz',
'-i',
$inFile,
]);
$siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; $this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
$this->assertSame('https://example.invalid/v1/resource', $cfg['feature']['endpoint']);
}
public function testWriteAcceptsJsonStringValueInSetting(): void
{
$result = $this->runWrite([
'module={"code":"X100","label":"demo-module"}',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
}
public function testWriteSupportsNestedPathWithColonNotation(): void
{
$result = $this->runWrite([
'module:flags:enabled=true',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertTrue($cfg['module']['flags']['enabled']);
}
public function testWriteCoercesSimpleStringValueToJsonString(): void
{
$result = $this->runWrite([
'module:code=X100',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteRejectsInvalidJsonStringValue(): void
{
$result = $this->runWrite([
'module={"code":',
'--siteDir=owner_xyz',
]);
$this->assertCfgFailure($result, 'The value does not appear to be a valid JSON string.');
}
public function testWriteWithSiteDirCreatesAndWritesSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$this->assertFileExists($siteFile); $this->assertFileExists($siteFile);
$this->assertFileDoesNotExist($this->tmpDir.'/config/myCEESV.conf.php'); $this->assertFileDoesNotExist($this->tmpDir.'/config/Extension.conf.php');
$cfg = require $siteFile; $cfg = require $siteFile;
$this->assertSame('218523', $cfg['auth']['projectId']); $this->assertSame('X100', $cfg['module']['code']);
} }
public function testWriteWithDirectoryNameMergesIntoExistingSiteConfig(): void public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void
{ {
$firstWrite = $this->runCfg([ $firstWrite = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir=owner_xyz',
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name',
'owner_xyz',
]); ]);
$this->assertSame(0, $firstWrite['code'], $firstWrite['output']); $this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runCfg([ $secondWrite = $this->runWrite([
'-a', 'module:label="demo-module"',
$this->tmpDir, '--siteDir=owner_xyz',
'write',
'myCEESV',
'auth:clientId="service-9999-qual@myceesv.ch"',
'--directory-name',
'owner_xyz',
]); ]);
$this->assertSame(0, $secondWrite['code'], $secondWrite['output']); $this->assertCfgSuccess($secondWrite);
$siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; $cfg = $this->loadWrittenConfig('owner_xyz');
$cfg = require $siteFile;
$this->assertSame('218523', $cfg['auth']['projectId']); $this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('service-9999-qual@myceesv.ch', $cfg['auth']['clientId']); $this->assertSame('demo-module', $cfg['module']['label']);
} }
public function testWriteWithDirectoryNameUsingConfigPrefixIsNormalized(): void public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir=config/owner_xyz',
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name=config/owner_xyz',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
$this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/myCEESV.conf.php'); $this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php');
} }
public function testWriteWithSiteOptionWritesToSiteConfig(): void public function testWriteWithSiteDirUsingAbsoluteConfigPrefixIsNormalized(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir='.$this->tmpDir.'/config/owner_xyz',
'write',
'myCEESV',
'auth:projectId="218523"',
'--site=owner_xyz',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
} }
public function testWriteWithDirectoryNameCamelCaseOptionWritesToSiteConfig(): void public function testWriteWithNestedSiteDirWritesToNestedSiteConfig(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir=owner_xyz/sub_a',
'write',
'myCEESV',
'auth:projectId="218523"',
'--directoryName=owner_xyz',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/sub_a/Extension.conf.php');
}
public function testWriteWithSiteDirWritesToSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
} }
public function testWriteToExistingSiteConfigCreatesBackup(): void public function testWriteToExistingSiteConfigCreatesBackup(): void
{ {
$firstWrite = $this->runCfg([ $firstWrite = $this->runWrite([
'-a', 'module:code="first"',
$this->tmpDir, '--siteDir=owner_xyz',
'write',
'myCEESV',
'auth:projectId="first"',
'--directory-name=owner_xyz',
]); ]);
$this->assertSame(0, $firstWrite['code'], $firstWrite['output']); $this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runCfg([ $secondWrite = $this->runWrite([
'-a', 'module:code="second"',
$this->tmpDir, '--siteDir=owner_xyz',
'write',
'myCEESV',
'auth:projectId="second"',
'--directory-name=owner_xyz',
]); ]);
$this->assertSame(0, $secondWrite['code'], $secondWrite['output']); $this->assertCfgSuccess($secondWrite);
$siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; $siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$backupFile = $siteFile.'.bak'; $backupFile = $siteFile.'.bak';
$this->assertFileExists($backupFile); $this->assertFileExists($backupFile);
$current = require $siteFile; $current = require $siteFile;
$backup = require $backupFile; $backup = require $backupFile;
$this->assertSame('second', $current['auth']['projectId']); $this->assertSame('second', $current['module']['code']);
$this->assertSame('first', $backup['auth']['projectId']); $this->assertSame('first', $backup['module']['code']);
} }
/* }}} */
/* Show Flow {{{ */
public function testShowWithSiteReturnsSiteSpecificValue(): void public function testShowWithSiteReturnsSiteSpecificValue(): void
{ {
$this->runCfg([ $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$show = $this->runShow([
'module:code',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($show);
$this->assertStringContainsString('X100', $show['output']);
}
/* }}} */
/* Validation {{{ */
public function testInvalidActionReturnsError(): void
{
$result = $this->runCfg([
'-a',
$this->tmpDir,
'delete',
'Extension',
]);
$this->assertCfgFailure($result, "'delete' not found");
}
public function testSiteDirRejectsEmptyValue(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=',
]);
$this->assertCfgFailure($result, 'a value is required for --siteDir');
}
/**
* @dataProvider siteDirValidationDataProvider
*/
public function testSiteDirValidation(string $siteDir, string $expectedMessage): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir='.$siteDir,
]);
$this->assertCfgFailure($result, $expectedMessage);
}
/**
* @dataProvider inputFileValidationDataProvider
*/
public function testInputFileValidation(?string $fileContents, string $pathSuffix, string $expectedMessage): void
{
$inFile = $this->tmpDir.'/'.$pathSuffix;
if ($fileContents !== null) {
file_put_contents($inFile, $fileContents);
}
$result = $this->runWrite([
'-i',
$inFile,
]);
$this->assertCfgFailure($result, $expectedMessage);
}
/**
* @dataProvider writePayloadSourceValidationDataProvider
*/
public function testWritePayloadSourceValidation(array $extraArgs, string $expectedMessage): void
{
$inFile = $this->createJsonInputFile(['module' => ['code' => 'X100']]);
$args = array_merge(
[
'-a',
$this->tmpDir,
'write',
'Extension',
],
array_map(
fn (string $arg): string => $arg === '__INPUT_FILE__' ? $inFile : $arg,
$extraArgs
)
);
$result = $this->runCfg($args);
$this->assertCfgFailure($result, $expectedMessage);
}
public function siteDirValidationDataProvider(): array
{
return [
'traversal' => ['../owner_xyz', 'Invalid directory in --siteDir'],
'invalid segment' => ['owner xyz', "Invalid directory name segment 'owner xyz' in --siteDir."],
'hidden dot segment' => ['owner_xyz/./secret', "Invalid directory in --siteDir: 'owner_xyz/./secret'."],
'config root only' => ['config/', 'Option --siteDir is empty.'],
];
}
public function inputFileValidationDataProvider(): array
{
return [
'missing file' => [null, 'missing.json', 'Input file is not readable'],
'invalid json' => ['{"module":', 'invalid.json', 'Input JSON is invalid'],
'scalar json' => ['true', 'scalar.json', 'Input JSON must decode to an object/array'],
];
}
public function writePayloadSourceValidationDataProvider(): array
{
return [
'missing payload source' => [[], 'Nothing to write: provide SETTING or --in.'],
'both payload sources' => [['module:code="x"', '-i', '__INPUT_FILE__'], 'Please use either SETTING or --in, not both.'],
];
}
/* }}} */
/* Helpers {{{ */
private function runWrite(array $args): array
{
return $this->runCfg(array_merge([
'-a', '-a',
$this->tmpDir, $this->tmpDir,
'write', 'write',
'myCEESV', 'Extension',
'auth:projectId="218523"', ], $args));
'--directory-name=owner_xyz', }
]);
$show = $this->runCfg([ private function runShow(array $args): array
{
return $this->runCfg(array_merge([
'-a', '-a',
$this->tmpDir, $this->tmpDir,
'show', 'show',
'myCEESV', 'Extension',
'auth:projectId', ], $args));
'--site=owner_xyz',
]);
$this->assertSame(0, $show['code'], $show['output']);
$this->assertStringContainsString('218523', $show['output']);
} }
public function testDirectoryNameRejectsTraversal(): void private function createJsonInputFile(array $contents, string $name = 'extension-in.json'): string
{ {
$result = $this->runCfg([ $path = $this->tmpDir.'/'.$name;
'-a', file_put_contents($path, json_encode($contents, JSON_PRETTY_PRINT));
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name=../owner_xyz',
]);
$this->assertSame(1, $result['code']); return $path;
$this->assertStringContainsString('Invalid directory in --directoryName', $result['output']);
} }
public function testDirectoryNameRejectsInvalidSegment(): void private function loadWrittenConfig(?string $siteDir = null): array
{ {
$result = $this->runCfg([ $path = $siteDir === null
'-a', ? $this->tmpDir.'/config/Extension.conf.php'
$this->tmpDir, : $this->tmpDir.'/config/'.$siteDir.'/Extension.conf.php';
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name=owner xyz',
]);
$this->assertSame(1, $result['code']); return require $path;
$this->assertStringContainsString("Invalid directory name segment 'owner xyz' in --directoryName.", $result['output']);
} }
public function testDirectoryNameRejectsEmptyValue(): void private function assertCfgSuccess(array $result): void
{ {
$result = $this->runCfg([ $this->assertSame(0, $result['code'], $result['output']);
'-a',
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name=',
]);
$this->assertSame(1, $result['code']);
$this->assertStringContainsString('Option --directoryName is empty.', $result['output']);
} }
public function testUsingSiteAndDirectoryNameTogetherReturnsError(): void private function assertCfgFailure(array $result, string $message): void
{ {
$result = $this->runCfg([ $this->assertSame(1, $result['code'], $result['output']);
'-a', $this->assertStringContainsString($message, $result['output']);
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--site',
'owner_xyz',
'--directory-name',
'owner_xyz',
]);
$this->assertSame(1, $result['code']);
$this->assertStringContainsString('Please use only one of --site or --directoryName (alias: --directory-name).', $result['output']);
} }
private function runCfg(array $args): array private function runCfg(array $args): array
@ -323,4 +453,5 @@ class CfgTest extends TestCase
rmdir($dir); rmdir($dir);
} }
/* }}} */
} }

View file

@ -224,17 +224,17 @@ class SettingsTest extends TestCase
public function testBuildFileNameWithPrefixAndSite(): void public function testBuildFileNameWithPrefixAndSite(): void
{ {
$cfg = new Settings(); $cfg = new Settings();
$cfg->appPath('./')->prefix('myCEESV')->site('owner_xyz'); $cfg->appPath('./')->prefix('Extension')->site('owner_xyz');
$this->assertEquals('./config/owner_xyz/myCEESV.conf.php', $cfg->buildFileName(0x01)); $this->assertEquals('./config/owner_xyz/Extension.conf.php', $cfg->buildFileName(0x01));
} }
public function testBuildFileNameWithPrefixAndNestedSite(): void public function testBuildFileNameWithPrefixAndNestedSite(): void
{ {
$cfg = new Settings(); $cfg = new Settings();
$cfg->appPath('./')->prefix('myCEESV')->site('owner_xyz/sub_a'); $cfg->appPath('./')->prefix('Extension')->site('owner_xyz/sub_a');
$this->assertEquals('./config/owner_xyz/sub_a/myCEESV.conf.php', $cfg->buildFileName(0x01)); $this->assertEquals('./config/owner_xyz/sub_a/Extension.conf.php', $cfg->buildFileName(0x01));
} }
public function fileNameData() public function fileNameData()