tmpDir = rtrim(sys_get_temp_dir(), '/').'/util-settings-cfg-'.bin2hex(random_bytes(8)); mkdir($this->tmpDir, 0775, true); mkdir($this->tmpDir.'/config', 0775, true); file_put_contents( $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" ); } protected function tearDown(): void { $this->removeDir($this->tmpDir); } /* }}} */ /* Write Flow {{{ */ public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void { $result = $this->runWrite([ 'module:code="X100"', ]); $this->assertCfgSuccess($result); $localFile = $this->tmpDir.'/config/Extension.conf.php'; $this->assertFileExists($localFile); $cfg = require $localFile; $this->assertSame('X100', $cfg['module']['code']); } public function testWriteWithoutModeFallsBackToDefaultMode(): void { file_put_contents( $this->tmpDir.'/config/Extension.default.conf.php', " [\n\t\t'code' => '',\n\t],\n];\n" ); $result = $this->runWrite([ 'module:code="X100"', ]); $this->assertCfgSuccess($result); $this->assertFileExists($this->tmpDir.'/config/Extension.conf.php'); } public function testWriteWithInputFileWritesMultipleSettings(): void { $inFile = $this->createJsonInputFile([ 'module' => [ 'code' => 'X100', 'label' => 'demo-module', ], 'feature' => [ 'endpoint' => 'https://example.invalid/v1/resource', ], ]); $result = $this->runWrite([ '--siteDir=owner_xyz', '-i', $inFile, ]); $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/Extension.conf.php'); $cfg = require $siteFile; $this->assertSame('X100', $cfg['module']['code']); } public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void { $firstWrite = $this->runWrite([ 'module:code="X100"', '--siteDir=owner_xyz', ]); $this->assertCfgSuccess($firstWrite); $secondWrite = $this->runWrite([ 'module:label="demo-module"', '--siteDir=owner_xyz', ]); $this->assertCfgSuccess($secondWrite); $cfg = $this->loadWrittenConfig('owner_xyz'); $this->assertSame('X100', $cfg['module']['code']); $this->assertSame('demo-module', $cfg['module']['label']); } public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void { $result = $this->runWrite([ 'module:code="X100"', '--siteDir=config/owner_xyz', ]); $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 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 { $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->runWrite([ 'module:code="first"', '--siteDir=owner_xyz', ]); $this->assertCfgSuccess($firstWrite); $secondWrite = $this->runWrite([ 'module:code="second"', '--siteDir=owner_xyz', ]); $this->assertCfgSuccess($secondWrite); $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['module']['code']); $this->assertSame('first', $backup['module']['code']); } /* }}} */ /* Show Flow {{{ */ public function testShowWithSiteReturnsSiteSpecificValue(): void { $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', '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 { $script = realpath(__DIR__.'/../bin/cfg'); $this->assertNotFalse($script); $command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($script); foreach ($args as $arg) { $command .= ' '.escapeshellarg($arg); } $command .= ' 2>&1'; $outputLines = []; $exitCode = 0; exec($command, $outputLines, $exitCode); return [ 'code' => $exitCode, 'output' => implode(PHP_EOL, $outputLines), ]; } private function removeDir(string $dir): void { if (!is_dir($dir)) { return; } foreach (scandir($dir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir.'/'.$entry; if (is_dir($path)) { $this->removeDir($path); } else { unlink($path); } } rmdir($dir); } /* }}} */ }