Task: #53687 Refactor CLI input validation into option validators, add validator coverage, and refactor cfg CLI tests

This commit is contained in:
Alejandro Sosa 2026-03-30 13:57:29 +02:00
commit 1c851632c3
2 changed files with 380 additions and 287 deletions

247
bin/cfg
View file

@ -22,40 +22,6 @@ foreach ($autoloadFiles as $autoloadFile) {
} }
} }
function readJsonInputFile(string $path): array
{
if (!is_readable($path))
{
throw new \RuntimeException("Input file is not readable: $path");
}
$content = file_get_contents($path);
if ($content === false)
{
throw new \RuntimeException("Can not read input file: $path");
}
try
{
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $ex)
{
throw new \RuntimeException(sprintf(
'Input JSON is invalid (%s): %s',
(string)$ex->getCode(),
$ex->getMessage()
));
}
if (!is_array($data))
{
throw new \RuntimeException('Input JSON must decode to an object/array');
}
return $data;
}
function verboseLog(bool $enabled, string $message): void function verboseLog(bool $enabled, string $message): void
{ {
if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL); if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL);
@ -80,9 +46,49 @@ $collection = (new Input\InputCollection())
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{ ->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 data file to read (for write)') ->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)
@ -100,9 +106,58 @@ $collection = (new Input\InputCollection())
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('siteDir') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('siteDir') // {{{
->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/. 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;
}
$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') // {{{ ->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{
->flags(AbstractInputType::FLAG_REQUIRED) ->flags(AbstractInputType::FLAG_REQUIRED)
@ -146,15 +201,23 @@ $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 ($setting === null || $setting === '') { if ($action === 'write')
return ['key' => '', 'value' => null]; {
} 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];
@ -274,47 +337,12 @@ if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/');
$site = null; $site = null;
$siteFlag = 0x01; $siteFlag = 0x01;
$siteInput = $argv->find('siteDir'); $site = $argv->find('siteDir');
if ($siteInput !== null && $siteInput !== false) if ($site !== null && $site !== false)
{ {
$siteInput = trim((string)$siteInput); // Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key. verboseLog($verbose, "siteDir resolved to '$site'");
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 --siteDir 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 --siteDir: '$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 --siteDir.".PHP_EOL);
exit(1);
}
}
$site = $siteInput;
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
verboseLog($verbose, "siteDir resolved to '$site'");
} }
try try
@ -361,38 +389,21 @@ case 'show':
echo $out.PHP_EOL; echo $out.PHP_EOL;
break; break;
case 'write': case 'write':
$inputFile = $argv->find('in'); $inputPayload = $argv->find('in');
// Write needs exactly one payload source: if (!$inputPayload && $settings['key'] === '')
// either SETTING (key=value) or --in (JSON file). {
// This avoids ambiguous input precedence and empty writes. fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
if ($inputFile && $settings['key'] !== '') exit(1);
{ }
fwrite(STDERR, 'Please use either SETTING or --in, not both.'.PHP_EOL); $path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
exit(1); verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING'));
} if ($inputPayload)
if (!$inputFile && $settings['key'] === '')
{
fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
exit(1);
}
$path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
verboseLog($verbose, 'write source: '.($inputFile ? "--in ($inputFile)" : 'SETTING'));
if ($inputFile)
{
try
{ {
$setting2write = readJsonInputFile($inputFile); $setting2write = $inputPayload;
} }
catch (\Throwable $e) else
{ {
fwrite(STDERR, $e->getMessage().PHP_EOL);
exit(1);
}
}
else
{
$setting2write = $settings['value']; $setting2write = $settings['value'];
} }
verboseLog($verbose, 'write path: '.json_encode($path)); verboseLog($verbose, 'write path: '.json_encode($path));
@ -402,7 +413,7 @@ case 'write':
$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"); verboseLog($verbose, "write target: $file");
if (is_readable($file)) if (is_readable($file))

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));
@ -24,18 +25,16 @@ 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',
'Extension',
'module:code="X100"',
]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$localFile = $this->tmpDir.'/config/Extension.conf.php'; $localFile = $this->tmpDir.'/config/Extension.conf.php';
$this->assertFileExists($localFile); $this->assertFileExists($localFile);
@ -51,46 +50,34 @@ class CfgTest extends TestCase
"<?php return [\n\t'module' => [\n\t\t'code' => '',\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',
'Extension',
'module:code="X100"',
]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/Extension.conf.php'); $this->assertFileExists($this->tmpDir.'/config/Extension.conf.php');
} }
public function testWriteWithInputFileWritesMultipleSettings(): void public function testWriteWithInputFileWritesMultipleSettings(): void
{ {
$inFile = $this->tmpDir.'/extension-in.json'; $inFile = $this->createJsonInputFile([
file_put_contents( 'module' => [
$inFile, 'code' => 'X100',
json_encode([ 'label' => 'demo-module',
'module' => [ ],
'code' => 'X100', 'feature' => [
'label' => 'demo-module', 'endpoint' => 'https://example.invalid/v1/resource',
], ],
'feature' => [ ]);
'endpoint' => 'https://example.invalid/v1/resource',
],
], JSON_PRETTY_PRINT)
);
$result = $this->runCfg([ $result = $this->runWrite([
'-a',
$this->tmpDir,
'write',
'Extension',
'--siteDir=owner_xyz', '--siteDir=owner_xyz',
'-i', '-i',
$inFile, $inFile,
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$cfg = require $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']); $this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']); $this->assertSame('demo-module', $cfg['module']['label']);
$this->assertSame('https://example.invalid/v1/resource', $cfg['feature']['endpoint']); $this->assertSame('https://example.invalid/v1/resource', $cfg['feature']['endpoint']);
@ -98,68 +85,59 @@ class CfgTest extends TestCase
public function testWriteAcceptsJsonStringValueInSetting(): void public function testWriteAcceptsJsonStringValueInSetting(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module={"code":"X100","label":"demo-module"}',
$this->tmpDir, '--siteDir=owner_xyz',
'write', ]);
'Extension',
'module={"code":"X100","label":"demo-module"}',
'--siteDir=owner_xyz',
]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$cfg = require $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']); $this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']); $this->assertSame('demo-module', $cfg['module']['label']);
} }
public function testWriteSupportsNestedPathWithColonNotation(): void public function testWriteSupportsNestedPathWithColonNotation(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a',
$this->tmpDir,
'write',
'Extension',
'module:flags:enabled=true', 'module:flags:enabled=true',
'--siteDir=owner_xyz', '--siteDir=owner_xyz',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$cfg = require $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertTrue($cfg['module']['flags']['enabled']); $this->assertTrue($cfg['module']['flags']['enabled']);
} }
public function testWriteWithInputFileAndSettingReturnsError(): void public function testWriteCoercesSimpleStringValueToJsonString(): void
{ {
$inFile = $this->tmpDir.'/extension-in.json'; $result = $this->runWrite([
file_put_contents($inFile, json_encode(['module' => ['code' => 'X100']])); 'module:code=X100',
'--siteDir=owner_xyz',
]);
$result = $this->runCfg([ $this->assertCfgSuccess($result);
'-a', $cfg = $this->loadWrittenConfig('owner_xyz');
$this->tmpDir, $this->assertSame('X100', $cfg['module']['code']);
'write', }
'Extension',
'module:code="x"',
'-i',
$inFile,
]);
$this->assertSame(1, $result['code']); public function testWriteRejectsInvalidJsonStringValue(): void
$this->assertStringContainsString('Please use either SETTING or --in, not both.', $result['output']); {
$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 public function testWriteWithSiteDirCreatesAndWritesSiteConfig(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a',
$this->tmpDir,
'write',
'Extension',
'module:code="X100"', 'module:code="X100"',
'--siteDir=owner_xyz', '--siteDir=owner_xyz',
]); ]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$this->assertFileExists($siteFile); $this->assertFileExists($siteFile);
@ -171,28 +149,19 @@ class CfgTest extends TestCase
public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void
{ {
$firstWrite = $this->runCfg([ $firstWrite = $this->runWrite([
'-a',
$this->tmpDir,
'write',
'Extension',
'module:code="X100"', 'module:code="X100"',
'--siteDir=owner_xyz', '--siteDir=owner_xyz',
]); ]);
$this->assertSame(0, $firstWrite['code'], $firstWrite['output']); $this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runCfg([ $secondWrite = $this->runWrite([
'-a',
$this->tmpDir,
'write',
'Extension',
'module:label="demo-module"', 'module:label="demo-module"',
'--siteDir=owner_xyz', '--siteDir=owner_xyz',
]); ]);
$this->assertSame(0, $secondWrite['code'], $secondWrite['output']); $this->assertCfgSuccess($secondWrite);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $cfg = $this->loadWrittenConfig('owner_xyz');
$cfg = require $siteFile;
$this->assertSame('X100', $cfg['module']['code']); $this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']); $this->assertSame('demo-module', $cfg['module']['label']);
@ -200,56 +169,62 @@ class CfgTest extends TestCase
public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir=config/owner_xyz',
'write', ]);
'Extension',
'module:code="X100"',
'--siteDir=config/owner_xyz',
]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php'); $this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
$this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php'); $this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php');
} }
public function testWriteWithSiteDirUsingAbsoluteConfigPrefixIsNormalized(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir='.$this->tmpDir.'/config/owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
}
public function testWriteWithNestedSiteDirWritesToNestedSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz/sub_a',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/sub_a/Extension.conf.php');
}
public function testWriteWithSiteDirWritesToSiteConfig(): void public function testWriteWithSiteDirWritesToSiteConfig(): void
{ {
$result = $this->runCfg([ $result = $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir=owner_xyz',
'write', ]);
'Extension',
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertSame(0, $result['code'], $result['output']); $this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php'); $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', ]);
'Extension', $this->assertCfgSuccess($firstWrite);
'module:code="first"',
'--siteDir=owner_xyz',
]);
$this->assertSame(0, $firstWrite['code'], $firstWrite['output']);
$secondWrite = $this->runCfg([ $secondWrite = $this->runWrite([
'-a', 'module:code="second"',
$this->tmpDir, '--siteDir=owner_xyz',
'write', ]);
'Extension', $this->assertCfgSuccess($secondWrite);
'module:code="second"',
'--siteDir=owner_xyz',
]);
$this->assertSame(0, $secondWrite['code'], $secondWrite['output']);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php'; $siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$backupFile = $siteFile.'.bak'; $backupFile = $siteFile.'.bak';
@ -260,74 +235,180 @@ class CfgTest extends TestCase
$this->assertSame('second', $current['module']['code']); $this->assertSame('second', $current['module']['code']);
$this->assertSame('first', $backup['module']['code']); $this->assertSame('first', $backup['module']['code']);
} }
/* }}} */
/* Show Flow {{{ */
public function testShowWithSiteReturnsSiteSpecificValue(): void public function testShowWithSiteReturnsSiteSpecificValue(): void
{ {
$this->runCfg([ $this->runWrite([
'-a', 'module:code="X100"',
$this->tmpDir, '--siteDir=owner_xyz',
'write', ]);
'Extension',
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$show = $this->runCfg([ $show = $this->runShow([
'-a', 'module:code',
$this->tmpDir, '--siteDir=owner_xyz',
'show', ]);
'Extension',
'module:code',
'--siteDir=owner_xyz',
]);
$this->assertSame(0, $show['code'], $show['output']); $this->assertCfgSuccess($show);
$this->assertStringContainsString('X100', $show['output']); $this->assertStringContainsString('X100', $show['output']);
} }
/* }}} */
public function testSiteDirRejectsTraversal(): void /* Validation {{{ */
public function testInvalidActionReturnsError(): void
{ {
$result = $this->runCfg([ $result = $this->runCfg([
'-a', '-a',
$this->tmpDir, $this->tmpDir,
'write', 'delete',
'Extension', 'Extension',
'module:code="X100"', ]);
'--siteDir=../owner_xyz',
]);
$this->assertSame(1, $result['code']); $this->assertCfgFailure($result, "'delete' not found");
$this->assertStringContainsString('Invalid directory in --siteDir', $result['output']);
}
public function testSiteDirRejectsInvalidSegment(): void
{
$result = $this->runCfg([
'-a',
$this->tmpDir,
'write',
'Extension',
'module:code="X100"',
'--siteDir=owner xyz',
]);
$this->assertSame(1, $result['code']);
$this->assertStringContainsString("Invalid directory name segment 'owner xyz' in --siteDir.", $result['output']);
} }
public function testSiteDirRejectsEmptyValue(): void public function testSiteDirRejectsEmptyValue(): void
{ {
$result = $this->runCfg([ $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', '-a',
$this->tmpDir, $this->tmpDir,
'write', 'write',
'Extension', 'Extension',
'module:code="X100"', ],
'--siteDir=', array_map(
]); fn (string $arg): string => $arg === '__INPUT_FILE__' ? $inFile : $arg,
$extraArgs
)
);
$this->assertSame(1, $result['code']); $result = $this->runCfg($args);
$this->assertStringContainsString('a value is required for --siteDir', $result['output']);
$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',
'Extension',
], $args));
}
private function runShow(array $args): array
{
return $this->runCfg(array_merge([
'-a',
$this->tmpDir,
'show',
'Extension',
], $args));
}
private function createJsonInputFile(array $contents, string $name = 'extension-in.json'): string
{
$path = $this->tmpDir.'/'.$name;
file_put_contents($path, json_encode($contents, JSON_PRETTY_PRINT));
return $path;
}
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
@ -372,4 +453,5 @@ class CfgTest extends TestCase
rmdir($dir); rmdir($dir);
} }
/* }}} */
} }