diff --git a/bin/cfg b/bin/cfg index 2ce253f..3c7c0ef 100755 --- a/bin/cfg +++ b/bin/cfg @@ -22,59 +22,9 @@ foreach ($autoloadFiles as $autoloadFile) { } } -// Pre-parse site options so they can be passed after positional args. -// The input helper library does not handle this case reliably. -$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; +function verboseLog(bool $enabled, string $message): void +{ + if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL); } $version = '0.4'; @@ -90,15 +40,55 @@ $collection = (new Input\InputCollection()) ->description('Display help text') ) // }}} - ->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{ - ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) - ->description('Path to a json data file to to read') + ->add( Input\InputTypeFactory::build('LongOption')->name('verbose')->short('v') // {{{ + ->flags(AbstractInputType::FLAG_OPTIONAL) + ->description('Print debug details to STDERR') ) // }}} - ->add( Input\InputTypeFactory::build('LongOption')->name('out')->short('o') // {{{ - ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) - ->description('Path to a json file to to write to') - ) // }}} + ->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{ + ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) + ->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') // {{{ ->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') ) // }}} - ->add( Input\InputTypeFactory::build('LongOption')->name('site')->short('s') // {{{ - ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) - ->description('Site/instance directory below config/ (target: config//.conf.php), e.g. owner_xyz') - ) // }}} + ->add( Input\InputTypeFactory::build('LongOption')->name('siteDir')->short('s') // {{{ + ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) + ->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') // {{{ - ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) - ->description('Alias for --site. Accepts owner_xyz or config/owner_xyz (also supports --directory-name)') - ) // }}} + $siteInput = trim((string)$siteInput); + $appPath = $context->find('appPath'); + 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') // {{{ ->flags(AbstractInputType::FLAG_REQUIRED) @@ -167,18 +201,30 @@ $collection = (new Input\InputCollection()) ->description( '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( - function (AbstractInputType $input, AbstractInputHandler $context) - { - $setting = $context->find('setting'); - $action = $context->find('action'); - + ->validator(new Input\Validator( + function (AbstractInputType $input, AbstractInputHandler $context) + { + $setting = $context->find('setting'); + $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); $settings['key'] = $setting[0]; if ($action === 'write') { - $value = $setting[1]; - if (! (isset($value) || $context->find('data'))) { + $value = $setting[1] ?? null; + if (! (isset($value) || $context->find('in'))) { throw new \Exception('You need a value to write'); } $specialValues = [ 'true', 'false', 'null' ]; @@ -204,28 +250,50 @@ $collection = (new Input\InputCollection()) $usage = Cli\manpage( basename(__FILE__), $version, 'read write settings', $collection, Colour::FG_GREEN, Colour::FG_WHITE, - [ - 'Examples' => - 'cfg write myCEESV \'auth:projectId="218523"\'' - .PHP_EOL - .'# writes local config: config/myCEESV.conf.php' - .PHP_EOL - .'cfg write myCEESV \'auth:projectId="218523"\' --directory-name=owner_xyz' - .PHP_EOL - .'# writes instance config: config/owner_xyz/myCEESV.conf.php' - .PHP_EOL - .'cfg show myCEESV auth:projectId --site=owner_xyz' - .PHP_EOL - .'# reads merged config including config/owner_xyz/myCEESV.conf.php' - .PHP_EOL - ] + [ + 'Examples' => + 'Basic usage:' + .PHP_EOL + .'cfg show VeruA db:host' + .PHP_EOL + .'cfg write VeruA \'db:host="newHost"\'' + .PHP_EOL + .PHP_EOL + .'Advance usage:' + .PHP_EOL + .'cfg write prefix \'module:enabled=true\'' + .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; // Get the supplied input. Passing the collection will make the handler bind values // and validate the input according to our collection -try { +try +{ $argv = Input\InputHandlerFactory::build('Argv', $collection); -} catch (\Exception $ex) { +} +catch (\Exception $ex) +{ echo $usage; if (isset($argv[1])) { @@ -247,27 +315,11 @@ if ($argv->find( 'help' ) || $argv->find('action') == 'help') $prefix = $argv->find('prefix'); -/* -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); +$verbose = (bool)$argv->find('verbose'); $appPath = $argv->find('appPath'); if (!$appPath) $appPath = getcwd().'/'; $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'); $cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix); // pkgPath points to package defaults (e.g. .default.conf.php) @@ -275,70 +327,29 @@ if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/'); $site = null; $siteFlag = 0x01; -// Only one site selector may be provided to avoid ambiguous targets. -$providedSiteOptions = []; -foreach ($preparsedSiteOptions as $option) { - $providedSiteOptions[$option['name']][] = $option['value'] ?? ''; -} -foreach (['site', 'directoryName'] as $optionName) { - $parsedValue = $argv->find($optionName); - if ($parsedValue !== null && $parsedValue !== '' && $parsedValue !== false) { - $providedSiteOptions[$optionName][] = $parsedValue; - } +$site = $argv->find('siteDir'); +if ($site !== null && $site !== false) +{ + // Reuse util-settings site resolution: config//.conf.php + $cfg->site($site); + verboseLog($verbose, "siteDir resolved to '$site'"); } -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//.conf.php - $cfg->site($site); -} - -try { +try +{ if (is_readable($cfg->buildFileName('default'))) { $cfg->load(); } elseif (is_readable($cfgFile = $cfg->buildFileName())) { $cfg->load(require($cfgFile)); } -} catch (\Throwable $e) { +} +catch (\Throwable $e) +{ fwrite(STDERR, "Error: ".$e->getMessage().PHP_EOL); exit(1); } -//var_dump($cfg); +verboseLog($verbose, 'config bootstrap loaded'); $result = $cfg; $settings = $argv->find('setting') ?? $settings; @@ -363,37 +374,57 @@ if ($result instanceof Settings) $result = $result->toArray(); switch ($argv->find('action')) { case 'show': + verboseLog($verbose, "show action for prefix '$prefix'"); $out = (is_string($result)) ? $result : json_encode($result, JSON_PRETTY_PRINT); echo $out.PHP_EOL; break; 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']) : []; - - $setting2write = $settings['value']; + verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING')); + + if ($inputPayload) + { + $setting2write = $inputPayload; + } + else + { + $setting2write = $settings['value']; + } + verboseLog($verbose, 'write path: '.json_encode($path)); while ( ! empty($path)) { $setting2write = [array_pop($path) => $setting2write]; } - $writeType = ($site !== null) ? $siteFlag : null; + $writeType = ($site !== null && $site !== false) ? $siteFlag : null; $file = $cfg->buildFileName($writeType); + verboseLog($verbose, "write target: $file"); if (is_readable($file)) { $setting2write = array_replace_recursive(require($file), $setting2write); copy($file, "$file.bak"); + verboseLog($verbose, "existing config merged from: $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); exit(1); } $writeCfg = $cfg->create($setting2write); -// var_dump($writeCfg->toArray()); - try { + verboseLog($verbose, 'payload prepared for writer'); + try + { (new SettingsWriter($writeCfg, '', $writeType))->write(); echo "Written modified settings to: $file".PHP_EOL; } diff --git a/tests/CfgTest.php b/tests/CfgTest.php index 79167ea..646bcb2 100644 --- a/tests/CfgTest.php +++ b/tests/CfgTest.php @@ -8,6 +8,7 @@ class CfgTest extends TestCase { private string $tmpDir; + /* Lifecycle {{{ */ protected function setUp(): void { $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); file_put_contents( - $this->tmpDir.'/config/myCEESV.default.conf.php', - " 'prod',\n\t'auth' => [\n\t\t'projectId' => '',\n\t\t'clientId' => '',\n\t],\n];\n" + $this->tmpDir.'/config/Extension.default.conf.php', + " '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); } + /* }}} */ + /* Write Flow {{{ */ public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', + $result = $this->runWrite([ + 'module:code="X100"', ]); - $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); $cfg = require $localFile; - $this->assertSame('218523', $cfg['auth']['projectId']); + $this->assertSame('X100', $cfg['module']['code']); } public function testWriteWithoutModeFallsBackToDefaultMode(): void { file_put_contents( - $this->tmpDir.'/config/myCEESV.default.conf.php', - " [\n\t\t'projectId' => '',\n\t],\n];\n" + $this->tmpDir.'/config/Extension.default.conf.php', + " [\n\t\t'code' => '',\n\t],\n];\n" ); - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', + $result = $this->runWrite([ + 'module:code="X100"', ]); - $this->assertSame(0, $result['code'], $result['output']); - $this->assertFileExists($this->tmpDir.'/config/myCEESV.conf.php'); + $this->assertCfgSuccess($result); + $this->assertFileExists($this->tmpDir.'/config/Extension.conf.php'); } - public function testWriteWithDirectoryNameCreatesAndWritesSiteConfig(): void + public function testWriteWithInputFileWritesMultipleSettings(): void { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name', - 'owner_xyz', + $inFile = $this->createJsonInputFile([ + 'module' => [ + 'code' => 'X100', + 'label' => 'demo-module', + ], + 'feature' => [ + 'endpoint' => 'https://example.invalid/v1/resource', + ], ]); - $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->assertFileDoesNotExist($this->tmpDir.'/config/myCEESV.conf.php'); + $this->assertFileDoesNotExist($this->tmpDir.'/config/Extension.conf.php'); $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([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name', - 'owner_xyz', + $firstWrite = $this->runWrite([ + 'module:code="X100"', + '--siteDir=owner_xyz', ]); - $this->assertSame(0, $firstWrite['code'], $firstWrite['output']); + $this->assertCfgSuccess($firstWrite); - $secondWrite = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:clientId="service-9999-qual@myceesv.ch"', - '--directory-name', - 'owner_xyz', + $secondWrite = $this->runWrite([ + 'module:label="demo-module"', + '--siteDir=owner_xyz', ]); - $this->assertSame(0, $secondWrite['code'], $secondWrite['output']); + $this->assertCfgSuccess($secondWrite); - $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; - $cfg = require $siteFile; + $cfg = $this->loadWrittenConfig('owner_xyz'); - $this->assertSame('218523', $cfg['auth']['projectId']); - $this->assertSame('service-9999-qual@myceesv.ch', $cfg['auth']['clientId']); + $this->assertSame('X100', $cfg['module']['code']); + $this->assertSame('demo-module', $cfg['module']['label']); } - public function testWriteWithDirectoryNameUsingConfigPrefixIsNormalized(): void + public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name=config/owner_xyz', + $result = $this->runWrite([ + 'module:code="X100"', + '--siteDir=config/owner_xyz', ]); - $this->assertSame(0, $result['code'], $result['output']); - $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); - $this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/myCEESV.conf.php'); + $this->assertCfgSuccess($result); + $this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php'); + $this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php'); } - public function testWriteWithSiteOptionWritesToSiteConfig(): void + public function testWriteWithSiteDirUsingAbsoluteConfigPrefixIsNormalized(): void { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--site=owner_xyz', + $result = $this->runWrite([ + 'module:code="X100"', + '--siteDir='.$this->tmpDir.'/config/owner_xyz', ]); - $this->assertSame(0, $result['code'], $result['output']); - $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); + $this->assertCfgSuccess($result); + $this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php'); } - public function testWriteWithDirectoryNameCamelCaseOptionWritesToSiteConfig(): void + public function testWriteWithNestedSiteDirWritesToNestedSiteConfig(): void { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directoryName=owner_xyz', + $result = $this->runWrite([ + 'module:code="X100"', + '--siteDir=owner_xyz/sub_a', ]); - $this->assertSame(0, $result['code'], $result['output']); - $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); + $this->assertCfgSuccess($result); + $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 { - $firstWrite = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="first"', - '--directory-name=owner_xyz', + $firstWrite = $this->runWrite([ + 'module:code="first"', + '--siteDir=owner_xyz', ]); - $this->assertSame(0, $firstWrite['code'], $firstWrite['output']); + $this->assertCfgSuccess($firstWrite); - $secondWrite = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="second"', - '--directory-name=owner_xyz', + $secondWrite = $this->runWrite([ + 'module:code="second"', + '--siteDir=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'; $this->assertFileExists($backupFile); $current = require $siteFile; $backup = require $backupFile; - $this->assertSame('second', $current['auth']['projectId']); - $this->assertSame('first', $backup['auth']['projectId']); + $this->assertSame('second', $current['module']['code']); + $this->assertSame('first', $backup['module']['code']); } + /* }}} */ + /* Show Flow {{{ */ 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', $this->tmpDir, 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name=owner_xyz', - ]); + 'Extension', + ], $args)); + } - $show = $this->runCfg([ + private function runShow(array $args): array + { + return $this->runCfg(array_merge([ '-a', $this->tmpDir, 'show', - 'myCEESV', - 'auth:projectId', - '--site=owner_xyz', - ]); - - $this->assertSame(0, $show['code'], $show['output']); - $this->assertStringContainsString('218523', $show['output']); + 'Extension', + ], $args)); } - public function testDirectoryNameRejectsTraversal(): void + private function createJsonInputFile(array $contents, string $name = 'extension-in.json'): string { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name=../owner_xyz', - ]); + $path = $this->tmpDir.'/'.$name; + file_put_contents($path, json_encode($contents, JSON_PRETTY_PRINT)); - $this->assertSame(1, $result['code']); - $this->assertStringContainsString('Invalid directory in --directoryName', $result['output']); + return $path; } - public function testDirectoryNameRejectsInvalidSegment(): void + private function loadWrittenConfig(?string $siteDir = null): array { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name=owner xyz', - ]); + $path = $siteDir === null + ? $this->tmpDir.'/config/Extension.conf.php' + : $this->tmpDir.'/config/'.$siteDir.'/Extension.conf.php'; - $this->assertSame(1, $result['code']); - $this->assertStringContainsString("Invalid directory name segment 'owner xyz' in --directoryName.", $result['output']); + return require $path; } - public function testDirectoryNameRejectsEmptyValue(): void + private function assertCfgSuccess(array $result): void { - $result = $this->runCfg([ - '-a', - $this->tmpDir, - 'write', - 'myCEESV', - 'auth:projectId="218523"', - '--directory-name=', - ]); - - $this->assertSame(1, $result['code']); - $this->assertStringContainsString('Option --directoryName is empty.', $result['output']); + $this->assertSame(0, $result['code'], $result['output']); } - public function testUsingSiteAndDirectoryNameTogetherReturnsError(): void + private function assertCfgFailure(array $result, string $message): void { - $result = $this->runCfg([ - '-a', - $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']); + $this->assertSame(1, $result['code'], $result['output']); + $this->assertStringContainsString($message, $result['output']); } private function runCfg(array $args): array @@ -323,4 +453,5 @@ class CfgTest extends TestCase rmdir($dir); } + /* }}} */ } diff --git a/tests/SettingsTest.php b/tests/SettingsTest.php index ae70ef7..09f311c 100644 --- a/tests/SettingsTest.php +++ b/tests/SettingsTest.php @@ -224,17 +224,17 @@ class SettingsTest extends TestCase public function testBuildFileNameWithPrefixAndSite(): void { $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 { $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()