Compare commits

..

No commits in common. "master" and "53687/task-support-instance-specific-cfg-write-target" have entirely different histories.

3 changed files with 334 additions and 496 deletions

369
bin/cfg
View file

@ -22,9 +22,59 @@ foreach ($autoloadFiles as $autoloadFile) {
} }
} }
function verboseLog(bool $enabled, string $message): void // Pre-parse site options so they can be passed after positional args.
{ // The input helper library does not handle this case reliably.
if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL); $preparsedSiteOptions = [];
$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';
@ -40,55 +90,15 @@ $collection = (new Input\InputCollection())
->description('Display help text') ->description('Display help text')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('verbose')->short('v') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Print debug details to STDERR') ->description('Path to a json data file to to read')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('out')->short('o') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a JSON data file to read (for write)') ->description('Path to a json file to to write to')
->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)
@ -105,59 +115,15 @@ $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('siteDir')->short('s') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('site')->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/. Accepts owner_xyz or config/owner_xyz') ->description('Site/instance directory below config/ (target: config/<site>/<prefix>.conf.php), e.g. owner_xyz')
->validator(new Input\Validator( ) // }}}
function (AbstractInputType $input, AbstractInputHandler $context)
{
$siteInput = $context->find('siteDir');
if ($siteInput === null || $siteInput === false) {
return null;
}
$siteInput = trim((string)$siteInput); ->add( Input\InputTypeFactory::build('LongOption')->name('directoryName') // {{{
$appPath = $context->find('appPath'); ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
if (!$appPath) { ->description('Alias for --site. Accepts owner_xyz or config/owner_xyz (also supports --directory-name)')
$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)
@ -201,30 +167,18 @@ $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] ?? null; $value = $setting[1];
if (! (isset($value) || $context->find('in'))) { if (! (isset($value) || $context->find('data'))) {
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' ];
@ -250,50 +204,28 @@ $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' =>
'Basic usage:' 'cfg write myCEESV \'auth:projectId="218523"\''
.PHP_EOL .PHP_EOL
.'cfg show VeruA db:host' .'# writes local config: config/myCEESV.conf.php'
.PHP_EOL .PHP_EOL
.'cfg write VeruA \'db:host="newHost"\'' .'cfg write myCEESV \'auth:projectId="218523"\' --directory-name=owner_xyz'
.PHP_EOL .PHP_EOL
.PHP_EOL .'# writes instance config: config/owner_xyz/myCEESV.conf.php'
.'Advance usage:' .PHP_EOL
.PHP_EOL .'cfg show myCEESV auth:projectId --site=owner_xyz'
.'cfg write prefix \'module:enabled=true\'' .PHP_EOL
.PHP_EOL .'# reads merged config including config/owner_xyz/myCEESV.conf.php'
.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])) {
@ -315,11 +247,27 @@ 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)
@ -327,29 +275,70 @@ if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/');
$site = null; $site = null;
$siteFlag = 0x01; $siteFlag = 0x01;
$site = $argv->find('siteDir'); // Only one site selector may be provided to avoid ambiguous targets.
if ($site !== null && $site !== false) $providedSiteOptions = [];
{ foreach ($preparsedSiteOptions as $option) {
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php $providedSiteOptions[$option['name']][] = $option['value'] ?? '';
$cfg->site($site); }
verboseLog($verbose, "siteDir resolved to '$site'"); foreach (['site', 'directoryName'] as $optionName) {
$parsedValue = $argv->find($optionName);
if ($parsedValue !== null && $parsedValue !== '' && $parsedValue !== false) {
$providedSiteOptions[$optionName][] = $parsedValue;
}
} }
try if (count($providedSiteOptions) > 1) {
{ 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);
} }
verboseLog($verbose, 'config bootstrap loaded'); //var_dump($cfg);
$result = $cfg; $result = $cfg;
$settings = $argv->find('setting') ?? $settings; $settings = $argv->find('setting') ?? $settings;
@ -374,57 +363,37 @@ 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 && $site !== false) ? $siteFlag : null; $writeType = ($site !== null) ? $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);
verboseLog($verbose, 'payload prepared for writer'); // var_dump($writeCfg->toArray());
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,7 +8,6 @@ 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));
@ -16,8 +15,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/Extension.default.conf.php', $this->tmpDir.'/config/myCEESV.default.conf.php',
"<?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" "<?php return [\n\t'mode' => 'prod',\n\t'auth' => [\n\t\t'projectId' => '',\n\t\t'clientId' => '',\n\t],\n];\n"
); );
} }
@ -25,390 +24,261 @@ class CfgTest extends TestCase
{ {
$this->removeDir($this->tmpDir); $this->removeDir($this->tmpDir);
} }
/* }}} */
/* Write Flow {{{ */
public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void
{ {
$result = $this->runWrite([ $result = $this->runCfg([
'module:code="X100"', '-a',
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
]); ]);
$this->assertCfgSuccess($result); $this->assertSame(0, $result['code'], $result['output']);
$localFile = $this->tmpDir.'/config/Extension.conf.php'; $localFile = $this->tmpDir.'/config/myCEESV.conf.php';
$this->assertFileExists($localFile); $this->assertFileExists($localFile);
$cfg = require $localFile; $cfg = require $localFile;
$this->assertSame('X100', $cfg['module']['code']); $this->assertSame('218523', $cfg['auth']['projectId']);
} }
public function testWriteWithoutModeFallsBackToDefaultMode(): void public function testWriteWithoutModeFallsBackToDefaultMode(): void
{ {
file_put_contents( file_put_contents(
$this->tmpDir.'/config/Extension.default.conf.php', $this->tmpDir.'/config/myCEESV.default.conf.php',
"<?php return [\n\t'module' => [\n\t\t'code' => '',\n\t],\n];\n" "<?php return [\n\t'auth' => [\n\t\t'projectId' => '',\n\t],\n];\n"
); );
$result = $this->runWrite([ $result = $this->runCfg([
'module:code="X100"', '-a',
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
]); ]);
$this->assertCfgSuccess($result); $this->assertSame(0, $result['code'], $result['output']);
$this->assertFileExists($this->tmpDir.'/config/Extension.conf.php'); $this->assertFileExists($this->tmpDir.'/config/myCEESV.conf.php');
} }
public function testWriteWithInputFileWritesMultipleSettings(): void public function testWriteWithDirectoryNameCreatesAndWritesSiteConfig(): void
{ {
$inFile = $this->createJsonInputFile([ $result = $this->runCfg([
'module' => [ '-a',
'code' => 'X100', $this->tmpDir,
'label' => 'demo-module', 'write',
], 'myCEESV',
'feature' => [ 'auth:projectId="218523"',
'endpoint' => 'https://example.invalid/v1/resource', '--directory-name',
], 'owner_xyz',
]); ]);
$result = $this->runWrite([ $this->assertSame(0, $result['code'], $result['output']);
'--siteDir=owner_xyz',
'-i',
$inFile,
]);
$this->assertCfgSuccess($result); $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php';
$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/Extension.conf.php'); $this->assertFileDoesNotExist($this->tmpDir.'/config/myCEESV.conf.php');
$cfg = require $siteFile; $cfg = require $siteFile;
$this->assertSame('X100', $cfg['module']['code']); $this->assertSame('218523', $cfg['auth']['projectId']);
} }
public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void public function testWriteWithDirectoryNameMergesIntoExistingSiteConfig(): void
{ {
$firstWrite = $this->runWrite([ $firstWrite = $this->runCfg([
'module:code="X100"', '-a',
'--siteDir=owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name',
'owner_xyz',
]); ]);
$this->assertCfgSuccess($firstWrite); $this->assertSame(0, $firstWrite['code'], $firstWrite['output']);
$secondWrite = $this->runWrite([ $secondWrite = $this->runCfg([
'module:label="demo-module"', '-a',
'--siteDir=owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:clientId="service-9999-qual@myceesv.ch"',
'--directory-name',
'owner_xyz',
]); ]);
$this->assertCfgSuccess($secondWrite); $this->assertSame(0, $secondWrite['code'], $secondWrite['output']);
$cfg = $this->loadWrittenConfig('owner_xyz'); $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php';
$cfg = require $siteFile;
$this->assertSame('X100', $cfg['module']['code']); $this->assertSame('218523', $cfg['auth']['projectId']);
$this->assertSame('demo-module', $cfg['module']['label']); $this->assertSame('service-9999-qual@myceesv.ch', $cfg['auth']['clientId']);
} }
public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void public function testWriteWithDirectoryNameUsingConfigPrefixIsNormalized(): void
{ {
$result = $this->runWrite([ $result = $this->runCfg([
'module:code="X100"', '-a',
'--siteDir=config/owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name=config/owner_xyz',
]); ]);
$this->assertCfgSuccess($result); $this->assertSame(0, $result['code'], $result['output']);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php');
$this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php'); $this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/myCEESV.conf.php');
} }
public function testWriteWithSiteDirUsingAbsoluteConfigPrefixIsNormalized(): void public function testWriteWithSiteOptionWritesToSiteConfig(): void
{ {
$result = $this->runWrite([ $result = $this->runCfg([
'module:code="X100"', '-a',
'--siteDir='.$this->tmpDir.'/config/owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--site=owner_xyz',
]); ]);
$this->assertCfgSuccess($result); $this->assertSame(0, $result['code'], $result['output']);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php');
} }
public function testWriteWithNestedSiteDirWritesToNestedSiteConfig(): void public function testWriteWithDirectoryNameCamelCaseOptionWritesToSiteConfig(): void
{ {
$result = $this->runWrite([ $result = $this->runCfg([
'module:code="X100"', '-a',
'--siteDir=owner_xyz/sub_a', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--directoryName=owner_xyz',
]); ]);
$this->assertCfgSuccess($result); $this->assertSame(0, $result['code'], $result['output']);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/sub_a/Extension.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.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->runWrite([ $firstWrite = $this->runCfg([
'module:code="first"', '-a',
'--siteDir=owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="first"',
'--directory-name=owner_xyz',
]); ]);
$this->assertCfgSuccess($firstWrite); $this->assertSame(0, $firstWrite['code'], $firstWrite['output']);
$secondWrite = $this->runWrite([ $secondWrite = $this->runCfg([
'module:code="second"', '-a',
'--siteDir=owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="second"',
'--directory-name=owner_xyz',
]); ]);
$this->assertCfgSuccess($secondWrite); $this->assertSame(0, $secondWrite['code'], $secondWrite['output']);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.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['module']['code']); $this->assertSame('second', $current['auth']['projectId']);
$this->assertSame('first', $backup['module']['code']); $this->assertSame('first', $backup['auth']['projectId']);
} }
/* }}} */
/* Show Flow {{{ */
public function testShowWithSiteReturnsSiteSpecificValue(): void public function testShowWithSiteReturnsSiteSpecificValue(): void
{ {
$this->runWrite([ $this->runCfg([
'module:code="X100"', '-a',
'--siteDir=owner_xyz', $this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--directory-name=owner_xyz',
]); ]);
$show = $this->runShow([ $show = $this->runCfg([
'module:code', '-a',
'--siteDir=owner_xyz', $this->tmpDir,
'show',
'myCEESV',
'auth:projectId',
'--site=owner_xyz',
]); ]);
$this->assertCfgSuccess($show); $this->assertSame(0, $show['code'], $show['output']);
$this->assertStringContainsString('X100', $show['output']); $this->assertStringContainsString('218523', $show['output']);
} }
/* }}} */
/* Validation {{{ */ public function testDirectoryNameRejectsTraversal(): void
public function testInvalidActionReturnsError(): void
{ {
$result = $this->runCfg([ $result = $this->runCfg([
'-a', '-a',
$this->tmpDir, $this->tmpDir,
'delete', 'write',
'Extension', 'myCEESV',
'auth:projectId="218523"',
'--directory-name=../owner_xyz',
]); ]);
$this->assertCfgFailure($result, "'delete' not found"); $this->assertSame(1, $result['code']);
$this->assertStringContainsString('Invalid directory in --directoryName', $result['output']);
} }
public function testSiteDirRejectsEmptyValue(): void public function testDirectoryNameRejectsInvalidSegment(): void
{ {
$result = $this->runWrite([ $result = $this->runCfg([
'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',
'Extension', 'myCEESV',
], $args)); 'auth:projectId="218523"',
'--directory-name=owner xyz',
]);
$this->assertSame(1, $result['code']);
$this->assertStringContainsString("Invalid directory name segment 'owner xyz' in --directoryName.", $result['output']);
} }
private function runShow(array $args): array public function testDirectoryNameRejectsEmptyValue(): void
{ {
return $this->runCfg(array_merge([ $result = $this->runCfg([
'-a', '-a',
$this->tmpDir, $this->tmpDir,
'show', 'write',
'Extension', 'myCEESV',
], $args)); 'auth:projectId="218523"',
'--directory-name=',
]);
$this->assertSame(1, $result['code']);
$this->assertStringContainsString('Option --directoryName is empty.', $result['output']);
} }
private function createJsonInputFile(array $contents, string $name = 'extension-in.json'): string public function testUsingSiteAndDirectoryNameTogetherReturnsError(): void
{ {
$path = $this->tmpDir.'/'.$name; $result = $this->runCfg([
file_put_contents($path, json_encode($contents, JSON_PRETTY_PRINT)); '-a',
$this->tmpDir,
'write',
'myCEESV',
'auth:projectId="218523"',
'--site',
'owner_xyz',
'--directory-name',
'owner_xyz',
]);
return $path; $this->assertSame(1, $result['code']);
} $this->assertStringContainsString('Please use only one of --site or --directoryName (alias: --directory-name).', $result['output']);
private function loadWrittenConfig(?string $siteDir = null): array
{
$path = $siteDir === null
? $this->tmpDir.'/config/Extension.conf.php'
: $this->tmpDir.'/config/'.$siteDir.'/Extension.conf.php';
return require $path;
}
private function assertCfgSuccess(array $result): void
{
$this->assertSame(0, $result['code'], $result['output']);
}
private function assertCfgFailure(array $result, string $message): void
{
$this->assertSame(1, $result['code'], $result['output']);
$this->assertStringContainsString($message, $result['output']);
} }
private function runCfg(array $args): array private function runCfg(array $args): array
@ -453,5 +323,4 @@ 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('Extension')->site('owner_xyz'); $cfg->appPath('./')->prefix('myCEESV')->site('owner_xyz');
$this->assertEquals('./config/owner_xyz/Extension.conf.php', $cfg->buildFileName(0x01)); $this->assertEquals('./config/owner_xyz/myCEESV.conf.php', $cfg->buildFileName(0x01));
} }
public function testBuildFileNameWithPrefixAndNestedSite(): void public function testBuildFileNameWithPrefixAndNestedSite(): void
{ {
$cfg = new Settings(); $cfg = new Settings();
$cfg->appPath('./')->prefix('Extension')->site('owner_xyz/sub_a'); $cfg->appPath('./')->prefix('myCEESV')->site('owner_xyz/sub_a');
$this->assertEquals('./config/owner_xyz/sub_a/Extension.conf.php', $cfg->buildFileName(0x01)); $this->assertEquals('./config/owner_xyz/sub_a/myCEESV.conf.php', $cfg->buildFileName(0x01));
} }
public function fileNameData() public function fileNameData()