From 312b29b7a261c52736c7e8a089501a13b6b69757 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 15:43:18 +0530 Subject: [PATCH 01/25] test(config-command): add behat context with shared steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base the context on site-command's. Only bh-config-command-* sites are cleaned up, commands expand {…} placeholders and bypass EE::launch so their output isn't copied into ee.log, and the real global config is snapshotted and restored around every scenario (non-@destructive scenarios that change it fail). Adds generic steps for temporary EE_CONFIG_PATH files, last-command STDOUT/STDERR/exit code, JSON/YAML/CSV/table output, YAML and file assertions. --- features/bootstrap/FeatureContext.php | 1056 +++++++++++++++++++++++++ features/bootstrap/support.php | 207 +++++ 2 files changed, 1263 insertions(+) create mode 100644 features/bootstrap/FeatureContext.php create mode 100644 features/bootstrap/support.php diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..9fb5632 --- /dev/null +++ b/features/bootstrap/FeatureContext.php @@ -0,0 +1,1056 @@ +init_logger(); +$runner->init_config(); +/* End. Loading required files to enable EE::launch() in tests. */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\AfterFeatureScope; +use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; + +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode; + +define( 'EE_SITE_ROOT', EE_ROOT_DIR . '/sites' ); + +class FeatureContext implements Context +{ + public $command; + public $webroot_path; + public $ee_path; + + /** + * Every site created by this suite starts with this prefix; cleanup only touches these. + */ + const SITE_PREFIX = 'bh-config-command-'; + + public $commands = []; + + /** + * Initializes context. + */ + public function __construct() + { + $this->commands = []; + $this->ee_path = getcwd(); + } + + /** + * @Given ee phar is generated + */ + public function eePharIsPresent() + { + // Checks if phar already exists, replaces it + if(file_exists('ee-old.phar')) { + // Below exec call is required as currenly `ee cli update` is ran with root + // which updates ee.phar with root privileges. + exec("sudo rm ee.phar"); + copy('ee-old.phar','ee.phar'); + return 0; + } + exec("php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status); + if (0 !== $return_status) { + throw new Exception("Unable to generate phar" . $return_status); + } + + // Cache generaed phar as it is expensive to generate one + copy('ee.phar','ee-old.phar'); + } + + /** + * @Given :command is installed + */ + public function isInstalled($command) + { + exec("type " . $command, $output, $return_status); + if (0 !== $return_status) { + throw new Exception($command . " is not installed! Exit code is:" . $return_status); + } + } + + /** + * @When /I run '(.*)'|"(.*)"/ + */ + public function iRun($command) + { + $command = $this->replaceVariables( $command ); + + // sudo resets the environment, so the command would silently use the real config. + if ( $this->tempConfigPath && preg_match( '/(^|[\s;&|])sudo\s+(?!-E\b)/', $command ) ) { + throw new Exception( "Refusing to run '$command': sudo drops EE_CONFIG_PATH. Drop sudo (the suite runs as root)." ); + } + + // Not EE::launch(): it copies the command's STDOUT/STDERR into ee.log, which would pollute log assertions. + $this->commands[] = \EE\Process::create( $command, null, null )->run(); + } + + /** + * @When I create subsite :subsite in :site + */ + public function iCreateSubsiteInOfType($subsite, $site ) + { + $php_container = implode( explode( '.', $subsite ) ) . '_php_1'; + + EE::launch( "docker exec -it --user='www-data' $php_container sh -c 'wp site create --slug=$site'", false, true ); + } + + /** + * @Then return value should be 0 + */ + public function returnValueShouldBe0() + { + if ( 0 !== $this->commands[0]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->command); + } + } + + /** + * @Then return value of command :index should be 0 + */ + public function returnValueOfCommandShouldBe0($index) + { + if ( 0 !== $this->commands[ $index - 1 ]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->command); + } + } + + /** + * @Then After delay of :time seconds + */ + public function afterDelayOfSeconds( $time ) + { + sleep( $time ); + } + + /** + * @Then /(STDOUT|STDERR) should return exactly/ + */ + public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + + if ( $expected_output->getRaw() !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /(STDOUT|STDERR) of command :index should return exactly/ + */ + public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + if ( $expected_out !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /(STDOUT|STDERR) should return something like/ + */ + public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + if (strpos($command_output, $expected_out) === false) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + + /** + * @Then /(STDOUT|STDERR) of command :index should return something like/ + */ + public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + if (strpos($command_output, $expected_out) === false) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then The site :site should be :type multisite + */ + public function theSiteShouldBeMultisite( $site, $type ) + { + $result = EE::launch( "cd " . EE_SITE_ROOT . "/$site && docker-compose exec --user='www-data' php sh -c 'wp config get SUBDOMAIN_INSTALL'", false, true ); + + if( $result->stderr ) { + throw new Exception("Found error while executing command: $result->stderr"); + } + + if($type === 'subdomain' && trim($result->stdout) !== '1') { + throw new Exception("Expecting SUBDOMAIN_INSTALL to be 1. Got: $result->stdout"); + } + else if($type === 'subdir' && trim($result->stdout) !== '') { + throw new Exception("Expecting SUBDOMAIN_INSTALL to be empty. Got: $result->stdout"); + } + else if($type !== 'subdomain' && $type !== 'subdir') { + throw new Exception("Found unknown site type: $type"); + } + } + + /** + * @Then The :site db entry should be removed + */ + public function theDbEntryShouldBeRemoved($site) + { + $out = shell_exec("sudo bin/ee site list"); + if (strpos($out, $site) !== false) { + throw new Exception("$site db entry not been removed!"); + } + + } + + /** + * @Then The :site webroot should be removed + */ + public function theWebrootShouldBeRemoved($site) + { + if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been removed!"); + } + } + + /** + * @Then Following containers of site :site should be removed: + */ + public function followingContainersOfSiteShouldBeRemoved($site, TableNode $table) + { + $containers = $table->getHash(); + $site_name = implode(explode('.', $site)); + + foreach ($containers as $container) { + + $sevice = $container['container']; + $container_name = $site_name . '_' . $sevice . '_1'; + + exec("docker inspect -f '{{.State.Running}}' $container_name > /dev/null 2>&1", $exec_out, $return); + if (!$return) { + throw new Exception("$container_name has not been removed!"); + } + } + } + + /** + * @Then The site :site should have webroot + */ + public function theSiteShouldHaveWebroot($site) + { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been created!"); + } + } + + /** + * @Then The site :site should have WordPress + */ + public function theSiteShouldHaveWordpress($site) + { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site . '/app/wp-config.php' ) ) { + throw new Exception("WordPress data not found!"); + } + } + + /** + * @Then Request on :site should contain following headers: + */ + public function requestOnShouldContainFollowingHeaders($site, TableNode $table) + { + $url = 'http://' . $site; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @Then Request on :host with header :header should contain following headers: + */ + public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) + { + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @Then Request on :host with resolve option :resolve should contain following headers: + */ + public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) + { + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_RESOLVE, [ $resolve ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) + { + $this->commands = []; + chdir($this->ee_path); + } + + /** + * @Then There should be :expected_running_containers containers with labels + */ + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + { + $labels = $pyStringNode->getStrings(); + $label_string = implode(' -f label=',$labels); + + $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); + $running_containers = (int) trim($result->stdout); + + if($expected_running_containers === $running_containers) { + throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + } + } + + /** + * @AfterFeature + */ + public static function cleanup(AfterFeatureScope $scope) + { + // Avoid invoking ee (and the live stack) when this suite left no sites behind. + if ( ! glob( EE_SITE_ROOT . '/' . self::SITE_PREFIX . '*' ) ) { + return; + } + + $result = EE::launch( 'bin/ee site list --format=text', false, true ); + foreach ( array_filter( array_map( 'trim', explode( "\n", $result->stdout ) ) ) as $site ) { + if ( 0 === strpos( $site, self::SITE_PREFIX ) ) { + exec( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes' ); + } + } + + if(file_exists('ee.phar')) { + unlink('ee.phar'); + } + if(file_exists('ee-old.phar')) { + unlink('ee-old.phar'); + } + } + + /* + * --------------------------------------------------------------------- + * Shared steps (added for config-command). Generic enough to be reused. + * + * Placeholders usable in commands and paths: {CONFIG_PATH}, {CONFIG_DIR}, + * {REAL_CONFIG_PATH}, {EE_LOG}, {RUN_ID}, {FAILING_DOCKER_DIR}. + * --------------------------------------------------------------------- + */ + + /** @var string|null EE_CONFIG_PATH exported for this scenario. */ + private $tempConfigPath = null; + + /** @var string|null Per-scenario scratch dir (holds the temp config). */ + private $scratchDir = null; + + /** @var string|null|false Real config content at scenario start (null = file absent, false = not snapshotted). */ + private $realConfigSnapshot = false; + + /** @var int|null Real config mode at scenario start. */ + private $realConfigMode = null; + + /** @var array Remembered file sizes, keyed by path. */ + private $rememberedSizes = []; + + /** @var array Extra placeholder values set by steps. */ + private $variables = []; + + /** @var array Environment variables exported by steps, unset after the scenario. */ + private $exportedEnv = []; + + private static function realConfigPath() { + return EE_ROOT_DIR . '/config/config.yml'; + } + + private function replaceVariables( $string ) { + $vars = array_merge( + [ + '{REAL_CONFIG_PATH}' => self::realConfigPath(), + '{EE_LOG}' => EE_ROOT_DIR . '/logs/ee.log', + '{RUN_ID}' => $this->runId(), + ], + $this->variables + ); + if ( $this->tempConfigPath ) { + $vars['{CONFIG_PATH}'] = $this->tempConfigPath; + $vars['{CONFIG_DIR}'] = dirname( $this->tempConfigPath ); + } + + return strtr( $string, $vars ); + } + + private function runId() { + static $id = null; + if ( null === $id ) { + $id = getmypid() . '-' . substr( md5( uniqid( '', true ) ), 0, 8 ); + } + + return $id; + } + + private function scratchDir() { + if ( ! $this->scratchDir ) { + $this->scratchDir = sys_get_temp_dir() . '/' . self::SITE_PREFIX . $this->runId() . '-' . substr( md5( uniqid( '', true ) ), 0, 6 ); + if ( ! mkdir( $this->scratchDir, 0700, true ) ) { + throw new Exception( "Unable to create scratch dir {$this->scratchDir}" ); + } + } + + return $this->scratchDir; + } + + private function useTempConfig( $path ) { + $this->tempConfigPath = $path; + putenv( 'EE_CONFIG_PATH=' . $path ); + $_ENV['EE_CONFIG_PATH'] = $path; + } + + private function lastCommand() { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private static function stripAnsi( $text ) { + return preg_replace( '/\e\[[0-9;]*[A-Za-z]/', '', $text ); + } + + private function lastOutput( $stream ) { + $result = $this->lastCommand(); + + return self::stripAnsi( 'STDOUT' === $stream ? $result->stdout : $result->stderr ); + } + + private function describeLastCommand() { + $result = $this->lastCommand(); + + return sprintf( + "Command: %s\nExit code: %d\nSTDOUT:\n%s\nSTDERR:\n%s", + $result->command, + $result->return_code, + self::stripAnsi( $result->stdout ), + self::stripAnsi( $result->stderr ) + ); + } + + private static function loadYamlFile( $path ) { + if ( ! is_file( $path ) ) { + throw new Exception( "YAML file $path does not exist." ); + } + + // Same parser EE uses, so assertions see what EE sees. + return \Mustangostang\Spyc::YAMLLoad( $path ); + } + + private static function normalizeForCompare( $value ) { + if ( is_array( $value ) ) { + $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); + if ( ! $is_list ) { + ksort( $value ); + } + foreach ( $value as $k => $v ) { + $value[ $k ] = self::normalizeForCompare( $v ); + } + } + + return $value; + } + + /** + * @BeforeScenario + */ + public function snapshotRealConfig( BeforeScenarioScope $scope ) { + // An EE_CONFIG_PATH inherited from the caller's shell must not leak into scenarios. + putenv( 'EE_CONFIG_PATH' ); + unset( $_ENV['EE_CONFIG_PATH'] ); + + $path = self::realConfigPath(); + $this->realConfigSnapshot = is_file( $path ) ? file_get_contents( $path ) : null; + $this->realConfigMode = is_file( $path ) ? ( fileperms( $path ) & 0777 ) : null; + } + + /** + * Restores the real config and fails any non-@destructive scenario that changed it. + * + * @AfterScenario + */ + public function restoreConfigState( AfterScenarioScope $scope ) { + putenv( 'EE_CONFIG_PATH' ); + unset( $_ENV['EE_CONFIG_PATH'] ); + $this->tempConfigPath = null; + $this->variables = []; + $this->rememberedSizes = []; + foreach ( $this->exportedEnv as $name ) { + putenv( $name ); + } + $this->exportedEnv = []; + + if ( $this->scratchDir && 0 === strpos( $this->scratchDir, sys_get_temp_dir() . '/' . self::SITE_PREFIX ) ) { + exec( 'rm -rf ' . escapeshellarg( $this->scratchDir ) ); + } + $this->scratchDir = null; + + if ( false === $this->realConfigSnapshot ) { + return; + } + + $path = self::realConfigPath(); + $current = is_file( $path ) ? file_get_contents( $path ) : null; + if ( $current === $this->realConfigSnapshot && ! is_link( $path ) ) { + return; + } + + if ( null === $this->realConfigSnapshot ) { + @unlink( $path ); + } else { + if ( is_link( $path ) ) { + unlink( $path ); + } + file_put_contents( $path, $this->realConfigSnapshot ); + chmod( $path, $this->realConfigMode ); + } + + $tags = array_merge( $scope->getFeature()->getTags(), $scope->getScenario()->getTags() ); + if ( ! in_array( 'destructive', $tags, true ) ) { + throw new Exception( "Scenario modified the real $path without the @destructive tag. It has been restored.\nContent it wrote:\n" . var_export( $current, true ) ); + } + } + + /** + * @Given a temporary ee config file with: + */ + public function aTemporaryEeConfigFileWith( PyStringNode $content ) { + $path = $this->scratchDir() . '/config.yml'; + file_put_contents( $path, $this->replaceVariables( $content->getRaw() ) . "\n" ); + $this->useTempConfig( $path ); + } + + /** + * @Given an empty temporary ee config file + */ + public function anEmptyTemporaryEeConfigFile() { + $path = $this->scratchDir() . '/config.yml'; + file_put_contents( $path, '' ); + $this->useTempConfig( $path ); + } + + /** + * @Given a temporary ee config path that does not exist yet + */ + public function aTemporaryEeConfigPathThatDoesNotExistYet() { + $this->useTempConfig( $this->scratchDir() . '/missing/nested/config.yml' ); + } + + /** + * @Given the temporary ee config file is a symlink + */ + public function theTemporaryEeConfigFileIsASymlink() { + $path = $this->requireTempConfig(); + $target = dirname( $path ) . '/real-target.yml'; + rename( $path, $target ); + symlink( $target, $path ); + $this->variables['{CONFIG_TARGET}'] = $target; + } + + /** + * @Then the temporary ee config file should be a symlink + */ + public function theTemporaryEeConfigFileShouldBeASymlink() { + $path = $this->requireTempConfig(); + if ( ! is_link( $path ) ) { + throw new Exception( "$path is no longer a symlink; it was replaced by a regular file with:\n" . file_get_contents( $path ) ); + } + } + + private function requireTempConfig() { + if ( ! $this->tempConfigPath ) { + throw new Exception( 'No temporary ee config file is set up for this scenario.' ); + } + + return $this->tempConfigPath; + } + + /** + * @Then the real ee config file should be unchanged + */ + public function theRealEeConfigFileShouldBeUnchanged() { + $path = self::realConfigPath(); + $current = is_file( $path ) ? file_get_contents( $path ) : null; + if ( $current !== $this->realConfigSnapshot ) { + throw new Exception( "The real $path changed.\nBefore:\n" . var_export( $this->realConfigSnapshot, true ) . "\nNow:\n" . var_export( $current, true ) ); + } + } + + /** + * @Then the real ee config file should have the same values as before + */ + public function theRealEeConfigFileShouldHaveTheSameValuesAsBefore() { + $before = null === $this->realConfigSnapshot ? [] : \Mustangostang\Spyc::YAMLLoadString( $this->realConfigSnapshot ); + $now = self::loadYamlFile( self::realConfigPath() ); + if ( $before !== $now ) { + throw new Exception( "Real config values differ.\nBefore: " . json_encode( $before ) . "\nNow: " . json_encode( $now ) ); + } + } + + /** + * Lets a command use a value without it appearing in the command line (which the runner logs). + * + * @Given the environment variable :name is set to :value + */ + public function theEnvironmentVariableIsSetTo( $name, $value ) { + if ( 'EE_CONFIG_PATH' === $name ) { + throw new Exception( 'Use a temporary ee config step to set EE_CONFIG_PATH.' ); + } + putenv( $name . '=' . $this->replaceVariables( $value ) ); + $this->exportedEnv[] = $name; + } + + /** + * @Then the real ee config file should have the same mode as before + */ + public function theRealEeConfigFileShouldHaveTheSameModeAsBefore() { + clearstatcache(); + $path = self::realConfigPath(); + $actual = is_file( $path ) ? ( fileperms( $path ) & 0777 ) : null; + if ( $actual !== $this->realConfigMode ) { + throw new Exception( sprintf( 'Mode of %s changed from %s to %s', $path, var_export( $this->realConfigMode, true ), null === $actual ? 'missing' : sprintf( '%04o', $actual ) ) ); + } + } + + /** + * @Given a directory in PATH where docker and docker-compose always fail + */ + public function aDirectoryInPathWhereDockerAndDockerComposeAlwaysFail() { + $dir = $this->scratchDir() . '/failing-docker-bin'; + mkdir( $dir, 0700, true ); + foreach ( [ 'docker', 'docker-compose' ] as $bin ) { + file_put_contents( "$dir/$bin", "#!/bin/sh\necho 'fake $bin: unavailable' >&2\nexit 1\n" ); + chmod( "$dir/$bin", 0755 ); + } + $this->variables['{FAILING_DOCKER_DIR}'] = $dir; + } + + /** + * @Given the file :path has mode :mode + */ + public function theFileHasMode( $path, $mode ) { + $path = $this->replaceVariables( $path ); + if ( ! chmod( $path, octdec( $mode ) ) ) { + throw new Exception( "Unable to chmod $path to $mode" ); + } + } + + /** + * @Then the file :path should have mode :mode + */ + public function theFileShouldHaveMode( $path, $mode ) { + $path = $this->replaceVariables( $path ); + clearstatcache(); + $actual = sprintf( '%04o', fileperms( $path ) & 0777 ); + if ( sprintf( '%04o', octdec( $mode ) ) !== $actual ) { + throw new Exception( "Expected $path to have mode $mode, got $actual" ); + } + } + + /** + * @Then the file :path should exist + */ + public function theFileShouldExist( $path ) { + $path = $this->replaceVariables( $path ); + if ( ! file_exists( $path ) ) { + throw new Exception( "$path does not exist." ); + } + } + + /** + * @Then the file :path should not exist + */ + public function theFileShouldNotExist( $path ) { + $path = $this->replaceVariables( $path ); + if ( file_exists( $path ) ) { + throw new Exception( "$path exists, with:\n" . ( is_file( $path ) ? file_get_contents( $path ) : '(not a regular file)' ) ); + } + } + + /** + * Exact match, ignoring trailing newlines. + * + * @Then the file :path should contain exactly: + */ + public function theFileShouldContainExactly( $path, PyStringNode $expected ) { + $path = $this->replaceVariables( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "$path does not exist." ); + } + $actual = file_get_contents( $path ); + if ( rtrim( $expected->getRaw(), "\n" ) !== rtrim( $actual, "\n" ) ) { + throw new Exception( "Content of $path differs.\nExpected:\n" . $expected->getRaw() . "\nActual:\n" . $actual ); + } + } + + /** + * @Then the file :path should contain: + */ + public function theFileShouldContain( $path, PyStringNode $expected ) { + $path = $this->replaceVariables( $path ); + $actual = is_file( $path ) ? file_get_contents( $path ) : ''; + if ( false === strpos( $actual, $this->replaceVariables( $expected->getRaw() ) ) ) { + throw new Exception( "$path does not contain:\n" . $expected->getRaw() . "\nActual:\n" . $actual ); + } + } + + /** + * @Then the file :path should not contain: + */ + public function theFileShouldNotContain( $path, PyStringNode $unexpected ) { + $path = $this->replaceVariables( $path ); + $actual = is_file( $path ) ? file_get_contents( $path ) : ''; + if ( false !== strpos( $actual, $this->replaceVariables( $unexpected->getRaw() ) ) ) { + throw new Exception( "$path unexpectedly contains:\n" . $unexpected->getRaw() . "\nActual:\n" . $actual ); + } + } + + /** + * @Given I remember the size of the file :path + */ + public function iRememberTheSizeOfTheFile( $path ) { + $path = $this->replaceVariables( $path ); + clearstatcache(); + $this->rememberedSizes[ $path ] = is_file( $path ) ? filesize( $path ) : 0; + } + + /** + * @Then the text appended to the file :path should not contain :text + */ + public function theTextAppendedToTheFileShouldNotContain( $path, $text ) { + $path = $this->replaceVariables( $path ); + $text = $this->replaceVariables( $text ); + if ( ! isset( $this->rememberedSizes[ $path ] ) ) { + throw new Exception( "Size of $path was not remembered earlier in the scenario." ); + } + clearstatcache(); + $content = is_file( $path ) ? file_get_contents( $path ) : ''; + // A rotated (shrunk) file is checked as a whole. + $appended = strlen( $content ) >= $this->rememberedSizes[ $path ] ? substr( $content, $this->rememberedSizes[ $path ] ) : $content; + $lines = array_filter( + explode( "\n", $appended ), + function ( $line ) use ( $text ) { + return false !== strpos( $line, $text ); + } + ); + if ( $lines ) { + throw new Exception( "$path gained lines containing '$text':\n" . implode( "\n", $lines ) ); + } + } + + /** + * @Then the YAML file :path should have key :key with value :value + */ + public function theYamlFileShouldHaveKeyWithValue( $path, $key, $value ) { + $path = $this->replaceVariables( $path ); + $config = self::loadYamlFile( $path ); + if ( ! array_key_exists( $key, $config ) ) { + throw new Exception( "Key '$key' not found in $path. Keys: " . implode( ', ', array_keys( $config ) ) ); + } + if ( $config[ $key ] !== $value ) { + throw new Exception( "Key '$key' in $path is " . var_export( $config[ $key ], true ) . ', expected ' . var_export( $value, true ) ); + } + } + + /** + * @Then the YAML file :path should have key :key + */ + public function theYamlFileShouldHaveKey( $path, $key ) { + $path = $this->replaceVariables( $path ); + $config = self::loadYamlFile( $path ); + if ( ! array_key_exists( $key, $config ) ) { + throw new Exception( "Key '$key' not found in $path. Keys: " . implode( ', ', array_keys( $config ) ) ); + } + } + + /** + * @Then the YAML file :path should not have key :key + */ + public function theYamlFileShouldNotHaveKey( $path, $key ) { + $path = $this->replaceVariables( $path ); + $config = self::loadYamlFile( $path ); + if ( array_key_exists( $key, $config ) ) { + throw new Exception( "Key '$key' unexpectedly present in $path with value " . var_export( $config[ $key ], true ) ); + } + } + + /** + * Values in the table are JSON literals, so types are checked too ("3" vs 3). + * + * @Then the YAML file :path should contain exactly these entries: + */ + public function theYamlFileShouldContainExactlyTheseEntries( $path, TableNode $table ) { + $path = $this->replaceVariables( $path ); + $config = self::loadYamlFile( $path ); + $expected = []; + foreach ( $table->getHash() as $row ) { + $decoded = json_decode( $row['value'], true ); + if ( null === $decoded && 'null' !== trim( $row['value'] ) ) { + throw new Exception( "Invalid JSON literal in table: {$row['value']}" ); + } + $expected[ $row['key'] ] = $decoded; + } + if ( $expected !== $config ) { + throw new Exception( "Entries of $path differ (order and types matter).\nExpected: " . json_encode( $expected ) . "\nActual: " . json_encode( $config ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be:$/ + */ + public function outputShouldBe( $stream, PyStringNode $expected ) { + $expected = $this->replaceVariables( $expected->getRaw() ); + if ( trim( $expected ) !== trim( $this->lastOutput( $stream ) ) ) { + throw new Exception( "$stream differs from:\n$expected\n\n" . $this->describeLastCommand() ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should (contain|not contain):$/ + */ + public function outputShouldContain( $stream, $action, PyStringNode $expected ) { + $expected = $this->replaceVariables( $expected->getRaw() ); + $found = false !== strpos( $this->lastOutput( $stream ), $expected ); + if ( ( 'contain' === $action ) !== $found ) { + throw new Exception( "$stream should $action:\n$expected\n\n" . $this->describeLastCommand() ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be empty$/ + */ + public function outputShouldBeEmpty( $stream ) { + if ( '' !== trim( $this->lastOutput( $stream ) ) ) { + throw new Exception( "$stream is not empty.\n\n" . $this->describeLastCommand() ); + } + } + + /** + * @Then /^the exit code should be (\d+)$/ + */ + public function theExitCodeShouldBe( $code ) { + if ( (int) $code !== $this->lastCommand()->return_code ) { + throw new Exception( "Expected exit code $code.\n\n" . $this->describeLastCommand() ); + } + } + + /** + * Strict structural comparison; object key order is ignored, list order is not. + * + * @Then STDOUT should be JSON equal to: + */ + public function stdoutShouldBeJsonEqualTo( PyStringNode $expected ) { + $actual = json_decode( $this->lastOutput( 'STDOUT' ), true ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + throw new Exception( 'STDOUT is not valid JSON: ' . json_last_error_msg() . "\n\n" . $this->describeLastCommand() ); + } + $wanted = json_decode( $this->replaceVariables( $expected->getRaw() ), true ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + throw new Exception( 'Expected value is not valid JSON: ' . json_last_error_msg() ); + } + if ( self::normalizeForCompare( $wanted ) !== self::normalizeForCompare( $actual ) ) { + throw new Exception( 'JSON differs from: ' . json_encode( $wanted ) . "\n\n" . $this->describeLastCommand() ); + } + } + + /** + * @Then STDOUT should be YAML equal to: + */ + public function stdoutShouldBeYamlEqualTo( PyStringNode $expected ) { + $actual = \Mustangostang\Spyc::YAMLLoadString( $this->lastOutput( 'STDOUT' ) ); + $wanted = \Mustangostang\Spyc::YAMLLoadString( $this->replaceVariables( $expected->getRaw() ) ); + if ( self::normalizeForCompare( $wanted ) !== self::normalizeForCompare( $actual ) ) { + throw new Exception( 'YAML differs from: ' . json_encode( $wanted ) . "\nParsed actual: " . json_encode( $actual ) . "\n\n" . $this->describeLastCommand() ); + } + } + + /** + * Accepts both the ASCII renderer (TTY) and the tab-separated one (pipe); rows must match exactly and in order. + * + * @Then STDOUT should be a table with rows: + */ + public function stdoutShouldBeATableWithRows( TableNode $expected ) { + $rows = []; + foreach ( explode( "\n", $this->lastOutput( 'STDOUT' ) ) as $line ) { + $line = rtrim( $line, "\r" ); + if ( '' === trim( $line ) || preg_match( '/^\+[-+]+\+$/', trim( $line ) ) ) { + continue; + } + if ( '|' === substr( $line, 0, 1 ) ) { + $rows[] = array_map( 'trim', explode( '|', trim( trim( $line ), '|' ) ) ); + } else { + $rows[] = explode( "\t", $line ); + } + } + $wanted = array_map( + function ( $row ) { + return array_map( [ $this, 'replaceVariables' ], $row ); + }, + $expected->getRows() + ); + if ( $wanted !== $rows ) { + throw new Exception( 'Table differs. Expected rows: ' . json_encode( $wanted ) . "\nParsed rows: " . json_encode( $rows ) . "\n\n" . $this->describeLastCommand() ); + } + } + + /** + * @Then STDOUT should be CSV with rows: + */ + public function stdoutShouldBeCsvWithRows( TableNode $expected ) { + $rows = []; + foreach ( explode( "\n", rtrim( $this->lastOutput( 'STDOUT' ), "\n" ) ) as $line ) { + $rows[] = str_getcsv( $line, ',', '"', '\\' ); + } + $wanted = array_map( + function ( $row ) { + return array_map( [ $this, 'replaceVariables' ], $row ); + }, + $expected->getRows() + ); + if ( $wanted !== $rows ) { + throw new Exception( 'CSV differs. Expected rows: ' . json_encode( $wanted ) . "\nParsed rows: " . json_encode( $rows ) . "\n\n" . $this->describeLastCommand() ); + } + } + + /** + * For `ee config list --format=json`-style output: [{"key":..,"value":..}] in file order. + * + * @Then STDOUT should be JSON listing the entries of the YAML file :path + */ + public function stdoutShouldBeJsonListingTheEntriesOfTheYamlFile( $path ) { + $config = self::loadYamlFile( $this->replaceVariables( $path ) ); + $expected = []; + foreach ( $config as $key => $value ) { + $expected[] = [ + 'key' => $key, + 'value' => $value, + ]; + } + $actual = json_decode( $this->lastOutput( 'STDOUT' ), true ); + if ( $expected !== $actual ) { + throw new Exception( 'Expected: ' . json_encode( $expected ) . "\n\n" . $this->describeLastCommand() ); + } + } +} diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php new file mode 100644 index 0000000..ed9a5b5 --- /dev/null +++ b/features/bootstrap/support.php @@ -0,0 +1,207 @@ + $value ) { + if ( ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) ) { + foreach ( $expected as $key => $value ) { + if ( ! compareContents( $value, $actual[$key] ) ) { + return false; + } + } + } else { + return $expected === $actual; + } + + return true; +} + +/** + * Compare two strings containing JSON to ensure that @a $actualJson contains at + * least what the JSON string @a $expectedJson contains. + * + * @return whether or not @a $actualJson contains @a $expectedJson + * @retval true @a $actualJson contains @a $expectedJson + * @retval false @a $actualJson does not contain @a $expectedJson + * + * @param [in] $actualJson the JSON string to be tested + * @param [in] $expectedJson the expected JSON string + * + * Examples: + * expected: {'a':1,'array':[1,3,5]} + * + * 1 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: true + * + * 2 ) + * actual: {'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * element 'a' is missing from the root object + * + * 3 ) + * actual: {'a':0,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * the value of element 'a' is not 1 + * + * 4 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,4,5]} + * return: false + * the contents of 'array' does not include 3 + */ +function checkThatJsonStringContainsJsonString( $actualJson, $expectedJson ) { + $actualValue = json_decode( $actualJson ); + $expectedValue = json_decode( $expectedJson ); + + if ( ! $actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + +/** + * Compare two strings to confirm $actualCSV contains $expectedCSV + * Both strings are expected to have headers for their CSVs. + * $actualCSV must match all data rows in $expectedCSV + * + * @param string A CSV string + * @param array A nested array of values + * + * @return bool Whether $actualCSV contains $expectedCSV + */ +function checkThatCsvStringContainsValues( $actualCSV, $expectedCSV ) { + $actualCSV = array_map( 'str_getcsv', explode( PHP_EOL, $actualCSV ) ); + + if ( empty( $actualCSV ) ) { + return false; + } + + // Each sample must have headers + $actualHeaders = array_values( array_shift( $actualCSV ) ); + $expectedHeaders = array_values( array_shift( $expectedCSV ) ); + + // Each expectedCSV must exist somewhere in actualCSV in the proper column + $expectedResult = 0; + foreach ( $expectedCSV as $expected_row ) { + $expected_row = array_combine( $expectedHeaders, $expected_row ); + foreach ( $actualCSV as $actual_row ) { + + if ( count( $actualHeaders ) != count( $actual_row ) ) { + continue; + } + + $actual_row = array_intersect_key( array_combine( $actualHeaders, $actual_row ), $expected_row ); + if ( $actual_row == $expected_row ) { + $expectedResult ++; + } + } + } + + return $expectedResult >= count( $expectedCSV ); +} + +/** + * Compare two strings containing YAML to ensure that @a $actualYaml contains at + * least what the YAML string @a $expectedYaml contains. + * + * @return whether or not @a $actualYaml contains @a $expectedJson + * @retval true @a $actualYaml contains @a $expectedJson + * @retval false @a $actualYaml does not contain @a $expectedJson + * + * @param [in] $actualYaml the YAML string to be tested + * @param [in] $expectedYaml the expected YAML string + */ +function checkThatYamlStringContainsYamlString( $actualYaml, $expectedYaml ) { + $actualValue = Mustangostang\Spyc::YAMLLoad( $actualYaml ); + $expectedValue = Mustangostang\Spyc::YAMLLoad( $expectedYaml ); + + if ( ! $actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + From fc238eb04f300fc3e02695239d7f7c39acce5be5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 15:57:36 +0530 Subject: [PATCH 02/25] test(config-command): cover config help and usage --- features/config.feature | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 features/config.feature diff --git a/features/config.feature b/features/config.feature new file mode 100644 index 0000000..9971b6f --- /dev/null +++ b/features/config.feature @@ -0,0 +1,137 @@ +Feature: ee config command group, help and usage + All scenarios use a throwaway EE_CONFIG_PATH; the real /opt/easyengine/config/config.yml is never touched. + + Background: + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + """ + + Scenario: Running ee config without a subcommand prints the usage of every subcommand + When I run 'bin/ee config' + Then the exit code should be 0 + And STDOUT should be: + """ + usage: ee config get + or: ee config list [--format=] + or: ee config set + or: ee config unset + + See 'ee help config ' for more information on a specific command. + """ + And STDERR should be empty + + Scenario: ee help config lists all subcommands + When I run 'bin/ee help config' + Then the exit code should be 0 + And STDOUT should contain: + """ + ee config + """ + And STDOUT should contain: + """ + get + """ + And STDOUT should contain: + """ + list Lists the config values. + """ + And STDOUT should contain: + """ + set Set a config value + """ + And STDOUT should contain: + """ + unset Unset a config value + """ + + Scenario Outline: ee help config shows its synopsis + When I run 'bin/ee help config ' + Then the exit code should be 0 + And STDOUT should contain: + """ + ee config + """ + And STDERR should be empty + + Examples: + | subcommand | synopsis | + | get | get | + | set | set | + | unset | unset | + | list | list [--format=] | + + Scenario: ee help config list documents every supported format + When I run 'bin/ee help config list' + Then the exit code should be 0 + And STDOUT should contain: + """ + default: table + options: + - table + - csv + - yaml + - json + - text + """ + And STDOUT should not contain: + """ + - count + """ + + Scenario: An unknown config subcommand is rejected with a suggestion + When I run 'bin/ee config gett locale' + Then the exit code should be 1 + And STDERR should contain: + """ + Error: 'gett' is not a registered subcommand of 'config'. See 'ee help config' for available subcommands. + """ + And STDERR should contain: + """ + Did you mean 'get'? + """ + And STDOUT should be empty + + Scenario: An unknown config subcommand without a close match gets no suggestion + When I run 'bin/ee config remove locale' + Then the exit code should be 1 + And STDERR should contain: + """ + Error: 'remove' is not a registered subcommand of 'config'. + """ + And STDERR should not contain: + """ + Did you mean + """ + + Scenario: config commands skip the Docker requirement check + Given a directory in PATH where docker and docker-compose always fail + When I run 'PATH={FAILING_DOCKER_DIR}:$PATH bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be: + """ + en_US + """ + And STDERR should not contain: + """ + Docker + """ + + # CFG-9 (proposed): `ee config get` is described as "Set a config value". + @known-bug + Scenario: The help text of config get describes getting a value + When I run 'bin/ee help config get' + Then STDOUT should not contain: + """ + Set a config value + """ + + # CFG-9 (proposed): typo "Manges" in the command group description. + @known-bug + Scenario: The config command group description has no typo + When I run 'bin/ee help config' + Then STDOUT should contain: + """ + Manages global EasyEngine configuration. + """ From cbf40eb83068e74be598ee98d037daaab8b7ccbe Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 16:14:13 +0530 Subject: [PATCH 03/25] test(config-command): cover config get --- features/config-get.feature | 204 ++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 features/config-get.feature diff --git a/features/config-get.feature b/features/config-get.feature new file mode 100644 index 0000000..eb7ad4e --- /dev/null +++ b/features/config-get.feature @@ -0,0 +1,204 @@ +Feature: ee config get + Reads one top-level key from the config file at EE_CONFIG_PATH (a throwaway file here). + + Background: + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + no-of-backups: "3" + rclone-path: remote:bh-config-command/backups + empty-value: "" + flag: true + port: 8080 + parent: + child: x + sibling: z + nullkey: ~ + """ + + Scenario: Get an existing string value + When I run 'bin/ee config get le-mail' + Then the exit code should be 0 + And STDOUT should be: + """ + admin@bh-config-command.test + """ + And STDERR should be empty + + Scenario: Get a value containing a colon + When I run 'bin/ee config get rclone-path' + Then the exit code should be 0 + And STDOUT should be: + """ + remote:bh-config-command/backups + """ + + Scenario: Get a quoted numeric value + When I run 'bin/ee config get no-of-backups' + Then the exit code should be 0 + And STDOUT should be: + """ + 3 + """ + + Scenario: Get an unquoted integer value + When I run 'bin/ee config get port' + Then the exit code should be 0 + And STDOUT should be: + """ + 8080 + """ + + Scenario: Get a boolean value prints PHP's string form of true + When I run 'bin/ee config get flag' + Then the exit code should be 0 + And STDOUT should be: + """ + 1 + """ + + Scenario: Get an empty value succeeds with empty output + When I run 'bin/ee config get empty-value' + Then the exit code should be 0 + And STDOUT should be empty + And STDERR should be empty + + Scenario: Get the defaults that the bootstrap guarantees + When I run 'bin/ee config get ee_installer_version' + Then the exit code should be 0 + And STDOUT should be: + """ + stable + """ + + Scenario: Get does not modify the config file + When I run 'bin/ee config get le-mail' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + no-of-backups: "3" + rclone-path: remote:bh-config-command/backups + empty-value: "" + flag: true + port: 8080 + parent: + child: x + sibling: z + nullkey: ~ + """ + And the real ee config file should be unchanged + + Scenario: Getting a missing key fails + When I run 'bin/ee config get bh-missing-key' + Then the exit code should be 1 + And STDERR should be: + """ + Error: No config value with key 'bh-missing-key' set + """ + And STDOUT should be empty + + Scenario: Keys are case-sensitive + When I run 'bin/ee config get LE-MAIL' + Then the exit code should be 1 + And STDERR should be: + """ + Error: No config value with key 'LE-MAIL' set + """ + + Scenario: Dotted keys are literal, not a path into nested values + When I run 'bin/ee config get parent.child' + Then the exit code should be 1 + And STDERR should be: + """ + Error: No config value with key 'parent.child' set + """ + + Scenario: Get without a key prints the usage + When I run 'bin/ee config get' + Then the exit code should be 1 + And STDOUT should be: + """ + usage: ee config get + """ + + Scenario: Get with two keys is rejected + When I run 'bin/ee config get locale le-mail' + Then the exit code should be 1 + And STDERR should be: + """ + Error: Too many positional arguments: le-mail + """ + And STDOUT should be empty + + Scenario: Get rejects unknown flags + When I run 'bin/ee config get locale --format=json' + Then the exit code should be 1 + And STDERR should contain: + """ + unknown --format parameter + """ + And STDOUT should be empty + + Scenario: Get works with a freshly created config file + Given a temporary ee config path that does not exist yet + When I run 'bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be: + """ + en_US + """ + And the file '{CONFIG_PATH}' should exist + + # CFG-1 (proposed): get uses isset(), so a key whose value is null is reported as not set. + @known-bug + Scenario: Get a key whose value is null + When I run 'bin/ee config get nullkey' + Then the exit code should be 0 + And STDERR should be empty + + # CFG-2 (proposed): non-scalar values are echoed as "Array" (Array to string conversion). + @known-bug + Scenario: Get a nested value prints its contents + When I run 'bin/ee config get parent' + Then the exit code should be 0 + And STDOUT should not contain: + """ + Array + """ + And STDOUT should contain: + """ + child + """ + + # CFG-5 (proposed): get prints through EE::log, so --quiet hides the value it was asked for. + @known-bug + Scenario: Get still prints the value with --quiet + When I run 'bin/ee config get le-mail --quiet' + Then the exit code should be 0 + And STDOUT should be: + """ + admin@bh-config-command.test + """ + + # CFG-8 (proposed): EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. + @known-bug + Scenario: Get does not copy the value into ee.log + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + cloudflare-api-key: bh-config-command-secret-{RUN_ID} + """ + And I remember the size of the file '{EE_LOG}' + When I run 'bin/ee config get cloudflare-api-key' + Then the exit code should be 0 + And STDOUT should be: + """ + bh-config-command-secret-{RUN_ID} + """ + And the text appended to the file '{EE_LOG}' should not contain 'bh-config-command-secret-{RUN_ID}' From 76cb66d849949fe1688231aa93b45a6d153cfc9c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 16:28:56 +0530 Subject: [PATCH 04/25] test(config-command): cover config set --- features/config-set.feature | 282 ++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 features/config-set.feature diff --git a/features/config-set.feature b/features/config-set.feature new file mode 100644 index 0000000..d9b0658 --- /dev/null +++ b/features/config-set.feature @@ -0,0 +1,282 @@ +Feature: ee config set + Writes one top-level key to the config file at EE_CONFIG_PATH (a throwaway file here). + + Background: + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + """ + + Scenario: Set a new key appends it and prints nothing + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And STDOUT should be empty + And STDERR should be empty + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + """ + And the real ee config file should be unchanged + + Scenario: A value that was set can be read back + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + And I run 'bin/ee config get le-mail' + Then the exit code should be 0 + And STDOUT should be: + """ + admin@bh-config-command.test + """ + + Scenario: Overwriting a key keeps its position in the file + When I run 'bin/ee config set locale fr_FR' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: fr_FR + ee_installer_version: stable + """ + + Scenario: Setting the same value twice is idempotent + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + And I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + """ + + Scenario: Numeric values are stored as quoted strings + When I run 'bin/ee config set no-of-backups 3' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should contain exactly these entries: + | key | value | + | locale | "en_US" | + | ee_installer_version | "stable" | + | no-of-backups | "3" | + And the file '{CONFIG_PATH}' should contain: + """ + no-of-backups: "3" + """ + + Scenario: An empty value is stored and read back as empty + When I run 'bin/ee config set bh-empty ""' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'bh-empty' with value '' + When I run 'bin/ee config get bh-empty' + Then the exit code should be 0 + And STDOUT should be empty + + Scenario: Dotted keys are stored literally at the top level + When I run 'bin/ee config set bh.dotted.key v1' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'bh.dotted.key' with value 'v1' + + Scenario: Setting a key that holds a nested map replaces the whole map with a string + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + parent: + child: x + """ + When I run 'bin/ee config set parent flat' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'parent' with value 'flat' + + Scenario: A multi-line value round-trips + When I run 'bin/ee config set bh-multiline "$(printf "line1\nline2")"' + Then the exit code should be 0 + When I run 'bin/ee config get bh-multiline' + Then STDOUT should be: + """ + line1 + line2 + """ + + Scenario Outline: Special value round-trips unchanged + When I run 'bin/ee config set bh-special ""' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'bh-special' with value '' + When I run 'bin/ee config get bh-special' + Then the exit code should be 0 + And STDOUT should be: + """ + + """ + + Examples: + | value | + | true | + | false | + | yes | + | no | + | on | + | null | + | ~ | + | 0 | + | 0123 | + | 1e3 | + | 1.50 | + | a: b | + | a #b | + | [1,2] | + | {a: b} | + | -dash | + | with spaces | + | x@bh-config-command.test | + | http://bh.test/a?b=c#d | + | ünïcode | + | %p | + | &anchor | + | *alias | + | !tag | + + Scenario: A value with leading and trailing spaces keeps them + When I run 'bin/ee config set bh-spaces " padded "' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'bh-spaces' with value ' padded ' + + Scenario: Set without a value prints the usage and leaves the file alone + When I run 'bin/ee config set le-mail' + Then the exit code should be 1 + And STDOUT should be: + """ + usage: ee config set + """ + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + """ + + Scenario: Set without arguments prints the usage + When I run 'bin/ee config set' + Then the exit code should be 1 + And STDOUT should be: + """ + usage: ee config set + """ + + Scenario: An unquoted value with spaces is rejected as extra arguments + When I run 'bin/ee config set bh-key two words' + Then the exit code should be 1 + And STDERR should be: + """ + Error: Too many positional arguments: words + """ + And the YAML file '{CONFIG_PATH}' should not have key 'bh-key' + + Scenario: A value that looks like a flag is not taken as the value + When I run 'bin/ee config set bh-key --bh-flag' + Then the exit code should be 1 + And STDOUT should be: + """ + usage: ee config set + """ + And the YAML file '{CONFIG_PATH}' should not have key 'bh-key' + + Scenario: Set rejects unknown flags + When I run 'bin/ee config set bh-key v --bh-flag=1' + Then the exit code should be 1 + And STDERR should contain: + """ + unknown --bh-flag parameter + """ + And the YAML file '{CONFIG_PATH}' should not have key 'bh-key' + + Scenario: Set creates a missing config file and its directories + Given a temporary ee config path that does not exist yet + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should contain exactly these entries: + | key | value | + | locale | "en_US" | + | ee_installer_version | "stable" | + | le-mail | "admin@bh-config-command.test" | + + Scenario: Set keeps the file mode + Given the file '{CONFIG_PATH}' has mode '0600' + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should have mode '0600' + + Scenario: Set with EE_CONFIG_PATH on the command line writes only that file + When I run 'EE_CONFIG_PATH={CONFIG_DIR}/other.yml bin/ee config set bh-key other' + Then the exit code should be 0 + And the YAML file '{CONFIG_DIR}/other.yml' should have key 'bh-key' with value 'other' + And the YAML file '{CONFIG_PATH}' should not have key 'bh-key' + And the real ee config file should be unchanged + + # CFG-6 (proposed): Spyc dumps these values unquoted, so they come back changed ("dq" loses quotes, | becomes empty). + @known-bug + Scenario Outline: Value round-trips unchanged + When I run 'bin/ee config set bh-special ' + Then the exit code should be 0 + When I run 'bin/ee config get bh-special' + Then STDOUT should be: + """ + + """ + + Examples: + | shell | value | + | '"quoted"' | "quoted" | + | '\|' | \| | + + # CFG-3 (proposed): set rewrites the whole file through Spyc; comments are dropped. + @known-bug + Scenario: Set keeps comments in the config file + Given a temporary ee config file with: + """ + # Managed by bh-config-command + locale: en_US + ee_installer_version: stable + """ + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain: + """ + # Managed by bh-config-command + """ + + # CFG-3 (proposed): unquoted YAML 1.1 booleans in other keys are rewritten as true/false. + @known-bug + Scenario: Set does not change unrelated hand-written values + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + bh-answer: y + bh-switch: off + """ + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain: + """ + bh-answer: y + bh-switch: off + """ + + # CFG-7 (proposed): Symfony dumpFile() renames a temp file over the path, replacing a symlinked config. + @known-bug + Scenario: Set writes through a symlinked config file + Given the temporary ee config file is a symlink + When I run 'bin/ee config set le-mail admin@bh-config-command.test' + Then the exit code should be 0 + And the temporary ee config file should be a symlink + And the YAML file '{CONFIG_TARGET}' should have key 'le-mail' with value 'admin@bh-config-command.test' + + # CFG-8 (proposed): the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. + @known-bug + Scenario: Set does not copy the value into ee.log + Given the environment variable 'BH_SECRET' is set to 'bh-config-command-secret-{RUN_ID}' + And I remember the size of the file '{EE_LOG}' + When I run 'bin/ee config set cloudflare-api-key "$BH_SECRET"' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'cloudflare-api-key' with value 'bh-config-command-secret-{RUN_ID}' + And the text appended to the file '{EE_LOG}' should not contain 'bh-config-command-secret-{RUN_ID}' From c41e99996bf43af3d1fe230e5cad78a38b43a512 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 16:44:17 +0530 Subject: [PATCH 05/25] test(config-command): cover config unset --- features/config-unset.feature | 177 ++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 features/config-unset.feature diff --git a/features/config-unset.feature b/features/config-unset.feature new file mode 100644 index 0000000..f3818b7 --- /dev/null +++ b/features/config-unset.feature @@ -0,0 +1,177 @@ +Feature: ee config unset + Removes one top-level key from the config file at EE_CONFIG_PATH (a throwaway file here). + + Background: + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + cloudflare-api-key: bh-dummy-key + """ + + Scenario: Unset an existing key removes only that key and prints nothing + When I run 'bin/ee config unset cloudflare-api-key' + Then the exit code should be 0 + And STDOUT should be empty + And STDERR should be empty + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + """ + And the real ee config file should be unchanged + + Scenario: An unset key can no longer be read + When I run 'bin/ee config unset le-mail' + And I run 'bin/ee config get le-mail' + Then the exit code should be 1 + And STDERR should be: + """ + Error: No config value with key 'le-mail' set + """ + + Scenario: Unsetting the same key twice fails the second time + When I run 'bin/ee config unset le-mail' + Then the exit code should be 0 + When I run 'bin/ee config unset le-mail' + Then the exit code should be 1 + And STDERR should be: + """ + Error: No config value with key 'le-mail' set + """ + + Scenario: Unsetting a missing key fails and leaves the file byte-identical + When I run 'bin/ee config unset bh-missing-key' + Then the exit code should be 1 + And STDERR should be: + """ + Error: No config value with key 'bh-missing-key' set + """ + And STDOUT should be empty + And the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + cloudflare-api-key: bh-dummy-key + """ + + Scenario: Unset keys are case-sensitive + When I run 'bin/ee config unset LE-MAIL' + Then the exit code should be 1 + And the YAML file '{CONFIG_PATH}' should have key 'le-mail' with value 'admin@bh-config-command.test' + + Scenario: Unset removes a nested map as a whole + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + parent: + child: x + """ + When I run 'bin/ee config unset parent' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should not have key 'parent' + + Scenario: Unset of a key holding an empty string works + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + bh-empty: "" + """ + When I run 'bin/ee config unset bh-empty' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should not have key 'bh-empty' + + Scenario: Unset without a key prints the usage + When I run 'bin/ee config unset' + Then the exit code should be 1 + And STDOUT should be: + """ + usage: ee config unset + """ + + Scenario: Unset with two keys is rejected and removes nothing + When I run 'bin/ee config unset le-mail cloudflare-api-key' + Then the exit code should be 1 + And STDERR should be: + """ + Error: Too many positional arguments: cloudflare-api-key + """ + And the YAML file '{CONFIG_PATH}' should have key 'le-mail' + And the YAML file '{CONFIG_PATH}' should have key 'cloudflare-api-key' + + Scenario: Unset rejects unknown flags + When I run 'bin/ee config unset le-mail --yes' + Then the exit code should be 1 + And STDERR should contain: + """ + unknown --yes parameter + """ + And the YAML file '{CONFIG_PATH}' should have key 'le-mail' + + Scenario: Unset keeps the file mode + Given the file '{CONFIG_PATH}' has mode '0640' + When I run 'bin/ee config unset cloudflare-api-key' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should have mode '0640' + + Scenario: Unsetting a bootstrap default is undone by the next ee invocation + # Every ee run re-adds locale/ee_installer_version (Runner::ensure_present_in_config). + When I run 'bin/ee config unset locale' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should not have key 'locale' + When I run 'bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be: + """ + en_US + """ + And the YAML file '{CONFIG_PATH}' should have key 'locale' with value 'en_US' + + # CFG-1 (proposed): unset uses isset(), so a key whose value is null can never be removed. + @known-bug + Scenario: Unset a key whose value is null + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + bh-null: ~ + bh-null-word: null + """ + When I run 'bin/ee config unset bh-null' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should not have key 'bh-null' + When I run 'bin/ee config unset bh-null-word' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should not have key 'bh-null-word' + + # CFG-3 (proposed): unset rewrites the whole file through Spyc; comments are dropped. + @known-bug + Scenario: Unset keeps comments in the config file + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + # Cloudflare token for DNS-01 + cloudflare-api-key: bh-dummy-key + le-mail: admin@bh-config-command.test + """ + When I run 'bin/ee config unset cloudflare-api-key' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain: + """ + # Cloudflare token for DNS-01 + """ + + # CFG-7 (proposed): the symlink is replaced by a regular file. + @known-bug + Scenario: Unset writes through a symlinked config file + Given the temporary ee config file is a symlink + When I run 'bin/ee config unset cloudflare-api-key' + Then the exit code should be 0 + And the temporary ee config file should be a symlink + And the YAML file '{CONFIG_TARGET}' should not have key 'cloudflare-api-key' From 4ecd568154b1be622160f66791ebea9fb2cb20e5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 17:01:02 +0530 Subject: [PATCH 06/25] test(config-command): cover config list --- features/config-list.feature | 277 +++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 features/config-list.feature diff --git a/features/config-list.feature b/features/config-list.feature new file mode 100644 index 0000000..21da42b --- /dev/null +++ b/features/config-list.feature @@ -0,0 +1,277 @@ +Feature: ee config list + Lists every top-level key of the config file at EE_CONFIG_PATH (a throwaway file here). + + Background: + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + no-of-backups: "3" + """ + + Scenario: The default format is a table of key and value + When I run 'bin/ee config list' + Then the exit code should be 0 + And STDOUT should be a table with rows: + | key | value | + | locale | en_US | + | ee_installer_version | stable | + | le-mail | admin@bh-config-command.test | + | no-of-backups | 3 | + And STDERR should be empty + + Scenario: --format=table matches the default + When I run 'bin/ee config list --format=table' + Then the exit code should be 0 + And STDOUT should be a table with rows: + | key | value | + | locale | en_US | + | ee_installer_version | stable | + | le-mail | admin@bh-config-command.test | + | no-of-backups | 3 | + + Scenario: --format=json keeps file order and value types + When I run 'bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON equal to: + """ + [ + {"key": "locale", "value": "en_US"}, + {"key": "ee_installer_version", "value": "stable"}, + {"key": "le-mail", "value": "admin@bh-config-command.test"}, + {"key": "no-of-backups", "value": "3"} + ] + """ + And STDOUT should be JSON listing the entries of the YAML file '{CONFIG_PATH}' + And STDERR should be empty + + Scenario: --format=csv prints a header and one row per key + When I run 'bin/ee config list --format=csv' + Then the exit code should be 0 + And STDOUT should be CSV with rows: + | key | value | + | locale | en_US | + | ee_installer_version | stable | + | le-mail | admin@bh-config-command.test | + | no-of-backups | 3 | + And STDERR should be empty + + Scenario: --format=yaml prints a list of key/value maps + When I run 'bin/ee config list --format=yaml' + Then the exit code should be 0 + And STDOUT should be YAML equal to: + """ + --- + - key: locale + value: en_US + - key: ee_installer_version + value: stable + - key: le-mail + value: admin@bh-config-command.test + - key: no-of-backups + value: "3" + """ + + Scenario: --format=text prints key: value lines + When I run 'bin/ee config list --format=text' + Then the exit code should be 0 + And STDOUT should be: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + no-of-backups: 3 + """ + And STDERR should be empty + + Scenario: List does not modify the config file + When I run 'bin/ee config list --format=json' + Then the file '{CONFIG_PATH}' should contain exactly: + """ + locale: en_US + ee_installer_version: stable + le-mail: admin@bh-config-command.test + no-of-backups: "3" + """ + And the real ee config file should be unchanged + + Scenario: List reflects set and unset + When I run 'bin/ee config set bh-added v1' + And I run 'bin/ee config unset le-mail' + And I run 'bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON equal to: + """ + [ + {"key": "locale", "value": "en_US"}, + {"key": "ee_installer_version", "value": "stable"}, + {"key": "no-of-backups", "value": "3"}, + {"key": "bh-added", "value": "v1"} + ] + """ + + Scenario: An empty config file still lists the bootstrap defaults + # "No config values found!" is unreachable: the bootstrap always writes locale/ee_installer_version first. + Given an empty temporary ee config file + When I run 'bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON equal to: + """ + [ + {"key": "locale", "value": "en_US"}, + {"key": "ee_installer_version", "value": "stable"} + ] + """ + And STDOUT should not contain: + """ + No config values found! + """ + + Scenario: List shows booleans, integers, empty strings and nested maps with their JSON types + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + flag: true + port: 8080 + empty-value: "" + parent: + child: x + sibling: z + """ + When I run 'bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON equal to: + """ + [ + {"key": "locale", "value": "en_US"}, + {"key": "ee_installer_version", "value": "stable"}, + {"key": "flag", "value": true}, + {"key": "port", "value": 8080}, + {"key": "empty-value", "value": ""}, + {"key": "parent", "value": {"child": "x", "sibling": "z"}} + ] + """ + + Scenario: Nested maps are JSON-encoded in table and CSV output + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + parent: + child: x + sibling: z + """ + When I run 'bin/ee config list --format=table' + Then the exit code should be 0 + And STDOUT should be a table with rows: + | key | value | + | locale | en_US | + | ee_installer_version | stable | + | parent | {"child":"x","sibling":"z"} | + When I run 'bin/ee config list --format=csv' + Then the exit code should be 0 + And STDOUT should be CSV with rows: + | key | value | + | locale | en_US | + | ee_installer_version | stable | + | parent | {"child":"x","sibling":"z"} | + + Scenario: The removed count format is rejected + When I run 'bin/ee config list --format=count' + Then the exit code should be 1 + And STDERR should contain: + """ + Error: Parameter errors: + """ + And STDERR should contain: + """ + Invalid value specified for 'format' + """ + And STDOUT should be empty + + Scenario: An unknown format is rejected + When I run 'bin/ee config list --format=xml' + Then the exit code should be 1 + And STDERR should contain: + """ + Invalid value specified for 'format' + """ + + Scenario: A misspelt flag gets a suggestion + When I run 'bin/ee config list --formt=json' + Then the exit code should be 1 + And STDERR should contain: + """ + unknown --formt parameter + """ + And STDERR should contain: + """ + Did you mean '--format'? + """ + + Scenario: --fields is not supported + When I run 'bin/ee config list --fields=key' + Then the exit code should be 1 + And STDERR should contain: + """ + unknown --fields parameter + """ + + Scenario: List takes no positional arguments + When I run 'bin/ee config list le-mail' + Then the exit code should be 1 + And STDERR should be: + """ + Error: Too many positional arguments: le-mail + """ + + Scenario: Machine-readable formats still print with --quiet + When I run 'bin/ee config list --format=json --quiet' + Then the exit code should be 0 + And STDOUT should be JSON listing the entries of the YAML file '{CONFIG_PATH}' + + # CFG-2 (proposed): --format=text concatenates values, so nested maps print as "Array". + @known-bug + Scenario: --format=text prints nested values readably + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + parent: + child: x + """ + When I run 'bin/ee config list --format=text' + Then the exit code should be 0 + And STDOUT should not contain: + """ + parent: Array + """ + + # CFG-1 (proposed): null values are listed although get/unset claim the key is not set. + @known-bug + Scenario: Keys listed by list can be read with get + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + bh-null: ~ + """ + When I run 'bin/ee config list --format=csv' + Then STDOUT should contain: + """ + bh-null, + """ + When I run 'bin/ee config get bh-null' + Then the exit code should be 0 + + # CFG-5 (proposed): table and text output go through the logger, so --quiet swallows them while json/csv/yaml still print. + @known-bug + Scenario: Human-readable formats still print with --quiet + When I run 'bin/ee config list --quiet' + Then the exit code should be 0 + And STDOUT should contain: + """ + le-mail + """ From fc233dbe40ded17ae5e50aef011d231662a94520 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 17:15:35 +0530 Subject: [PATCH 07/25] test(config-command): cover EE_CONFIG_PATH, bootstrap defaults and runtime keys --- features/config-path.feature | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 features/config-path.feature diff --git a/features/config-path.feature b/features/config-path.feature new file mode 100644 index 0000000..33be24e --- /dev/null +++ b/features/config-path.feature @@ -0,0 +1,169 @@ +Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults + Every ee run first makes sure locale and ee_installer_version exist in the config file (Runner::ensure_present_in_config). + + Scenario: A missing config file and its directories are created with the defaults + Given a temporary ee config path that does not exist yet + When I run 'bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON equal to: + """ + [ + {"key": "locale", "value": "en_US"}, + {"key": "ee_installer_version", "value": "stable"} + ] + """ + And the file '{CONFIG_PATH}' should contain exactly: + """ + --- + locale: en_US + ee_installer_version: stable + """ + And the real ee config file should be unchanged + + Scenario: An empty config file gets the defaults + Given an empty temporary ee config file + When I run 'bin/ee config get ee_installer_version' + Then the exit code should be 0 + And STDOUT should be: + """ + stable + """ + And the YAML file '{CONFIG_PATH}' should contain exactly these entries: + | key | value | + | locale | "en_US" | + | ee_installer_version | "stable" | + + Scenario: Missing defaults are appended and existing values are kept + Given a temporary ee config file with: + """ + le-mail: admin@bh-config-command.test + locale: de_DE + """ + When I run 'bin/ee config get le-mail' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should contain exactly these entries: + | key | value | + | le-mail | "admin@bh-config-command.test" | + | locale | "de_DE" | + | ee_installer_version | "stable" | + + Scenario: A config file that already has the defaults is not rewritten by read-only commands + Given a temporary ee config file with: + """ + # hand-written + locale: en_US + ee_installer_version: nightly + """ + When I run 'bin/ee config list' + Then the exit code should be 0 + And the file '{CONFIG_PATH}' should contain exactly: + """ + # hand-written + locale: en_US + ee_installer_version: nightly + """ + + Scenario: EE_CONFIG_PATH switches files per invocation + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + le-mail: first@bh-config-command.test + """ + When I run 'EE_CONFIG_PATH={CONFIG_DIR}/second.yml bin/ee config set le-mail second@bh-config-command.test' + Then the exit code should be 0 + When I run 'bin/ee config get le-mail' + Then STDOUT should be: + """ + first@bh-config-command.test + """ + When I run 'EE_CONFIG_PATH={CONFIG_DIR}/second.yml bin/ee config get le-mail' + Then STDOUT should be: + """ + second@bh-config-command.test + """ + And the real ee config file should be unchanged + + Scenario: A hand-written disabled_commands list disables a config subcommand + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - config set + """ + When I run 'bin/ee config set bh-key v' + Then the exit code should be 1 + And STDERR should contain: + """ + The 'config set' command has been disabled from the config file. + """ + And the YAML file '{CONFIG_PATH}' should not have key 'bh-key' + When I run 'bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be: + """ + en_US + """ + + Scenario: quiet in the config file silences config get until it is unset + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'bin/ee config set quiet true' + Then the exit code should be 0 + When I run 'bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be empty + When I run 'bin/ee config unset quiet' + Then the exit code should be 0 + When I run 'bin/ee config get locale' + Then STDOUT should be: + """ + en_US + """ + + # CFG-4 (proposed): set always writes a string, and the string 'false' is truthy for the quiet runtime flag. + @known-bug + Scenario: Setting quiet to false keeps output visible + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'bin/ee config set quiet false' + Then the exit code should be 0 + When I run 'bin/ee config get locale' + Then STDOUT should be: + """ + en_US + """ + + # CFG-4 (proposed): a string disabled_commands reaches in_array() in Runner::is_command_disabled(): TypeError on PHP 8 for every ee command + # (including the `config unset` needed to undo it); on 7.4 a hidden warning and the command is not disabled. + @known-bug + Scenario: Setting disabled_commands with config set disables that command without breaking ee + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'bin/ee config set disabled_commands "config list"' + Then the exit code should be 0 + When I run 'bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be: + """ + en_US + """ + And STDERR should be empty + When I run 'bin/ee config list' + Then the exit code should be 1 + And STDERR should contain: + """ + The 'config list' command has been disabled from the config file. + """ + When I run 'bin/ee config unset disabled_commands' + Then the exit code should be 0 From dc62ab7aa03fa64a85ebeb4b0252c62aad94dc2d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 17:28:15 +0530 Subject: [PATCH 08/25] test(config-command): cover the default global config path Tagged @destructive; the context restores the real config after each scenario. --- features/config-global.feature | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 features/config-global.feature diff --git a/features/config-global.feature b/features/config-global.feature new file mode 100644 index 0000000..7fbe091 --- /dev/null +++ b/features/config-global.feature @@ -0,0 +1,37 @@ +@destructive +Feature: Default config location (/opt/easyengine/config/config.yml) + These scenarios run without EE_CONFIG_PATH, so they read or write the real global config. + The context snapshots the real file before every scenario and restores it byte-for-byte afterwards. + + Scenario: Without EE_CONFIG_PATH, list reads the real global config + When I run 'bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON listing the entries of the YAML file '{REAL_CONFIG_PATH}' + + Scenario: An empty EE_CONFIG_PATH falls back to the real global config + When I run 'EE_CONFIG_PATH= bin/ee config list --format=json' + Then the exit code should be 0 + And STDOUT should be JSON listing the entries of the YAML file '{REAL_CONFIG_PATH}' + + Scenario: Without EE_CONFIG_PATH, set, get and unset operate on the real global config + When I run 'bin/ee config set bh-config-command-key bh-value' + Then the exit code should be 0 + And the YAML file '{REAL_CONFIG_PATH}' should have key 'bh-config-command-key' with value 'bh-value' + When I run 'bin/ee config get bh-config-command-key' + Then the exit code should be 0 + And STDOUT should be: + """ + bh-value + """ + When I run 'bin/ee config unset bh-config-command-key' + Then the exit code should be 0 + And the YAML file '{REAL_CONFIG_PATH}' should not have key 'bh-config-command-key' + And the real ee config file should have the same values as before + + Scenario: Set and unset keep the mode of the real global config + When I run 'bin/ee config set bh-config-command-key bh-value' + Then the exit code should be 0 + And the real ee config file should have the same mode as before + When I run 'bin/ee config unset bh-config-command-key' + Then the exit code should be 0 + And the real ee config file should have the same mode as before From b0287e5a6d8f8ce661bb00a36fe0889313544752 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 17:41:54 +0530 Subject: [PATCH 09/25] test(config-command): add coverage inventory --- features/COVERAGE.md | 81 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 features/COVERAGE.md diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 0000000..0927a31 --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,81 @@ +# config-command Behat coverage + +Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in `config-global.feature` points `EE_CONFIG_PATH` at a throwaway file under `sys_get_temp_dir()`, so `/opt/easyengine/config/config.yml` is never written. The context snapshots the real config before each scenario, restores it byte-for-byte (content and mode) afterwards, and fails any scenario not tagged `@destructive` that changed it. No sites, containers, `/etc/hosts` entries, LE certificates or remote backups are involved; the config commands don't even need Docker. + +## Inventory + +| Item | Source | Covered by | +|---|---|---| +| `ee config` (no subcommand): usage of all subcommands, exit 0 | core `CompositeCommand::show_usage()` | config.feature: "Running ee config without a subcommand…" | +| `ee help config`, `ee help config ` (synopsis, list formats) | docblocks | config.feature: "ee help config lists…", outline "ee help config …", "…documents every supported format" | +| Unknown subcommand (with and without suggestion) | core `Runner::find_command_to_run()` | config.feature: two "unknown config subcommand" scenarios | +| No Docker requirement check for `config` | core `Runner::init_ee()` | config.feature: "config commands skip the Docker requirement check" (docker/docker-compose shims that always fail) | +| `get `: string, value with colon, quoted number, integer, boolean, empty value, bootstrap default | `get()` | config-get.feature: "Get an existing string value" … "Get the defaults that the bootstrap guarantees" | +| `get`: read-only (file byte-identical) | `get()` | config-get.feature: "Get does not modify the config file" | +| `get`: missing key error, case sensitivity, dotted keys are literal | `get()` isset check | config-get.feature: "Getting a missing key fails", "Keys are case-sensitive", "Dotted keys are literal…" | +| `get`: no arg (usage), two args, unknown flag | core `Subcommand::validate_args()` | config-get.feature: "Get without a key…", "Get with two keys…", "Get rejects unknown flags" | +| `get` on a config path that doesn't exist yet | `get()` + bootstrap | config-get.feature: "Get works with a freshly created config file" | +| `get` of a null value | `get()` isset | config-get.feature `@known-bug` CFG-1 | +| `get` of a nested map | `get()` → `EE::log(array)` | config-get.feature `@known-bug` CFG-2 | +| `get --quiet` | `get()` uses `EE::log` | config-get.feature `@known-bug` CFG-5 | +| `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature `@known-bug` CFG-8 | +| `set `: new key (exact file content, no output), read back, overwrite keeps position, idempotent | `set()` | config-set.feature: first four scenarios | +| `set`: numeric stored as quoted string, empty value, dotted key literal, replacing a nested map, multi-line value, leading/trailing spaces | `set()` + Spyc dump | config-set.feature: "Numeric values…", "An empty value…", "Dotted keys…", "Setting a key that holds a nested map…", "A multi-line value…", "A value with leading and trailing spaces…" | +| `set`: 24 YAML-special values round-trip (true/false/yes/no/on/null/~/0/0123/1e3/1.50/`a: b`/`a #b`/`[1,2]`/`{a: b}`/-dash/spaces/email/URL with `#`/unicode/`%p`/`&`/`*`/`!`) | Spyc quoting | config-set.feature outline "Special value round-trips unchanged" | +| `set`: missing value / no args (usage, file untouched), unquoted spaces, flag-looking value, unknown flag | core validation | config-set.feature: "Set without a value…", "Set without arguments…", "An unquoted value with spaces…", "A value that looks like a flag…", "Set rejects unknown flags" | +| `set` creates a missing file and directories | `set()` + bootstrap + Symfony `dumpFile()` | config-set.feature: "Set creates a missing config file…" | +| `set` keeps the file mode | Symfony `dumpFile()` | config-set.feature: "Set keeps the file mode" | +| `EE_CONFIG_PATH` given inline for one invocation | `set()` path resolution | config-set.feature: "Set with EE_CONFIG_PATH on the command line…" | +| `set` of `"quoted"` and `\|` values | Spyc dump | config-set.feature `@known-bug` CFG-6 (outline, 2 examples) | +| `set` drops comments / rewrites unrelated `y`/`off` values | whole-file Spyc rewrite | config-set.feature `@known-bug` CFG-3 (2 scenarios) | +| `set` on a symlinked config | Symfony `dumpFile()` rename | config-set.feature `@known-bug` CFG-7 | +| `set` secret ends up in ee.log (argv debug line) | core `Runner::start()` | config-set.feature `@known-bug` CFG-8 | +| `unset `: removes only that key (exact content, no output), then get fails, twice (second fails), missing key (file byte-identical), case sensitivity, nested map, empty-string value | `unset()` | config-unset.feature: first seven scenarios | +| `unset`: no arg, two args, unknown flag | core validation | config-unset.feature: "Unset without a key…", "Unset with two keys…", "Unset rejects unknown flags" | +| `unset` keeps the file mode | Symfony `dumpFile()` | config-unset.feature: "Unset keeps the file mode" | +| `unset locale` is undone by the next run | core `ensure_present_in_config()` | config-unset.feature: "Unsetting a bootstrap default is undone…" (documents current behaviour) | +| `unset` of null values | `unset()` isset | config-unset.feature `@known-bug` CFG-1 | +| `unset` drops comments / replaces a symlink | whole-file rewrite | config-unset.feature `@known-bug` CFG-3, CFG-7 | +| `list` default format and `--format=table` | `_list()` + Formatter | config-list.feature: "The default format…", "--format=table…" | +| `list --format=json|csv|yaml|text` | `_list()` | config-list.feature: one scenario per format | +| `list`: read-only, reflects set/unset, value types in JSON, nested maps JSON-encoded in table/csv | `_list()` | config-list.feature: "List does not modify…", "List reflects set and unset", "List shows booleans…", "Nested maps are JSON-encoded…" | +| `list` on an empty file ("No config values found!") | `_list()` empty branch | config-list.feature: "An empty config file still lists the bootstrap defaults" (the error branch is unreachable, see below) | +| `list`: removed `count` format (db58c81), unknown format, misspelt flag suggestion, `--fields`, positional arg | synopsis validation | config-list.feature: five scenarios | +| `list --format=json --quiet` | Formatter echoes directly | config-list.feature: "Machine-readable formats still print with --quiet" | +| `list --format=text` with nested map | `_list()` string concat | config-list.feature `@known-bug` CFG-2 | +| `list` shows null keys that get can't read | isset vs list | config-list.feature `@known-bug` CFG-1 | +| `list` (table) with `--quiet` | `EE::line` goes through the logger | config-list.feature `@known-bug` CFG-5 | +| Bootstrap: missing file/dirs created with `locale`/`ee_installer_version`, empty file, missing defaults appended, file untouched when defaults exist | core `Runner::ensure_present_in_config()` | config-path.feature: first four scenarios | +| `EE_CONFIG_PATH` switching per invocation | all subcommands | config-path.feature: "EE_CONFIG_PATH switches files per invocation" | +| Config keys that are global runtime parameters: hand-written `disabled_commands` list, `quiet: true` | core Configurator/Runner | config-path.feature: "A hand-written disabled_commands list…", "quiet in the config file silences…" | +| `set quiet false`, `set disabled_commands …` | set writes strings | config-path.feature `@known-bug` CFG-4 (2 scenarios) | +| Default path (no `EE_CONFIG_PATH`, empty `EE_CONFIG_PATH`): list, set/get/unset, mode kept | path fallback | config-global.feature (`@destructive`, 4 scenarios, restored by the context) | +| Config not readable/writable errors | core `ensure_present_in_config()` | not covered: the suite runs as root, which bypasses file permissions; core code, not this package | +| `~/easyengine` config root on macOS | core `init-ee.php` | not covered: Linux-only CI | + +## Counts + +7 features, 127 scenarios (outline examples expanded): `@known-bug` 20, `@destructive` 4, `@le` 0, `@remote` 0. 103 scenarios run with neither tag. + +Coverage estimate: about 95% of the package's reachable behaviour (every subcommand, argument, format and error path in `Config_Command.php`, plus the core bootstrap/validation behaviour they depend on). The only uncovered package branch is `list`'s "No config values found!", which the bootstrap makes unreachable. + +Estimated runtime: about 215 `bin/ee` invocations with no Docker work, roughly 1.5 to 3 minutes. + +## Known bugs exposed (proposed IDs, not yet in issues.md) + +- CFG-1 (Low): `get` and `unset` use `isset()`, so a key whose value is null (`k: ~`, `k: null`) is "not set" although `list` shows it, and it can't be removed with `unset`. Fix: `array_key_exists()`. +- CFG-2 (Low): non-scalar values: `get` prints `Array` (PHP "Array to string conversion"), `list --format=text` prints `key: Array`. +- CFG-3 (Medium): `set`/`unset` rewrite the whole file through Spyc: comments are dropped, and hand-written unquoted `y`/`yes`/`on`/`off`/`no` in unrelated keys become `true`/`false`. The bootstrap writer also adds `---`, which `set` then removes. +- CFG-4 (Medium): `set` always writes strings. `ee config set disabled_commands ""` makes every later ee command fatal on PHP 8 (TypeError from `in_array()` in `Runner::is_command_disabled()`), including the `ee config unset` needed to repair it; on 7.4 it's a hidden warning and nothing is disabled. `ee config set quiet false` enables quiet mode because `'false'` is truthy. +- CFG-5 (Low): `get` prints through `EE::log`, and `list` table/text through the logger, so `--quiet` (or `quiet` in the config) hides exactly the output that was asked for; json/csv/yaml still print. +- CFG-6 (Low): the values `"quoted"` (loses its quotes) and `|` (becomes empty) don't round-trip through `set` then `get` (Spyc dumper). +- CFG-7 (Low): `set`/`unset` replace a symlinked config file with a regular file (Symfony `dumpFile()` renames a temp file over the path), while the bootstrap writer writes through the link. +- CFG-8 (Medium, security): secrets reach `/opt/easyengine/logs/ee.log`: `get` logs the value (`EE::log` also writes to the file logger at INFO), and core logs the full argv at DEBUG, so `ee config set cloudflare-api-key ` persists the key. +- CFG-9 (Info): `ee help config get` says "Set a config value"; the group description says "Manges"; the README documents only `set`/`get`. + +## Notes + +- Asserted as current behaviour, not tagged as bugs: `set`/`unset` print nothing on success (other ee commands print `Success:`); unsetting `locale`/`ee_installer_version` is undone by the next invocation. Seen in the code but not tested: `ee config set debug true` stores the string `'true'`, which the logger treats as a debug group name, so it doesn't enable debug output. +- The CSV scenarios may also expose PHP 8.4+'s `fputcsv()` `$escape` deprecation if `display_errors` is on (it's off on this host's CLI). +- New shared steps are in the "Shared steps" section of `bootstrap/FeatureContext.php`. The inherited "I run" step now expands `{…}` placeholders, runs the command without copying its output into ee.log, and refuses `sudo` while a temporary config is active, because sudo drops `EE_CONFIG_PATH`. +- The site-command pattern of deleting every site after each feature is replaced by deleting only `bh-config-command-*` sites, and only when such a site directory exists, so this suite never calls `ee site` at all in practice. From 6f5aabd2ba32e1c3eca52fad88e30b6a3d1028a8 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 18:00:07 +0530 Subject: [PATCH 10/25] test(config-command): harden real config restore and fix context steps Back up the real config.yml (bytes, mode, owner, symlink) to /var/tmp before each scenario, restore it atomically and verify it, and restore from a shutdown function on Ctrl-C, SIGTERM/SIGHUP and fatals, which skip AfterScenario. The next run recovers a backup left by kill -9. Symlinked configs are no longer replaced by a regular file, overlapping runs are locked out, and failure messages name changed keys instead of printing values. Fix the YAML value step ignoring placeholders, which failed the CFG-8 set scenario on the wrong step. Undefined placeholders are now an error, missing files fail the file steps, the ee.log step fails when nothing was logged, and the docker shims record calls. Env changes are restored, the sudo guard also catches su and env options, and PAGER plus PHP error settings are pinned. --- features/bootstrap/FeatureContext.php | 376 ++++++++++++++++++++++---- 1 file changed, 316 insertions(+), 60 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 9fb5632..28a0cbf 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -128,8 +128,8 @@ public function iRun($command) $command = $this->replaceVariables( $command ); // sudo resets the environment, so the command would silently use the real config. - if ( $this->tempConfigPath && preg_match( '/(^|[\s;&|])sudo\s+(?!-E\b)/', $command ) ) { - throw new Exception( "Refusing to run '$command': sudo drops EE_CONFIG_PATH. Drop sudo (the suite runs as root)." ); + if ( $this->tempConfigPath && preg_match( '/\b(sudo|su)\b|\benv\s+-/', $command ) ) { + throw new Exception( "Refusing to run '$command': sudo, su and env options can drop EE_CONFIG_PATH. Drop them (the suite runs as root)." ); } // Not EE::launch(): it copies the command's STDOUT/STDERR into ee.log, which would pollute log assertions. @@ -458,25 +458,141 @@ public static function cleanup(AfterFeatureScope $scope) /** @var string|null Per-scenario scratch dir (holds the temp config). */ private $scratchDir = null; - /** @var string|null|false Real config content at scenario start (null = file absent, false = not snapshotted). */ - private $realConfigSnapshot = false; - - /** @var int|null Real config mode at scenario start. */ - private $realConfigMode = null; - /** @var array Remembered file sizes, keyed by path. */ private $rememberedSizes = []; /** @var array Extra placeholder values set by steps. */ private $variables = []; - /** @var array Environment variables exported by steps, unset after the scenario. */ - private $exportedEnv = []; + /** @var array Environment variables changed by this scenario => previous value (false = was unset). */ + private $savedEnv = []; + + /** @var resource|null Lock held for the whole run, so two runs never snapshot each other's changes. */ + private static $runLock = null; + + /** + * On-disk copy of the real config, written before every scenario and removed once the file is verified intact. + * It survives kill -9, so an interrupted run can be recovered (see recoverInterruptedRun()). + */ + const REAL_CONFIG_BACKUP = '/var/tmp/bh-config-command-real-config.backup'; + + /** Key the @destructive scenarios write to the real config; marks leftovers of an interrupted run. */ + const REAL_CONFIG_MARKER = 'bh-config-command-key:'; + + /** @var array|null State of the real config at scenario start; null outside a scenario. Static so the shutdown handler sees it. */ + private static $realConfigState = null; + + /** @var bool */ + private static $shutdownHandlerRegistered = false; private static function realConfigPath() { return EE_ROOT_DIR . '/config/config.yml'; } + /** + * Content, mode, owner and symlink target of the real config. + */ + private static function captureRealConfig() { + $path = self::realConfigPath(); + clearstatcache(); + $state = [ + 'link' => is_link( $path ) ? readlink( $path ) : null, + 'exists' => is_file( $path ), + ]; + if ( $state['exists'] ) { + $content = file_get_contents( $path ); + $stat = stat( $path ); + if ( false === $content || false === $stat ) { + throw new Exception( "Unable to read $path" ); + } + $state['content'] = $content; + $state['mode'] = $stat['mode'] & 07777; + $state['uid'] = $stat['uid']; + $state['gid'] = $stat['gid']; + } + + return $state; + } + + /** + * Puts the real config back as captured: same bytes (written atomically), mode, owner and symlink. + */ + private static function restoreRealConfig( array $state ) { + $path = self::realConfigPath(); + clearstatcache(); + + if ( null !== $state['link'] ) { + if ( ! is_link( $path ) || readlink( $path ) !== $state['link'] ) { + if ( ( file_exists( $path ) || is_link( $path ) ) && ! unlink( $path ) ) { + throw new Exception( "Unable to remove $path to restore its symlink" ); + } + if ( ! symlink( $state['link'], $path ) ) { + throw new Exception( "Unable to restore the symlink $path -> {$state['link']}" ); + } + } + $dest = '/' === substr( $state['link'], 0, 1 ) ? $state['link'] : dirname( $path ) . '/' . $state['link']; + } else { + if ( is_link( $path ) && ! unlink( $path ) ) { + throw new Exception( "Unable to remove the symlink $path" ); + } + $dest = $path; + } + + if ( ! $state['exists'] ) { + if ( null === $state['link'] && file_exists( $path ) && ! unlink( $path ) ) { + throw new Exception( "Unable to remove $path, which did not exist before the scenario" ); + } + } else { + // Temp file + rename, so concurrent ee runs never read a half-written config. + $tmp = $dest . '.bh-restore-' . getmypid(); + if ( strlen( $state['content'] ) !== file_put_contents( $tmp, $state['content'] ) + || ! chmod( $tmp, $state['mode'] ) + || ! chown( $tmp, $state['uid'] ) + || ! chgrp( $tmp, $state['gid'] ) + || ! rename( $tmp, $dest ) ) { + @unlink( $tmp ); + throw new Exception( "Unable to restore $dest" ); + } + } + + if ( ! self::sameState( self::captureRealConfig(), $state ) ) { + throw new Exception( "Restoring $path did not reproduce the original file; the original is saved in " . self::REAL_CONFIG_BACKUP ); + } + } + + private static function writeRealConfigBackup( array $state ) { + $tmp = self::REAL_CONFIG_BACKUP . '.tmp'; + $old = umask( 077 ); + // The config can hold secrets (cloudflare-api-key), hence 0600. + $meta = $state; + unset( $meta['content'] ); + $ok = false !== file_put_contents( $tmp, json_encode( [ 'path' => self::realConfigPath(), 'state' => $meta, 'content_b64' => isset( $state['content'] ) ? base64_encode( $state['content'] ) : null ] ) ); + umask( $old ); + if ( ! $ok || ! chmod( $tmp, 0600 ) || ! rename( $tmp, self::REAL_CONFIG_BACKUP ) ) { + throw new Exception( 'Unable to write ' . self::REAL_CONFIG_BACKUP . '; refusing to run without a backup of the real config.' ); + } + } + + private static function readRealConfigBackup() { + $data = json_decode( (string) file_get_contents( self::REAL_CONFIG_BACKUP ), true ); + if ( ! is_array( $data ) || ! isset( $data['state'] ) || self::realConfigPath() !== $data['path'] ) { + throw new Exception( self::REAL_CONFIG_BACKUP . ' is unreadable or belongs to another config path; inspect it and delete it by hand.' ); + } + $state = $data['state']; + if ( $state['exists'] ) { + $state['content'] = base64_decode( $data['content_b64'] ); + } + + return $state; + } + + private static function sameState( array $a, array $b ) { + ksort( $a ); + ksort( $b ); + + return $a === $b; + } + private function replaceVariables( $string ) { $vars = array_merge( [ @@ -491,7 +607,13 @@ private function replaceVariables( $string ) { $vars['{CONFIG_DIR}'] = dirname( $this->tempConfigPath ); } - return strtr( $string, $vars ); + $string = strtr( $string, $vars ); + // A leftover placeholder would silently become a relative path (or the real config) in commands. + if ( preg_match( '/\{[A-Z][A-Z_]*\}/', $string, $m ) ) { + throw new Exception( "Placeholder {$m[0]} is not defined at this point of the scenario." ); + } + + return $string; } private function runId() { @@ -573,6 +695,74 @@ private static function normalizeForCompare( $value ) { return $value; } + private function setEnv( $name, $value ) { + if ( ! array_key_exists( $name, $this->savedEnv ) ) { + $this->savedEnv[ $name ] = getenv( $name ); + } + putenv( false === $value ? $name : "$name=$value" ); + } + + private function restoreEnv() { + foreach ( $this->savedEnv as $name => $value ) { + putenv( false === $value ? $name : "$name=$value" ); + } + $this->savedEnv = []; + } + + /** Changed top-level keys only: failure messages must not print the real config (it can hold secrets). */ + private static function describeRealConfigChange( array $before, array $after ) { + $load = function ( array $state ) { + return $state['exists'] ? (array) \Mustangostang\Spyc::YAMLLoadString( $state['content'] ) : []; + }; + $a = $load( $before ); + $b = $load( $after ); + $keys = []; + foreach ( array_unique( array_merge( array_keys( $a ), array_keys( $b ) ) ) as $key ) { + if ( ! array_key_exists( $key, $a ) || ! array_key_exists( $key, $b ) || $a[ $key ] !== $b[ $key ] ) { + $keys[] = $key; + } + } + $meta = []; + foreach ( [ 'exists', 'link', 'mode', 'uid', 'gid' ] as $field ) { + $x = isset( $before[ $field ] ) ? $before[ $field ] : null; + $y = isset( $after[ $field ] ) ? $after[ $field ] : null; + if ( $x !== $y ) { + $meta[] = "$field: " . var_export( $x, true ) . ' -> ' . var_export( $y, true ); + } + } + + return 'Changed keys: ' . ( $keys ? implode( ', ', $keys ) : '(none; formatting or comments only)' ) . ( $meta ? '; ' . implode( '; ', $meta ) : '' ); + } + + /** + * Recovers the real config after a run that died mid-scenario (kill -9, power loss). + * + * @BeforeSuite + */ + public static function recoverInterruptedRun() { + self::$runLock = fopen( self::REAL_CONFIG_BACKUP . '.lock', 'c' ); + if ( ! self::$runLock || ! flock( self::$runLock, LOCK_EX | LOCK_NB ) ) { + throw new Exception( 'Another run of this suite holds ' . self::REAL_CONFIG_BACKUP . '.lock; runs must not overlap.' ); + } + if ( ! file_exists( self::REAL_CONFIG_BACKUP ) ) { + return; + } + $state = self::readRealConfigBackup(); + $current = self::captureRealConfig(); + if ( self::sameState( $state, $current ) ) { + unlink( self::REAL_CONFIG_BACKUP ); + return; + } + // Only undo what is recognisably ours; anything else may be a legitimate change made since. + if ( isset( $current['content'] ) && false !== strpos( $current['content'], self::REAL_CONFIG_MARKER ) ) { + self::restoreRealConfig( $state ); + unlink( self::REAL_CONFIG_BACKUP ); + fwrite( STDERR, 'Restored ' . self::realConfigPath() . ' from ' . self::REAL_CONFIG_BACKUP . ", left by an interrupted run.\n" ); + return; + } + throw new Exception( self::REAL_CONFIG_BACKUP . ' was left by an interrupted run and ' . self::realConfigPath() . ' differs from it (' . self::describeRealConfigChange( $state, $current ) . '). Restore the config from the backup by hand if the change is ours, then delete the backup.' ); + } + /** * @BeforeScenario */ @@ -580,10 +770,58 @@ public function snapshotRealConfig( BeforeScenarioScope $scope ) { // An EE_CONFIG_PATH inherited from the caller's shell must not leak into scenarios. putenv( 'EE_CONFIG_PATH' ); unset( $_ENV['EE_CONFIG_PATH'] ); + // Help output goes through $PAGER (less -r by default), and host php.ini decides where PHP notices go. + $this->setEnv( 'PAGER', 'cat' ); + if ( false === getenv( 'EE_PHP_ARGS' ) ) { + $this->setEnv( 'EE_PHP_ARGS', '-d display_errors=0 -d log_errors=1 -d error_log= -d error_reporting=' . ( E_ALL & ~E_DEPRECATED ) ); + } + + if ( file_exists( self::REAL_CONFIG_BACKUP ) ) { + throw new Exception( self::REAL_CONFIG_BACKUP . ' still exists: restoring ' . self::realConfigPath() . ' failed earlier in this run. Fix it by hand before running more scenarios.' ); + } + $state = self::captureRealConfig(); + self::writeRealConfigBackup( $state ); + self::$realConfigState = $state; + self::registerShutdownHandler(); + } - $path = self::realConfigPath(); - $this->realConfigSnapshot = is_file( $path ) ? file_get_contents( $path ) : null; - $this->realConfigMode = is_file( $path ) ? ( fileperms( $path ) & 0777 ) : null; + private static function registerShutdownHandler() { + if ( self::$shutdownHandlerRegistered ) { + return; + } + self::$shutdownHandlerRegistered = true; + // Behat turns SIGINT into exit() without AfterScenario, and an Error in a step is fatal; both still run shutdown functions. + register_shutdown_function( [ __CLASS__, 'restoreRealConfigOnShutdown' ] ); + if ( function_exists( 'pcntl_signal' ) && function_exists( 'pcntl_async_signals' ) ) { + pcntl_async_signals( true ); + foreach ( [ 'SIGTERM', 'SIGHUP' ] as $signal ) { + if ( defined( $signal ) ) { + pcntl_signal( + constant( $signal ), + function ( $signo ) { + exit( 128 + $signo ); + } + ); + } + } + } + } + + public static function restoreRealConfigOnShutdown() { + $state = self::$realConfigState; + if ( null === $state ) { + return; + } + try { + if ( ! self::sameState( $state, self::captureRealConfig() ) ) { + self::restoreRealConfig( $state ); + fwrite( STDERR, 'Restored ' . self::realConfigPath() . " after an interrupted scenario.\n" ); + } + self::$realConfigState = null; + unlink( self::REAL_CONFIG_BACKUP ); + } catch ( \Throwable $e ) { + fwrite( STDERR, 'Could not restore ' . self::realConfigPath() . ': ' . $e->getMessage() . "\nThe original is saved in " . self::REAL_CONFIG_BACKUP . "\n" ); + } } /** @@ -592,44 +830,38 @@ public function snapshotRealConfig( BeforeScenarioScope $scope ) { * @AfterScenario */ public function restoreConfigState( AfterScenarioScope $scope ) { + // The real config first: nothing below may prevent its restore. + $state = self::$realConfigState; + if ( null !== $state ) { + $current = self::captureRealConfig(); + $changed = ! self::sameState( $state, $current ); + if ( $changed ) { + // Throws (keeping the backup and the state for the shutdown handler) if the restore can't be verified. + self::restoreRealConfig( $state ); + } + self::$realConfigState = null; + if ( ! unlink( self::REAL_CONFIG_BACKUP ) ) { + throw new Exception( 'Unable to remove ' . self::REAL_CONFIG_BACKUP ); + } + } + putenv( 'EE_CONFIG_PATH' ); unset( $_ENV['EE_CONFIG_PATH'] ); + $this->restoreEnv(); $this->tempConfigPath = null; $this->variables = []; $this->rememberedSizes = []; - foreach ( $this->exportedEnv as $name ) { - putenv( $name ); - } - $this->exportedEnv = []; if ( $this->scratchDir && 0 === strpos( $this->scratchDir, sys_get_temp_dir() . '/' . self::SITE_PREFIX ) ) { exec( 'rm -rf ' . escapeshellarg( $this->scratchDir ) ); } $this->scratchDir = null; - if ( false === $this->realConfigSnapshot ) { - return; - } - - $path = self::realConfigPath(); - $current = is_file( $path ) ? file_get_contents( $path ) : null; - if ( $current === $this->realConfigSnapshot && ! is_link( $path ) ) { - return; - } - - if ( null === $this->realConfigSnapshot ) { - @unlink( $path ); - } else { - if ( is_link( $path ) ) { - unlink( $path ); + if ( null !== $state && $changed ) { + $tags = array_merge( $scope->getFeature()->getTags(), $scope->getScenario()->getTags() ); + if ( ! in_array( 'destructive', $tags, true ) ) { + throw new Exception( 'Scenario modified the real ' . self::realConfigPath() . ' without the @destructive tag; it has been restored (if another process changed it meanwhile, re-apply that change). ' . self::describeRealConfigChange( $state, $current ) ); } - file_put_contents( $path, $this->realConfigSnapshot ); - chmod( $path, $this->realConfigMode ); - } - - $tags = array_merge( $scope->getFeature()->getTags(), $scope->getScenario()->getTags() ); - if ( ! in_array( 'destructive', $tags, true ) ) { - throw new Exception( "Scenario modified the real $path without the @destructive tag. It has been restored.\nContent it wrote:\n" . var_export( $current, true ) ); } } @@ -691,21 +923,29 @@ private function requireTempConfig() { * @Then the real ee config file should be unchanged */ public function theRealEeConfigFileShouldBeUnchanged() { - $path = self::realConfigPath(); - $current = is_file( $path ) ? file_get_contents( $path ) : null; - if ( $current !== $this->realConfigSnapshot ) { - throw new Exception( "The real $path changed.\nBefore:\n" . var_export( $this->realConfigSnapshot, true ) . "\nNow:\n" . var_export( $current, true ) ); + $current = self::captureRealConfig(); + if ( ! self::sameState( self::requireRealConfigState(), $current ) ) { + throw new Exception( 'The real ' . self::realConfigPath() . ' changed. ' . self::describeRealConfigChange( self::requireRealConfigState(), $current ) ); } } + private static function requireRealConfigState() { + if ( null === self::$realConfigState ) { + throw new Exception( 'The real ee config was not snapshotted for this scenario.' ); + } + + return self::$realConfigState; + } + /** * @Then the real ee config file should have the same values as before */ public function theRealEeConfigFileShouldHaveTheSameValuesAsBefore() { - $before = null === $this->realConfigSnapshot ? [] : \Mustangostang\Spyc::YAMLLoadString( $this->realConfigSnapshot ); + $state = self::requireRealConfigState(); + $before = $state['exists'] ? \Mustangostang\Spyc::YAMLLoadString( $state['content'] ) : []; $now = self::loadYamlFile( self::realConfigPath() ); if ( $before !== $now ) { - throw new Exception( "Real config values differ.\nBefore: " . json_encode( $before ) . "\nNow: " . json_encode( $now ) ); + throw new Exception( 'Real config values differ. ' . self::describeRealConfigChange( $state, self::captureRealConfig() ) ); } } @@ -718,8 +958,7 @@ public function theEnvironmentVariableIsSetTo( $name, $value ) { if ( 'EE_CONFIG_PATH' === $name ) { throw new Exception( 'Use a temporary ee config step to set EE_CONFIG_PATH.' ); } - putenv( $name . '=' . $this->replaceVariables( $value ) ); - $this->exportedEnv[] = $name; + $this->setEnv( $name, $this->replaceVariables( $value ) ); } /** @@ -728,9 +967,11 @@ public function theEnvironmentVariableIsSetTo( $name, $value ) { public function theRealEeConfigFileShouldHaveTheSameModeAsBefore() { clearstatcache(); $path = self::realConfigPath(); - $actual = is_file( $path ) ? ( fileperms( $path ) & 0777 ) : null; - if ( $actual !== $this->realConfigMode ) { - throw new Exception( sprintf( 'Mode of %s changed from %s to %s', $path, var_export( $this->realConfigMode, true ), null === $actual ? 'missing' : sprintf( '%04o', $actual ) ) ); + $state = self::requireRealConfigState(); + $expected = $state['exists'] ? $state['mode'] : null; + $actual = is_file( $path ) ? ( fileperms( $path ) & 07777 ) : null; + if ( $actual !== $expected ) { + throw new Exception( sprintf( 'Mode of %s changed from %s to %s', $path, null === $expected ? 'missing' : sprintf( '%04o', $expected ), null === $actual ? 'missing' : sprintf( '%04o', $actual ) ) ); } } @@ -741,7 +982,8 @@ public function aDirectoryInPathWhereDockerAndDockerComposeAlwaysFail() { $dir = $this->scratchDir() . '/failing-docker-bin'; mkdir( $dir, 0700, true ); foreach ( [ 'docker', 'docker-compose' ] as $bin ) { - file_put_contents( "$dir/$bin", "#!/bin/sh\necho 'fake $bin: unavailable' >&2\nexit 1\n" ); + // Each call is recorded, so a scenario can prove the shims were never run. + file_put_contents( "$dir/$bin", "#!/bin/sh\necho \"$bin \$*\" >> '$dir/calls'\necho 'fake $bin: unavailable' >&2\nexit 1\n" ); chmod( "$dir/$bin", 0755 ); } $this->variables['{FAILING_DOCKER_DIR}'] = $dir; @@ -799,9 +1041,10 @@ public function theFileShouldContainExactly( $path, PyStringNode $expected ) { if ( ! is_file( $path ) ) { throw new Exception( "$path does not exist." ); } - $actual = file_get_contents( $path ); - if ( rtrim( $expected->getRaw(), "\n" ) !== rtrim( $actual, "\n" ) ) { - throw new Exception( "Content of $path differs.\nExpected:\n" . $expected->getRaw() . "\nActual:\n" . $actual ); + $actual = file_get_contents( $path ); + $expected = $this->replaceVariables( $expected->getRaw() ); + if ( rtrim( $expected, "\n" ) !== rtrim( $actual, "\n" ) ) { + throw new Exception( "Content of $path differs.\nExpected:\n" . $expected . "\nActual:\n" . $actual ); } } @@ -809,8 +1052,11 @@ public function theFileShouldContainExactly( $path, PyStringNode $expected ) { * @Then the file :path should contain: */ public function theFileShouldContain( $path, PyStringNode $expected ) { - $path = $this->replaceVariables( $path ); - $actual = is_file( $path ) ? file_get_contents( $path ) : ''; + $path = $this->replaceVariables( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "$path does not exist." ); + } + $actual = file_get_contents( $path ); if ( false === strpos( $actual, $this->replaceVariables( $expected->getRaw() ) ) ) { throw new Exception( "$path does not contain:\n" . $expected->getRaw() . "\nActual:\n" . $actual ); } @@ -820,8 +1066,11 @@ public function theFileShouldContain( $path, PyStringNode $expected ) { * @Then the file :path should not contain: */ public function theFileShouldNotContain( $path, PyStringNode $unexpected ) { - $path = $this->replaceVariables( $path ); - $actual = is_file( $path ) ? file_get_contents( $path ) : ''; + $path = $this->replaceVariables( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "$path does not exist." ); + } + $actual = file_get_contents( $path ); if ( false !== strpos( $actual, $this->replaceVariables( $unexpected->getRaw() ) ) ) { throw new Exception( "$path unexpectedly contains:\n" . $unexpected->getRaw() . "\nActual:\n" . $actual ); } @@ -846,9 +1095,15 @@ public function theTextAppendedToTheFileShouldNotContain( $path, $text ) { throw new Exception( "Size of $path was not remembered earlier in the scenario." ); } clearstatcache(); - $content = is_file( $path ) ? file_get_contents( $path ) : ''; + if ( ! is_file( $path ) ) { + throw new Exception( "$path does not exist, so nothing can be said about what was logged." ); + } + $content = file_get_contents( $path ); // A rotated (shrunk) file is checked as a whole. $appended = strlen( $content ) >= $this->rememberedSizes[ $path ] ? substr( $content, $this->rememberedSizes[ $path ] ) : $content; + if ( '' === $appended ) { + throw new Exception( "Nothing was appended to $path, so the command didn't log there and the check would be vacuous." ); + } $lines = array_filter( explode( "\n", $appended ), function ( $line ) use ( $text ) { @@ -865,6 +1120,7 @@ function ( $line ) use ( $text ) { */ public function theYamlFileShouldHaveKeyWithValue( $path, $key, $value ) { $path = $this->replaceVariables( $path ); + $value = $this->replaceVariables( $value ); $config = self::loadYamlFile( $path ); if ( ! array_key_exists( $key, $config ) ) { throw new Exception( "Key '$key' not found in $path. Keys: " . implode( ', ', array_keys( $config ) ) ); From 2f36ea3454663de3bb304b95e4acae56e0e81d91 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 18:15:06 +0530 Subject: [PATCH 11/25] test(config-command): correct quiet scenarios and extend coverage quiet never takes effect in core, so the quiet-in-config scenario always failed and the CFG-5 and quiet-false CFG-4 known-bug scenarios always passed. They now assert today's behaviour and guard against the latent problem. Strengthen the help, docker-shim and empty-config checks. Add scenarios for keys that need YAML quoting, CFG-8 via list --format=text and CFG-10 (a newline in a key). Update COVERAGE.md. --- features/COVERAGE.md | 50 ++++++++++++++++++++--------------- features/config-get.feature | 11 ++++---- features/config-list.feature | 40 +++++++++++++++++++++------- features/config-path.feature | 24 ++++++++--------- features/config-set.feature | 44 ++++++++++++++++++++++++++---- features/config-unset.feature | 6 ++--- features/config.feature | 20 ++++++++------ 7 files changed, 131 insertions(+), 64 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 0927a31..3161fd6 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,6 +1,6 @@ # config-command Behat coverage -Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in `config-global.feature` points `EE_CONFIG_PATH` at a throwaway file under `sys_get_temp_dir()`, so `/opt/easyengine/config/config.yml` is never written. The context snapshots the real config before each scenario, restores it byte-for-byte (content and mode) afterwards, and fails any scenario not tagged `@destructive` that changed it. No sites, containers, `/etc/hosts` entries, LE certificates or remote backups are involved; the config commands don't even need Docker. +Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in `config-global.feature` points `EE_CONFIG_PATH` at a throwaway file under `sys_get_temp_dir()`, so `/opt/easyengine/config/config.yml` is never written. Before each scenario the context captures the real config (bytes, mode, owner, symlink target) and also writes it to `/var/tmp/bh-config-command-real-config.backup` (0600). Afterwards it restores the file atomically if anything changed, verifies the result, deletes the backup, and fails any scenario not tagged `@destructive` that changed it (the message names the changed keys only, never values). A shutdown function restores it on Ctrl-C (Behat exits without AfterScenario), SIGTERM/SIGHUP and PHP fatals; after a `kill -9` the next run's `@BeforeSuite` restores it from the backup when it carries this suite's `bh-config-command-key`, and otherwise stops and asks for a manual check. A lock file prevents two runs from overlapping. The context also pins `PAGER=cat` and, unless `EE_PHP_ARGS` is set, PHP's error settings (notices to STDERR, no deprecations), so results don't depend on the host php.ini. No sites, containers, `/etc/hosts` entries, LE certificates or remote backups are involved; the config commands don't even need Docker. ## Inventory @@ -9,19 +9,21 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `ee config` (no subcommand): usage of all subcommands, exit 0 | core `CompositeCommand::show_usage()` | config.feature: "Running ee config without a subcommand…" | | `ee help config`, `ee help config ` (synopsis, list formats) | docblocks | config.feature: "ee help config lists…", outline "ee help config …", "…documents every supported format" | | Unknown subcommand (with and without suggestion) | core `Runner::find_command_to_run()` | config.feature: two "unknown config subcommand" scenarios | -| No Docker requirement check for `config` | core `Runner::init_ee()` | config.feature: "config commands skip the Docker requirement check" (docker/docker-compose shims that always fail) | -| `get `: string, value with colon, quoted number, integer, boolean, empty value, bootstrap default | `get()` | config-get.feature: "Get an existing string value" … "Get the defaults that the bootstrap guarantees" | +| No Docker requirement check for `config` | core `Runner::init_ee()` | config.feature: "config commands skip the Docker requirement check" (docker/docker-compose shims that always fail and record calls; asserts they were never called) | +| `get `: string, value with colon, quoted number, integer, boolean, empty value, bootstrap default | `get()` | config-get.feature: "Get an existing string value" … "Get ee_installer_version" | | `get`: read-only (file byte-identical) | `get()` | config-get.feature: "Get does not modify the config file" | | `get`: missing key error, case sensitivity, dotted keys are literal | `get()` isset check | config-get.feature: "Getting a missing key fails", "Keys are case-sensitive", "Dotted keys are literal…" | | `get`: no arg (usage), two args, unknown flag | core `Subcommand::validate_args()` | config-get.feature: "Get without a key…", "Get with two keys…", "Get rejects unknown flags" | | `get` on a config path that doesn't exist yet | `get()` + bootstrap | config-get.feature: "Get works with a freshly created config file" | | `get` of a null value | `get()` isset | config-get.feature `@known-bug` CFG-1 | | `get` of a nested map | `get()` → `EE::log(array)` | config-get.feature `@known-bug` CFG-2 | -| `get --quiet` | `get()` uses `EE::log` | config-get.feature `@known-bug` CFG-5 | +| `get --quiet` still prints the value | `get()` uses `EE::log`; quiet is ignored (CFG-5) | config-get.feature: "Get still prints the value with --quiet" (guards the latent CFG-5 problem) | | `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature `@known-bug` CFG-8 | | `set `: new key (exact file content, no output), read back, overwrite keeps position, idempotent | `set()` | config-set.feature: first four scenarios | | `set`: numeric stored as quoted string, empty value, dotted key literal, replacing a nested map, multi-line value, leading/trailing spaces | `set()` + Spyc dump | config-set.feature: "Numeric values…", "An empty value…", "Dotted keys…", "Setting a key that holds a nested map…", "A multi-line value…", "A value with leading and trailing spaces…" | | `set`: 24 YAML-special values round-trip (true/false/yes/no/on/null/~/0/0123/1e3/1.50/`a: b`/`a #b`/`[1,2]`/`{a: b}`/-dash/spaces/email/URL with `#`/unicode/`%p`/`&`/`*`/`!`) | Spyc quoting | config-set.feature outline "Special value round-trips unchanged" | +| `set`/`get`/`unset` of keys that need YAML quoting (space, `: `, `#`, `@`) | Spyc key quoting | config-set.feature outline "Key that needs YAML quoting round-trips" | +| `set` of a key containing a newline | no key validation | config-set.feature `@known-bug` CFG-10 | | `set`: missing value / no args (usage, file untouched), unquoted spaces, flag-looking value, unknown flag | core validation | config-set.feature: "Set without a value…", "Set without arguments…", "An unquoted value with spaces…", "A value that looks like a flag…", "Set rejects unknown flags" | | `set` creates a missing file and directories | `set()` + bootstrap + Symfony `dumpFile()` | config-set.feature: "Set creates a missing config file…" | | `set` keeps the file mode | Symfony `dumpFile()` | config-set.feature: "Set keeps the file mode" | @@ -44,38 +46,44 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `list --format=json --quiet` | Formatter echoes directly | config-list.feature: "Machine-readable formats still print with --quiet" | | `list --format=text` with nested map | `_list()` string concat | config-list.feature `@known-bug` CFG-2 | | `list` shows null keys that get can't read | isset vs list | config-list.feature `@known-bug` CFG-1 | -| `list` (table) with `--quiet` | `EE::line` goes through the logger | config-list.feature `@known-bug` CFG-5 | +| `list` (table/text) with `--quiet` | `EE::line`/`EE::log` go through the logger; quiet is ignored (CFG-5) | config-list.feature: "Human-readable formats still print with --quiet" | +| `list --format=text` copies values into ee.log | `EE::log` → file logger | config-list.feature `@known-bug` CFG-8 | | Bootstrap: missing file/dirs created with `locale`/`ee_installer_version`, empty file, missing defaults appended, file untouched when defaults exist | core `Runner::ensure_present_in_config()` | config-path.feature: first four scenarios | | `EE_CONFIG_PATH` switching per invocation | all subcommands | config-path.feature: "EE_CONFIG_PATH switches files per invocation" | -| Config keys that are global runtime parameters: hand-written `disabled_commands` list, `quiet: true` | core Configurator/Runner | config-path.feature: "A hand-written disabled_commands list…", "quiet in the config file silences…" | -| `set quiet false`, `set disabled_commands …` | set writes strings | config-path.feature `@known-bug` CFG-4 (2 scenarios) | +| Config keys that are global runtime parameters: hand-written `disabled_commands` list, `quiet` | core Configurator/Runner | config-path.feature: "A hand-written disabled_commands list…", "quiet in the config file does not hide…", "Setting quiet to false stores a string…" | +| `set disabled_commands …` | set writes strings | config-path.feature `@known-bug` CFG-4 | | Default path (no `EE_CONFIG_PATH`, empty `EE_CONFIG_PATH`): list, set/get/unset, mode kept | path fallback | config-global.feature (`@destructive`, 4 scenarios, restored by the context) | | Config not readable/writable errors | core `ensure_present_in_config()` | not covered: the suite runs as root, which bypasses file permissions; core code, not this package | | `~/easyengine` config root on macOS | core `init-ee.php` | not covered: Linux-only CI | +| Global runtime flags accepted by config subcommands (`--le-mail=…`, `--debug`), project `ee.yml`/`ee.local.yml` merged from the working directory | core Configurator | not covered: core behaviour, not specific to this package | ## Counts -7 features, 127 scenarios (outline examples expanded): `@known-bug` 20, `@destructive` 4, `@le` 0, `@remote` 0. 103 scenarios run with neither tag. +7 features, 134 scenarios (outline examples expanded): `@known-bug` 19, `@destructive` 4, `@le` 0, `@remote` 0. 111 scenarios run with neither tag. -Coverage estimate: about 95% of the package's reachable behaviour (every subcommand, argument, format and error path in `Config_Command.php`, plus the core bootstrap/validation behaviour they depend on). The only uncovered package branch is `list`'s "No config values found!", which the bootstrap makes unreachable. +Coverage estimate: about 93% of the package's reachable behaviour (every subcommand, argument, format and error path in `Config_Command.php`, plus the core bootstrap/validation behaviour they depend on). The only uncovered package branch is `list`'s "No config values found!", which the bootstrap makes unreachable; the gaps are the core behaviours listed as not covered above. The mode check in `config-global.feature` can't catch a mode regression when the real file is 0644 (the default for a new file); the temp-file mode scenarios (0600, 0640) do. -Estimated runtime: about 215 `bin/ee` invocations with no Docker work, roughly 1.5 to 3 minutes. +Estimated runtime: about 235 `bin/ee` invocations with no Docker work, roughly 2 to 3 minutes. -## Known bugs exposed (proposed IDs, not yet in issues.md) +## Known bugs exposed -- CFG-1 (Low): `get` and `unset` use `isset()`, so a key whose value is null (`k: ~`, `k: null`) is "not set" although `list` shows it, and it can't be removed with `unset`. Fix: `array_key_exists()`. -- CFG-2 (Low): non-scalar values: `get` prints `Array` (PHP "Array to string conversion"), `list --format=text` prints `key: Array`. -- CFG-3 (Medium): `set`/`unset` rewrite the whole file through Spyc: comments are dropped, and hand-written unquoted `y`/`yes`/`on`/`off`/`no` in unrelated keys become `true`/`false`. The bootstrap writer also adds `---`, which `set` then removes. -- CFG-4 (Medium): `set` always writes strings. `ee config set disabled_commands ""` makes every later ee command fatal on PHP 8 (TypeError from `in_array()` in `Runner::is_command_disabled()`), including the `ee config unset` needed to repair it; on 7.4 it's a hidden warning and nothing is disabled. `ee config set quiet false` enables quiet mode because `'false'` is truthy. -- CFG-5 (Low): `get` prints through `EE::log`, and `list` table/text through the logger, so `--quiet` (or `quiet` in the config) hides exactly the output that was asked for; json/csv/yaml still print. -- CFG-6 (Low): the values `"quoted"` (loses its quotes) and `|` (becomes empty) don't round-trip through `set` then `get` (Spyc dumper). -- CFG-7 (Low): `set`/`unset` replace a symlinked config file with a regular file (Symfony `dumpFile()` renames a temp file over the path), while the bootstrap writer writes through the link. -- CFG-8 (Medium, security): secrets reach `/opt/easyengine/logs/ee.log`: `get` logs the value (`EE::log` also writes to the file logger at INFO), and core logs the full argv at DEBUG, so `ee config set cloudflare-api-key ` persists the key. -- CFG-9 (Info): `ee help config get` says "Set a config value"; the group description says "Manges"; the README documents only `set`/`get`. +All are in `/root/workspace/issues.md` ("config-command" section), each reproduced on PHP 7.4 and 8.5: + +- CFG-1 (Low): `get`/`unset` use `isset()`, so null-valued keys can't be read or removed. +- CFG-2 (Low): nested values print `Array` in `get` and `list --format=text`. +- CFG-3 (Medium): `set`/`unset` rewrite the whole file (comments dropped, `y`/`off` become `true`/`false`). +- CFG-4 (Medium): `set` stores strings; `set disabled_commands X` makes every ee command fatal on PHP 8, including the `unset` needed to fix it. +- CFG-5 (Low, core): `--quiet` and `quiet:` never take effect (the logger is set up before the config is read). No failing scenario: the suspected "quiet hides get/list output" doesn't happen today; the quiet scenarios guard against it once quiet is fixed. +- CFG-6 (Low): `"quoted"` and `|` values change after `set` then `get`. +- CFG-7 (Low): `set`/`unset` replace a symlinked config with a regular file. +- CFG-8 (Medium, security): secrets reach ee.log (`get` and `list --format=text` log values; core logs argv, so `set cloudflare-api-key ` persists the key). +- CFG-9 (Info): help text bugs ("Set a config value" on get, "Manges", incomplete README; the README part has no scenario). +- CFG-10 (Low): a key containing a newline corrupts the config so every ee command dies. ## Notes +- `@known-bug` scenarios name their ID in a comment above the tag. - Asserted as current behaviour, not tagged as bugs: `set`/`unset` print nothing on success (other ee commands print `Success:`); unsetting `locale`/`ee_installer_version` is undone by the next invocation. Seen in the code but not tested: `ee config set debug true` stores the string `'true'`, which the logger treats as a debug group name, so it doesn't enable debug output. - The CSV scenarios may also expose PHP 8.4+'s `fputcsv()` `$escape` deprecation if `display_errors` is on (it's off on this host's CLI). -- New shared steps are in the "Shared steps" section of `bootstrap/FeatureContext.php`. The inherited "I run" step now expands `{…}` placeholders, runs the command without copying its output into ee.log, and refuses `sudo` while a temporary config is active, because sudo drops `EE_CONFIG_PATH`. +- New shared steps are in the "Shared steps" section of `bootstrap/FeatureContext.php`. The inherited "I run" step now expands `{…}` placeholders (an undefined placeholder is an error, never a literal path), runs the command without copying its output into ee.log, and refuses `sudo`/`su`/`env -…` while a temporary config is active, because they can drop `EE_CONFIG_PATH`. The ee.log steps fail if nothing was appended, so they can't pass vacuously. - The site-command pattern of deleting every site after each feature is replaced by deleting only `bh-config-command-*` sites, and only when such a site directory exists, so this suite never calls `ee site` at all in practice. diff --git a/features/config-get.feature b/features/config-get.feature index eb7ad4e..c1c62f0 100644 --- a/features/config-get.feature +++ b/features/config-get.feature @@ -65,7 +65,7 @@ Feature: ee config get And STDOUT should be empty And STDERR should be empty - Scenario: Get the defaults that the bootstrap guarantees + Scenario: Get ee_installer_version When I run 'bin/ee config get ee_installer_version' Then the exit code should be 0 And STDOUT should be: @@ -154,14 +154,14 @@ Feature: ee config get """ And the file '{CONFIG_PATH}' should exist - # CFG-1 (proposed): get uses isset(), so a key whose value is null is reported as not set. + # CFG-1: get uses isset(), so a key whose value is null is reported as not set. @known-bug Scenario: Get a key whose value is null When I run 'bin/ee config get nullkey' Then the exit code should be 0 And STDERR should be empty - # CFG-2 (proposed): non-scalar values are echoed as "Array" (Array to string conversion). + # CFG-2: non-scalar values are echoed as "Array" (Array to string conversion). @known-bug Scenario: Get a nested value prints its contents When I run 'bin/ee config get parent' @@ -175,8 +175,7 @@ Feature: ee config get child """ - # CFG-5 (proposed): get prints through EE::log, so --quiet hides the value it was asked for. - @known-bug + # --quiet is ignored today (CFG-5). get prints through EE::log, so fixing quiet must not make it swallow the value. Scenario: Get still prints the value with --quiet When I run 'bin/ee config get le-mail --quiet' Then the exit code should be 0 @@ -185,7 +184,7 @@ Feature: ee config get admin@bh-config-command.test """ - # CFG-8 (proposed): EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. + # CFG-8: EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. @known-bug Scenario: Get does not copy the value into ee.log Given a temporary ee config file with: diff --git a/features/config-list.feature b/features/config-list.feature index 21da42b..28957a4 100644 --- a/features/config-list.feature +++ b/features/config-list.feature @@ -123,10 +123,7 @@ Feature: ee config list {"key": "ee_installer_version", "value": "stable"} ] """ - And STDOUT should not contain: - """ - No config values found! - """ + And STDERR should be empty Scenario: List shows booleans, integers, empty strings and nested maps with their JSON types Given a temporary ee config file with: @@ -232,7 +229,7 @@ Feature: ee config list Then the exit code should be 0 And STDOUT should be JSON listing the entries of the YAML file '{CONFIG_PATH}' - # CFG-2 (proposed): --format=text concatenates values, so nested maps print as "Array". + # CFG-2: --format=text concatenates values, so nested maps print as "Array". @known-bug Scenario: --format=text prints nested values readably Given a temporary ee config file with: @@ -249,7 +246,7 @@ Feature: ee config list parent: Array """ - # CFG-1 (proposed): null values are listed although get/unset claim the key is not set. + # CFG-1: null values are listed although get/unset claim the key is not set. @known-bug Scenario: Keys listed by list can be read with get Given a temporary ee config file with: @@ -266,12 +263,37 @@ Feature: ee config list When I run 'bin/ee config get bh-null' Then the exit code should be 0 - # CFG-5 (proposed): table and text output go through the logger, so --quiet swallows them while json/csv/yaml still print. - @known-bug + # --quiet is ignored today (CFG-5). Table and text output go through the logger, so fixing quiet must not swallow them. Scenario: Human-readable formats still print with --quiet When I run 'bin/ee config list --quiet' Then the exit code should be 0 + And STDOUT should be a table with rows: + | key | value | + | locale | en_US | + | ee_installer_version | stable | + | le-mail | admin@bh-config-command.test | + | no-of-backups | 3 | + When I run 'bin/ee config list --format=text --quiet' + Then the exit code should be 0 + And STDOUT should contain: + """ + le-mail: admin@bh-config-command.test + """ + + # CFG-8: --format=text prints through EE::log, which also writes every value (secrets included) to ee.log. + @known-bug + Scenario: List --format=text does not copy values into ee.log + Given a temporary ee config file with: + """ + locale: en_US + ee_installer_version: stable + cloudflare-api-key: bh-config-command-secret-{RUN_ID} + """ + And I remember the size of the file '{EE_LOG}' + When I run 'bin/ee config list --format=text' + Then the exit code should be 0 And STDOUT should contain: """ - le-mail + cloudflare-api-key: bh-config-command-secret-{RUN_ID} """ + And the text appended to the file '{EE_LOG}' should not contain 'bh-config-command-secret-{RUN_ID}' diff --git a/features/config-path.feature b/features/config-path.feature index 33be24e..8959754 100644 --- a/features/config-path.feature +++ b/features/config-path.feature @@ -106,7 +106,9 @@ Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults en_US """ - Scenario: quiet in the config file silences config get until it is unset + # CFG-5: quiet (config key or --quiet) never takes effect, because core picks the logger before reading the config. + # If that gets fixed, get must print its value in a way quiet doesn't swallow, or this scenario fails. + Scenario: quiet in the config file does not hide the value config get prints Given a temporary ee config file with: """ locale: en_US @@ -114,20 +116,16 @@ Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults """ When I run 'bin/ee config set quiet true' Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'quiet' with value 'true' When I run 'bin/ee config get locale' Then the exit code should be 0 - And STDOUT should be empty - When I run 'bin/ee config unset quiet' - Then the exit code should be 0 - When I run 'bin/ee config get locale' - Then STDOUT should be: + And STDOUT should be: """ en_US """ - # CFG-4 (proposed): set always writes a string, and the string 'false' is truthy for the quiet runtime flag. - @known-bug - Scenario: Setting quiet to false keeps output visible + # CFG-4: set stores the string 'false' (truthy), which only stays harmless while quiet is ignored (CFG-5). + Scenario: Setting quiet to false stores a string and keeps output visible Given a temporary ee config file with: """ locale: en_US @@ -135,14 +133,16 @@ Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults """ When I run 'bin/ee config set quiet false' Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key 'quiet' with value 'false' When I run 'bin/ee config get locale' - Then STDOUT should be: + Then the exit code should be 0 + And STDOUT should be: """ en_US """ - # CFG-4 (proposed): a string disabled_commands reaches in_array() in Runner::is_command_disabled(): TypeError on PHP 8 for every ee command - # (including the `config unset` needed to undo it); on 7.4 a hidden warning and the command is not disabled. + # CFG-4: a string disabled_commands reaches in_array() in Runner::is_command_disabled(): TypeError on PHP 8 for every ee command + # (including the `config unset` needed to undo it); on 7.4 in_array() warnings on STDERR and the command is not disabled. @known-bug Scenario: Setting disabled_commands with config set disables that command without breaking ee Given a temporary ee config file with: diff --git a/features/config-set.feature b/features/config-set.feature index d9b0658..29370f2 100644 --- a/features/config-set.feature +++ b/features/config-set.feature @@ -212,7 +212,41 @@ Feature: ee config set And the YAML file '{CONFIG_PATH}' should not have key 'bh-key' And the real ee config file should be unchanged - # CFG-6 (proposed): Spyc dumps these values unquoted, so they come back changed ("dq" loses quotes, | becomes empty). + Scenario Outline: Key that needs YAML quoting round-trips + When I run 'bin/ee config set "" bh-value' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should have key '' with value 'bh-value' + When I run 'bin/ee config get ""' + Then the exit code should be 0 + And STDOUT should be: + """ + bh-value + """ + When I run 'bin/ee config unset ""' + Then the exit code should be 0 + And the YAML file '{CONFIG_PATH}' should not have key '' + + Examples: + | key | + | bh key | + | bh: key | + | #bh-key | + | bh#key | + | @bh-key | + + # CFG-10: keys aren't validated; a newline in the key writes YAML that Spyc can't parse, so every later ee command dies. + @known-bug + Scenario: A key containing a newline is rejected and the config stays readable + When I run 'bin/ee config set "$(printf "bh-key\nlocale: fr_FR")" v' + Then the exit code should be 1 + When I run 'bin/ee config get locale' + Then the exit code should be 0 + And STDOUT should be: + """ + en_US + """ + + # CFG-6: Spyc dumps these values unquoted, so they come back changed ("dq" loses quotes, | becomes empty). @known-bug Scenario Outline: Value round-trips unchanged When I run 'bin/ee config set bh-special ' @@ -228,7 +262,7 @@ Feature: ee config set | '"quoted"' | "quoted" | | '\|' | \| | - # CFG-3 (proposed): set rewrites the whole file through Spyc; comments are dropped. + # CFG-3: set rewrites the whole file through Spyc; comments are dropped. @known-bug Scenario: Set keeps comments in the config file Given a temporary ee config file with: @@ -244,7 +278,7 @@ Feature: ee config set # Managed by bh-config-command """ - # CFG-3 (proposed): unquoted YAML 1.1 booleans in other keys are rewritten as true/false. + # CFG-3: unquoted YAML 1.1 booleans in other keys are rewritten as true/false. @known-bug Scenario: Set does not change unrelated hand-written values Given a temporary ee config file with: @@ -262,7 +296,7 @@ Feature: ee config set bh-switch: off """ - # CFG-7 (proposed): Symfony dumpFile() renames a temp file over the path, replacing a symlinked config. + # CFG-7: Symfony dumpFile() renames a temp file over the path, replacing a symlinked config. @known-bug Scenario: Set writes through a symlinked config file Given the temporary ee config file is a symlink @@ -271,7 +305,7 @@ Feature: ee config set And the temporary ee config file should be a symlink And the YAML file '{CONFIG_TARGET}' should have key 'le-mail' with value 'admin@bh-config-command.test' - # CFG-8 (proposed): the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. + # CFG-8: the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. @known-bug Scenario: Set does not copy the value into ee.log Given the environment variable 'BH_SECRET' is set to 'bh-config-command-secret-{RUN_ID}' diff --git a/features/config-unset.feature b/features/config-unset.feature index f3818b7..fa8ebb1 100644 --- a/features/config-unset.feature +++ b/features/config-unset.feature @@ -132,7 +132,7 @@ Feature: ee config unset """ And the YAML file '{CONFIG_PATH}' should have key 'locale' with value 'en_US' - # CFG-1 (proposed): unset uses isset(), so a key whose value is null can never be removed. + # CFG-1: unset uses isset(), so a key whose value is null can never be removed. @known-bug Scenario: Unset a key whose value is null Given a temporary ee config file with: @@ -149,7 +149,7 @@ Feature: ee config unset Then the exit code should be 0 And the YAML file '{CONFIG_PATH}' should not have key 'bh-null-word' - # CFG-3 (proposed): unset rewrites the whole file through Spyc; comments are dropped. + # CFG-3: unset rewrites the whole file through Spyc; comments are dropped. @known-bug Scenario: Unset keeps comments in the config file Given a temporary ee config file with: @@ -167,7 +167,7 @@ Feature: ee config unset # Cloudflare token for DNS-01 """ - # CFG-7 (proposed): the symlink is replaced by a regular file. + # CFG-7: the symlink is replaced by a regular file. @known-bug Scenario: Unset writes through a symlinked config file Given the temporary ee config file is a symlink diff --git a/features/config.feature b/features/config.feature index 9971b6f..293a877 100644 --- a/features/config.feature +++ b/features/config.feature @@ -113,25 +113,29 @@ Feature: ee config command group, help and usage """ en_US """ - And STDERR should not contain: - """ - Docker - """ + And STDERR should be empty + And the file '{FAILING_DOCKER_DIR}/calls' should not exist - # CFG-9 (proposed): `ee config get` is described as "Set a config value". + # CFG-9: `ee config get` is described as "Set a config value". @known-bug Scenario: The help text of config get describes getting a value When I run 'bin/ee help config get' - Then STDOUT should not contain: + Then the exit code should be 0 + And STDOUT should contain: + """ + ee config get + """ + And STDOUT should not contain: """ Set a config value """ - # CFG-9 (proposed): typo "Manges" in the command group description. + # CFG-9: typo "Manges" in the command group description. @known-bug Scenario: The config command group description has no typo When I run 'bin/ee help config' - Then STDOUT should contain: + Then the exit code should be 0 + And STDOUT should contain: """ Manages global EasyEngine configuration. """ From 28f5f13c883cd62100e615faf52087fa4750f2ec Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 17 Oct 2026 18:23:37 +0530 Subject: [PATCH 12/25] test(config-command): run the CFG-8 scenarios as regular ones --- features/COVERAGE.md | 8 ++++---- features/config-get.feature | 3 +-- features/config-list.feature | 3 +-- features/config-set.feature | 3 +-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 3161fd6..c17e9e2 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -18,7 +18,7 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `get` of a null value | `get()` isset | config-get.feature `@known-bug` CFG-1 | | `get` of a nested map | `get()` → `EE::log(array)` | config-get.feature `@known-bug` CFG-2 | | `get --quiet` still prints the value | `get()` uses `EE::log`; quiet is ignored (CFG-5) | config-get.feature: "Get still prints the value with --quiet" (guards the latent CFG-5 problem) | -| `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature `@known-bug` CFG-8 | +| `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature regression check, CFG-8 fixed in v4.13.2 | | `set `: new key (exact file content, no output), read back, overwrite keeps position, idempotent | `set()` | config-set.feature: first four scenarios | | `set`: numeric stored as quoted string, empty value, dotted key literal, replacing a nested map, multi-line value, leading/trailing spaces | `set()` + Spyc dump | config-set.feature: "Numeric values…", "An empty value…", "Dotted keys…", "Setting a key that holds a nested map…", "A multi-line value…", "A value with leading and trailing spaces…" | | `set`: 24 YAML-special values round-trip (true/false/yes/no/on/null/~/0/0123/1e3/1.50/`a: b`/`a #b`/`[1,2]`/`{a: b}`/-dash/spaces/email/URL with `#`/unicode/`%p`/`&`/`*`/`!`) | Spyc quoting | config-set.feature outline "Special value round-trips unchanged" | @@ -31,7 +31,7 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `set` of `"quoted"` and `\|` values | Spyc dump | config-set.feature `@known-bug` CFG-6 (outline, 2 examples) | | `set` drops comments / rewrites unrelated `y`/`off` values | whole-file Spyc rewrite | config-set.feature `@known-bug` CFG-3 (2 scenarios) | | `set` on a symlinked config | Symfony `dumpFile()` rename | config-set.feature `@known-bug` CFG-7 | -| `set` secret ends up in ee.log (argv debug line) | core `Runner::start()` | config-set.feature `@known-bug` CFG-8 | +| `set` secret ends up in ee.log (argv debug line) | core `Runner::start()` | config-set.feature regression check, CFG-8 fixed in v4.13.2 | | `unset `: removes only that key (exact content, no output), then get fails, twice (second fails), missing key (file byte-identical), case sensitivity, nested map, empty-string value | `unset()` | config-unset.feature: first seven scenarios | | `unset`: no arg, two args, unknown flag | core validation | config-unset.feature: "Unset without a key…", "Unset with two keys…", "Unset rejects unknown flags" | | `unset` keeps the file mode | Symfony `dumpFile()` | config-unset.feature: "Unset keeps the file mode" | @@ -47,7 +47,7 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `list --format=text` with nested map | `_list()` string concat | config-list.feature `@known-bug` CFG-2 | | `list` shows null keys that get can't read | isset vs list | config-list.feature `@known-bug` CFG-1 | | `list` (table/text) with `--quiet` | `EE::line`/`EE::log` go through the logger; quiet is ignored (CFG-5) | config-list.feature: "Human-readable formats still print with --quiet" | -| `list --format=text` copies values into ee.log | `EE::log` → file logger | config-list.feature `@known-bug` CFG-8 | +| `list --format=text` copies values into ee.log | `EE::log` → file logger | config-list.feature regression check, CFG-8 fixed in v4.13.2 | | Bootstrap: missing file/dirs created with `locale`/`ee_installer_version`, empty file, missing defaults appended, file untouched when defaults exist | core `Runner::ensure_present_in_config()` | config-path.feature: first four scenarios | | `EE_CONFIG_PATH` switching per invocation | all subcommands | config-path.feature: "EE_CONFIG_PATH switches files per invocation" | | Config keys that are global runtime parameters: hand-written `disabled_commands` list, `quiet` | core Configurator/Runner | config-path.feature: "A hand-written disabled_commands list…", "quiet in the config file does not hide…", "Setting quiet to false stores a string…" | @@ -76,7 +76,7 @@ All are in `/root/workspace/issues.md` ("config-command" section), each reproduc - CFG-5 (Low, core): `--quiet` and `quiet:` never take effect (the logger is set up before the config is read). No failing scenario: the suspected "quiet hides get/list output" doesn't happen today; the quiet scenarios guard against it once quiet is fixed. - CFG-6 (Low): `"quoted"` and `|` values change after `set` then `get`. - CFG-7 (Low): `set`/`unset` replace a symlinked config with a regular file. -- CFG-8 (Medium, security): secrets reach ee.log (`get` and `list --format=text` log values; core logs argv, so `set cloudflare-api-key ` persists the key). +- CFG-8 (Medium, security): secrets reach ee.log (`get` and `list --format=text` log values; core logs argv, so `set cloudflare-api-key ` persists the key). Fixed in v4.13.2 (config-command#14, easyengine#1944); the three scenarios are regular now. - CFG-9 (Info): help text bugs ("Set a config value" on get, "Manges", incomplete README; the README part has no scenario). - CFG-10 (Low): a key containing a newline corrupts the config so every ee command dies. diff --git a/features/config-get.feature b/features/config-get.feature index c1c62f0..9962d7c 100644 --- a/features/config-get.feature +++ b/features/config-get.feature @@ -184,8 +184,7 @@ Feature: ee config get admin@bh-config-command.test """ - # CFG-8: EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. - @known-bug + # CFG-8 (fixed in v4.13.2), regression check: EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. Scenario: Get does not copy the value into ee.log Given a temporary ee config file with: """ diff --git a/features/config-list.feature b/features/config-list.feature index 28957a4..e83943e 100644 --- a/features/config-list.feature +++ b/features/config-list.feature @@ -280,8 +280,7 @@ Feature: ee config list le-mail: admin@bh-config-command.test """ - # CFG-8: --format=text prints through EE::log, which also writes every value (secrets included) to ee.log. - @known-bug + # CFG-8 (fixed in v4.13.2), regression check: --format=text prints through EE::log, which also writes every value (secrets included) to ee.log. Scenario: List --format=text does not copy values into ee.log Given a temporary ee config file with: """ diff --git a/features/config-set.feature b/features/config-set.feature index 29370f2..d4512b2 100644 --- a/features/config-set.feature +++ b/features/config-set.feature @@ -305,8 +305,7 @@ Feature: ee config set And the temporary ee config file should be a symlink And the YAML file '{CONFIG_TARGET}' should have key 'le-mail' with value 'admin@bh-config-command.test' - # CFG-8: the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. - @known-bug + # CFG-8 (fixed in v4.13.2), regression check: the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. Scenario: Set does not copy the value into ee.log Given the environment variable 'BH_SECRET' is set to 'bh-config-command-secret-{RUN_ID}' And I remember the size of the file '{EE_LOG}' From 53e6736574fcbb4368f41e13fa54facb7724a775 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 18:15:35 +0530 Subject: [PATCH 13/25] ci: run the behat suite on GitHub Actions Runs the package's features/ inside a fresh core develop checkout, with this checkout installed as the package through a Composer path repository, so the PR's own code is under test. @le is excluded (no public DNS on runners); @remote backups use a runner-local rclone alias remote. @known-bug scenarios run in a separate non-gating job. --- .github/workflows/behat.yml | 167 ++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 .github/workflows/behat.yml diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 0000000..8c026b0 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,167 @@ +# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +name: Behat + +on: + pull_request: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + # Not RCLONE_*: rclone reads those variables as its own flags. + RCLONE_RELEASE: v1.75.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + # Local rclone alias written to EE's rclone-path; empty = no rclone. + EE_RCLONE_PATH: '' + COMPOSE_INTERACTIVE_NO_CLI: 1 + RCLONE_CONFIG: /etc/rclone/rclone.conf + EE_BEHAT_DESTRUCTIVE: 1 + +jobs: + behat: + name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 150 + # Known bugs are expected to fail; they're reported, not gating. + continue-on-error: ${{ matrix.lane == 'known-bug' }} + strategy: + fail-fast: false + matrix: + php: ['8.4'] + lane: [main, known-bug] + include: + - lane: main + tags: '~@known-bug' + - lane: known-bug + tags: '@known-bug' + steps: + - name: Check out the package + uses: actions/checkout@v7 + with: + path: pkg + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer + extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config -g cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Set up Composer caching + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-php${{ matrix.php }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-composer-php${{ matrix.php }}- + + - name: Prepare the host + run: | + # The runner image ships web and database servers; EE needs 80/443. + sudo systemctl stop nginx apache2 mysql 2>/dev/null || true + sudo ss -ltnp | grep -E ':(80|443|3306) ' || true + sudo curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker version --format 'docker {{.Server.Version}}' + docker-compose version + openssl version + echo "umask $(umask), under sudo $(sudo sh -c umask)" + if [ -n "$EE_RCLONE_PATH" ]; then + curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" + unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" + sudo install -m 0755 "$RUNNER_TEMP/rclone" /usr/local/bin/rclone + sudo mkdir -p "$(dirname "$RCLONE_CONFIG")" /var/tmp/ee-rclone + printf '[%s]\ntype = alias\nremote = /var/tmp/ee-rclone\n' "${EE_RCLONE_PATH%%:*}" | sudo tee "$RCLONE_CONFIG" >/dev/null + sudo -E rclone listremotes + fi + + - name: Set up EasyEngine core + run: | + git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine + cd easyengine + # All easyengine/* packages on dev-develop (as core CI does), except the one under test: that's this checkout. + php -r ' + $pkg = json_decode(file_get_contents("../pkg/composer.json"), true)["name"]; + $j = json_decode(file_get_contents("composer.json"), true); + foreach ($j["require"] as $k => $v) if (strpos($k, "easyengine/") === 0) $j["require"][$k] = "dev-develop"; + $repos = isset($j["repositories"]) ? $j["repositories"] : []; + array_unshift($repos, ["type" => "path", "url" => "../pkg", "options" => ["symlink" => false, "versions" => [$pkg => "dev-develop"]]]); + $j["repositories"] = $repos; + file_put_contents("composer.json", json_encode($j, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + file_put_contents(getenv("GITHUB_ENV"), "PKG=$pkg\n", FILE_APPEND);' + composer update --prefer-dist --no-progress --no-interaction + composer show "$(jq -r .name ../pkg/composer.json)" | grep -E '^(versions|source|dist) ' || true + rm -rf features + cp -R ../pkg/features features + # Some commands (clone, sync) shell out to `ee` from PATH. + sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee + + - name: Initialise EasyEngine + working-directory: easyengine + run: | + sudo -E bin/ee cli info + # The first non-cli command runs the initial setup (DB, config, image pulls). + sudo -E bin/ee site list || true + # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. + sudo -E bin/ee config set le-mail behat@example.com + if [ -n "$EE_RCLONE_PATH" ]; then + sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" + sudo -E bin/ee config set no-of-backups 3 + fi + + - name: Run Behat + working-directory: easyengine + shell: 'script -q -e -c "bash {0}"' + run: | + set -e + mkdir -p "$RUNNER_TEMP/behat/junit" + sudo -E vendor/bin/behat --strict --tags="${EXCLUDE_TAGS}&&${{ matrix.tags }}" \ + --format=progress --out=std \ + --format=junit --out="$RUNNER_TEMP/behat/junit" \ + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log" + + - name: Collect logs + if: always() + run: | + out="$RUNNER_TEMP/behat" + mkdir -p "$out" + sudo cp /opt/easyengine/logs/ee.log "$out/" 2>/dev/null || echo 'No ee.log.' + sudo stat -c '%a %U:%G %n' /opt/easyengine /opt/easyengine/logs > "$out/dirs.txt" 2>/dev/null || true + sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true + sudo chown -R "$USER" "$out" + # One block per failed scenario, from the junit files. + python3 - "$out" <<'PY' + import glob, sys, xml.etree.ElementTree as ET + out = sys.argv[1] + lines = [] + for f in sorted(glob.glob(out + '/junit/*.xml')): + for case in ET.parse(f).getroot().iter('testcase'): + for bad in list(case.findall('failure')) + list(case.findall('error')): + lines.append('### %s :: %s\n%s\n%s\n' % (case.get('classname'), case.get('name'), bad.get('message') or '', bad.text or '')) + open(out + '/failures.txt', 'w').write('\n'.join(lines) or 'No failures.\n') + print('%d failed scenarios' % len(lines)) + PY + head -c 20000 "$out/failures.txt" + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: behat-${{ matrix.lane }}-php${{ matrix.php }} + path: ${{ runner.temp }}/behat + retention-days: 14 From 9e79e67d86b1f391d2ae13f53bf68ecb37ad5108 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 18:19:07 +0530 Subject: [PATCH 14/25] ci: finalize the behat workflow Run PHP 7.4 and 8.5, strip control characters from the junit files before building failures.txt, and cap the Behat step at 75 minutes so a hung run still uploads its logs. Diagnostics: the host addresses and routes, and the docker-compose version after the first run (MIG-20). --- .github/workflows/behat.yml | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 8c026b0..f3bec34 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -22,7 +22,7 @@ env: RCLONE_RELEASE: v1.75.1 # No public DNS (Let's Encrypt) or real backup storage on hosted runners. EXCLUDE_TAGS: '~@le&&~@remote-real' - # Local rclone alias written to EE's rclone-path; empty = no rclone. + # Local rclone alias written to EE's rclone-path, for suites with @remote scenarios ('eetest:ee-backups'); empty = no rclone. EE_RCLONE_PATH: '' COMPOSE_INTERACTIVE_NO_CLI: 1 RCLONE_CONFIG: /etc/rclone/rclone.conf @@ -34,11 +34,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 150 # Known bugs are expected to fail; they're reported, not gating. - continue-on-error: ${{ matrix.lane == 'known-bug' }} + continue-on-error: ${{ endsWith(matrix.lane, 'known-bug') }} strategy: fail-fast: false matrix: - php: ['8.4'] + php: ['7.4', '8.5'] lane: [main, known-bug] include: - lane: main @@ -81,6 +81,9 @@ jobs: docker-compose version openssl version echo "umask $(umask), under sudo $(sudo sh -c umask)" + # Suites that request the host's public IP use the first of these; EE picks a frontend subnet outside eth0's. + echo "hostname -I: $(hostname -I)" + ip -4 route if [ -n "$EE_RCLONE_PATH" ]; then curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" @@ -118,22 +121,29 @@ jobs: # The first non-cli command runs the initial setup (DB, config, image pulls). sudo -E bin/ee site list || true # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. - sudo -E bin/ee config set le-mail behat@example.com + sudo -E bin/ee config set le-mail behat-ci@example.org if [ -n "$EE_RCLONE_PATH" ]; then sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" sudo -E bin/ee config set no-of-backups 3 fi + # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. + docker-compose version + - name: Run Behat working-directory: easyengine shell: 'script -q -e -c "bash {0}"' + # A hung step (e.g. a command stopped on the TTY) should still leave logs. + timeout-minutes: 75 run: | set -e mkdir -p "$RUNNER_TEMP/behat/junit" - sudo -E vendor/bin/behat --strict --tags="${EXCLUDE_TAGS}&&${{ matrix.tags }}" \ - --format=progress --out=std \ - --format=junit --out="$RUNNER_TEMP/behat/junit" \ - --format=pretty --out="$RUNNER_TEMP/behat/pretty.log" + base="$EXCLUDE_TAGS" + behat=(sudo -E vendor/bin/behat --strict --tags="${base}&&${{ matrix.tags }}" + --format=progress --out=std + --format=junit --out="$RUNNER_TEMP/behat/junit" + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log") + "${behat[@]}" - name: Collect logs if: always() @@ -146,11 +156,13 @@ jobs: sudo chown -R "$USER" "$out" # One block per failed scenario, from the junit files. python3 - "$out" <<'PY' - import glob, sys, xml.etree.ElementTree as ET + import glob, re, sys, xml.etree.ElementTree as ET out = sys.argv[1] lines = [] for f in sorted(glob.glob(out + '/junit/*.xml')): - for case in ET.parse(f).getroot().iter('testcase'): + # Behat copies raw output (docker logs with ANSI escapes) into the junit file; XML 1.0 forbids those bytes. + data = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', open(f, 'rb').read()) + for case in ET.fromstring(data).iter('testcase'): for bad in list(case.findall('failure')) + list(case.findall('error')): lines.append('### %s :: %s\n%s\n%s\n' % (case.get('classname'), case.get('name'), bad.get('message') or '', bad.text or '')) open(out + '/failures.txt', 'w').write('\n'.join(lines) or 'No failures.\n') @@ -158,6 +170,7 @@ jobs: PY head -c 20000 "$out/failures.txt" + - name: Upload results if: always() uses: actions/upload-artifact@v7 From f7191ca1a136f98874c9ab36bcd76028930db44a Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 18:22:14 +0530 Subject: [PATCH 15/25] ci: re-fetch docker-compose when the first run leaves a broken binary EE's first run downloads docker-compose 2.27.0 without --fail (MIG-10); on a GitHub error it moves the HTML error page over the binary, as on an admin-tools-command runner. Fetch 2.27.0 again with --fail and retries in that case. --- .github/workflows/behat.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index f3bec34..a7ae6f5 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -127,7 +127,12 @@ jobs: sudo -E bin/ee config set no-of-backups 3 fi # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. - docker-compose version + # Its download has no --fail (MIG-10): a GitHub error page can replace the binary, so fetch 2.27.0 again then. + if ! docker-compose version; then + sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose version + fi - name: Run Behat From eb3759b4594f1c50f610d24dc26b3a3191faad28 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 18:27:51 +0530 Subject: [PATCH 16/25] test(behat): leave the known-bug scenarios out until their fixes land The 16 scenario rows written for open bugs assert the fixed behaviour and fail today, so they stay out of the suite until their fixes land, and COVERAGE.md lists those bugs as not covered yet. Every scenario that ships now gates, and the non-gating known-bug CI lane goes with them. --- .github/workflows/behat.yml | 6 +-- features/COVERAGE.md | 31 +++++++-------- features/config-get.feature | 21 ----------- features/config-list.feature | 34 ----------------- features/config-path.feature | 27 ------------- features/config-set.feature | 71 ----------------------------------- features/config-unset.feature | 44 ---------------------- features/config.feature | 24 ------------ 8 files changed, 17 insertions(+), 241 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index a7ae6f5..6ade07b 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -33,18 +33,14 @@ jobs: name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) runs-on: ubuntu-latest timeout-minutes: 150 - # Known bugs are expected to fail; they're reported, not gating. - continue-on-error: ${{ endsWith(matrix.lane, 'known-bug') }} strategy: fail-fast: false matrix: php: ['7.4', '8.5'] - lane: [main, known-bug] + lane: [main] include: - lane: main tags: '~@known-bug' - - lane: known-bug - tags: '@known-bug' steps: - name: Check out the package uses: actions/checkout@v7 diff --git a/features/COVERAGE.md b/features/COVERAGE.md index c17e9e2..c3c83f4 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -15,43 +15,43 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `get`: missing key error, case sensitivity, dotted keys are literal | `get()` isset check | config-get.feature: "Getting a missing key fails", "Keys are case-sensitive", "Dotted keys are literal…" | | `get`: no arg (usage), two args, unknown flag | core `Subcommand::validate_args()` | config-get.feature: "Get without a key…", "Get with two keys…", "Get rejects unknown flags" | | `get` on a config path that doesn't exist yet | `get()` + bootstrap | config-get.feature: "Get works with a freshly created config file" | -| `get` of a null value | `get()` isset | config-get.feature `@known-bug` CFG-1 | -| `get` of a nested map | `get()` → `EE::log(array)` | config-get.feature `@known-bug` CFG-2 | +| `get` of a null value | `get()` isset | not covered yet: CFG-1 (its scenario comes back with the fix) | +| `get` of a nested map | `get()` → `EE::log(array)` | not covered yet: CFG-2 (its scenario comes back with the fix) | | `get --quiet` still prints the value | `get()` uses `EE::log`; quiet is ignored (CFG-5) | config-get.feature: "Get still prints the value with --quiet" (guards the latent CFG-5 problem) | | `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature regression check, CFG-8 fixed in v4.13.2 | | `set `: new key (exact file content, no output), read back, overwrite keeps position, idempotent | `set()` | config-set.feature: first four scenarios | | `set`: numeric stored as quoted string, empty value, dotted key literal, replacing a nested map, multi-line value, leading/trailing spaces | `set()` + Spyc dump | config-set.feature: "Numeric values…", "An empty value…", "Dotted keys…", "Setting a key that holds a nested map…", "A multi-line value…", "A value with leading and trailing spaces…" | | `set`: 24 YAML-special values round-trip (true/false/yes/no/on/null/~/0/0123/1e3/1.50/`a: b`/`a #b`/`[1,2]`/`{a: b}`/-dash/spaces/email/URL with `#`/unicode/`%p`/`&`/`*`/`!`) | Spyc quoting | config-set.feature outline "Special value round-trips unchanged" | | `set`/`get`/`unset` of keys that need YAML quoting (space, `: `, `#`, `@`) | Spyc key quoting | config-set.feature outline "Key that needs YAML quoting round-trips" | -| `set` of a key containing a newline | no key validation | config-set.feature `@known-bug` CFG-10 | +| `set` of a key containing a newline | no key validation | not covered yet: CFG-10 (its scenario comes back with the fix) | | `set`: missing value / no args (usage, file untouched), unquoted spaces, flag-looking value, unknown flag | core validation | config-set.feature: "Set without a value…", "Set without arguments…", "An unquoted value with spaces…", "A value that looks like a flag…", "Set rejects unknown flags" | | `set` creates a missing file and directories | `set()` + bootstrap + Symfony `dumpFile()` | config-set.feature: "Set creates a missing config file…" | | `set` keeps the file mode | Symfony `dumpFile()` | config-set.feature: "Set keeps the file mode" | | `EE_CONFIG_PATH` given inline for one invocation | `set()` path resolution | config-set.feature: "Set with EE_CONFIG_PATH on the command line…" | -| `set` of `"quoted"` and `\|` values | Spyc dump | config-set.feature `@known-bug` CFG-6 (outline, 2 examples) | -| `set` drops comments / rewrites unrelated `y`/`off` values | whole-file Spyc rewrite | config-set.feature `@known-bug` CFG-3 (2 scenarios) | -| `set` on a symlinked config | Symfony `dumpFile()` rename | config-set.feature `@known-bug` CFG-7 | +| `set` of `"quoted"` and `\|` values | Spyc dump | not covered yet: CFG-6 (its outline, 2 examples, comes back with the fix) | +| `set` drops comments / rewrites unrelated `y`/`off` values | whole-file Spyc rewrite | not covered yet: CFG-3 (its 2 scenarios come back with the fix) | +| `set` on a symlinked config | Symfony `dumpFile()` rename | not covered yet: CFG-7 (its scenario comes back with the fix) | | `set` secret ends up in ee.log (argv debug line) | core `Runner::start()` | config-set.feature regression check, CFG-8 fixed in v4.13.2 | | `unset `: removes only that key (exact content, no output), then get fails, twice (second fails), missing key (file byte-identical), case sensitivity, nested map, empty-string value | `unset()` | config-unset.feature: first seven scenarios | | `unset`: no arg, two args, unknown flag | core validation | config-unset.feature: "Unset without a key…", "Unset with two keys…", "Unset rejects unknown flags" | | `unset` keeps the file mode | Symfony `dumpFile()` | config-unset.feature: "Unset keeps the file mode" | | `unset locale` is undone by the next run | core `ensure_present_in_config()` | config-unset.feature: "Unsetting a bootstrap default is undone…" (documents current behaviour) | -| `unset` of null values | `unset()` isset | config-unset.feature `@known-bug` CFG-1 | -| `unset` drops comments / replaces a symlink | whole-file rewrite | config-unset.feature `@known-bug` CFG-3, CFG-7 | +| `unset` of null values | `unset()` isset | not covered yet: CFG-1 (its scenario comes back with the fix) | +| `unset` drops comments / replaces a symlink | whole-file rewrite | not covered yet: CFG-3, CFG-7 (their scenarios come back with the fixes) | | `list` default format and `--format=table` | `_list()` + Formatter | config-list.feature: "The default format…", "--format=table…" | | `list --format=json|csv|yaml|text` | `_list()` | config-list.feature: one scenario per format | | `list`: read-only, reflects set/unset, value types in JSON, nested maps JSON-encoded in table/csv | `_list()` | config-list.feature: "List does not modify…", "List reflects set and unset", "List shows booleans…", "Nested maps are JSON-encoded…" | | `list` on an empty file ("No config values found!") | `_list()` empty branch | config-list.feature: "An empty config file still lists the bootstrap defaults" (the error branch is unreachable, see below) | | `list`: removed `count` format (db58c81), unknown format, misspelt flag suggestion, `--fields`, positional arg | synopsis validation | config-list.feature: five scenarios | | `list --format=json --quiet` | Formatter echoes directly | config-list.feature: "Machine-readable formats still print with --quiet" | -| `list --format=text` with nested map | `_list()` string concat | config-list.feature `@known-bug` CFG-2 | -| `list` shows null keys that get can't read | isset vs list | config-list.feature `@known-bug` CFG-1 | +| `list --format=text` with nested map | `_list()` string concat | not covered yet: CFG-2 (its scenario comes back with the fix) | +| `list` shows null keys that get can't read | isset vs list | not covered yet: CFG-1 (its scenario comes back with the fix) | | `list` (table/text) with `--quiet` | `EE::line`/`EE::log` go through the logger; quiet is ignored (CFG-5) | config-list.feature: "Human-readable formats still print with --quiet" | | `list --format=text` copies values into ee.log | `EE::log` → file logger | config-list.feature regression check, CFG-8 fixed in v4.13.2 | | Bootstrap: missing file/dirs created with `locale`/`ee_installer_version`, empty file, missing defaults appended, file untouched when defaults exist | core `Runner::ensure_present_in_config()` | config-path.feature: first four scenarios | | `EE_CONFIG_PATH` switching per invocation | all subcommands | config-path.feature: "EE_CONFIG_PATH switches files per invocation" | | Config keys that are global runtime parameters: hand-written `disabled_commands` list, `quiet` | core Configurator/Runner | config-path.feature: "A hand-written disabled_commands list…", "quiet in the config file does not hide…", "Setting quiet to false stores a string…" | -| `set disabled_commands …` | set writes strings | config-path.feature `@known-bug` CFG-4 | +| `set disabled_commands …` | set writes strings | not covered yet: CFG-4 (its scenario comes back with the fix) | | Default path (no `EE_CONFIG_PATH`, empty `EE_CONFIG_PATH`): list, set/get/unset, mode kept | path fallback | config-global.feature (`@destructive`, 4 scenarios, restored by the context) | | Config not readable/writable errors | core `ensure_present_in_config()` | not covered: the suite runs as root, which bypasses file permissions; core code, not this package | | `~/easyengine` config root on macOS | core `init-ee.php` | not covered: Linux-only CI | @@ -59,13 +59,15 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in ## Counts -7 features, 134 scenarios (outline examples expanded): `@known-bug` 19, `@destructive` 4, `@le` 0, `@remote` 0. 111 scenarios run with neither tag. +7 features, 118 scenarios (outline examples expanded): `@destructive` 4, `@le` 0, `@remote` 0. 114 scenarios run without `@destructive`. + +No `@known-bug` scenarios ship here: the 16 scenario rows written for open bugs (CFG-1, CFG-2, CFG-3, CFG-4, CFG-6, CFG-7, CFG-9, CFG-10) are held back until their fixes land and then return as regular scenarios. Coverage estimate: about 93% of the package's reachable behaviour (every subcommand, argument, format and error path in `Config_Command.php`, plus the core bootstrap/validation behaviour they depend on). The only uncovered package branch is `list`'s "No config values found!", which the bootstrap makes unreachable; the gaps are the core behaviours listed as not covered above. The mode check in `config-global.feature` can't catch a mode regression when the real file is 0644 (the default for a new file); the temp-file mode scenarios (0600, 0640) do. Estimated runtime: about 235 `bin/ee` invocations with no Docker work, roughly 2 to 3 minutes. -## Known bugs exposed +## Known bugs (scenarios held back until fixed) All are in `/root/workspace/issues.md` ("config-command" section), each reproduced on PHP 7.4 and 8.5: @@ -73,7 +75,7 @@ All are in `/root/workspace/issues.md` ("config-command" section), each reproduc - CFG-2 (Low): nested values print `Array` in `get` and `list --format=text`. - CFG-3 (Medium): `set`/`unset` rewrite the whole file (comments dropped, `y`/`off` become `true`/`false`). - CFG-4 (Medium): `set` stores strings; `set disabled_commands X` makes every ee command fatal on PHP 8, including the `unset` needed to fix it. -- CFG-5 (Low, core): `--quiet` and `quiet:` never take effect (the logger is set up before the config is read). No failing scenario: the suspected "quiet hides get/list output" doesn't happen today; the quiet scenarios guard against it once quiet is fixed. +- CFG-5 (Low, core): `--quiet` and `quiet:` never take effect (the logger is set up before the config is read). No held-back scenario: the suspected "quiet hides get/list output" doesn't happen today; the quiet scenarios guard against it once quiet is fixed. - CFG-6 (Low): `"quoted"` and `|` values change after `set` then `get`. - CFG-7 (Low): `set`/`unset` replace a symlinked config with a regular file. - CFG-8 (Medium, security): secrets reach ee.log (`get` and `list --format=text` log values; core logs argv, so `set cloudflare-api-key ` persists the key). Fixed in v4.13.2 (config-command#14, easyengine#1944); the three scenarios are regular now. @@ -82,7 +84,6 @@ All are in `/root/workspace/issues.md` ("config-command" section), each reproduc ## Notes -- `@known-bug` scenarios name their ID in a comment above the tag. - Asserted as current behaviour, not tagged as bugs: `set`/`unset` print nothing on success (other ee commands print `Success:`); unsetting `locale`/`ee_installer_version` is undone by the next invocation. Seen in the code but not tested: `ee config set debug true` stores the string `'true'`, which the logger treats as a debug group name, so it doesn't enable debug output. - The CSV scenarios may also expose PHP 8.4+'s `fputcsv()` `$escape` deprecation if `display_errors` is on (it's off on this host's CLI). - New shared steps are in the "Shared steps" section of `bootstrap/FeatureContext.php`. The inherited "I run" step now expands `{…}` placeholders (an undefined placeholder is an error, never a literal path), runs the command without copying its output into ee.log, and refuses `sudo`/`su`/`env -…` while a temporary config is active, because they can drop `EE_CONFIG_PATH`. The ee.log steps fail if nothing was appended, so they can't pass vacuously. diff --git a/features/config-get.feature b/features/config-get.feature index 9962d7c..6781b8d 100644 --- a/features/config-get.feature +++ b/features/config-get.feature @@ -154,27 +154,6 @@ Feature: ee config get """ And the file '{CONFIG_PATH}' should exist - # CFG-1: get uses isset(), so a key whose value is null is reported as not set. - @known-bug - Scenario: Get a key whose value is null - When I run 'bin/ee config get nullkey' - Then the exit code should be 0 - And STDERR should be empty - - # CFG-2: non-scalar values are echoed as "Array" (Array to string conversion). - @known-bug - Scenario: Get a nested value prints its contents - When I run 'bin/ee config get parent' - Then the exit code should be 0 - And STDOUT should not contain: - """ - Array - """ - And STDOUT should contain: - """ - child - """ - # --quiet is ignored today (CFG-5). get prints through EE::log, so fixing quiet must not make it swallow the value. Scenario: Get still prints the value with --quiet When I run 'bin/ee config get le-mail --quiet' diff --git a/features/config-list.feature b/features/config-list.feature index e83943e..7bd2104 100644 --- a/features/config-list.feature +++ b/features/config-list.feature @@ -229,40 +229,6 @@ Feature: ee config list Then the exit code should be 0 And STDOUT should be JSON listing the entries of the YAML file '{CONFIG_PATH}' - # CFG-2: --format=text concatenates values, so nested maps print as "Array". - @known-bug - Scenario: --format=text prints nested values readably - Given a temporary ee config file with: - """ - locale: en_US - ee_installer_version: stable - parent: - child: x - """ - When I run 'bin/ee config list --format=text' - Then the exit code should be 0 - And STDOUT should not contain: - """ - parent: Array - """ - - # CFG-1: null values are listed although get/unset claim the key is not set. - @known-bug - Scenario: Keys listed by list can be read with get - Given a temporary ee config file with: - """ - locale: en_US - ee_installer_version: stable - bh-null: ~ - """ - When I run 'bin/ee config list --format=csv' - Then STDOUT should contain: - """ - bh-null, - """ - When I run 'bin/ee config get bh-null' - Then the exit code should be 0 - # --quiet is ignored today (CFG-5). Table and text output go through the logger, so fixing quiet must not swallow them. Scenario: Human-readable formats still print with --quiet When I run 'bin/ee config list --quiet' diff --git a/features/config-path.feature b/features/config-path.feature index 8959754..d63d80f 100644 --- a/features/config-path.feature +++ b/features/config-path.feature @@ -140,30 +140,3 @@ Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults """ en_US """ - - # CFG-4: a string disabled_commands reaches in_array() in Runner::is_command_disabled(): TypeError on PHP 8 for every ee command - # (including the `config unset` needed to undo it); on 7.4 in_array() warnings on STDERR and the command is not disabled. - @known-bug - Scenario: Setting disabled_commands with config set disables that command without breaking ee - Given a temporary ee config file with: - """ - locale: en_US - ee_installer_version: stable - """ - When I run 'bin/ee config set disabled_commands "config list"' - Then the exit code should be 0 - When I run 'bin/ee config get locale' - Then the exit code should be 0 - And STDOUT should be: - """ - en_US - """ - And STDERR should be empty - When I run 'bin/ee config list' - Then the exit code should be 1 - And STDERR should contain: - """ - The 'config list' command has been disabled from the config file. - """ - When I run 'bin/ee config unset disabled_commands' - Then the exit code should be 0 diff --git a/features/config-set.feature b/features/config-set.feature index d4512b2..24d0735 100644 --- a/features/config-set.feature +++ b/features/config-set.feature @@ -234,77 +234,6 @@ Feature: ee config set | bh#key | | @bh-key | - # CFG-10: keys aren't validated; a newline in the key writes YAML that Spyc can't parse, so every later ee command dies. - @known-bug - Scenario: A key containing a newline is rejected and the config stays readable - When I run 'bin/ee config set "$(printf "bh-key\nlocale: fr_FR")" v' - Then the exit code should be 1 - When I run 'bin/ee config get locale' - Then the exit code should be 0 - And STDOUT should be: - """ - en_US - """ - - # CFG-6: Spyc dumps these values unquoted, so they come back changed ("dq" loses quotes, | becomes empty). - @known-bug - Scenario Outline: Value round-trips unchanged - When I run 'bin/ee config set bh-special ' - Then the exit code should be 0 - When I run 'bin/ee config get bh-special' - Then STDOUT should be: - """ - - """ - - Examples: - | shell | value | - | '"quoted"' | "quoted" | - | '\|' | \| | - - # CFG-3: set rewrites the whole file through Spyc; comments are dropped. - @known-bug - Scenario: Set keeps comments in the config file - Given a temporary ee config file with: - """ - # Managed by bh-config-command - locale: en_US - ee_installer_version: stable - """ - When I run 'bin/ee config set le-mail admin@bh-config-command.test' - Then the exit code should be 0 - And the file '{CONFIG_PATH}' should contain: - """ - # Managed by bh-config-command - """ - - # CFG-3: unquoted YAML 1.1 booleans in other keys are rewritten as true/false. - @known-bug - Scenario: Set does not change unrelated hand-written values - Given a temporary ee config file with: - """ - locale: en_US - ee_installer_version: stable - bh-answer: y - bh-switch: off - """ - When I run 'bin/ee config set le-mail admin@bh-config-command.test' - Then the exit code should be 0 - And the file '{CONFIG_PATH}' should contain: - """ - bh-answer: y - bh-switch: off - """ - - # CFG-7: Symfony dumpFile() renames a temp file over the path, replacing a symlinked config. - @known-bug - Scenario: Set writes through a symlinked config file - Given the temporary ee config file is a symlink - When I run 'bin/ee config set le-mail admin@bh-config-command.test' - Then the exit code should be 0 - And the temporary ee config file should be a symlink - And the YAML file '{CONFIG_TARGET}' should have key 'le-mail' with value 'admin@bh-config-command.test' - # CFG-8 (fixed in v4.13.2), regression check: the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. Scenario: Set does not copy the value into ee.log Given the environment variable 'BH_SECRET' is set to 'bh-config-command-secret-{RUN_ID}' diff --git a/features/config-unset.feature b/features/config-unset.feature index fa8ebb1..2238c49 100644 --- a/features/config-unset.feature +++ b/features/config-unset.feature @@ -131,47 +131,3 @@ Feature: ee config unset en_US """ And the YAML file '{CONFIG_PATH}' should have key 'locale' with value 'en_US' - - # CFG-1: unset uses isset(), so a key whose value is null can never be removed. - @known-bug - Scenario: Unset a key whose value is null - Given a temporary ee config file with: - """ - locale: en_US - ee_installer_version: stable - bh-null: ~ - bh-null-word: null - """ - When I run 'bin/ee config unset bh-null' - Then the exit code should be 0 - And the YAML file '{CONFIG_PATH}' should not have key 'bh-null' - When I run 'bin/ee config unset bh-null-word' - Then the exit code should be 0 - And the YAML file '{CONFIG_PATH}' should not have key 'bh-null-word' - - # CFG-3: unset rewrites the whole file through Spyc; comments are dropped. - @known-bug - Scenario: Unset keeps comments in the config file - Given a temporary ee config file with: - """ - locale: en_US - ee_installer_version: stable - # Cloudflare token for DNS-01 - cloudflare-api-key: bh-dummy-key - le-mail: admin@bh-config-command.test - """ - When I run 'bin/ee config unset cloudflare-api-key' - Then the exit code should be 0 - And the file '{CONFIG_PATH}' should contain: - """ - # Cloudflare token for DNS-01 - """ - - # CFG-7: the symlink is replaced by a regular file. - @known-bug - Scenario: Unset writes through a symlinked config file - Given the temporary ee config file is a symlink - When I run 'bin/ee config unset cloudflare-api-key' - Then the exit code should be 0 - And the temporary ee config file should be a symlink - And the YAML file '{CONFIG_TARGET}' should not have key 'cloudflare-api-key' diff --git a/features/config.feature b/features/config.feature index 293a877..713b0de 100644 --- a/features/config.feature +++ b/features/config.feature @@ -115,27 +115,3 @@ Feature: ee config command group, help and usage """ And STDERR should be empty And the file '{FAILING_DOCKER_DIR}/calls' should not exist - - # CFG-9: `ee config get` is described as "Set a config value". - @known-bug - Scenario: The help text of config get describes getting a value - When I run 'bin/ee help config get' - Then the exit code should be 0 - And STDOUT should contain: - """ - ee config get - """ - And STDOUT should not contain: - """ - Set a config value - """ - - # CFG-9: typo "Manges" in the command group description. - @known-bug - Scenario: The config command group description has no typo - When I run 'bin/ee help config' - Then the exit code should be 0 - And STDOUT should contain: - """ - Manages global EasyEngine configuration. - """ From f5165ab1338a88ab7c3b56f17da1e50eebc3721e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 01:52:54 +0530 Subject: [PATCH 17/25] test(config-command): harden the real-config snapshot and the JSON assertions - The cleanup removes ee.phar and ee-old.phar only when the "ee phar is generated" step made them. - Restoring a dangling config symlink removes a target the scenario created. - The restore temp file is private from the first byte; the backup temp file in /var/tmp is created exclusively, and a leftover backup or lock is used only when it is a regular file owned by the running user and not group- or world-writable. - SIGTERM/SIGHUP stop and reap the running command before the shutdown handler restores the config. - JSON assertions keep objects and lists apart. - The global-config scenarios require the real config to exist instead of comparing against an empty baseline. --- features/bootstrap/FeatureContext.php | 105 +++++++++++++++++++++----- features/config-global.feature | 3 + 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 28a0cbf..a27622b 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -92,6 +92,7 @@ public function __construct() */ public function eePharIsPresent() { + self::$pharGenerated = true; // Checks if phar already exists, replaces it if(file_exists('ee-old.phar')) { // Below exec call is required as currenly `ee cli update` is ran with root @@ -423,6 +424,15 @@ public function thereShouldBeContainersWithLabel($expected_running_containers, P */ public static function cleanup(AfterFeatureScope $scope) { + // Only phars built by "ee phar is generated", never a build of the checkout's owner. + if ( self::$pharGenerated ) { + foreach ( [ 'ee.phar', 'ee-old.phar' ] as $phar ) { + if ( file_exists( $phar ) ) { + unlink( $phar ); + } + } + } + // Avoid invoking ee (and the live stack) when this suite left no sites behind. if ( ! glob( EE_SITE_ROOT . '/' . self::SITE_PREFIX . '*' ) ) { return; @@ -434,13 +444,6 @@ public static function cleanup(AfterFeatureScope $scope) exec( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes' ); } } - - if(file_exists('ee.phar')) { - unlink('ee.phar'); - } - if(file_exists('ee-old.phar')) { - unlink('ee-old.phar'); - } } /* @@ -470,6 +473,9 @@ public static function cleanup(AfterFeatureScope $scope) /** @var resource|null Lock held for the whole run, so two runs never snapshot each other's changes. */ private static $runLock = null; + /** @var bool Whether "ee phar is generated" ran, so the cleanup may remove ee.phar and ee-old.phar. */ + private static $pharGenerated = false; + /** * On-disk copy of the real config, written before every scenario and removed once the file is verified intact. * It survives kill -9, so an interrupted run can be recovered (see recoverInterruptedRun()). @@ -539,14 +545,18 @@ private static function restoreRealConfig( array $state ) { } if ( ! $state['exists'] ) { - if ( null === $state['link'] && file_exists( $path ) && ! unlink( $path ) ) { - throw new Exception( "Unable to remove $path, which did not exist before the scenario" ); + // For a dangling symlink $dest is its target, which a scenario may have created. + if ( is_file( $dest ) && ! is_link( $dest ) && ! unlink( $dest ) ) { + throw new Exception( "Unable to remove $dest, which did not exist before the scenario" ); } } else { // Temp file + rename, so concurrent ee runs never read a half-written config. $tmp = $dest . '.bh-restore-' . getmypid(); - if ( strlen( $state['content'] ) !== file_put_contents( $tmp, $state['content'] ) - || ! chmod( $tmp, $state['mode'] ) + // Private from the first byte: the config can hold secrets. + $old = umask( 077 ); + $written = file_put_contents( $tmp, $state['content'] ); + umask( $old ); + if ( strlen( $state['content'] ) !== $written || ! chown( $tmp, $state['uid'] ) || ! chgrp( $tmp, $state['gid'] ) || ! rename( $tmp, $dest ) ) { @@ -562,18 +572,35 @@ private static function restoreRealConfig( array $state ) { private static function writeRealConfigBackup( array $state ) { $tmp = self::REAL_CONFIG_BACKUP . '.tmp'; + // /var/tmp is shared: never reuse a file (or symlink) someone else put at the temp path. + if ( ( file_exists( $tmp ) || is_link( $tmp ) ) && ! unlink( $tmp ) ) { + throw new Exception( "Unable to remove the stale $tmp" ); + } $old = umask( 077 ); // The config can hold secrets (cloudflare-api-key), hence 0600. $meta = $state; unset( $meta['content'] ); - $ok = false !== file_put_contents( $tmp, json_encode( [ 'path' => self::realConfigPath(), 'state' => $meta, 'content_b64' => isset( $state['content'] ) ? base64_encode( $state['content'] ) : null ] ) ); + $fh = fopen( $tmp, 'x' ); + $ok = $fh && false !== fwrite( $fh, json_encode( [ 'path' => self::realConfigPath(), 'state' => $meta, 'content_b64' => isset( $state['content'] ) ? base64_encode( $state['content'] ) : null ] ) ) && fclose( $fh ); umask( $old ); if ( ! $ok || ! chmod( $tmp, 0600 ) || ! rename( $tmp, self::REAL_CONFIG_BACKUP ) ) { throw new Exception( 'Unable to write ' . self::REAL_CONFIG_BACKUP . '; refusing to run without a backup of the real config.' ); } } + /** + * A leftover in shared /var/tmp is only trusted when it's a regular file of ours that nobody else can write. + */ + private static function assertTrustedFile( $path ) { + clearstatcache(); + $stat = lstat( $path ); + if ( is_link( $path ) || ! is_file( $path ) || false === $stat || posix_geteuid() !== $stat['uid'] || ( $stat['mode'] & 022 ) ) { + throw new Exception( "$path is not a regular file owned by this user and writable only by it; refusing to use it. Inspect it and delete it by hand." ); + } + } + private static function readRealConfigBackup() { + self::assertTrustedFile( self::REAL_CONFIG_BACKUP ); $data = json_decode( (string) file_get_contents( self::REAL_CONFIG_BACKUP ), true ); if ( ! is_array( $data ) || ! isset( $data['state'] ) || self::realConfigPath() !== $data['path'] ) { throw new Exception( self::REAL_CONFIG_BACKUP . ' is unreadable or belongs to another config path; inspect it and delete it by hand.' ); @@ -695,6 +722,20 @@ private static function normalizeForCompare( $value ) { return $value; } + /** + * Decoded JSON (objects as stdClass) with object keys sorted; objects are wrapped so they never equal a list. + */ + private static function normalizeJson( $value ) { + if ( $value instanceof \stdClass ) { + $value = (array) $value; + ksort( $value ); + + return [ '{}' => array_map( [ __CLASS__, 'normalizeJson' ], $value ) ]; + } + + return is_array( $value ) ? array_map( [ __CLASS__, 'normalizeJson' ], $value ) : $value; + } + private function setEnv( $name, $value ) { if ( ! array_key_exists( $name, $this->savedEnv ) ) { $this->savedEnv[ $name ] = getenv( $name ); @@ -740,7 +781,13 @@ private static function describeRealConfigChange( array $before, array $after ) * @BeforeSuite */ public static function recoverInterruptedRun() { - self::$runLock = fopen( self::REAL_CONFIG_BACKUP . '.lock', 'c' ); + $lock = self::REAL_CONFIG_BACKUP . '.lock'; + if ( file_exists( $lock ) || is_link( $lock ) ) { + self::assertTrustedFile( $lock ); + } + $old = umask( 077 ); + self::$runLock = fopen( $lock, 'c' ); + umask( $old ); if ( ! self::$runLock || ! flock( self::$runLock, LOCK_EX | LOCK_NB ) ) { throw new Exception( 'Another run of this suite holds ' . self::REAL_CONFIG_BACKUP . '.lock; runs must not overlap.' ); } @@ -799,6 +846,16 @@ private static function registerShutdownHandler() { pcntl_signal( constant( $signal ), function ( $signo ) { + // Stop and reap the running command first, so it can't write the config after the restore. + $children = @file_get_contents( '/proc/self/task/' . getmypid() . '/children' ); + if ( $children && function_exists( 'posix_kill' ) ) { + foreach ( preg_split( '/\s+/', trim( $children ) ) as $pid ) { + posix_kill( (int) $pid, $signo ); + } + while ( pcntl_waitpid( -1, $status ) > 0 ) { + continue; + } + } exit( 128 + $signo ); } ); @@ -937,6 +994,17 @@ private static function requireRealConfigState() { return self::$realConfigState; } + /** + * The global-config scenarios compare against the real file, so it must exist (any ee command on an initialised install creates it). + * + * @Given the real ee config file exists + */ + public function theRealEeConfigFileExists() { + if ( ! self::requireRealConfigState()['exists'] ) { + throw new Exception( self::realConfigPath() . ' does not exist; these scenarios need an initialised EasyEngine.' ); + } + } + /** * @Then the real ee config file should have the same values as before */ @@ -1218,15 +1286,15 @@ public function theExitCodeShouldBe( $code ) { * @Then STDOUT should be JSON equal to: */ public function stdoutShouldBeJsonEqualTo( PyStringNode $expected ) { - $actual = json_decode( $this->lastOutput( 'STDOUT' ), true ); + $actual = json_decode( $this->lastOutput( 'STDOUT' ) ); if ( JSON_ERROR_NONE !== json_last_error() ) { throw new Exception( 'STDOUT is not valid JSON: ' . json_last_error_msg() . "\n\n" . $this->describeLastCommand() ); } - $wanted = json_decode( $this->replaceVariables( $expected->getRaw() ), true ); + $wanted = json_decode( $this->replaceVariables( $expected->getRaw() ) ); if ( JSON_ERROR_NONE !== json_last_error() ) { throw new Exception( 'Expected value is not valid JSON: ' . json_last_error_msg() ); } - if ( self::normalizeForCompare( $wanted ) !== self::normalizeForCompare( $actual ) ) { + if ( self::normalizeJson( $wanted ) !== self::normalizeJson( $actual ) ) { throw new Exception( 'JSON differs from: ' . json_encode( $wanted ) . "\n\n" . $this->describeLastCommand() ); } } @@ -1304,8 +1372,9 @@ public function stdoutShouldBeJsonListingTheEntriesOfTheYamlFile( $path ) { 'value' => $value, ]; } - $actual = json_decode( $this->lastOutput( 'STDOUT' ), true ); - if ( $expected !== $actual ) { + $actual = json_decode( $this->lastOutput( 'STDOUT' ) ); + // Encoded the way ee encodes it, so maps become objects and lists stay lists. + if ( self::normalizeJson( json_decode( json_encode( $expected ) ) ) !== self::normalizeJson( $actual ) ) { throw new Exception( 'Expected: ' . json_encode( $expected ) . "\n\n" . $this->describeLastCommand() ); } } diff --git a/features/config-global.feature b/features/config-global.feature index 7fbe091..a959f10 100644 --- a/features/config-global.feature +++ b/features/config-global.feature @@ -3,6 +3,9 @@ Feature: Default config location (/opt/easyengine/config/config.yml) These scenarios run without EE_CONFIG_PATH, so they read or write the real global config. The context snapshots the real file before every scenario and restores it byte-for-byte afterwards. + Background: + Given the real ee config file exists + Scenario: Without EE_CONFIG_PATH, list reads the real global config When I run 'bin/ee config list --format=json' Then the exit code should be 0 From fd470ad469cfec61b0405799e68744553e454f9b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 02:25:56 +0530 Subject: [PATCH 18/25] test(config-command): reapply the original mode when restoring the real config The restore file is created 0600 and its mode was never set back, so restoring a 0644 config failed verification and kept the backup, blocking later scenarios. Also fixes the unused container count step (string vs int, threw on a match) and drops the unused subsite step, which swapped its arguments. --- features/bootstrap/FeatureContext.php | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index a27622b..2d6d2af 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -137,16 +137,6 @@ public function iRun($command) $this->commands[] = \EE\Process::create( $command, null, null )->run(); } - /** - * @When I create subsite :subsite in :site - */ - public function iCreateSubsiteInOfType($subsite, $site ) - { - $php_container = implode( explode( '.', $subsite ) ) . '_php_1'; - - EE::launch( "docker exec -it --user='www-data' $php_container sh -c 'wp site create --slug=$site'", false, true ); - } - /** * @Then return value should be 0 */ @@ -414,7 +404,7 @@ public function thereShouldBeContainersWithLabel($expected_running_containers, P $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); $running_containers = (int) trim($result->stdout); - if($expected_running_containers === $running_containers) { + if ( (int) $expected_running_containers !== $running_containers ) { throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); } } @@ -559,6 +549,7 @@ private static function restoreRealConfig( array $state ) { if ( strlen( $state['content'] ) !== $written || ! chown( $tmp, $state['uid'] ) || ! chgrp( $tmp, $state['gid'] ) + || ! chmod( $tmp, $state['mode'] ) || ! rename( $tmp, $dest ) ) { @unlink( $tmp ); throw new Exception( "Unable to restore $dest" ); From cd259ffab3c00ee90c78dff8a697e22b8e975112 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 02:48:49 +0530 Subject: [PATCH 19/25] test(config-command): refuse chained config symlinks and keep real config values out of failure messages The restore rewrites the first link's target, which would turn a further link of a chain into a regular file, so a chain is refused when the config is captured. A failed JSON comparison against the real config no longer prints its values or the command output, since the config can hold credentials. --- features/bootstrap/FeatureContext.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 2d6d2af..f4c4e46 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -495,6 +495,13 @@ private static function captureRealConfig() { 'link' => is_link( $path ) ? readlink( $path ) : null, 'exists' => is_file( $path ), ]; + if ( null !== $state['link'] ) { + $target = '/' === substr( $state['link'], 0, 1 ) ? $state['link'] : dirname( $path ) . '/' . $state['link']; + // The restore rewrites the first link's target, which would replace a further link with a file. + if ( is_link( $target ) ) { + throw new Exception( "$path is a chain of symlinks; only a single symlink to the real file is supported." ); + } + } if ( $state['exists'] ) { $content = file_get_contents( $path ); $stat = stat( $path ); @@ -1355,7 +1362,8 @@ function ( $row ) { * @Then STDOUT should be JSON listing the entries of the YAML file :path */ public function stdoutShouldBeJsonListingTheEntriesOfTheYamlFile( $path ) { - $config = self::loadYamlFile( $this->replaceVariables( $path ) ); + $file = $this->replaceVariables( $path ); + $config = self::loadYamlFile( $file ); $expected = []; foreach ( $config as $key => $value ) { $expected[] = [ @@ -1366,6 +1374,10 @@ public function stdoutShouldBeJsonListingTheEntriesOfTheYamlFile( $path ) { $actual = json_decode( $this->lastOutput( 'STDOUT' ) ); // Encoded the way ee encodes it, so maps become objects and lists stay lists. if ( self::normalizeJson( json_decode( json_encode( $expected ) ) ) !== self::normalizeJson( $actual ) ) { + // The real config can hold credentials (cloudflare-api-key), so no values or output then. + if ( false !== realpath( $file ) && realpath( $file ) === realpath( self::realConfigPath() ) ) { + throw new Exception( 'STDOUT does not list the entries of the real config (values omitted).' ); + } throw new Exception( 'Expected: ' . json_encode( $expected ) . "\n\n" . $this->describeLastCommand() ); } } From c2eb33d95ccf72afd4cd9f1d0a521be8ef9b0db5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 02:59:57 +0530 Subject: [PATCH 20/25] test(config-command): reject short backup writes and omit command output that used the real config The real-config backup is serialized once and only published when every byte was written. Failure messages no longer include STDOUT/STDERR of commands that ran against the real config, whose values can be credentials. --- features/bootstrap/FeatureContext.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index f4c4e46..7b74843 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -578,9 +578,18 @@ private static function writeRealConfigBackup( array $state ) { // The config can hold secrets (cloudflare-api-key), hence 0600. $meta = $state; unset( $meta['content'] ); - $fh = fopen( $tmp, 'x' ); - $ok = $fh && false !== fwrite( $fh, json_encode( [ 'path' => self::realConfigPath(), 'state' => $meta, 'content_b64' => isset( $state['content'] ) ? base64_encode( $state['content'] ) : null ] ) ) && fclose( $fh ); + $json = json_encode( [ 'path' => self::realConfigPath(), 'state' => $meta, 'content_b64' => isset( $state['content'] ) ? base64_encode( $state['content'] ) : null ] ); + $fh = fopen( $tmp, 'x' ); umask( $old ); + // A short write (disk full) must not publish a truncated backup. + $ok = false; + if ( $fh ) { + $ok = false !== $json && strlen( $json ) === fwrite( $fh, $json ); + $ok = fclose( $fh ) && $ok; + } + if ( ! $ok && file_exists( $tmp ) ) { + unlink( $tmp ); + } if ( ! $ok || ! chmod( $tmp, 0600 ) || ! rename( $tmp, self::REAL_CONFIG_BACKUP ) ) { throw new Exception( 'Unable to write ' . self::REAL_CONFIG_BACKUP . '; refusing to run without a backup of the real config.' ); } @@ -687,6 +696,10 @@ private function lastOutput( $stream ) { private function describeLastCommand() { $result = $this->lastCommand(); + // Without a temporary config the command read the real one, whose values can be credentials. + if ( null === $this->tempConfigPath ) { + return sprintf( "Command: %s\nExit code: %d\n(output omitted: the command used the real config)", $result->command, $result->return_code ); + } return sprintf( "Command: %s\nExit code: %d\nSTDOUT:\n%s\nSTDERR:\n%s", From a7cbf8196c70adb19a95ffb69ba3a8232bed9ea1 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 03:09:44 +0530 Subject: [PATCH 21/25] test(config-command): stop the whole command tree before the signal-time restore The SIGTERM/SIGHUP handler signalled only Behat's direct child, the shell, while the PHP process of bin/ee below it could keep running and write the config after the restore. It now signals every descendant, waits up to 10 seconds, kills what is left and reaps the children. --- features/bootstrap/FeatureContext.php | 47 ++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 7b74843..3fb80e1 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -857,11 +857,25 @@ private static function registerShutdownHandler() { pcntl_signal( constant( $signal ), function ( $signo ) { - // Stop and reap the running command first, so it can't write the config after the restore. - $children = @file_get_contents( '/proc/self/task/' . getmypid() . '/children' ); - if ( $children && function_exists( 'posix_kill' ) ) { - foreach ( preg_split( '/\s+/', trim( $children ) ) as $pid ) { - posix_kill( (int) $pid, $signo ); + // Stop the whole command tree (sh -> bin/ee -> php) first, so nothing writes the config after the restore. + $tree = self::descendants( getmypid() ); + if ( $tree && function_exists( 'posix_kill' ) ) { + foreach ( $tree as $pid ) { + posix_kill( $pid, $signo ); + } + $deadline = microtime( true ) + 10; + while ( true ) { + while ( pcntl_waitpid( -1, $status, WNOHANG ) > 0 ) { + continue; + } + $alive = array_filter( $tree, [ __CLASS__, 'isAlive' ] ); + if ( ! $alive || microtime( true ) >= $deadline ) { + break; + } + usleep( 100000 ); + } + foreach ( $alive as $pid ) { + posix_kill( $pid, SIGKILL ); } while ( pcntl_waitpid( -1, $status ) > 0 ) { continue; @@ -875,6 +889,29 @@ function ( $signo ) { } } + /** + * Every descendant process of $pid, parents before children. + */ + private static function descendants( $pid ) { + $tree = []; + foreach ( glob( "/proc/$pid/task/*/children" ) ?: [] as $file ) { + foreach ( preg_split( '/\s+/', trim( (string) @file_get_contents( $file ) ) ) as $child ) { + if ( '' !== $child ) { + $tree[] = (int) $child; + $tree = array_merge( $tree, self::descendants( (int) $child ) ); + } + } + } + + return $tree; + } + + private static function isAlive( $pid ) { + $stat = @file_get_contents( "/proc/$pid/stat" ); + + return false !== $stat && ! preg_match( '/\) Z /', $stat ); + } + public static function restoreRealConfigOnShutdown() { $state = self::$realConfigState; if ( null === $state ) { From f10059d39af5ec06409bcca2657a93c5a305da91 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:20:39 +0530 Subject: [PATCH 22/25] docs(config-command): describe coverage gaps without workspace references --- .github/workflows/behat.yml | 4 +-- features/COVERAGE.md | 56 ++++++++++++++++++------------------ features/config-get.feature | 4 +-- features/config-list.feature | 4 +-- features/config-path.feature | 4 +-- features/config-set.feature | 2 +- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 6ade07b..a9a727a 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -122,8 +122,8 @@ jobs: sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" sudo -E bin/ee config set no-of-backups 3 fi - # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. - # Its download has no --fail (MIG-10): a GitHub error page can replace the binary, so fetch 2.27.0 again then. + # EasyEngine's first run replaces the installed docker-compose with 2.27.0, so that's what the suite runs on, as on a fresh install. + # That download doesn't use curl --fail, so a GitHub error page can replace the binary; fetch 2.27.0 again then. if ! docker-compose version; then sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose diff --git a/features/COVERAGE.md b/features/COVERAGE.md index c3c83f4..58d0c2e 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -15,43 +15,43 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in | `get`: missing key error, case sensitivity, dotted keys are literal | `get()` isset check | config-get.feature: "Getting a missing key fails", "Keys are case-sensitive", "Dotted keys are literal…" | | `get`: no arg (usage), two args, unknown flag | core `Subcommand::validate_args()` | config-get.feature: "Get without a key…", "Get with two keys…", "Get rejects unknown flags" | | `get` on a config path that doesn't exist yet | `get()` + bootstrap | config-get.feature: "Get works with a freshly created config file" | -| `get` of a null value | `get()` isset | not covered yet: CFG-1 (its scenario comes back with the fix) | -| `get` of a nested map | `get()` → `EE::log(array)` | not covered yet: CFG-2 (its scenario comes back with the fix) | -| `get --quiet` still prints the value | `get()` uses `EE::log`; quiet is ignored (CFG-5) | config-get.feature: "Get still prints the value with --quiet" (guards the latent CFG-5 problem) | -| `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature regression check, CFG-8 fixed in v4.13.2 | +| `get` of a null value | `get()` isset | not covered yet: null-valued keys can't be read (its scenario comes back with the fix) | +| `get` of a nested map | `get()` → `EE::log(array)` | not covered yet: nested values print `Array` (its scenario comes back with the fix) | +| `get --quiet` still prints the value | `get()` uses `EE::log`; quiet is ignored | config-get.feature: "Get still prints the value with --quiet" (guards against quiet swallowing the value once it works) | +| `get` copies the value into ee.log | `EE::log` → file logger | config-get.feature regression check, fixed in v4.13.2 by EasyEngine/config-command#14 | | `set `: new key (exact file content, no output), read back, overwrite keeps position, idempotent | `set()` | config-set.feature: first four scenarios | | `set`: numeric stored as quoted string, empty value, dotted key literal, replacing a nested map, multi-line value, leading/trailing spaces | `set()` + Spyc dump | config-set.feature: "Numeric values…", "An empty value…", "Dotted keys…", "Setting a key that holds a nested map…", "A multi-line value…", "A value with leading and trailing spaces…" | | `set`: 24 YAML-special values round-trip (true/false/yes/no/on/null/~/0/0123/1e3/1.50/`a: b`/`a #b`/`[1,2]`/`{a: b}`/-dash/spaces/email/URL with `#`/unicode/`%p`/`&`/`*`/`!`) | Spyc quoting | config-set.feature outline "Special value round-trips unchanged" | | `set`/`get`/`unset` of keys that need YAML quoting (space, `: `, `#`, `@`) | Spyc key quoting | config-set.feature outline "Key that needs YAML quoting round-trips" | -| `set` of a key containing a newline | no key validation | not covered yet: CFG-10 (its scenario comes back with the fix) | +| `set` of a key containing a newline | no key validation | not covered yet: such a key corrupts the config (its scenario comes back with the fix) | | `set`: missing value / no args (usage, file untouched), unquoted spaces, flag-looking value, unknown flag | core validation | config-set.feature: "Set without a value…", "Set without arguments…", "An unquoted value with spaces…", "A value that looks like a flag…", "Set rejects unknown flags" | | `set` creates a missing file and directories | `set()` + bootstrap + Symfony `dumpFile()` | config-set.feature: "Set creates a missing config file…" | | `set` keeps the file mode | Symfony `dumpFile()` | config-set.feature: "Set keeps the file mode" | | `EE_CONFIG_PATH` given inline for one invocation | `set()` path resolution | config-set.feature: "Set with EE_CONFIG_PATH on the command line…" | -| `set` of `"quoted"` and `\|` values | Spyc dump | not covered yet: CFG-6 (its outline, 2 examples, comes back with the fix) | -| `set` drops comments / rewrites unrelated `y`/`off` values | whole-file Spyc rewrite | not covered yet: CFG-3 (its 2 scenarios come back with the fix) | -| `set` on a symlinked config | Symfony `dumpFile()` rename | not covered yet: CFG-7 (its scenario comes back with the fix) | -| `set` secret ends up in ee.log (argv debug line) | core `Runner::start()` | config-set.feature regression check, CFG-8 fixed in v4.13.2 | +| `set` of `"quoted"` and `\|` values | Spyc dump | not covered yet: the values change after a round trip (its outline, 2 examples, comes back with the fix) | +| `set` drops comments / rewrites unrelated `y`/`off` values | whole-file Spyc rewrite | not covered yet: comments are dropped and `y`/`off` become booleans (its 2 scenarios come back with the fix) | +| `set` on a symlinked config | Symfony `dumpFile()` rename | not covered yet: the symlink is replaced by a regular file (its scenario comes back with the fix) | +| `set` secret ends up in ee.log (argv debug line) | core `Runner::start()` | config-set.feature regression check, fixed in v4.13.2 by EasyEngine/easyengine#1944 | | `unset `: removes only that key (exact content, no output), then get fails, twice (second fails), missing key (file byte-identical), case sensitivity, nested map, empty-string value | `unset()` | config-unset.feature: first seven scenarios | | `unset`: no arg, two args, unknown flag | core validation | config-unset.feature: "Unset without a key…", "Unset with two keys…", "Unset rejects unknown flags" | | `unset` keeps the file mode | Symfony `dumpFile()` | config-unset.feature: "Unset keeps the file mode" | | `unset locale` is undone by the next run | core `ensure_present_in_config()` | config-unset.feature: "Unsetting a bootstrap default is undone…" (documents current behaviour) | -| `unset` of null values | `unset()` isset | not covered yet: CFG-1 (its scenario comes back with the fix) | -| `unset` drops comments / replaces a symlink | whole-file rewrite | not covered yet: CFG-3, CFG-7 (their scenarios come back with the fixes) | +| `unset` of null values | `unset()` isset | not covered yet: null-valued keys can't be removed (its scenario comes back with the fix) | +| `unset` drops comments / replaces a symlink | whole-file rewrite | not covered yet: comments are dropped and a symlink is replaced (their scenarios come back with the fixes) | | `list` default format and `--format=table` | `_list()` + Formatter | config-list.feature: "The default format…", "--format=table…" | | `list --format=json|csv|yaml|text` | `_list()` | config-list.feature: one scenario per format | | `list`: read-only, reflects set/unset, value types in JSON, nested maps JSON-encoded in table/csv | `_list()` | config-list.feature: "List does not modify…", "List reflects set and unset", "List shows booleans…", "Nested maps are JSON-encoded…" | | `list` on an empty file ("No config values found!") | `_list()` empty branch | config-list.feature: "An empty config file still lists the bootstrap defaults" (the error branch is unreachable, see below) | | `list`: removed `count` format (db58c81), unknown format, misspelt flag suggestion, `--fields`, positional arg | synopsis validation | config-list.feature: five scenarios | | `list --format=json --quiet` | Formatter echoes directly | config-list.feature: "Machine-readable formats still print with --quiet" | -| `list --format=text` with nested map | `_list()` string concat | not covered yet: CFG-2 (its scenario comes back with the fix) | -| `list` shows null keys that get can't read | isset vs list | not covered yet: CFG-1 (its scenario comes back with the fix) | -| `list` (table/text) with `--quiet` | `EE::line`/`EE::log` go through the logger; quiet is ignored (CFG-5) | config-list.feature: "Human-readable formats still print with --quiet" | -| `list --format=text` copies values into ee.log | `EE::log` → file logger | config-list.feature regression check, CFG-8 fixed in v4.13.2 | +| `list --format=text` with nested map | `_list()` string concat | not covered yet: nested values print `Array` (its scenario comes back with the fix) | +| `list` shows null keys that get can't read | isset vs list | not covered yet: null-valued keys (its scenario comes back with the fix) | +| `list` (table/text) with `--quiet` | `EE::line`/`EE::log` go through the logger; quiet is ignored | config-list.feature: "Human-readable formats still print with --quiet" | +| `list --format=text` copies values into ee.log | `EE::log` → file logger | config-list.feature regression check, fixed in v4.13.2 by EasyEngine/config-command#14 | | Bootstrap: missing file/dirs created with `locale`/`ee_installer_version`, empty file, missing defaults appended, file untouched when defaults exist | core `Runner::ensure_present_in_config()` | config-path.feature: first four scenarios | | `EE_CONFIG_PATH` switching per invocation | all subcommands | config-path.feature: "EE_CONFIG_PATH switches files per invocation" | | Config keys that are global runtime parameters: hand-written `disabled_commands` list, `quiet` | core Configurator/Runner | config-path.feature: "A hand-written disabled_commands list…", "quiet in the config file does not hide…", "Setting quiet to false stores a string…" | -| `set disabled_commands …` | set writes strings | not covered yet: CFG-4 (its scenario comes back with the fix) | +| `set disabled_commands …` | set writes strings | not covered yet: a string `disabled_commands` breaks every command on PHP 8 (its scenario comes back with the fix) | | Default path (no `EE_CONFIG_PATH`, empty `EE_CONFIG_PATH`): list, set/get/unset, mode kept | path fallback | config-global.feature (`@destructive`, 4 scenarios, restored by the context) | | Config not readable/writable errors | core `ensure_present_in_config()` | not covered: the suite runs as root, which bypasses file permissions; core code, not this package | | `~/easyengine` config root on macOS | core `init-ee.php` | not covered: Linux-only CI | @@ -61,7 +61,7 @@ Suite for `ee config` (`src/Config_Command.php`). Every scenario except those in 7 features, 118 scenarios (outline examples expanded): `@destructive` 4, `@le` 0, `@remote` 0. 114 scenarios run without `@destructive`. -No `@known-bug` scenarios ship here: the 16 scenario rows written for open bugs (CFG-1, CFG-2, CFG-3, CFG-4, CFG-6, CFG-7, CFG-9, CFG-10) are held back until their fixes land and then return as regular scenarios. +No `@known-bug` scenarios ship here: the 16 scenario rows written for the open bugs listed below are held back until their fixes land and then return as regular scenarios. Coverage estimate: about 93% of the package's reachable behaviour (every subcommand, argument, format and error path in `Config_Command.php`, plus the core bootstrap/validation behaviour they depend on). The only uncovered package branch is `list`'s "No config values found!", which the bootstrap makes unreachable; the gaps are the core behaviours listed as not covered above. The mode check in `config-global.feature` can't catch a mode regression when the real file is 0644 (the default for a new file); the temp-file mode scenarios (0600, 0640) do. @@ -69,18 +69,18 @@ Estimated runtime: about 235 `bin/ee` invocations with no Docker work, roughly 2 ## Known bugs (scenarios held back until fixed) -All are in `/root/workspace/issues.md` ("config-command" section), each reproduced on PHP 7.4 and 8.5: +Each was reproduced on PHP 7.4 and 8.5: -- CFG-1 (Low): `get`/`unset` use `isset()`, so null-valued keys can't be read or removed. -- CFG-2 (Low): nested values print `Array` in `get` and `list --format=text`. -- CFG-3 (Medium): `set`/`unset` rewrite the whole file (comments dropped, `y`/`off` become `true`/`false`). -- CFG-4 (Medium): `set` stores strings; `set disabled_commands X` makes every ee command fatal on PHP 8, including the `unset` needed to fix it. -- CFG-5 (Low, core): `--quiet` and `quiet:` never take effect (the logger is set up before the config is read). No held-back scenario: the suspected "quiet hides get/list output" doesn't happen today; the quiet scenarios guard against it once quiet is fixed. -- CFG-6 (Low): `"quoted"` and `|` values change after `set` then `get`. -- CFG-7 (Low): `set`/`unset` replace a symlinked config with a regular file. -- CFG-8 (Medium, security): secrets reach ee.log (`get` and `list --format=text` log values; core logs argv, so `set cloudflare-api-key ` persists the key). Fixed in v4.13.2 (config-command#14, easyengine#1944); the three scenarios are regular now. -- CFG-9 (Info): help text bugs ("Set a config value" on get, "Manges", incomplete README; the README part has no scenario). -- CFG-10 (Low): a key containing a newline corrupts the config so every ee command dies. +- Low: `get`/`unset` use `isset()`, so null-valued keys can't be read or removed. +- Low: nested values print `Array` in `get` and `list --format=text`. +- Medium: `set`/`unset` rewrite the whole file (comments dropped, `y`/`off` become `true`/`false`). +- Medium: `set` stores strings; `set disabled_commands X` makes every ee command fatal on PHP 8, including the `unset` needed to fix it. +- Low, core: `--quiet` and `quiet:` never take effect (the logger is set up before the config is read). No held-back scenario: the suspected "quiet hides get/list output" doesn't happen today; the quiet scenarios guard against it once quiet is fixed. +- Low: `"quoted"` and `|` values change after `set` then `get`. +- Low: `set`/`unset` replace a symlinked config with a regular file. +- Medium, security: secrets reach ee.log (`get` and `list --format=text` log values; core logs argv, so `set cloudflare-api-key ` persists the key). Fixed in v4.13.2 (EasyEngine/config-command#14, EasyEngine/easyengine#1944); the three scenarios are regular now. +- Info: help text bugs ("Set a config value" on get, "Manges", incomplete README; the README part has no scenario). +- Low: a key containing a newline corrupts the config so every ee command dies. ## Notes diff --git a/features/config-get.feature b/features/config-get.feature index 6781b8d..4dd80e3 100644 --- a/features/config-get.feature +++ b/features/config-get.feature @@ -154,7 +154,7 @@ Feature: ee config get """ And the file '{CONFIG_PATH}' should exist - # --quiet is ignored today (CFG-5). get prints through EE::log, so fixing quiet must not make it swallow the value. + # --quiet is ignored today (core sets up the logger before reading the config). get prints through EE::log, so fixing quiet must not make it swallow the value. Scenario: Get still prints the value with --quiet When I run 'bin/ee config get le-mail --quiet' Then the exit code should be 0 @@ -163,7 +163,7 @@ Feature: ee config get admin@bh-config-command.test """ - # CFG-8 (fixed in v4.13.2), regression check: EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. + # Fixed in v4.13.2 (EasyEngine/config-command#14), regression check: EE::log also writes to ee.log, so `config get` copies values (e.g. cloudflare-api-key) into the log. Scenario: Get does not copy the value into ee.log Given a temporary ee config file with: """ diff --git a/features/config-list.feature b/features/config-list.feature index 7bd2104..ca6cb81 100644 --- a/features/config-list.feature +++ b/features/config-list.feature @@ -229,7 +229,7 @@ Feature: ee config list Then the exit code should be 0 And STDOUT should be JSON listing the entries of the YAML file '{CONFIG_PATH}' - # --quiet is ignored today (CFG-5). Table and text output go through the logger, so fixing quiet must not swallow them. + # --quiet is ignored today (core sets up the logger before reading the config). Table and text output go through the logger, so fixing quiet must not swallow them. Scenario: Human-readable formats still print with --quiet When I run 'bin/ee config list --quiet' Then the exit code should be 0 @@ -246,7 +246,7 @@ Feature: ee config list le-mail: admin@bh-config-command.test """ - # CFG-8 (fixed in v4.13.2), regression check: --format=text prints through EE::log, which also writes every value (secrets included) to ee.log. + # Fixed in v4.13.2 (EasyEngine/config-command#14), regression check: --format=text prints through EE::log, which also writes every value (secrets included) to ee.log. Scenario: List --format=text does not copy values into ee.log Given a temporary ee config file with: """ diff --git a/features/config-path.feature b/features/config-path.feature index d63d80f..b279a4f 100644 --- a/features/config-path.feature +++ b/features/config-path.feature @@ -106,7 +106,7 @@ Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults en_US """ - # CFG-5: quiet (config key or --quiet) never takes effect, because core picks the logger before reading the config. + # quiet (config key or --quiet) never takes effect, because core picks the logger before reading the config. # If that gets fixed, get must print its value in a way quiet doesn't swallow, or this scenario fails. Scenario: quiet in the config file does not hide the value config get prints Given a temporary ee config file with: @@ -124,7 +124,7 @@ Feature: Config file location (EE_CONFIG_PATH) and bootstrap defaults en_US """ - # CFG-4: set stores the string 'false' (truthy), which only stays harmless while quiet is ignored (CFG-5). + # set stores the string 'false' (truthy), which only stays harmless while quiet is ignored. Scenario: Setting quiet to false stores a string and keeps output visible Given a temporary ee config file with: """ diff --git a/features/config-set.feature b/features/config-set.feature index 24d0735..6285719 100644 --- a/features/config-set.feature +++ b/features/config-set.feature @@ -234,7 +234,7 @@ Feature: ee config set | bh#key | | @bh-key | - # CFG-8 (fixed in v4.13.2), regression check: the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. + # Fixed in v4.13.2 (EasyEngine/easyengine#1944), regression check: the runner logs argv at debug level to ee.log, so secrets passed to `config set` are persisted there. Scenario: Set does not copy the value into ee.log Given the environment variable 'BH_SECRET' is set to 'bh-config-command-secret-{RUN_ID}' And I remember the size of the file '{EE_LOG}' From bcff141a3840e9efdc46527e2199272a7fcbbb43 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:33:26 +0530 Subject: [PATCH 23/25] ci(config-command): run the behat lanes on every supported PHP version --- .github/workflows/behat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index a9a727a..e68504e 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.4', '8.5'] + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] lane: [main] include: - lane: main From 42e1f177e0f8ed7ac134ce09da36ff4c5c3abcd6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:45:35 +0530 Subject: [PATCH 24/25] test(config-command): match lists by membership and compare whole multiline output compareContents() compared list elements by position, so [1,3,5] wasn't found in [1,2,3,4,5], and a missing key or property raised a notice. The indexed exact-output step and both something-like steps only used the first line of the expected text. --- features/bootstrap/FeatureContext.php | 9 +++------ features/bootstrap/support.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 3fb80e1..19ae7b9 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -188,8 +188,7 @@ public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStr $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if ( $expected_out !== trim($command_output)) { + if ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); } } @@ -201,8 +200,7 @@ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $ex { $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } @@ -215,8 +213,7 @@ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, { $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index ed9a5b5..a5f1a1e 100644 --- a/features/bootstrap/support.php +++ b/features/bootstrap/support.php @@ -81,13 +81,27 @@ function compareContents( $expected, $actual ) { if ( is_object( $expected ) ) { foreach ( get_object_vars( $expected ) as $name => $value ) { - if ( ! compareContents( $value, $actual->$name ) ) { + if ( ! property_exists( $actual, $name ) || ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) && array_values( $expected ) === $expected ) { + // Lists match by membership, so [1,3,5] is contained in [1,2,3,4,5]. + foreach ( $expected as $value ) { + $found = false; + foreach ( $actual as $candidate ) { + if ( compareContents( $value, $candidate ) ) { + $found = true; + break; + } + } + if ( ! $found ) { return false; } } } else if ( is_array( $expected ) ) { foreach ( $expected as $key => $value ) { - if ( ! compareContents( $value, $actual[$key] ) ) { + if ( ! array_key_exists( $key, $actual ) || ! compareContents( $value, $actual[ $key ] ) ) { return false; } } From d416d846c6f32916e98141ba29dfddb26984bb79 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 19:48:27 +0530 Subject: [PATCH 25/25] test(config-command): drop the site cleanup for sites the suite never creates No config feature creates a site, so the @AfterFeature site list and delete could only ever touch a leftover from another run. --- features/bootstrap/FeatureContext.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 19ae7b9..63c2d05 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -419,18 +419,6 @@ public static function cleanup(AfterFeatureScope $scope) } } } - - // Avoid invoking ee (and the live stack) when this suite left no sites behind. - if ( ! glob( EE_SITE_ROOT . '/' . self::SITE_PREFIX . '*' ) ) { - return; - } - - $result = EE::launch( 'bin/ee site list --format=text', false, true ); - foreach ( array_filter( array_map( 'trim', explode( "\n", $result->stdout ) ) ) as $site ) { - if ( 0 === strpos( $site, self::SITE_PREFIX ) ) { - exec( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes' ); - } - } } /*