From 424cbb30a6819de873dbb6432745775e5e70b2d1 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 06:04:49 +0530 Subject: [PATCH 01/37] test(site-command): add shared behat steps and prefix-scoped cleanup Rework the context the site-type packages copy: - delete only sites matching SITE_PREFIX after each feature (never every listed site), purge the prefix's remote backups for @remote features, and undo per-scenario hosts entries, scratch dirs and backup leftovers - anchor the old regex steps: the unanchored `I run '(.*)'|"(.*)"` matched any step with a quoted string, and the `:index` placeholders inside regexes never matched - fix the inverted container label count check and look containers up by label instead of v1 compose names - add generic steps for exit codes, output, JSON, the sites table, files, hosts entries, docker objects, HTTP (resolved to 127.0.0.1, the public IP or verified DNS), TLS certificates, polling waits, config guards and rclone backups, with {NAME} variables in every argument --- features/bootstrap/FeatureContext.php | 1360 +++++++++++++++++++++++-- 1 file changed, 1285 insertions(+), 75 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index c24728c6..eee2ccb3 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -22,7 +22,10 @@ $root_alias = '/Users/' . $sys_name; } } -define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +// php/init-ee.php may already have defined it (same value on Linux). +if ( ! defined( 'EE_ROOT_DIR' ) ) { + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -55,33 +58,71 @@ 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' ); +if ( ! defined( 'EE_TEST_PROXY_CONTAINER' ) ) { + define( 'EE_TEST_PROXY_CONTAINER', 'services_global-nginx-proxy_1' ); +} + class FeatureContext implements Context { + /** + * Every site, hosts entry, file and remote backup the suite creates or removes must contain this prefix. + * Packages copying this context change only this constant. + */ + const SITE_PREFIX = 'bh-site-command-'; + public $command; + public $commands; public $webroot_path; public $ee_path; /** - * Contain all test sites name. + * Variables usable as {NAME} in step arguments. + * + * @var array + */ + private $variables = []; + + /** + * Last HTTP response: code, headers (lowercased name => value), raw_headers, body, error. + * + * @var array|null + */ + private $response = null; + + /** + * Container start times noted by "I note the start time of ...". + * + * @var array + */ + private $start_times = []; + + /** + * Hosts entries added by the current scenario, removed afterwards. * * @var array */ - public static $test_sites = [ - 'wp.test', - 'wpsubdom.test', - 'wpsubdir.test', - 'example.test', - 'www.example1.test', - 'example2.test', - 'www.example3.test', - 'labels.test' - ]; + private $added_hosts = []; + + /** + * Per-scenario scratch directory ({TMP}). + * + * @var string + */ + private $tmp_dir = ''; + + /** + * Unique id of this Behat run ({RUN_ID}), used to keep Let's Encrypt names distinct across runs. + * + * @var string + */ + private static $run_id = ''; /** * Initializes context. @@ -90,6 +131,9 @@ public function __construct() { $this->commands = []; $this->ee_path = getcwd(); + if ( empty( self::$run_id ) ) { + self::$run_id = date( 'mdHis' ); + } } /** @@ -126,11 +170,13 @@ public function isInstalled($command) } /** - * @When /I run '(.*)'|"(.*)"/ + * Runs a shell command from the core root; {NAME} variables are expanded. + * + * @When /^I run (['"])(.*)\1$/ */ - public function iRun($command) + public function iRun($quote, $command) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = EE::launch( $this->v( $command ), false, true ); } /** @@ -149,17 +195,17 @@ public function iCreateSubsiteInOfType($subsite, $site ) public function returnValueShouldBe0() { if ( 0 !== $this->commands[0]->return_code ) { - throw new Exception("Actual return code is not zero: \n" . $this->command); + throw new Exception( "Actual return code is not zero:\n" . $this->describe( $this->commands[0] ) ); } } /** - * @Then return value of command :index should be 0 + * @Then /^return value of command (\d+) 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); + throw new Exception( "Actual return code is not zero:\n" . $this->describe( $this->commands[ $index - 1 ] ) ); } } @@ -172,13 +218,13 @@ public function afterDelayOfSeconds( $time ) } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * @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); + $command_output = $this->strip_colors( $command_output ); if ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); @@ -186,22 +232,21 @@ public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected } /** - * @Then /(STDOUT|STDERR) of command :index should return exactly/ + * @Then /^(STDOUT|STDERR) of command (\d+) 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); + $command_output = $this->strip_colors( $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); } } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) should return something like$/ */ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) { @@ -215,7 +260,7 @@ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $ex /** - * @Then /(STDOUT|STDERR) of command :index should return something like/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return something like$/ */ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) { @@ -254,11 +299,9 @@ public function theSiteShouldBeMultisite( $site, $type ) */ public function theDbEntryShouldBeRemoved($site) { - $out = shell_exec("sudo bin/ee site list"); - if (strpos($out, $site) !== false) { + if ( false !== $this->get_site_row( $site ) ) { throw new Exception("$site db entry not been removed!"); } - } /** @@ -276,17 +319,10 @@ public function theWebrootShouldBeRemoved($site) */ 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!"); + foreach ( $table->getHash() as $container ) { + $ids = $this->site_container_ids( $site, $container['container'], true ); + if ( ! empty( $ids ) ) { + throw new Exception( "The {$container['container']} container of $site has not been removed: " . implode( ', ', $ids ) ); } } } @@ -316,23 +352,11 @@ public function theSiteShouldHaveWordpress($site) */ 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); + $response = $this->http_request( 'http://' . $site . '/', 'HEAD' ); - $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); + foreach ( $table->getHash() as $row ) { + if ( strpos( $response['raw_headers'], $row['header'] ) === false ) { + throw new Exception( "Unable to find " . $row['header'] . "\nActual output is : " . $response['raw_headers'] . $response['error'] ); } } } @@ -349,7 +373,6 @@ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, 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); @@ -377,7 +400,6 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ 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); @@ -391,41 +413,80 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ } /** - * @AfterScenario + * @Then There should be :expected_running_containers containers with labels */ - public function cleanupScenario(AfterScenarioScope $scope) + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) { - $this->commands = []; - chdir($this->ee_path); + $filters = ''; + foreach ( $pyStringNode->getStrings() as $label ) { + $filters .= ' --filter ' . escapeshellarg( 'label=' . trim( $this->v( $label ) ) ); + } + + $result = EE::launch( "docker ps -q $filters | wc -l", false, true ); + $running_containers = (int) trim( $result->stdout ); + + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception( "Expected $expected_running_containers running containers. Found: $running_containers" ); + } } + /* ===================================================================== + * Shared generic steps. + * + * Written to be copied as-is into other packages' contexts: only SITE_PREFIX changes. + * Every string argument goes through v(), so {NAME} variables work everywhere: + * built-ins are {EE}, {EE_ROOT_DIR}, {SITES_DIR}, {PROXY_DIR}, {BACKUP_DIR}, {TMP}, {RUN_ID}, {PUBLIC_IP} + * and {RCLONE_PATH}; others come from the "I save ..." steps. + * ===================================================================== */ + /** - * @Then There should be :expected_running_containers containers with labels + * @BeforeScenario */ - public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + public function prepareScenario( BeforeScenarioScope $scope ) + { + $this->tmp_dir = '/var/tmp/' . self::SITE_PREFIX . 'behat-' . getmypid() . '-' . mt_rand( 1000, 9999 ); + @mkdir( $this->tmp_dir, 0755, true ); + $this->variables = []; + $this->response = null; + $this->start_times = []; + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) { - $labels = $pyStringNode->getStrings(); - $label_string = implode(' -f label=',$labels); + $this->commands = []; + chdir($this->ee_path); - $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); - $running_containers = (int) trim($result->stdout); + foreach ( $this->added_hosts as $host ) { + $this->remove_hosts_entry( $host ); + } + $this->added_hosts = []; + + if ( $this->tmp_dir && 0 === strpos( $this->tmp_dir, '/var/tmp/' . self::SITE_PREFIX ) ) { + EE::launch( 'rm -rf ' . escapeshellarg( $this->tmp_dir ), false, true ); + } - if($expected_running_containers === $running_containers) { - throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + // Leftovers of failed backups/restores of our own sites would make the next one fail. + foreach ( glob( EE_ROOT_DIR . '/.backup/' . self::SITE_PREFIX . '*' ) ?: [] as $path ) { + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); } } /** + * Deletes only the sites (and remote backups) of this suite; never touches other sites. + * * @AfterFeature */ public static function cleanup(AfterFeatureScope $scope) { - $result = EE::launch( 'sudo bin/ee site list --format=text',false, true ); - $running_sites = explode( "\n", $result->stdout ); - $sites_to_delete = array_intersect( self::$test_sites, $running_sites ); + foreach ( self::own_sites() as $site ) { + EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + } - foreach ( $sites_to_delete as $site ) { - exec("sudo bin/ee site delete $site --yes" ); + if ( in_array( 'remote', $scope->getFeature()->getTags(), true ) ) { + self::purge_own_remote_backups(); } if(file_exists('ee.phar')) { @@ -435,4 +496,1153 @@ public static function cleanup(AfterFeatureScope $scope) unlink('ee-old.phar'); } } + + /* ----- command execution and output ----- */ + + /** + * Runs a command, killing it after the given time (for commands with known hangs, e.g. CACHE-1). + * + * @When /^I run (['"])(.*)\1 with a timeout of (\d+) seconds$/ + */ + public function iRunWithATimeout( $quote, $command, $seconds ) + { + $this->commands[] = EE::launch( sprintf( 'timeout -k 10 %d sh -c %s', $seconds, escapeshellarg( $this->v( $command ) ) ), false, true ); + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $run = $this->last_command(); + if ( 0 !== $run->return_code ) { + throw new Exception( "Expected exit code 0.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $run = $this->last_command(); + if ( 0 === $run->return_code ) { + throw new Exception( "Expected a non-zero exit code.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^the exit code should be (\d+)$/ + */ + public function theExitCodeShouldBe( $code ) + { + $run = $this->last_command(); + if ( (int) $code !== $run->return_code ) { + throw new Exception( "Expected exit code $code.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?contain "(.*)"$/ + */ + public function outputShouldContain( $stream, $not, $text ) + { + $this->assert_output_contains( $stream, $not, $this->v( $text ) ); + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?contain:$/ + */ + public function outputShouldContainBlock( $stream, $not, PyStringNode $text ) + { + $this->assert_output_contains( $stream, $not, $this->v( $text->getRaw() ) ); + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?match "(.*)"$/ + */ + public function outputShouldMatch( $stream, $not, $regex ) + { + $run = $this->last_command(); + $output = $this->stream_of( $run, $stream ); + $regex = $this->v( $regex ); + $found = preg_match( $regex, $output ); + if ( false === $found ) { + throw new Exception( "Invalid regular expression: $regex" ); + } + if ( (bool) $found === (bool) $not ) { + throw new Exception( sprintf( "Expected %s %sto match %s.\n%s", $stream, $not, $regex, $this->describe( $run ) ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be empty$/ + */ + public function outputShouldBeEmpty( $stream ) + { + $run = $this->last_command(); + if ( '' !== trim( $this->stream_of( $run, $stream ) ) ) { + throw new Exception( "Expected $stream to be empty.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be exactly:$/ + */ + public function outputShouldBeExactly( $stream, PyStringNode $expected ) + { + $run = $this->last_command(); + if ( trim( $this->v( $expected->getRaw() ) ) !== trim( $this->stream_of( $run, $stream ) ) ) { + throw new Exception( "Expected $stream to be exactly:\n" . $this->v( $expected->getRaw() ) . "\n" . $this->describe( $run ) ); + } + } + + /** + * @Then STDOUT should be valid JSON + */ + public function stdoutShouldBeValidJson() + { + $this->decode_stdout_json(); + } + + /** + * Dot path into the decoded STDOUT JSON, e.g. "site_ssl" or "0.ssl_type". + * + * @Then /^the JSON field "([^"]*)" in STDOUT should (not )?be "(.*)"$/ + */ + public function theJsonFieldShouldBe( $path, $not, $expected ) + { + $actual = $this->json_path( $this->decode_stdout_json(), $path ); + $expected = $this->v( $expected ); + $actual = is_bool( $actual ) ? ( $actual ? 'true' : 'false' ) : ( is_scalar( $actual ) || null === $actual ? (string) $actual : json_encode( $actual ) ); + if ( ( $actual === $expected ) === (bool) $not ) { + throw new Exception( sprintf( "Expected JSON field %s %sto be %s, got %s.\n%s", $path, $not, var_export( $expected, true ), var_export( $actual, true ), $this->describe( $this->last_command() ) ) ); + } + } + + /** + * @Then /^the JSON field "([^"]*)" in STDOUT should contain "(.*)"$/ + */ + public function theJsonFieldShouldContain( $path, $expected ) + { + $actual = $this->json_path( $this->decode_stdout_json(), $path ); + $actual = is_scalar( $actual ) ? (string) $actual : json_encode( $actual ); + $expected = $this->v( $expected ); + if ( false === strpos( $actual, $expected ) ) { + throw new Exception( sprintf( "Expected JSON field %s to contain %s, got %s.\n%s", $path, var_export( $expected, true ), var_export( $actual, true ), $this->describe( $this->last_command() ) ) ); + } + } + + /** + * @Then /^the JSON array in STDOUT should (not )?contain an item with "([^"]*)" set to "(.*)"$/ + */ + public function theJsonArrayShouldContainItem( $not, $key, $value ) + { + $items = $this->decode_stdout_json(); + $value = $this->v( $value ); + if ( ! is_array( $items ) ) { + throw new Exception( "STDOUT JSON is not an array.\n" . $this->describe( $this->last_command() ) ); + } + $found = false; + foreach ( $items as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && (string) $item[ $key ] === $value ) { + $found = true; + break; + } + } + if ( $found === (bool) $not ) { + throw new Exception( sprintf( "Expected the JSON array %sto contain an item with %s = %s.\n%s", $not, $key, $value, $this->describe( $this->last_command() ) ) ); + } + } + + /** + * @When /^I save the JSON field "([^"]*)" in STDOUT as "([A-Z][A-Z0-9_]*)"$/ + */ + public function iSaveTheJsonField( $path, $name ) + { + $value = $this->json_path( $this->decode_stdout_json(), $path ); + $this->variables[ $name ] = is_scalar( $value ) ? (string) $value : json_encode( $value ); + } + + /** + * Saves the first capture group (or the whole match) of a regex applied to STDOUT. + * + * @When /^I save the first match of "(.*)" in STDOUT as "([A-Z][A-Z0-9_]*)"$/ + */ + public function iSaveTheFirstMatch( $regex, $name ) + { + $run = $this->last_command(); + if ( ! preg_match( $regex, $this->stream_of( $run, 'STDOUT' ), $matches ) ) { + throw new Exception( "No match for $regex in STDOUT.\n" . $this->describe( $run ) ); + } + $this->variables[ $name ] = isset( $matches[1] ) ? $matches[1] : $matches[0]; + } + + /** + * @Given /^the variable "([A-Z][A-Z0-9_]*)" is "(.*)"$/ + */ + public function theVariableIs( $name, $value ) + { + $this->variables[ $name ] = $this->v( $value ); + } + + /* ----- sites (database state) ----- */ + + /** + * @Given /^the html site "([^"]*)" exists$/ + */ + public function theHtmlSiteExists( $site ) + { + $this->ensure_site( $this->v( $site ), '--type=html' ); + } + + /** + * @Given /^the site "([^"]*)" exists, created with "(.*)"$/ + */ + public function theSiteExistsCreatedWith( $site, $flags ) + { + $this->ensure_site( $this->v( $site ), $this->v( $flags ) ); + } + + /** + * @Given /^the site "([^"]*)" does not exist$/ + */ + public function theSiteDoesNotExist( $site ) + { + $site = $this->v( $site ); + $this->assert_own_name( $site ); + if ( false !== $this->get_site_row( $site ) ) { + $run = EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + if ( 0 !== $run->return_code ) { + throw new Exception( "Could not delete $site.\n" . $this->describe( $run ) ); + } + } + } + + /** + * @Given /^the site "([^"]*)" is (enabled|disabled)$/ + */ + public function theSiteIs( $site, $state ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + throw new Exception( "Site $site does not exist." ); + } + if ( ( 'enabled' === $state ) !== (bool) $row['site_enabled'] ) { + $run = EE::launch( 'bin/ee site ' . ( 'enabled' === $state ? 'enable' : 'disable' ) . ' ' . escapeshellarg( $site ), false, true ); + if ( 0 !== $run->return_code ) { + throw new Exception( "Could not make $site $state.\n" . $this->describe( $run ) ); + } + } + } + + /** + * @Then /^the site "([^"]*)" should (not )?exist$/ + */ + public function theSiteShouldExist( $site, $not = '' ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( ( false !== $row ) === (bool) $not ) { + throw new Exception( "Expected site $site " . ( $not ? 'not ' : '' ) . 'to exist in the EasyEngine database.' ); + } + } + + /** + * @Then /^the site "([^"]*)" should be (enabled|disabled)$/ + */ + public function theSiteShouldBe( $site, $state ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + throw new Exception( "Site $site does not exist in the EasyEngine database." ); + } + if ( ( 'enabled' === $state ) !== (bool) $row['site_enabled'] ) { + throw new Exception( "Expected $site to be $state, site_enabled is " . var_export( $row['site_enabled'], true ) ); + } + } + + /** + * @Then /^the "([^"]*)" field of site "([^"]*)" in the database should (not )?be "(.*)"$/ + */ + public function theFieldOfSiteShouldBe( $field, $site, $not, $expected ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + throw new Exception( "Site $site does not exist in the EasyEngine database." ); + } + if ( ! array_key_exists( $field, $row ) ) { + throw new Exception( "Unknown column $field. Columns: " . implode( ', ', array_keys( $row ) ) ); + } + $expected = $this->v( $expected ); + $actual = null === $row[ $field ] ? '' : (string) $row[ $field ]; + if ( ( $actual === $expected ) === (bool) $not ) { + throw new Exception( sprintf( 'Expected %s of %s %sto be %s, got %s', $field, $site, $not, var_export( $expected, true ), var_export( $actual, true ) ) ); + } + } + + /* ----- files ----- */ + + /** + * @Then /^the (file|directory|symlink) "([^"]*)" should (not )?exist$/ + */ + public function thePathShouldExist( $type, $path, $not = '' ) + { + $path = $this->v( $path ); + $exists = 'file' === $type ? is_file( $path ) : ( 'directory' === $type ? is_dir( $path ) : is_link( $path ) ); + if ( $exists === (bool) $not ) { + $listing = EE::launch( 'ls -la ' . escapeshellarg( dirname( $path ) ), false, true ); + throw new Exception( sprintf( "Expected %s %s %sto exist.\nls -la %s:\n%s", $type, $path, $not, dirname( $path ), $listing->stdout . $listing->stderr ) ); + } + } + + /** + * @Then /^the file "([^"]*)" should (not )?contain "(.*)"$/ + */ + public function theFileShouldContain( $path, $not, $text ) + { + $this->assert_file_contains( $this->v( $path ), $not, $this->v( $text ) ); + } + + /** + * @Then /^the file "([^"]*)" should (not )?contain:$/ + */ + public function theFileShouldContainBlock( $path, $not, PyStringNode $text ) + { + $this->assert_file_contains( $this->v( $path ), $not, $this->v( $text->getRaw() ) ); + } + + /** + * @Then /^the file "([^"]*)" should be identical to the file "([^"]*)"$/ + */ + public function theFileShouldBeIdenticalTo( $path, $other ) + { + $path = $this->v( $path ); + $other = $this->v( $other ); + if ( ! is_file( $path ) || ! is_file( $other ) || sha1_file( $path ) !== sha1_file( $other ) ) { + throw new Exception( "Expected $path to be identical to $other." ); + } + } + + /** + * @Then /^the directory "([^"]*)" should be empty$/ + */ + public function theDirectoryShouldBeEmpty( $path ) + { + $path = $this->v( $path ); + $entries = is_dir( $path ) ? array_diff( scandir( $path ), [ '.', '..' ] ) : false; + if ( false === $entries || ! empty( $entries ) ) { + throw new Exception( "Expected $path to be an empty directory, found: " . var_export( $entries, true ) ); + } + } + + /** + * Writes only inside {TMP} or paths containing the site prefix. + * + * @When /^I (write|append) "(.*)" to the file "([^"]*)"$/ + */ + public function iWriteToTheFile( $mode, $content, $path ) + { + $path = $this->v( $path ); + $this->assert_writable_path( $path ); + if ( ! is_dir( dirname( $path ) ) ) { + mkdir( dirname( $path ), 0755, true ); + } + $content = str_replace( '\n', "\n", $this->v( $content ) ); + if ( false === file_put_contents( $path, $content, 'append' === $mode ? FILE_APPEND : 0 ) ) { + throw new Exception( "Could not write to $path" ); + } + } + + /** + * @When /^I remove the (?:file|directory) "([^"]*)"$/ + */ + public function iRemoveThePath( $path ) + { + $path = $this->v( $path ); + $this->assert_writable_path( $path ); + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } + + /* ----- /etc/hosts ----- */ + + /** + * Adds a 127.0.0.1 entry that is removed again after the scenario. + * + * @Given /^the hosts file contains an entry for "([^"]*)"$/ + */ + public function theHostsFileContainsAnEntryFor( $host ) + { + $host = $this->v( $host ); + $this->assert_own_name( $host ); + if ( ! $this->hosts_has_entry( $host ) ) { + file_put_contents( '/etc/hosts', "127.0.0.1\t$host\n", FILE_APPEND ); + $this->added_hosts[] = $host; + } + } + + /** + * @When /^I remove the hosts entry for "([^"]*)"$/ + */ + public function iRemoveTheHostsEntryFor( $host ) + { + $host = $this->v( $host ); + $this->assert_own_name( $host ); + $this->remove_hosts_entry( $host ); + $this->added_hosts = array_values( array_diff( $this->added_hosts, [ $host ] ) ); + } + + /** + * @Then /^the hosts file should (not )?contain an entry for "([^"]*)"$/ + */ + public function theHostsFileShouldContainAnEntryFor( $not, $host ) + { + $host = $this->v( $host ); + if ( $this->hosts_has_entry( $host ) === (bool) $not ) { + throw new Exception( "Expected /etc/hosts {$not}to contain an entry for $host:\n" . file_get_contents( '/etc/hosts' ) ); + } + } + + /* ----- docker ----- */ + + /** + * @Then /^the service "([^"]*)" of site "([^"]*)" should be running$/ + */ + public function theServiceOfSiteShouldBeRunning( $service, $site ) + { + $site = $this->v( $site ); + if ( empty( $this->site_container_ids( $site, $service ) ) ) { + $all = EE::launch( 'docker ps -a --filter ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) . ' --format "{{.Names}} {{.Status}}"', false, true ); + throw new Exception( "No running $service container for $site. Containers:\n" . $all->stdout ); + } + } + + /** + * @Then /^the site "([^"]*)" should have no containers$/ + */ + public function theSiteShouldHaveNoContainers( $site ) + { + $site = $this->v( $site ); + $ids = $this->site_container_ids( $site, null, true ); + if ( ! empty( $ids ) ) { + throw new Exception( "Expected no containers for $site, found: " . implode( ', ', $ids ) ); + } + } + + /** + * @Then /^the site "([^"]*)" should have (\d+) docker volumes?$/ + */ + public function theSiteShouldHaveDockerVolumes( $site, $count ) + { + $site = $this->v( $site ); + $run = EE::launch( 'docker volume ls -q --filter label=org.label-schema.vendor=EasyEngine --filter ' . escapeshellarg( 'label=io.easyengine.site=' . $site ), false, true ); + $volumes = array_filter( explode( "\n", trim( $run->stdout ) ) ); + if ( (int) $count !== count( $volumes ) ) { + throw new Exception( "Expected $count volumes for $site, found " . count( $volumes ) . ': ' . implode( ', ', $volumes ) ); + } + } + + /** + * @Then /^the docker (network|volume|container) "([^"]*)" should (not )?exist$/ + */ + public function theDockerObjectShouldExist( $type, $name, $not = '' ) + { + $name = $this->v( $name ); + $run = EE::launch( sprintf( 'docker %s inspect %s', $type, escapeshellarg( $name ) ), false, true ); + if ( ( 0 === $run->return_code ) === (bool) $not ) { + throw new Exception( "Expected docker $type $name {$not}to exist.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^the docker container "([^"]*)" should be running$/ + */ + public function theDockerContainerShouldBeRunning( $name ) + { + $name = $this->v( $name ); + $run = EE::launch( "docker inspect -f '{{.State.Running}}' " . escapeshellarg( $name ), false, true ); + if ( 'true' !== trim( $run->stdout ) ) { + throw new Exception( "Container $name is not running.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^the service "([^"]*)" of site "([^"]*)" should (not )?have the environment variable "([^"]*)" containing "(.*)"$/ + */ + public function theServiceShouldHaveEnv( $service, $site, $not, $variable, $text ) + { + $site = $this->v( $site ); + $text = $this->v( $text ); + $ids = $this->site_container_ids( $site, $service ); + if ( empty( $ids ) ) { + throw new Exception( "No running $service container for $site." ); + } + $run = EE::launch( "docker inspect -f '{{json .Config.Env}}' " . $ids[0], false, true ); + $env = json_decode( trim( $run->stdout ), true ) ?: []; + $found = false; + foreach ( $env as $line ) { + if ( 0 === strpos( $line, $variable . '=' ) && false !== strpos( substr( $line, strlen( $variable ) + 1 ), $text ) ) { + $found = true; + } + } + if ( $found === (bool) $not ) { + throw new Exception( "Expected $variable of $service ($site) {$not}to contain $text. Environment:\n" . implode( "\n", $env ) ); + } + } + + /** + * @When /^I note the start time of service "([^"]*)" of site "([^"]*)"$/ + */ + public function iNoteTheStartTimeOfService( $service, $site ) + { + $site = $this->v( $site ); + $this->start_times[ "$site/$service" ] = $this->service_started_at( $site, $service ); + } + + /** + * @Then /^the service "([^"]*)" of site "([^"]*)" should (not )?have been restarted$/ + */ + public function theServiceShouldHaveBeenRestarted( $service, $site, $not = '' ) + { + $site = $this->v( $site ); + if ( ! isset( $this->start_times[ "$site/$service" ] ) ) { + throw new Exception( "The start time of $service ($site) was not noted." ); + } + $before = $this->start_times[ "$site/$service" ]; + $now = $this->service_started_at( $site, $service ); + if ( ( $before !== $now ) === (bool) $not ) { + throw new Exception( "Expected $service of $site {$not}to have been restarted (StartedAt before: $before, now: $now)." ); + } + } + + /* ----- HTTP ----- */ + + /** + * The host of the URL is resolved to 127.0.0.1 (whitelisted, no HTTP auth), TLS is not verified, redirects are not followed. + * + * @When /^I send a (GET|HEAD) request to "([^"]*)"$/ + */ + public function iSendARequestTo( $method, $url ) + { + $this->response = $this->http_request( $this->v( $url ), $method ); + } + + /** + * Resolves through the host's public IP instead, so the global whitelist does not apply. + * + * @When /^I send a GET request to "([^"]*)" through the public IP$/ + */ + public function iSendARequestThroughThePublicIp( $url ) + { + $this->response = $this->http_request( $this->v( $url ), 'GET', [ 'resolve_ip' => $this->public_ip() ] ); + } + + /** + * Real DNS and full TLS verification (Let's Encrypt sites). + * + * @When /^I send a verified GET request to "([^"]*)"$/ + */ + public function iSendAVerifiedRequestTo( $url ) + { + $this->response = $this->http_request( $this->v( $url ), 'GET', [ 'resolve_ip' => false, 'verify' => true ] ); + } + + /** + * @Then /^the response code should (not )?be (\d{3})$/ + */ + public function theResponseCodeShouldBe( $not, $code ) + { + $response = $this->last_response(); + if ( ( (int) $code === $response['code'] ) === (bool) $not ) { + throw new Exception( "Expected HTTP {$not}$code, got {$response['code']}.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^the response body should (not )?contain "(.*)"$/ + */ + public function theResponseBodyShouldContain( $not, $text ) + { + $response = $this->last_response(); + $text = $this->v( $text ); + if ( ( false !== strpos( $response['body'], $text ) ) === (bool) $not ) { + throw new Exception( "Expected the response body {$not}to contain $text.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^the response header "([^"]*)" should (not )?(be|contain) "(.*)"$/ + */ + public function theResponseHeaderShould( $header, $not, $mode, $value ) + { + $response = $this->last_response(); + $value = $this->v( $value ); + $name = strtolower( $header ); + $actual = isset( $response['headers'][ $name ] ) ? $response['headers'][ $name ] : null; + $matches = null !== $actual && ( 'be' === $mode ? $actual === $value : false !== strpos( $actual, $value ) ); + if ( $matches === (bool) $not ) { + throw new Exception( "Expected header $header {$not}to $mode $value.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^the response should (not )?have the header "([^"]*)"$/ + */ + public function theResponseShouldHaveTheHeader( $not, $header ) + { + $response = $this->last_response(); + if ( isset( $response['headers'][ strtolower( $header ) ] ) === (bool) $not ) { + throw new Exception( "Expected the response {$not}to have the header $header.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^a GET request to "([^"]*)" with Host "([^"]*)" should return (\d{3})$/ + */ + public function aGetRequestWithHostShouldReturn( $url, $host, $code ) + { + $response = $this->http_request( $this->v( $url ), 'GET', [ 'resolve_ip' => false, 'host_header' => $this->v( $host ) ] ); + if ( (int) $code !== $response['code'] ) { + throw new Exception( "Expected HTTP $code, got {$response['code']}.\n" . $this->describe_response( $response ) ); + } + } + + /** + * Polls instead of sleeping (proxy reloads, container restarts). + * + * @When /^I wait up to (\d+) seconds for "([^"]*)" to (not )?return (\d{3})$/ + */ + public function iWaitForUrlToReturn( $seconds, $url, $not, $code ) + { + $url = $this->v( $url ); + $deadline = time() + (int) $seconds; + do { + $this->response = $this->http_request( $url, 'GET' ); + if ( ( (int) $code === $this->response['code'] ) !== (bool) $not ) { + return; + } + sleep( 1 ); + } while ( time() < $deadline ); + throw new Exception( "$url did not {$not}return $code within $seconds seconds.\n" . $this->describe_response( $this->response ) ); + } + + /** + * @When /^I wait up to (\d+) seconds until the command "(.*)" succeeds$/ + */ + public function iWaitUntilTheCommandSucceeds( $seconds, $command ) + { + $command = $this->v( $command ); + $deadline = time() + (int) $seconds; + do { + $run = EE::launch( $command, false, true ); + if ( 0 === $run->return_code ) { + return; + } + sleep( 1 ); + } while ( time() < $deadline ); + throw new Exception( "Command did not succeed within $seconds seconds.\n" . $this->describe( $run ) ); + } + + /* ----- TLS ----- */ + + /** + * Connects to 127.0.0.1:443 with SNI set to the host. + * + * @Then /^the TLS certificate served for "([^"]*)" should (not )?cover "([^"]*)"$/ + */ + public function theTlsCertificateShouldCover( $host, $not, $domain ) + { + $cert = $this->peer_certificate( $this->v( $host ) ); + $domain = $this->v( $domain ); + $names = $this->certificate_names( $cert ); + $covered = false; + foreach ( $names as $name ) { + if ( strtolower( $name ) === strtolower( $domain ) || ( 0 === strpos( $name, '*.' ) && strtolower( substr( $name, 1 ) ) === strtolower( strstr( $domain, '.' ) ) ) ) { + $covered = true; + } + } + if ( $covered === (bool) $not ) { + throw new Exception( "Expected the certificate served for $host {$not}to cover $domain. Names: " . implode( ', ', $names ) ); + } + } + + /** + * @Then /^the TLS certificate served for "([^"]*)" should be issued by "(.*)"$/ + */ + public function theTlsCertificateShouldBeIssuedBy( $host, $issuer ) + { + $cert = $this->peer_certificate( $this->v( $host ) ); + $issuer = $this->v( $issuer ); + $parsed = openssl_x509_parse( $cert ); + $actual = isset( $parsed['issuer'] ) ? $parsed['issuer'] : []; + if ( false === strpos( implode( ' / ', $actual ), $issuer ) ) { + throw new Exception( "Expected the certificate for $host to be issued by $issuer, issuer is: " . json_encode( $actual ) ); + } + } + + /* ----- configuration ----- */ + + /** + * Fails fast instead of letting a command prompt for input (e.g. le-mail). + * + * @Given /^the EasyEngine config value "([^"]*)" is set$/ + */ + public function theConfigValueIsSet( $key ) + { + if ( '' === (string) $this->config_value( $key ) ) { + throw new Exception( "$key is not set in " . EE_ROOT_DIR . '/config/config.yml; this scenario needs it.' ); + } + } + + /** + * @Given /^the EasyEngine config value "([^"]*)" is (not set|"(.*)")$/ + */ + public function theConfigValueIs( $key, $what, $value = null ) + { + $actual = (string) $this->config_value( $key ); + if ( 'not set' === $what ? '' !== $actual : $actual !== $value ) { + throw new Exception( "This scenario needs $key to be $what in " . EE_ROOT_DIR . "/config/config.yml, it is '$actual'." ); + } + } + + /* ----- remote backups (rclone) ----- */ + + /** + * @Given /^there are no remote backups for site "([^"]*)"$/ + */ + public function thereAreNoRemoteBackupsForSite( $site ) + { + $site = $this->v( $site ); + $this->assert_own_name( $site ); + EE::launch( 'rclone purge ' . escapeshellarg( $this->rclone_path() . '/' . $site ), false, true ); + } + + /** + * @Then /^the remote backup storage should have (\d+) backups? for site "([^"]*)"$/ + */ + public function theRemoteStorageShouldHaveBackups( $count, $site ) + { + $backups = $this->remote_backups( $this->v( $site ) ); + if ( (int) $count !== count( $backups ) ) { + throw new Exception( "Expected $count remote backups for $site, found " . count( $backups ) . ":\n" . implode( "\n", $backups ) ); + } + } + + /** + * @Then /^the remote backup storage should have at most the configured "no-of-backups" backups for site "([^"]*)"$/ + */ + public function theRemoteStorageShouldRespectRetention( $site ) + { + $limit = (int) ( $this->config_value( 'no-of-backups' ) ?: 7 ); + $backups = $this->remote_backups( $this->v( $site ) ); + if ( count( $backups ) > $limit ) { + throw new Exception( "no-of-backups is $limit but " . count( $backups ) . " backups are kept for $site:\n" . implode( "\n", $backups ) ); + } + } + + /** + * @Then /^the remote backup "([^"]*)" of site "([^"]*)" should contain the file "([^"]*)"$/ + */ + public function theRemoteBackupShouldContainTheFile( $backup_id, $site, $file ) + { + $site = $this->v( $site ); + $run = EE::launch( 'rclone lsf ' . escapeshellarg( $this->rclone_path() . '/' . $site . '/' . $this->v( $backup_id ) ), false, true ); + if ( ! in_array( $this->v( $file ), explode( "\n", trim( $run->stdout ) ), true ) ) { + throw new Exception( "Remote backup does not contain $file.\n" . $this->describe( $run ) ); + } + } + + /** + * Simulates a restore that died after downloading (BKP-1). + * + * @When /^I download the remote backup "([^"]*)" of site "([^"]*)" into the local backup directory$/ + */ + public function iDownloadTheRemoteBackup( $backup_id, $site ) + { + $site = $this->v( $site ); + $this->assert_own_name( $site ); + $run = EE::launch( sprintf( 'rclone copy %s %s', escapeshellarg( $this->rclone_path() . '/' . $site . '/' . $this->v( $backup_id ) ), escapeshellarg( EE_ROOT_DIR . '/.backup/' . $site ) ), false, true ); + if ( 0 !== $run->return_code ) { + throw new Exception( "rclone copy failed.\n" . $this->describe( $run ) ); + } + } + + /* ----- helpers ----- */ + + /** + * Expands {NAME} variables. + */ + private function v( $value ) + { + if ( ! is_string( $value ) || false === strpos( $value, '{' ) ) { + return $value; + } + + return preg_replace_callback( + '/\{([A-Z][A-Z0-9_]*)\}/', + function ( $m ) { + $value = $this->variable( $m[1] ); + + return null === $value ? $m[0] : $value; + }, + $value + ); + } + + private function variable( $name ) + { + if ( array_key_exists( $name, $this->variables ) ) { + return $this->variables[ $name ]; + } + switch ( $name ) { + case 'EE': + return $this->ee_path . '/bin/ee'; + case 'EE_ROOT_DIR': + return EE_ROOT_DIR; + case 'SITES_DIR': + return EE_SITE_ROOT; + case 'PROXY_DIR': + return EE_ROOT_DIR . '/services/nginx-proxy'; + case 'BACKUP_DIR': + return EE_ROOT_DIR . '/.backup'; + case 'TMP': + return $this->tmp_dir; + case 'RUN_ID': + return self::$run_id; + case 'PUBLIC_IP': + return $this->public_ip(); + case 'RCLONE_PATH': + return $this->rclone_path(); + } + + return null; + } + + private function last_command() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function strip_colors( $text ) + { + return preg_replace( '/\x1b\[[0-9;]*[A-Za-z]/', '', (string) $text ); + } + + private function stream_of( $run, $stream ) + { + return $this->strip_colors( 'STDOUT' === $stream ? $run->stdout : $run->stderr ); + } + + private function describe( $run ) + { + return sprintf( + "Command: %s\nExit code: %s\n--- STDOUT ---\n%s\n--- STDERR ---\n%s", + $run->command, + $run->return_code, + rtrim( $this->strip_colors( $run->stdout ) ), + rtrim( $this->strip_colors( $run->stderr ) ) + ); + } + + private function assert_output_contains( $stream, $not, $text ) + { + $run = $this->last_command(); + $found = false !== strpos( $this->stream_of( $run, $stream ), $text ); + if ( $found === (bool) $not ) { + throw new Exception( sprintf( "Expected %s %sto contain:\n%s\n%s", $stream, $not, $text, $this->describe( $run ) ) ); + } + } + + private function assert_file_contains( $path, $not, $text ) + { + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + $content = file_get_contents( $path ); + if ( ( false !== strpos( $content, $text ) ) === (bool) $not ) { + throw new Exception( "Expected $path {$not}to contain:\n$text\n--- content ---\n$content" ); + } + } + + private function decode_stdout_json() + { + $run = $this->last_command(); + $data = json_decode( trim( $this->stream_of( $run, 'STDOUT' ) ), true ); + if ( null === $data && JSON_ERROR_NONE !== json_last_error() ) { + throw new Exception( 'STDOUT is not valid JSON (' . json_last_error_msg() . ").\n" . $this->describe( $run ) ); + } + + return $data; + } + + private function json_path( $data, $path ) + { + foreach ( explode( '.', $path ) as $key ) { + if ( ! is_array( $data ) || ! array_key_exists( $key, $data ) ) { + throw new Exception( "JSON path $path not found in: " . json_encode( $data ) ); + } + $data = $data[ $key ]; + } + + return $data; + } + + private function is_own_name( $name ) + { + return 1 === preg_match( '/(^|\.)' . preg_quote( self::SITE_PREFIX, '/' ) . '/', strtolower( $name ) ); + } + + private function assert_own_name( $name ) + { + if ( ! $this->is_own_name( $name ) ) { + throw new Exception( "Refusing to touch $name: it does not use the suite prefix " . self::SITE_PREFIX ); + } + } + + private function assert_writable_path( $path ) + { + if ( ( $this->tmp_dir && 0 === strpos( $path, $this->tmp_dir ) ) || false !== strpos( $path, '/' . self::SITE_PREFIX ) || false !== strpos( $path, '.' . self::SITE_PREFIX ) ) { + return; + } + throw new Exception( "Refusing to modify $path: only {TMP} and paths of the suite's own sites may be changed." ); + } + + private static function db() + { + static $pdo = null; + if ( null === $pdo ) { + $pdo = new PDO( 'sqlite:' . EE_ROOT_DIR . '/db/ee.sqlite' ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + } + + return $pdo; + } + + private function get_site_row( $site ) + { + if ( ! is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ) { + return false; + } + $stmt = self::db()->prepare( 'SELECT * FROM sites WHERE site_url = ?' ); + $stmt->execute( [ $site ] ); + + return $stmt->fetch( PDO::FETCH_ASSOC ); + } + + private static function own_sites() + { + // Opening a missing SQLite file would create it. + if ( ! is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ) { + return []; + } + $sites = self::db()->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ); + + return array_values( array_filter( $sites, function ( $site ) { + return 1 === preg_match( '/(^|\.)' . preg_quote( self::SITE_PREFIX, '/' ) . '/', $site ); + } ) ); + } + + private function ensure_site( $site, $flags ) + { + $this->assert_own_name( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + $run = EE::launch( 'bin/ee site create ' . escapeshellarg( $site ) . ' ' . $flags, false, true ); + if ( 0 !== $run->return_code || false === $this->get_site_row( $site ) ) { + throw new Exception( "Could not create $site.\n" . $this->describe( $run ) ); + } + } elseif ( ! $row['site_enabled'] ) { + $this->theSiteIs( $site, 'enabled' ); + } + } + + private function site_container_ids( $site, $service = null, $all = false ) + { + $command = 'docker ps -q' . ( $all ? ' -a' : '' ) . ' --filter ' . escapeshellarg( 'label=io.easyengine.site=' . $site ); + if ( $service ) { + $command .= ' --filter ' . escapeshellarg( 'label=com.docker.compose.service=' . $service ); + } + $run = EE::launch( $command, false, true ); + + return array_values( array_filter( explode( "\n", trim( $run->stdout ) ) ) ); + } + + private function service_started_at( $site, $service ) + { + $ids = $this->site_container_ids( $site, $service ); + if ( empty( $ids ) ) { + throw new Exception( "No running $service container for $site." ); + } + $run = EE::launch( "docker inspect -f '{{.State.StartedAt}}' " . $ids[0], false, true ); + + return trim( $run->stdout ); + } + + private function hosts_has_entry( $host ) + { + return 1 === preg_match( '/^\s*[0-9a-f.:]+\s+(.*\s)?' . preg_quote( $host, '/' ) . '(\s|$)/mi', file_get_contents( '/etc/hosts' ) ); + } + + private function remove_hosts_entry( $host ) + { + $content = file_get_contents( '/etc/hosts' ); + $updated = preg_replace( '/^127\.0\.0\.1\t' . preg_quote( $host, '/' ) . '\n/m', '', $content, 1 ); + if ( null !== $updated && $updated !== $content ) { + file_put_contents( '/etc/hosts', $updated ); + } + } + + private function public_ip() + { + $run = EE::launch( "hostname -I | awk '{print $1}'", false, true ); + + return trim( $run->stdout ); + } + + private function config_value( $key ) + { + $file = EE_ROOT_DIR . '/config/config.yml'; + if ( ! is_file( $file ) ) { + return null; + } + $config = class_exists( 'Mustangostang\Spyc' ) ? Mustangostang\Spyc::YAMLLoad( $file ) : []; + + return isset( $config[ $key ] ) ? $config[ $key ] : null; + } + + private function rclone_path() + { + return rtrim( (string) ( $this->config_value( 'rclone-path' ) ?: 'easyengine:easyengine' ), '/' ); + } + + private function remote_backups( $site ) + { + $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $this->rclone_path() . '/' . $site ), false, true ); + + return array_values( array_filter( array_map( function ( $line ) { + return rtrim( trim( $line ), '/' ); + }, explode( "\n", $run->stdout ) ), function ( $line ) { + return 1 === preg_match( '/\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}/', $line ); + } ) ); + } + + private static function purge_own_remote_backups() + { + $context = new self(); + $base = $context->rclone_path(); + $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $base ), false, true ); + foreach ( explode( "\n", trim( $run->stdout ) ) as $dir ) { + $dir = rtrim( trim( $dir ), '/' ); + if ( '' !== $dir && 0 === strpos( $dir, self::SITE_PREFIX ) ) { + EE::launch( 'rclone purge ' . escapeshellarg( $base . '/' . $dir ), false, true ); + } + } + } + + private function http_request( $url, $method = 'GET', $options = [] ) + { + $options = array_merge( [ 'resolve_ip' => '127.0.0.1', 'verify' => false, 'host_header' => null ], $options ); + $parts = parse_url( $url ); + $ch = curl_init( $url ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, (bool) $options['verify'] ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, $options['verify'] ? 2 : 0 ); + if ( 'HEAD' === $method ) { + curl_setopt( $ch, CURLOPT_NOBODY, true ); + } + if ( $options['resolve_ip'] && ! empty( $parts['host'] ) ) { + $port = isset( $parts['port'] ) ? $parts['port'] : ( 'https' === $parts['scheme'] ? 443 : 80 ); + curl_setopt( $ch, CURLOPT_RESOLVE, [ $parts['host'] . ':' . $port . ':' . $options['resolve_ip'] ] ); + } + if ( $options['host_header'] ) { + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Host: ' . $options['host_header'] ] ); + } + $raw = curl_exec( $ch ); + $error = curl_error( $ch ); + $code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $header_size = (int) curl_getinfo( $ch, CURLINFO_HEADER_SIZE ); + curl_close( $ch ); + + $raw_headers = false === $raw ? '' : substr( $raw, 0, $header_size ); + $headers = []; + foreach ( explode( "\n", $raw_headers ) as $line ) { + if ( false !== strpos( $line, ':' ) ) { + list( $name, $value ) = explode( ':', $line, 2 ); + $headers[ strtolower( trim( $name ) ) ] = trim( $value ); + } + } + + return [ + 'url' => $url, + 'code' => $code, + 'headers' => $headers, + 'raw_headers' => $raw_headers, + 'body' => false === $raw ? '' : substr( $raw, $header_size ), + 'error' => $error, + ]; + } + + private function last_response() + { + if ( null === $this->response ) { + throw new Exception( 'No HTTP request has been sent in this scenario.' ); + } + + return $this->response; + } + + private function describe_response( $response ) + { + return sprintf( "URL: %s\nError: %s\n--- headers ---\n%s--- body (first 500 bytes) ---\n%s", $response['url'], $response['error'], $response['raw_headers'], substr( $response['body'], 0, 500 ) ); + } + + private function peer_certificate( $host ) + { + $context = stream_context_create( [ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'peer_name' => $host, + 'SNI_enabled' => true, + ], + ] ); + $socket = @stream_socket_client( 'ssl://127.0.0.1:443', $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context ); + if ( ! $socket ) { + throw new Exception( "TLS connection for $host failed: $errstr ($errno)" ); + } + $params = stream_context_get_params( $socket ); + fclose( $socket ); + + return $params['options']['ssl']['peer_certificate']; + } + + private function certificate_names( $cert ) + { + $parsed = openssl_x509_parse( $cert ); + $names = []; + if ( ! empty( $parsed['subject']['CN'] ) ) { + $names[] = $parsed['subject']['CN']; + } + if ( ! empty( $parsed['extensions']['subjectAltName'] ) ) { + foreach ( explode( ',', $parsed['extensions']['subjectAltName'] ) as $san ) { + $san = trim( $san ); + if ( 0 === strpos( $san, 'DNS:' ) ) { + $names[] = substr( $san, 4 ); + } + } + } + + return array_values( array_unique( $names ) ); + } } From 81436afa155f6d77e44b679751924ee1ea62fe99 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 06:20:28 +0530 Subject: [PATCH 02/37] test(site-command): cover routing, create, delete, list and info --- features/create.feature | 144 ++++++++++++++++++++++++++++++++++ features/delete.feature | 65 ++++++++++++++++ features/info.feature | 51 ++++++++++++ features/labels.feature | 22 +++++- features/list.feature | 70 +++++++++++++++++ features/site.feature | 169 ++++++++++++++++++++++++++++++++-------- 6 files changed, 487 insertions(+), 34 deletions(-) create mode 100644 features/create.feature create mode 100644 features/delete.feature create mode 100644 features/info.feature create mode 100644 features/list.feature diff --git a/features/create.feature b/features/create.feature new file mode 100644 index 00000000..ba995428 --- /dev/null +++ b/features/create.feature @@ -0,0 +1,144 @@ +Feature: Create html sites + `ee site create` for the html site type: files, containers, volumes, network, hosts entry, DB row and HTTP. + + Scenario: Create an html site + Given the site "bh-site-command-10.test" does not exist + When I run 'bin/ee site create bh-site-command-10.test --type=html' + Then the command should succeed + And STDOUT should contain "Configuration files copied." + And STDOUT should contain "Site entry created." + And STDOUT should contain "http://bh-site-command-10.test" + And the site "bh-site-command-10.test" should exist + And the site "bh-site-command-10.test" should be enabled + And the "site_type" field of site "bh-site-command-10.test" in the database should be "html" + And the "site_ssl" field of site "bh-site-command-10.test" in the database should be "" + And the "alias_domains" field of site "bh-site-command-10.test" in the database should be "bh-site-command-10.test" + And the "site_fs_path" field of site "bh-site-command-10.test" in the database should be "{SITES_DIR}/bh-site-command-10.test" + And the "site_container_fs_path" field of site "bh-site-command-10.test" in the database should be "/var/www/htdocs" + And the "proxy_cache" field of site "bh-site-command-10.test" in the database should be "off" + And the file "{SITES_DIR}/bh-site-command-10.test/docker-compose.yml" should contain "io.easyengine.site=${VIRTUAL_HOST}" + And the file "{SITES_DIR}/bh-site-command-10.test/.env" should contain "VIRTUAL_HOST=bh-site-command-10.test" + And the file "{SITES_DIR}/bh-site-command-10.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-command-10.test;" + And the file "{SITES_DIR}/bh-site-command-10.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs;" + And the file "{SITES_DIR}/bh-site-command-10.test/config/nginx/custom/user.conf" should exist + And the file "{SITES_DIR}/bh-site-command-10.test/app/htdocs/index.html" should contain "Your html site with EasyEngine" + And the service "nginx" of site "bh-site-command-10.test" should be running + And the service "nginx" of site "bh-site-command-10.test" should have the environment variable "HTTPS_METHOD" containing "nohttps" + And the site "bh-site-command-10.test" should have 3 docker volumes + And the docker network "bh-site-command-10.test" should exist + And the hosts file should contain an entry for "bh-site-command-10.test" + When I send a GET request to "http://bh-site-command-10.test/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + + Scenario: The old site assertions still work on a created site + Given the html site "bh-site-command-10.test" exists + Then The site 'bh-site-command-10.test' should have webroot + And Request on 'bh-site-command-10.test' should contain following headers: + | header | + | HTTP/1.1 200 OK | + + Scenario: Creating a site that already exists fails and keeps the site + Given the html site "bh-site-command-10.test" exists + When I run 'bin/ee site create bh-site-command-10.test --type=html' + Then the command should fail + And STDERR should contain "Site bh-site-command-10.test already exists." + And the site "bh-site-command-10.test" should be enabled + When I send a GET request to "http://bh-site-command-10.test/" + Then the response code should be 200 + + Scenario: A site name without a dot gets the .test TLD + Given the site "bh-site-command-11.test" does not exist + When I run 'bin/ee site create bh-site-command-11' + Then the command should succeed + And the site "bh-site-command-11.test" should exist + And the site "bh-site-command-11" should not exist + + Scenario: The URL scheme and a trailing slash are stripped from the site name + Given the site "bh-site-command-12.test" does not exist + When I run 'bin/ee site create http://bh-site-command-12.test/' + Then the command should succeed + And the site "bh-site-command-12.test" should exist + And the directory "{SITES_DIR}/bh-site-command-12.test" should exist + + Scenario: Upper-case site names are lower-cased + Given the site "bh-site-command-13.test" does not exist + When I run 'bin/ee site create BH-SITE-COMMAND-13.TEST' + Then the command should succeed + And the site "bh-site-command-13.test" should exist + And the directory "{SITES_DIR}/bh-site-command-13.test" should exist + + Scenario: Create a site with a custom public directory + Given the site "bh-site-command-14.test" does not exist + When I run 'bin/ee site create bh-site-command-14.test --public-dir=public' + Then the command should succeed + And the "site_container_fs_path" field of site "bh-site-command-14.test" in the database should be "/var/www/htdocs/public" + And the file "{SITES_DIR}/bh-site-command-14.test/app/htdocs/public/index.html" should exist + And the file "{SITES_DIR}/bh-site-command-14.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs/public;" + When I send a GET request to "http://bh-site-command-14.test/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + + Scenario: Create a site without the status check + Given the site "bh-site-command-15.test" does not exist + When I run 'bin/ee site create bh-site-command-15.test --skip-status-check' + Then the command should succeed + And STDOUT should not contain "Checking and verifying site-up status" + And the site "bh-site-command-15.test" should exist + When I wait up to 60 seconds for "http://bh-site-command-15.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Create a site with alias domains + Given the site "bh-site-command-16.test" does not exist + When I run 'bin/ee site create bh-site-command-16.test --alias-domains=a.bh-site-command-16.test,bh-site-command-16-alias.test' + Then the command should succeed + And STDOUT should contain "a.bh-site-command-16.test,bh-site-command-16-alias.test" + And the "alias_domains" field of site "bh-site-command-16.test" in the database should be "bh-site-command-16.test,a.bh-site-command-16.test,bh-site-command-16-alias.test" + And the file "{SITES_DIR}/bh-site-command-16.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-command-16.test a.bh-site-command-16.test bh-site-command-16-alias.test;" + And the service "nginx" of site "bh-site-command-16.test" should have the environment variable "VIRTUAL_HOST" containing "bh-site-command-16.test,a.bh-site-command-16.test,bh-site-command-16-alias.test" + When I send a GET request to "http://bh-site-command-16-alias.test/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + When I send a GET request to "http://a.bh-site-command-16.test/" + Then the response code should be 200 + + Scenario: A domain that is an alias of another site cannot be created as a site + Given the site "bh-site-command-16.test" exists, created with "--type=html --alias-domains=a.bh-site-command-16.test,bh-site-command-16-alias.test" + When I run 'bin/ee site create bh-site-command-16-alias.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-16-alias.test already exists as an alias domain for site: bh-site-command-16.test." + And the site "bh-site-command-16-alias.test" should not exist + + Scenario: An alias domain that belongs to another site is rejected on create + Given the site "bh-site-command-16.test" exists, created with "--type=html --alias-domains=a.bh-site-command-16.test,bh-site-command-16-alias.test" + And the site "bh-site-command-17.test" does not exist + When I run 'bin/ee site create bh-site-command-17.test --alias-domains=bh-site-command-16-alias.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-16-alias.test already exists as an alias domain for site: bh-site-command-16.test." + And the site "bh-site-command-17.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-17.test" should not exist + + Scenario: The EE v3 --html flag still selects the html type + Given the site "bh-site-command-18.test" does not exist + When I run 'bin/ee site create bh-site-command-18.test --html' + Then the command should succeed + And the "site_type" field of site "bh-site-command-18.test" in the database should be "html" + + # Suspected new bug: get_parent_of_alias() finds the parent with LIKE '%alias%', so a longer alias of another site (ab.X contains b.X) is reported as the owner. + @known-bug + Scenario: The owner of an alias is reported correctly when another alias contains it + Given the site "bh-site-command-20.test" exists, created with "--type=html --alias-domains=ab.bh-site-command-20.test" + And the site "bh-site-command-21.test" exists, created with "--type=html --alias-domains=b.bh-site-command-20.test" + When I run 'bin/ee site create b.bh-site-command-20.test' + Then the command should fail + And STDERR should contain "already exists as an alias domain for site: bh-site-command-21.test." + + # SEC-1: site names are not validated, and unescaped shell sinks run before the create fails. + @known-bug + Scenario: Site names that are not valid hostnames are rejected before anything runs + Given the site "bh-site-command-22;id.test" does not exist + When I run "bin/ee site create 'bh-site-command-22;id.test'" + Then the command should fail + And STDERR should contain "Invalid site name" + And the site "bh-site-command-22;id.test" should not exist + And the docker volume "bhsitecommand22" should not exist diff --git a/features/delete.feature b/features/delete.feature new file mode 100644 index 00000000..4b57b7d7 --- /dev/null +++ b/features/delete.feature @@ -0,0 +1,65 @@ +Feature: Delete sites + `ee site delete` removes containers, volumes, network, files, hosts entry, proxy config and the DB row. + + Scenario: Delete an html site and everything that belongs to it + Given the html site "bh-site-command-30.test" exists + When I run 'bin/ee site delete bh-site-command-30.test --yes' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-30.test deleted." + And STDERR should be empty + And the site "bh-site-command-30.test" should not exist + And The 'bh-site-command-30.test' db entry should be removed + And The 'bh-site-command-30.test' webroot should be removed + And Following containers of site 'bh-site-command-30.test' should be removed: + | container | + | nginx | + And the site "bh-site-command-30.test" should have no containers + And the site "bh-site-command-30.test" should have 0 docker volumes + And the docker network "bh-site-command-30.test" should not exist + And the hosts file should not contain an entry for "bh-site-command-30.test" + And the file "{PROXY_DIR}/conf.d/bh-site-command-30.test-redirect.conf" should not exist + When I run 'bin/ee site delete bh-site-command-30.test --yes' + Then the command should fail + And STDERR should contain "Site bh-site-command-30.test does not exist." + + Scenario: Answering no at the prompt keeps the site + Given the html site "bh-site-command-31.test" exists + When I run 'echo n | bin/ee site delete bh-site-command-31.test' + Then the command should succeed + And STDOUT should contain "Are you sure you want to delete bh-site-command-31.test? [y/n]" + And the site "bh-site-command-31.test" should exist + And the service "nginx" of site "bh-site-command-31.test" should be running + + Scenario: Answering yes at the prompt deletes the site + Given the html site "bh-site-command-31.test" exists + When I run 'echo y | bin/ee site delete bh-site-command-31.test' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-31.test deleted." + And the site "bh-site-command-31.test" should not exist + + Scenario: A disabled site can be deleted + Given the html site "bh-site-command-32.test" exists + And the site "bh-site-command-32.test" is disabled + When I run 'bin/ee site delete bh-site-command-32.test --yes' + Then the command should succeed + And the site "bh-site-command-32.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-32.test" should not exist + And the site "bh-site-command-32.test" should have 0 docker volumes + + Scenario: Deleting a self-signed site removes its certificate + Given the site "bh-site-command-33.test" exists, created with "--type=html --ssl=self" + And the file "{PROXY_DIR}/certs/bh-site-command-33.test.crt" should exist + When I run 'bin/ee site delete bh-site-command-33.test --yes' + Then the command should succeed + And STDOUT should contain "Removing ssl certs and other config files." + And the file "{PROXY_DIR}/certs/bh-site-command-33.test.crt" should not exist + And the file "{PROXY_DIR}/certs/bh-site-command-33.test.key" should not exist + + Scenario: Deleting a site frees its alias domains + Given the site "bh-site-command-34.test" exists, created with "--type=html --alias-domains=bh-site-command-34-alias.test" + And the site "bh-site-command-34-alias.test" does not exist + When I run 'bin/ee site delete bh-site-command-34.test --yes' + Then the command should succeed + When I run 'bin/ee site create bh-site-command-34-alias.test' + Then the command should succeed + And the site "bh-site-command-34-alias.test" should exist diff --git a/features/info.feature b/features/info.feature new file mode 100644 index 00000000..cf2b7588 --- /dev/null +++ b/features/info.feature @@ -0,0 +1,51 @@ +Feature: Site info + `ee site info` table and JSON output, site auto-detection from the working directory. + + Background: + Given the html site "bh-site-command-45.test" exists + + Scenario: Show site info as a table + When I run 'bin/ee site info bh-site-command-45.test' + Then the command should succeed + And STDOUT should match "/\|\s*Site\s*\|\s*http:\/\/bh-site-command-45\.test\s*\|/" + And STDOUT should match "/\|\s*Site Root\s*\|\s*\/opt\/easyengine\/sites\/bh-site-command-45\.test\s*\|/" + And STDOUT should match "/\|\s*Alias Domains\s*\|\s*None\s*\|/" + And STDOUT should match "/\|\s*SSL\s*\|\s*Not Enabled\s*\|/" + And STDOUT should not contain "SSL Wildcard" + + Scenario: Show site info as JSON + When I run 'bin/ee site info bh-site-command-45.test --format=json' + Then the command should succeed + And STDOUT should be valid JSON + And the JSON field "site_url" in STDOUT should be "bh-site-command-45.test" + And the JSON field "site_type" in STDOUT should be "html" + And the JSON field "site_enabled" in STDOUT should be "1" + And the JSON field "site_fs_path" in STDOUT should be "{SITES_DIR}/bh-site-command-45.test" + And the JSON field "alias_domains" in STDOUT should be "bh-site-command-45.test" + + Scenario: The site name may be given as a URL + When I run 'bin/ee site info http://bh-site-command-45.test/' + Then the command should succeed + And STDOUT should contain "http://bh-site-command-45.test" + + Scenario: Info works for a disabled site + Given the html site "bh-site-command-46.test" exists + And the site "bh-site-command-46.test" is disabled + When I run 'bin/ee site info bh-site-command-46.test' + Then the command should succeed + And STDOUT should contain "http://bh-site-command-46.test" + + Scenario: The site is detected from the working directory + When I run 'cd {SITES_DIR}/bh-site-command-45.test && {EE} site info' + Then the command should succeed + And STDOUT should contain "http://bh-site-command-45.test" + + Scenario: Another site name inside a site directory is rejected with a hint + When I run 'cd {SITES_DIR}/bh-site-command-45.test && {EE} site info bh-site-command-47.test' + Then the command should fail + And STDERR should contain "bh-site-command-47.test is not a valid site-name. Did you mean `ee site info bh-site-command-45.test`?" + + Scenario: An unsupported format is rejected + When I run 'bin/ee site info bh-site-command-45.test --format=csv' + Then the command should fail + And STDERR should contain "Invalid value specified for 'format'" diff --git a/features/labels.feature b/features/labels.feature index 25ff2184..1102171c 100644 --- a/features/labels.feature +++ b/features/labels.feature @@ -1,9 +1,25 @@ Feature: Container Labels - + Site containers, volumes and networks carry the EasyEngine labels other commands filter on. Scenario: All easyengine containers are tagged - Given I run "bin/ee site create labels.test" + Given the html site "bh-site-command-130.test" exists Then There should be 1 containers with labels """ - io.easyengine.site=labels.test + io.easyengine.site=bh-site-command-130.test + """ + And There should be 1 containers with labels """ + io.easyengine.site=bh-site-command-130.test + com.docker.compose.service=nginx + """ + + Scenario: Site volumes and network are tagged + Given the html site "bh-site-command-130.test" exists + Then the site "bh-site-command-130.test" should have 3 docker volumes + And the docker volume "bhsitecommand130test_htdocs" should exist + And the docker volume "bhsitecommand130test_config_nginx" should exist + And the docker volume "bhsitecommand130test_log_nginx" should exist + When I run 'docker network inspect -f "{{json .Labels}}" bh-site-command-130.test' + Then the command should succeed + And STDOUT should contain ""io.easyengine.site":"bh-site-command-130.test"" + And STDOUT should contain ""org.label-schema.vendor":"EasyEngine"" diff --git a/features/list.feature b/features/list.feature new file mode 100644 index 00000000..88be8361 --- /dev/null +++ b/features/list.feature @@ -0,0 +1,70 @@ +Feature: List sites + `ee site list` formats and the enabled/disabled filters. Other sites may exist on the host, so only this suite's sites are asserted. + + Background: + Given the html site "bh-site-command-40.test" exists + And the html site "bh-site-command-41.test" exists + And the site "bh-site-command-41.test" is disabled + + Scenario: List sites as text + When I run 'bin/ee site list --format=text' + Then the command should succeed + And STDOUT should match "/^bh-site-command-40\.test$/m" + And STDOUT should match "/^bh-site-command-41\.test$/m" + + Scenario: List sites as a table by default + When I run 'bin/ee site list' + Then the command should succeed + And STDOUT should match "/\|\s*site\s*\|\s*status\s*\|/" + And STDOUT should match "/\|\s*bh-site-command-40\.test\s*\|\s*enabled\s*\|/" + And STDOUT should match "/\|\s*bh-site-command-41\.test\s*\|\s*disabled\s*\|/" + + Scenario: List sites as JSON + When I run 'bin/ee site list --format=json' + Then the command should succeed + And STDOUT should be valid JSON + And STDOUT should contain "{"site":"bh-site-command-40.test","status":"enabled"}" + And STDOUT should contain "{"site":"bh-site-command-41.test","status":"disabled"}" + + Scenario: List sites as CSV + When I run 'bin/ee site list --format=csv' + Then the command should succeed + And STDOUT should match "/^site,status$/m" + And STDOUT should match "/^bh-site-command-40\.test,enabled$/m" + And STDOUT should match "/^bh-site-command-41\.test,disabled$/m" + + Scenario: List sites as YAML + When I run 'bin/ee site list --format=yaml' + Then the command should succeed + And STDOUT should contain "site: bh-site-command-40.test" + And STDOUT should contain "site: bh-site-command-41.test" + + Scenario: Count sites + When I run 'bin/ee site list --format=count' + Then the command should succeed + And STDOUT should match "/^\s*([2-9]|\d{2,})\s*$/" + + Scenario: List only enabled sites + When I run 'bin/ee site list --enabled --format=json' + Then the command should succeed + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-40.test" + And the JSON array in STDOUT should not contain an item with "site" set to "bh-site-command-41.test" + And the JSON array in STDOUT should not contain an item with "status" set to "disabled" + + Scenario: List only disabled sites + When I run 'bin/ee site list --disabled --format=json' + Then the command should succeed + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-41.test" + And the JSON array in STDOUT should not contain an item with "site" set to "bh-site-command-40.test" + And the JSON array in STDOUT should not contain an item with "status" set to "enabled" + + Scenario: Both filters together list every site + When I run 'bin/ee site list --enabled --disabled --format=json' + Then the command should succeed + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-40.test" + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-41.test" + + Scenario: An unsupported format is rejected + When I run 'bin/ee site list --format=xml' + Then the command should fail + And STDERR should contain "Invalid value specified for 'format'" diff --git a/features/site.feature b/features/site.feature index 827f6ed8..5f15b4b5 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1,4 +1,5 @@ -Feature: Site Command +Feature: Site command basics + Routing, help, argument validation and error messages that need no site of their own. Scenario: ee executable is command working correctly Given 'bin/ee' is installed @@ -12,36 +13,142 @@ Feature: Site Command Scenario: Check site command is present When I run 'bin/ee site' - Then STDOUT should return something like - """ - usage: ee site - """ + Then STDOUT should contain "usage: ee site" - Scenario: Create html site successfully - When I run 'bin/ee site create site.test --type=html' - Then The site 'site.test' should have webroot - And Request on 'site.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | + Scenario: Help lists every site subcommand + When I run 'bin/ee help site' + Then the command should succeed + And STDOUT should contain "backup" + And STDOUT should contain "clean" + And STDOUT should contain "clone" + And STDOUT should contain "create" + And STDOUT should contain "delete" + And STDOUT should contain "disable" + And STDOUT should contain "enable" + And STDOUT should contain "info" + And STDOUT should contain "list" + And STDOUT should contain "refresh" + And STDOUT should contain "reload" + And STDOUT should contain "restart" + And STDOUT should contain "restore" + And STDOUT should contain "share" + And STDOUT should contain "ssl-info" + And STDOUT should contain "ssl-renew" + And STDOUT should contain "ssl-verify" + And STDOUT should contain "sync" + And STDOUT should contain "update" - Scenario: List the sites - When I run 'bin/ee site list --format=text' - Then STDOUT should return exactly - """ - site.test - """ + Scenario: Help of site create documents the html options + When I run 'bin/ee help site create' + Then the command should succeed + And STDOUT should contain "--ssl" + And STDOUT should contain "--alias-domains" + And STDOUT should contain "--public-dir" + And STDOUT should contain "--skip-status-check" - Scenario: Delete the sites - When I run 'bin/ee site delete site.test --yes' - Then STDOUT should return something like - """ - Site site.test deleted. - """ - And STDERR should return exactly - """ - """ - And The 'site.test' db entry should be removed - And The 'site.test' webroot should be removed - And Following containers of site 'site.test' should be removed: - | container | - | nginx | + Scenario: Help for an unregistered site type fails + When I run 'bin/ee help site --type=bh-bogus' + Then the command should fail + And STDERR should contain "'bh-bogus' is not a registered site type of 'ee site --type=bh-bogus'" + + Scenario: cmd-dump prints the site command tree as JSON + When I run 'bin/ee site cmd-dump' + Then the command should succeed + And STDOUT should be valid JSON + And STDOUT should contain "ssl-renew" + + Scenario: Creating a site of an unregistered type fails + Given the site "bh-site-command-1.test" does not exist + When I run 'bin/ee site create bh-site-command-1.test --type=bh-bogus' + Then the command should fail + And STDERR should contain "'bh-bogus' is not a registered site type of 'ee site --type=bh-bogus'" + And the site "bh-site-command-1.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-1.test" should not exist + + Scenario: Creating a site without a name prints the usage + When I run 'bin/ee site create' + Then the exit code should be 1 + And STDOUT should contain "usage: ee site create " + + Scenario Outline: Subcommands that need a site fail when none is given outside a site directory + When I run 'bin/ee site ' + Then the command should fail + And STDERR should contain "Could not find the site you wish to run site command on." + + Examples: + | command | function | + | info | info | + | update | update | + | enable | enable | + | disable | disable | + | restart | restart | + | reload | reload | + | clean | clean | + | ssl-info | ssl_info | + | backup | backup | + | restore | restore | + + Scenario: ssl-renew without a site fails outside a site directory + Given the EasyEngine config value "le-mail" is set + When I run 'bin/ee site ssl-renew' + Then the command should fail + And STDERR should contain "Could not find the site you wish to run site ssl_renew command on." + + Scenario Outline: Subcommands with a required site name print the usage without one + When I run 'bin/ee site ' + Then the exit code should be 1 + And STDOUT should contain "usage: ee site " + + Examples: + | command | + | delete | + | ssl-verify | + | share | + | clone | + | sync | + + Scenario Outline: Subcommands that look the site up directly report an unknown site + When I run 'bin/ee site bh-site-command-9.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-9.test does not exist." + + Examples: + | command | + | delete | + | refresh | + | ssl-verify | + | ssl | + + Scenario Outline: Subcommands that auto-detect the site report an unknown site + When I run 'bin/ee site bh-site-command-9.test' + Then the command should fail + And STDERR should contain "Could not find the site you wish to run site command on." + + Examples: + | command | function | + | info | info | + | enable | enable | + | disable | disable | + | restart | restart | + | update | update | + | share | share | + + Scenario: Unknown parameters are rejected + When I run 'bin/ee site list --bh-unknown' + Then the command should fail + And STDERR should contain "unknown --bh-unknown parameter" + + Scenario: Extra positional arguments are rejected + Given the html site "bh-site-command-2.test" exists + When I run 'bin/ee site info bh-site-command-2.test bh-site-command-2.test' + Then the command should fail + And STDERR should contain "Too many positional arguments" + + # Suspected new bug: convert_old_args_to_new_args() checks $args[1] === 'create', but $args[1] is the site name, so the EE v3 flag message never shows. + @known-bug + Scenario: Removed EE v3 cache flags give a clear error + Given the site "bh-site-command-3.test" does not exist + When I run 'bin/ee site create bh-site-command-3.test --w3tc' + Then the command should fail + And STDERR should contain "flag/s is/are no longer supported in EE v4" + And the site "bh-site-command-3.test" should not exist From 3ca2128ee7d75b885a01ec1d0ae0c1107226769a Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 06:35:18 +0530 Subject: [PATCH 03/37] test(site-command): cover enable, disable, refresh, restart, reload and clean --- features/clean.feature | 56 ++++++++++++++++++++ features/enable-disable.feature | 94 +++++++++++++++++++++++++++++++++ features/restart-reload.feature | 74 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 features/clean.feature create mode 100644 features/enable-disable.feature create mode 100644 features/restart-reload.feature diff --git a/features/clean.feature b/features/clean.feature new file mode 100644 index 00000000..aef72eb4 --- /dev/null +++ b/features/clean.feature @@ -0,0 +1,56 @@ +Feature: Clean site caches + `ee site clean` on html sites: object/page caches never exist for html, proxy cache only when enabled. + + Scenario: Cleaning all caches of a plain html site fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test' + Then the command should fail + And STDERR should contain "Site object cache is not enabled. Site page cache is not enabled." + + Scenario: Cleaning the object cache of an html site fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test --object' + Then the command should fail + And STDERR should contain "Site object cache is not enabled." + And STDERR should not contain "page cache" + + Scenario: Cleaning the page cache of an html site fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test --page' + Then the command should fail + And STDERR should contain "Site page cache is not enabled." + And STDERR should not contain "object cache" + + Scenario: Cleaning the proxy cache when it is off fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test --proxy' + Then the command should fail + And STDERR should contain "Proxy cache is not enabled on site." + + # CACHE-1 pattern: the proxy purge uses `docker exec -it` and ignores the exit code (no TTY: fails silently; nested ptys: can hang), hence the timeout. + @destructive @known-bug + Scenario: Cleaning the proxy cache empties it + Given the html site "bh-site-command-89.test" exists + When I run 'bin/ee site update bh-site-command-89.test --proxy-cache=on' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-89.test/" to return 200 + And I send a GET request to "http://bh-site-command-89.test/" + And I wait up to 30 seconds until the command "docker exec services_global-nginx-proxy_1 sh -c 'find /var/cache/nginx/bh-site-command-89.test -type f | grep -q .'" succeeds + And I run 'bin/ee site clean bh-site-command-89.test --proxy' with a timeout of 120 seconds + Then the command should succeed + And STDOUT should contain "Proxy cache cleared for bh-site-command-89.test" + When I run "docker exec services_global-nginx-proxy_1 sh -c 'find /var/cache/nginx/bh-site-command-89.test -type f | wc -l'" + Then STDOUT should be exactly: + """ + 0 + """ + And the docker container "services_global-nginx-proxy_1" should be running + + # Suspected new bug: without flags, clean always tries object and page caches, which html sites cannot have, so it fails even after clearing the proxy cache. + @destructive @known-bug + Scenario: Cleaning without flags on a proxy-cached html site succeeds + Given the html site "bh-site-command-89.test" exists + When I run 'bin/ee site update bh-site-command-89.test --proxy-cache=on' with a timeout of 300 seconds + And I run 'bin/ee site clean bh-site-command-89.test' with a timeout of 120 seconds + Then the command should succeed + And STDOUT should contain "Proxy cache cleared for bh-site-command-89.test" diff --git a/features/enable-disable.feature b/features/enable-disable.feature new file mode 100644 index 00000000..6d06b9e1 --- /dev/null +++ b/features/enable-disable.feature @@ -0,0 +1,94 @@ +Feature: Enable, disable and refresh sites + `ee site disable` stops and removes the containers, `ee site enable` brings them back, `--refresh`/`refresh` regenerate docker-compose.yml. + + Scenario: Disable a site + Given the html site "bh-site-command-50.test" exists + When I run 'bin/ee site disable bh-site-command-50.test' + Then the command should succeed + And STDOUT should contain "Disabling site bh-site-command-50.test." + And STDOUT should contain "Site bh-site-command-50.test disabled." + And the site "bh-site-command-50.test" should be disabled + And the site "bh-site-command-50.test" should have no containers + And the site "bh-site-command-50.test" should have 3 docker volumes + And the directory "{SITES_DIR}/bh-site-command-50.test/app/htdocs" should exist + When I wait up to 60 seconds for "http://bh-site-command-50.test/" to not return 200 + Then the response code should not be 200 + + Scenario: Disabling a disabled site is harmless + Given the html site "bh-site-command-50.test" exists + And the site "bh-site-command-50.test" is disabled + When I run 'bin/ee site disable bh-site-command-50.test' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-50.test disabled." + And the site "bh-site-command-50.test" should be disabled + + Scenario Outline: Subcommands that need a running site refuse a disabled one + Given the html site "bh-site-command-51.test" exists + And the site "bh-site-command-51.test" is disabled + When I run 'bin/ee site bh-site-command-51.test ' + Then the command should fail + And STDERR should contain "Site bh-site-command-51.test is not enabled. Use `ee site enable bh-site-command-51.test` to enable it." + + Examples: + | command | flags | + | restart | | + | reload | | + | update | --ssl=self | + | refresh | | + | share | | + | backup | | + | restore | | + + Scenario: Enable a disabled site + Given the html site "bh-site-command-52.test" exists + And the site "bh-site-command-52.test" is disabled + When I run 'bin/ee site enable bh-site-command-52.test' + Then the command should succeed + And STDOUT should contain "Enabling site bh-site-command-52.test." + And STDOUT should contain "Site bh-site-command-52.test enabled." + And STDOUT should contain "Post enable configurations complete." + And the site "bh-site-command-52.test" should be enabled + And the service "nginx" of site "bh-site-command-52.test" should be running + When I wait up to 60 seconds for "http://bh-site-command-52.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Enabling an enabled site fails + Given the html site "bh-site-command-52.test" exists + When I run 'bin/ee site enable bh-site-command-52.test' + Then the command should fail + And STDERR should contain "bh-site-command-52.test is already enabled!" + + Scenario: Force-enable an enabled site + Given the html site "bh-site-command-52.test" exists + When I run 'bin/ee site enable bh-site-command-52.test --force' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-52.test enabled." + And the service "nginx" of site "bh-site-command-52.test" should be running + + Scenario: Enable with --refresh regenerates docker-compose.yml + Given the html site "bh-site-command-53.test" exists + When I append "# bh-site-command-marker" to the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" + And I run 'bin/ee site enable bh-site-command-53.test --refresh' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" should not contain "# bh-site-command-marker" + And the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" should contain "HTTPS_METHOD=nohttps" + And the service "nginx" of site "bh-site-command-53.test" should be running + When I wait up to 60 seconds for "http://bh-site-command-53.test/" to return 200 + + Scenario: refresh is a shortcut for enable --refresh + Given the html site "bh-site-command-53.test" exists + When I append "# bh-site-command-marker" to the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" + And I run 'bin/ee site refresh bh-site-command-53.test' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-53.test enabled." + And the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" should not contain "# bh-site-command-marker" + When I wait up to 60 seconds for "http://bh-site-command-53.test/" to return 200 + + Scenario: Enable with verification of the global services + Given the html site "bh-site-command-54.test" exists + And the site "bh-site-command-54.test" is disabled + When I run 'bin/ee site enable bh-site-command-54.test --verify' + Then the command should succeed + And the site "bh-site-command-54.test" should be enabled + And the docker container "services_global-nginx-proxy_1" should be running + When I wait up to 60 seconds for "http://bh-site-command-54.test/" to return 200 diff --git a/features/restart-reload.feature b/features/restart-reload.feature new file mode 100644 index 00000000..dab7c6f8 --- /dev/null +++ b/features/restart-reload.feature @@ -0,0 +1,74 @@ +Feature: Restart and reload site services + `ee site restart` recreates nothing but restarts containers after `nginx -t`; `ee site reload` reloads nginx in place. + + Scenario Outline: Restart the site containers + Given the html site "bh-site-command-56.test" exists + When I note the start time of service "nginx" of site "bh-site-command-56.test" + And I run 'bin/ee site restart bh-site-command-56.test ' + Then the command should succeed + And STDOUT should contain "Restarting nginx" + And the service "nginx" of site "bh-site-command-56.test" should have been restarted + When I wait up to 60 seconds for "http://bh-site-command-56.test/" to return 200 + + Examples: + | flags | + | | + | --nginx | + | --all | + + Scenario: Restart rejects services the html type does not have + Given the html site "bh-site-command-56.test" exists + When I run 'bin/ee site restart bh-site-command-56.test --php' + Then the command should fail + And STDERR should contain "unknown --php parameter" + + Scenario Outline: Reload nginx without restarting the container + Given the html site "bh-site-command-56.test" exists + When I note the start time of service "nginx" of site "bh-site-command-56.test" + And I run 'bin/ee site reload bh-site-command-56.test ' + Then the command should succeed + And STDOUT should contain "Reloading nginx" + And the service "nginx" of site "bh-site-command-56.test" should not have been restarted + When I send a GET request to "http://bh-site-command-56.test/" + Then the response code should be 200 + + Examples: + | flags | + | | + | --nginx | + | --all | + + Scenario: Restart refuses a broken nginx config and keeps the site serving + Given the html site "bh-site-command-57.test" exists + And I write "# Custom Nginx configuration can be put here\nbh_invalid_directive on;\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" + When I note the start time of service "nginx" of site "bh-site-command-57.test" + And I run 'bin/ee site restart bh-site-command-57.test --nginx' + Then the command should fail + And STDERR should contain "Nginx test failed" + And the service "nginx" of site "bh-site-command-57.test" should not have been restarted + When I send a GET request to "http://bh-site-command-57.test/" + Then the response code should be 200 + When I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" + And I run 'bin/ee site restart bh-site-command-57.test --nginx' + Then the command should succeed + + Scenario: Custom nginx config is applied on reload + Given the html site "bh-site-command-58.test" exists + When I write "location = /bh-probe { return 200 'bh-site-command-probe'; }\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" + And I run 'bin/ee site reload bh-site-command-58.test' + Then the command should succeed + When I send a GET request to "http://bh-site-command-58.test/bh-probe" + Then the response code should be 200 + And the response body should contain "bh-site-command-probe" + When I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" + And I run 'bin/ee site reload bh-site-command-58.test' + Then the command should succeed + + # Suspected new bug: reload_services() ignores the exit code of `nginx -t && nginx -s reload`, so a failed reload exits 0. + @known-bug + Scenario: Reload fails when the nginx config test fails + Given the html site "bh-site-command-57.test" exists + And I write "# Custom Nginx configuration can be put here\nbh_invalid_directive on;\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" + When I run 'bin/ee site reload bh-site-command-57.test' + And I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" + Then the command should fail From 30f9463a0992b57661539b47e26c690f8e7631b4 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 06:53:01 +0530 Subject: [PATCH 04/37] test(site-command): cover updates, ssl, redirects and share --- features/proxy-cache.feature | 89 ++++++++++++++++ features/redirect.feature | 92 ++++++++++++++--- features/share.feature | 21 ++++ features/ssl.feature | 176 ++++++++++++++++++++++++++++++++ features/update-alias.feature | 97 ++++++++++++++++++ features/update-ssl-php.feature | 112 ++++++++++++++++++++ 6 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 features/proxy-cache.feature create mode 100644 features/share.feature create mode 100644 features/ssl.feature create mode 100644 features/update-alias.feature create mode 100644 features/update-ssl-php.feature diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature new file mode 100644 index 00000000..8be2d228 --- /dev/null +++ b/features/proxy-cache.feature @@ -0,0 +1,89 @@ +@destructive +Feature: Proxy cache + `ee site update --proxy-cache` writes nginx-proxy config and restarts the GLOBAL nginx-proxy (all sites blip), hence @destructive. + Every scenario ends with the proxy running. + + Scenario: Enable the proxy cache with defaults + Given the html site "bh-site-command-80.test" exists + When I run 'bin/ee site update bh-site-command-80.test --proxy-cache=on' + Then the command should succeed + And STDOUT should contain "Enabling proxy cache for: bh-site-command-80.test" + And STDOUT should contain "Proxy cache update on site bh-site-command-80.test completed." + And the "proxy_cache" field of site "bh-site-command-80.test" in the database should be "on" + And the file "{PROXY_DIR}/conf.d/bh-site-command-80.test.conf" should contain "keys_zone=bh-site-command-80-test:10m max_size=256m" + And the file "{PROXY_DIR}/vhost.d/bh-site-command-80.test_location" should contain "proxy_cache_valid 200 301 302 404 1s;" + And the docker container "services_global-nginx-proxy_1" should be running + When I wait up to 60 seconds for "http://bh-site-command-80.test/" to return 200 + Then the response should have the header "X-Proxy-Cache" + + Scenario: Enabling the proxy cache twice fails + Given the html site "bh-site-command-81.test" exists + When I run 'bin/ee site update bh-site-command-81.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-81.test --proxy-cache=on' + Then the command should fail + And STDERR should contain "Site bh-site-command-81.test already has proxy cache: on" + + Scenario: Enable the proxy cache with custom sizes and time + Given the html site "bh-site-command-82.test" exists + When I run 'bin/ee site update bh-site-command-82.test --proxy-cache=on --proxy-cache-max-size=1g --proxy-cache-max-time=30s --proxy-cache-key-zone-size=20m' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-82.test.conf" should contain "keys_zone=bh-site-command-82-test:20m max_size=1g" + And the file "{PROXY_DIR}/vhost.d/bh-site-command-82.test_location" should contain "proxy_cache_valid 200 301 302 404 30s;" + When I wait up to 60 seconds for "http://bh-site-command-82.test/" to return 200 + + Scenario: Invalid proxy cache values fall back to the defaults with warnings + Given the html site "bh-site-command-83.test" exists + When I run 'bin/ee site update bh-site-command-83.test --proxy-cache=on --proxy-cache-max-size=big --proxy-cache-max-time=forever --proxy-cache-key-zone-size=1g' + Then the command should succeed + And STDERR should contain "Wrong value `big` supplied to param: `proxy-cache-max-size`, replacing it with default:256m" + And STDERR should contain "Wrong value `forever` supplied to param: `proxy-cache-max-time`, replacing it with default:1s" + And STDERR should contain "Wrong value `1g` supplied to param: `proxy-cache-key-zone-size`, replacing it with default:10m" + And the file "{PROXY_DIR}/conf.d/bh-site-command-83.test.conf" should contain "keys_zone=bh-site-command-83-test:10m max_size=256m" + When I wait up to 60 seconds for "http://bh-site-command-83.test/" to return 200 + + Scenario: Disable the proxy cache + Given the html site "bh-site-command-84.test" exists + When I run 'bin/ee site update bh-site-command-84.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-84.test --proxy-cache=off' + Then the command should succeed + And STDOUT should contain "Disabling proxy cache for: bh-site-command-84.test" + And the "proxy_cache" field of site "bh-site-command-84.test" in the database should be "off" + And the file "{PROXY_DIR}/conf.d/bh-site-command-84.test.conf" should not exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-84.test_location" should not exist + When I wait up to 60 seconds for "http://bh-site-command-84.test/" to return 200 + Then the response should not have the header "X-Proxy-Cache" + + Scenario: Disabling a proxy cache that is off fails + Given the html site "bh-site-command-85.test" exists + When I run 'bin/ee site update bh-site-command-85.test --proxy-cache=off' + Then the command should fail + And STDERR should contain "Site bh-site-command-85.test already has proxy cache: off" + + Scenario: An invalid proxy cache switch is rejected + Given the html site "bh-site-command-85.test" exists + When I run 'bin/ee site update bh-site-command-85.test --proxy-cache=maybe' + Then the command should fail + And STDERR should contain "Invalid value specified for 'proxy-cache'" + + Scenario: Alias domains get proxy cache locations, also when added later + Given the site "bh-site-command-86.test" exists, created with "--type=html --alias-domains=bh-site-command-86-a.test" + When I run 'bin/ee site update bh-site-command-86.test --proxy-cache=on' + Then the command should succeed + And the file "{PROXY_DIR}/vhost.d/bh-site-command-86-a.test_location" should exist + When I run 'bin/ee site update bh-site-command-86.test --add-alias-domains=bh-site-command-86-b.test' + Then the command should succeed + And STDOUT should contain "As proxy cache is enabled on this site, updating config to enable it in newly added alias domains." + And the file "{PROXY_DIR}/vhost.d/bh-site-command-86-b.test_location" should exist + And the docker container "services_global-nginx-proxy_1" should be running + + Scenario: Deleting a proxy-cached site removes its proxy config + Given the html site "bh-site-command-87.test" exists + When I run 'bin/ee site update bh-site-command-87.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site delete bh-site-command-87.test --yes' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-87.test.conf" should not exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-87.test_location" should not exist + And the docker container "services_global-nginx-proxy_1" should be running diff --git a/features/redirect.feature b/features/redirect.feature index 3af18947..df12a8a2 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,19 +1,83 @@ Feature: Site Redirection + www <-> non-www redirects in nginx-proxy. EE only writes the redirect when the other name resolves to this host, so each scenario adds (and afterwards removes) the needed hosts entry. Scenario: no_www-no_ssl redirection works properly - When I run '/bin/bash -c 'echo "127.0.0.1 www.example.test" >> /etc/hosts'' - And I run 'bin/ee site create example.test' - Then After delay of 5 seconds - Then Request on 'localhost' with header 'Host: www.example.test' should contain following headers: - | header | - | HTTP/1.1 301 Moved Permanently | - | Location: http://example.test/ | + Given the hosts file contains an entry for "www.bh-site-command-120.test" + And the site "bh-site-command-120.test" does not exist + When I run 'bin/ee site create bh-site-command-120.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-120.test-redirect.conf" should contain "server_name www.bh-site-command-120.test;" + When I wait up to 60 seconds for "http://www.bh-site-command-120.test/" to return 301 + Then the response header "Location" should be "http://bh-site-command-120.test/" Scenario: www-no_ssl redirection works properly - When I run '/bin/bash -c 'echo "127.0.0.1 example1.test" >> /etc/hosts'' - And I run 'bin/ee site create www.example1.test' - Then After delay of 5 seconds - Then Request on 'localhost' with header 'Host: example1.test' should contain following headers: - | header | - | HTTP/1.1 301 Moved Permanently | - | Location: http://www.example1.test/ | + Given the hosts file contains an entry for "bh-site-command-121.test" + And the site "www.bh-site-command-121.test" does not exist + When I run 'bin/ee site create www.bh-site-command-121.test' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-121.test/" to return 301 + Then the response header "Location" should be "http://www.bh-site-command-121.test/" + + Scenario: No redirect is written when the www name does not resolve here + Given the site "bh-site-command-122.test" does not exist + And the hosts file should not contain an entry for "www.bh-site-command-122.test" + When I run 'bin/ee site create bh-site-command-122.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-122.test-redirect.conf" should not exist + + Scenario: Disable removes the redirect and enable restores it + Given the hosts file contains an entry for "www.bh-site-command-123.test" + And the site "bh-site-command-123.test" does not exist + When I run 'bin/ee site create bh-site-command-123.test' + Then the file "{PROXY_DIR}/conf.d/bh-site-command-123.test-redirect.conf" should exist + When I run 'bin/ee site disable bh-site-command-123.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-123.test-redirect.conf" should not exist + When I run 'bin/ee site enable bh-site-command-123.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-123.test-redirect.conf" should exist + When I wait up to 60 seconds for "http://www.bh-site-command-123.test/" to return 301 + Then the response header "Location" should be "http://bh-site-command-123.test/" + + Scenario: Delete removes the redirect + Given the hosts file contains an entry for "www.bh-site-command-124.test" + And the site "bh-site-command-124.test" does not exist + When I run 'bin/ee site create bh-site-command-124.test' + Then the file "{PROXY_DIR}/conf.d/bh-site-command-124.test-redirect.conf" should exist + When I run 'bin/ee site delete bh-site-command-124.test --yes' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-124.test-redirect.conf" should not exist + + Scenario: HTTPS www redirect on a self-signed site + Given the hosts file contains an entry for "www.bh-site-command-126.test" + And the site "bh-site-command-126.test" does not exist + When I run 'bin/ee site create bh-site-command-126.test --ssl=self' + Then the command should succeed + When I wait up to 60 seconds for "https://www.bh-site-command-126.test/" to return 301 + Then the response header "Location" should be "https://bh-site-command-126.test/" + When I send a GET request to "http://www.bh-site-command-126.test/" + Then the response code should be 301 + And the response header "Location" should be "https://bh-site-command-126.test/" + + # Suspected new bug: add_site_redirects()/check_www_or_non_www_domain()/get_cert_domains() use ltrim($site, 'www.'), which strips characters, not the prefix: www.w.x.test becomes x.test. + @known-bug + Scenario: A www site whose next label starts with w redirects the right name + Given the hosts file contains an entry for "w.bh-site-command-125.test" + And the site "www.w.bh-site-command-125.test" does not exist + When I run 'bin/ee site create www.w.bh-site-command-125.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/www.w.bh-site-command-125.test-redirect.conf" should contain "server_name w.bh-site-command-125.test;" + When I wait up to 60 seconds for "http://w.bh-site-command-125.test/" to return 301 + Then the response header "Location" should be "http://www.w.bh-site-command-125.test/" + + # MIG-15: enable (also run by the image migration) re-checks www with a real DNS lookup and deletes the redirect when the check fails. + @known-bug + Scenario: Re-enabling a site keeps its www redirect when the www check fails + Given the hosts file contains an entry for "www.bh-site-command-127.test" + And the site "bh-site-command-127.test" does not exist + When I run 'bin/ee site create bh-site-command-127.test' + Then the file "{PROXY_DIR}/conf.d/bh-site-command-127.test-redirect.conf" should exist + When I remove the hosts entry for "www.bh-site-command-127.test" + And I run 'bin/ee site enable bh-site-command-127.test --force' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-127.test-redirect.conf" should exist diff --git a/features/share.feature b/features/share.feature new file mode 100644 index 00000000..5265b915 --- /dev/null +++ b/features/share.feature @@ -0,0 +1,21 @@ +Feature: Share a site + `ee site share` guards. Sharing itself needs the retired ngrok v2 download and an ngrok account, so it is not exercised. + + Scenario: Disabling a share that is not running fails + Given the html site "bh-site-command-90.test" exists + When I run 'bin/ee site share bh-site-command-90.test --disable' + Then the command should fail + And STDERR should contain "bh-site-command-90.test does not have active share running." + + Scenario: Refreshing a share that is not running fails + Given the html site "bh-site-command-90.test" exists + When I run 'bin/ee site share bh-site-command-90.test --refresh' + Then the command should fail + And STDERR should contain "bh-site-command-90.test does not have active share running." + + Scenario: SSL sites cannot be shared + Given the site "bh-site-command-91.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site share bh-site-command-91.test' + Then the command should fail + And STDERR should contain "site share is not yet supported for ssl sites." + And the file "{EE_ROOT_DIR}/services/ngrok/ngrok" should not exist diff --git a/features/ssl.feature b/features/ssl.feature new file mode 100644 index 00000000..b98afd9a --- /dev/null +++ b/features/ssl.feature @@ -0,0 +1,176 @@ +Feature: Self-signed, custom and inherited SSL + `ee site create --ssl=self|custom|inherit`, `ee site ssl-info` and the `ee site ssl-renew` guards. Let's Encrypt is in le.feature. + + Scenario: Create a site with a self-signed certificate + Given the site "bh-site-command-100.test" does not exist + When I run 'bin/ee site create bh-site-command-100.test --ssl=self' + Then the command should succeed + And the "site_ssl" field of site "bh-site-command-100.test" in the database should be "self" + And the "site_ssl_wildcard" field of site "bh-site-command-100.test" in the database should be "1" + And the file "{PROXY_DIR}/certs/bh-site-command-100.test.crt" should exist + And the file "{PROXY_DIR}/certs/bh-site-command-100.test.key" should exist + And the hosts file should contain an entry for "bh-site-command-100.test" + And the service "nginx" of site "bh-site-command-100.test" should not have the environment variable "HTTPS_METHOD" containing "nohttps" + When I wait up to 60 seconds for "https://bh-site-command-100.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + And the TLS certificate served for "bh-site-command-100.test" should cover "bh-site-command-100.test" + And the TLS certificate served for "bh-site-command-100.test" should cover "anything.bh-site-command-100.test" + And the TLS certificate served for "bh-site-command-100.test" should be issued by "EasyEngine" + When I send a GET request to "http://bh-site-command-100.test/" + Then the response code should be 301 + And the response header "Location" should be "https://bh-site-command-100.test/" + + Scenario: Info of a self-signed site shows SSL and wildcard + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site info bh-site-command-100.test' + Then the command should succeed + And STDOUT should match "/\|\s*Site\s*\|\s*https:\/\/bh-site-command-100\.test\s*\|/" + And STDOUT should match "/\|\s*SSL\s*\|\s*Enabled\s*\|/" + And STDOUT should match "/\|\s*SSL Wildcard\s*\|\s*Yes\s*\|/" + + Scenario: ssl-info shows the certificate of a self-signed site + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test' + Then the command should succeed + And STDOUT should be valid JSON + And the JSON field "0.ssl_type" in STDOUT should be "self" + And the JSON field "0.cert_file" in STDOUT should be "{PROXY_DIR}/certs/bh-site-command-100.test.crt" + And the JSON field "0.issued_to_CN" in STDOUT should be "bh-site-command-100.test" + And the JSON field "0.issued_by_CN" in STDOUT should be "EasyEngine" + And the JSON field "0.SANs" in STDOUT should contain "*.bh-site-command-100.test" + And the JSON field "0.status" in STDOUT should be "SSL certificate details loaded." + + Scenario: ssl-info supports other formats + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test --format=yaml' + Then the command should succeed + And STDOUT should contain "ssl_type: self" + + Scenario: ssl-info rejects an unsupported format + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test --format=xml' + Then the command should fail + And STDERR should contain "Invalid value specified for 'format'" + + Scenario: ssl-info of a site without SSL + Given the html site "bh-site-command-101.test" exists + When I run 'bin/ee site ssl-info bh-site-command-101.test' + Then the command should succeed + And the JSON field "0.ssl_type" in STDOUT should be "off" + And the JSON field "0.status" in STDOUT should be "SSL is not enabled for this site." + + Scenario: ssl-info DNS records of a site that does not use DNS-01 + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test --get-dns-records' + Then the command should succeed + And the JSON field "0.warnings" in STDOUT should contain "This site does not use DNS-based (DNS-01) SSL challenge." + + Scenario Outline: ssl-renew only renews Let's Encrypt certificates + Given the EasyEngine config value "le-mail" is set + And the site "" exists, created with "" + When I run 'bin/ee site ssl-renew ' + Then the command should fail + And STDERR should contain "Only Letsencrypt certificate renewal is supported." + + Examples: + | site | flags | + | bh-site-command-101.test | --type=html | + | bh-site-command-100.test | --type=html --ssl=self | + + Scenario: Create a site with a custom certificate + Given the site "bh-site-command-103.test" does not exist + When I run "openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout {TMP}/bh.key -out {TMP}/bh.crt -subj '/CN=bh-site-command-103.test/O=bh-site-command-custom' -addext 'subjectAltName=DNS:bh-site-command-103.test'" + Then the command should succeed + When I run 'bin/ee site create bh-site-command-103.test --ssl=custom --ssl-key={TMP}/bh.key --ssl-crt={TMP}/bh.crt' + Then the command should succeed + And the "site_ssl" field of site "bh-site-command-103.test" in the database should be "custom" + And the file "{PROXY_DIR}/certs/bh-site-command-103.test.crt" should be identical to the file "{TMP}/bh.crt" + And the file "{PROXY_DIR}/certs/bh-site-command-103.test.key" should be identical to the file "{TMP}/bh.key" + When I wait up to 60 seconds for "https://bh-site-command-103.test/" to return 200 + Then the TLS certificate served for "bh-site-command-103.test" should be issued by "bh-site-command-custom" + When I run 'bin/ee site ssl-info bh-site-command-103.test' + Then the JSON field "0.ssl_type" in STDOUT should be "custom" + And the JSON field "0.issued_by_CN" in STDOUT should be "bh-site-command-103.test" + + Scenario: Custom SSL needs both a key and a certificate + Given the site "bh-site-command-104.test" does not exist + When I run 'bin/ee site create bh-site-command-104.test --ssl=custom' + Then the command should fail + And STDERR should contain "Pass --ssl-key and --ssl-crt for custom SSL" + And the site "bh-site-command-104.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-104.test" should not exist + + Scenario: Custom SSL files must exist + Given the site "bh-site-command-104.test" does not exist + When I run 'bin/ee site create bh-site-command-104.test --ssl=custom --ssl-key={TMP}/missing.key --ssl-crt={TMP}/missing.crt' + Then the command should fail + And STDERR should contain "ssl-key OR ssl-crt path does not exist" + And the site "bh-site-command-104.test" should not exist + + Scenario: A subdomain site inherits the wildcard certificate of its parent + Given the site "bh-site-command-105.test" exists, created with "--type=html --ssl=self" + And the site "sub.bh-site-command-105.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-105.test --ssl=inherit' + Then the command should succeed + And STDOUT should contain "Inherited certs from parent" + And the "site_ssl" field of site "sub.bh-site-command-105.test" in the database should be "inherit" + When I wait up to 60 seconds for "https://sub.bh-site-command-105.test/" to return 200 + Then the TLS certificate served for "sub.bh-site-command-105.test" should cover "sub.bh-site-command-105.test" + When I run 'bin/ee site ssl-info sub.bh-site-command-105.test' + Then the JSON field "0.cert_file" in STDOUT should be "{PROXY_DIR}/certs/bh-site-command-105.test.crt" + + Scenario: ssl-renew refuses inherited certificates + Given the EasyEngine config value "le-mail" is set + And the site "bh-site-command-105.test" exists, created with "--type=html --ssl=self" + And the site "sub.bh-site-command-105.test" exists, created with "--ssl=inherit" + When I run 'bin/ee site ssl-renew sub.bh-site-command-105.test' + Then the command should fail + And STDERR should contain "No need to renew certs for site who have inherited ssl. Please renew certificate of the parent site." + + Scenario: Inheriting needs an existing parent site + Given the site "bh-site-command-106.test" does not exist + And the site "sub.bh-site-command-106.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-106.test --ssl=inherit' + Then the command should fail + And STDERR should contain "Unable to find existing site: bh-site-command-106.test" + And the site "sub.bh-site-command-106.test" should not exist + And the directory "{SITES_DIR}/sub.bh-site-command-106.test" should not exist + + Scenario: Inheriting needs a parent with SSL + Given the html site "bh-site-command-107.test" exists + And the site "sub.bh-site-command-107.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-107.test --ssl=inherit' + Then the command should fail + And STDERR should contain "Cannot inherit from bh-site-command-107.test as site does not have SSL cert" + And the site "sub.bh-site-command-107.test" should not exist + + Scenario: Inheriting cannot be combined with --wildcard + Given the site "bh-site-command-105.test" exists, created with "--type=html --ssl=self" + And the site "sub2.bh-site-command-105.test" does not exist + When I run 'bin/ee site create sub2.bh-site-command-105.test --ssl=inherit --wildcard' + Then the command should fail + And STDERR should contain "Cannot use --wildcard with --ssl=inherit" + And the site "sub2.bh-site-command-105.test" should not exist + + # Suspected new bug: check_parent_site_certs() builds its exception message with var_dump(), which prints the parent site object to STDOUT. + @known-bug + Scenario: A failed inherit does not dump the parent site object + Given the html site "bh-site-command-107.test" exists + And the site "sub.bh-site-command-107.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-107.test --ssl=inherit' + Then the command should fail + And STDOUT should not contain "object(" + + # SEC-3: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3 rejects every leaf it signed (error 79). + @known-bug + Scenario: Self-signed site certificates verify against the EasyEngine root CA + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'openssl verify -CAfile {PROXY_DIR}/self-signed-certs/rootCA.pem {PROXY_DIR}/certs/bh-site-command-100.test.crt' + Then the command should succeed + + # SSL-4: self-signed certs cover only the site and *.site, alias domains get a mismatched cert. + @known-bug + Scenario: The self-signed certificate covers alias domains + Given the site "bh-site-command-102.test" exists, created with "--type=html --ssl=self --alias-domains=bh-site-command-102-a.test" + When I wait up to 60 seconds for "https://bh-site-command-102-a.test/" to return 200 + Then the TLS certificate served for "bh-site-command-102-a.test" should cover "bh-site-command-102-a.test" diff --git a/features/update-alias.feature b/features/update-alias.feature new file mode 100644 index 00000000..bdbd3cb2 --- /dev/null +++ b/features/update-alias.feature @@ -0,0 +1,97 @@ +Feature: Update alias domains + `ee site update --add-alias-domains/--delete-alias-domains` validation, DB, proxy env and HTTP. + + Scenario: Add alias domains + Given the html site "bh-site-command-60.test" exists + When I run 'bin/ee site update bh-site-command-60.test --add-alias-domains=bh-site-command-60-a.test,b.bh-site-command-60.test' + Then the command should succeed + And STDOUT should contain "Alias domains updated on site bh-site-command-60.test." + And the "alias_domains" field of site "bh-site-command-60.test" in the database should be "bh-site-command-60.test,bh-site-command-60-a.test,b.bh-site-command-60.test" + And the service "nginx" of site "bh-site-command-60.test" should have the environment variable "VIRTUAL_HOST" containing "bh-site-command-60-a.test,b.bh-site-command-60.test" + When I wait up to 60 seconds for "http://bh-site-command-60-a.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + When I wait up to 60 seconds for "http://b.bh-site-command-60.test/" to return 200 + When I run 'bin/ee site info bh-site-command-60.test' + Then STDOUT should match "/\|\s*Alias Domains\s*\|\s*bh-site-command-60-a\.test,b\.bh-site-command-60\.test\s*\|/" + + Scenario: Adding an alias that is already present fails + Given the site "bh-site-command-61.test" exists, created with "--type=html --alias-domains=bh-site-command-61-a.test" + When I run 'bin/ee site update bh-site-command-61.test --add-alias-domains=bh-site-command-61-a.test' + Then the command should fail + And STDERR should contain "Alias domains: bh-site-command-61-a.test is/are already present on the site." + + Scenario: Adding present and new aliases skips the present ones with a warning + Given the site "bh-site-command-61.test" exists, created with "--type=html --alias-domains=bh-site-command-61-a.test" + When I run 'bin/ee site update bh-site-command-61.test --add-alias-domains=bh-site-command-61-a.test,bh-site-command-61-b.test' + Then the command should succeed + And STDERR should contain "Following domains: bh-site-command-61-a.test is/are already present on site, skipping addition of those." + And the "alias_domains" field of site "bh-site-command-61.test" in the database should be "bh-site-command-61.test,bh-site-command-61-a.test,bh-site-command-61-b.test" + + Scenario: Delete an alias domain + Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" + When I run 'bin/ee site update bh-site-command-62.test --delete-alias-domains=bh-site-command-62-b.test' + Then the command should succeed + And STDOUT should contain "Alias domains updated on site bh-site-command-62.test." + And the "alias_domains" field of site "bh-site-command-62.test" in the database should be "bh-site-command-62.test,bh-site-command-62-a.test" + And the service "nginx" of site "bh-site-command-62.test" should not have the environment variable "VIRTUAL_HOST" containing "bh-site-command-62-b.test" + When I wait up to 60 seconds for "http://bh-site-command-62-b.test/" to not return 200 + And I send a GET request to "http://bh-site-command-62-a.test/" + Then the response code should be 200 + + Scenario: The primary domain cannot be deleted + Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" + When I run 'bin/ee site update bh-site-command-62.test --delete-alias-domains=bh-site-command-62.test' + Then the command should fail + And STDERR should contain "Primary site domain: `bh-site-command-62.test` can not be deleted." + + Scenario: Deleting an alias the site does not have fails + Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" + When I run 'bin/ee site update bh-site-command-62.test --delete-alias-domains=bh-site-command-62-zzz.test' + Then the command should fail + And STDERR should contain "Domains to delete is/are not a subset of already existing alias domains." + + Scenario: Another site cannot be added as an alias + Given the html site "bh-site-command-63.test" exists + And the html site "bh-site-command-64.test" exists + When I run 'bin/ee site update bh-site-command-63.test --add-alias-domains=bh-site-command-64.test' + Then the command should fail + And STDERR should contain "bh-site-command-64.test already exists as a site." + And the "alias_domains" field of site "bh-site-command-63.test" in the database should be "bh-site-command-63.test" + + Scenario: An alias of another site cannot be added + Given the html site "bh-site-command-63.test" exists + And the site "bh-site-command-65.test" exists, created with "--type=html --alias-domains=bh-site-command-65-a.test" + When I run 'bin/ee site update bh-site-command-63.test --add-alias-domains=bh-site-command-65-a.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-65-a.test already exists as an alias domain for site: bh-site-command-65.test." + + Scenario: Add and delete aliases in one call + Given the site "bh-site-command-66.test" exists, created with "--type=html --alias-domains=bh-site-command-66-a.test" + When I run 'bin/ee site update bh-site-command-66.test --add-alias-domains=bh-site-command-66-b.test --delete-alias-domains=bh-site-command-66-a.test' + Then the command should succeed + And the "alias_domains" field of site "bh-site-command-66.test" in the database should be "bh-site-command-66.test,bh-site-command-66-b.test" + + Scenario: A wildcard alias serves every subdomain + Given the html site "bh-site-command-67.test" exists + When I run "bin/ee site update bh-site-command-67.test --add-alias-domains='*.bh-site-command-67.test'" + Then the command should succeed + And the "alias_domains" field of site "bh-site-command-67.test" in the database should be "bh-site-command-67.test,*.bh-site-command-67.test" + When I wait up to 60 seconds for "http://anything.bh-site-command-67.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Changing aliases on a self-signed site keeps HTTPS + Given the site "bh-site-command-69.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site update bh-site-command-69.test --add-alias-domains=bh-site-command-69-a.test' + Then the command should succeed + And STDOUT should contain "No SSL certificate action needed for self SSL on alias domain change." + And the service "nginx" of site "bh-site-command-69.test" should not have the environment variable "HTTPS_METHOD" containing "nohttps" + When I wait up to 60 seconds for "https://bh-site-command-69.test/" to return 200 + Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" + + # AUTH-1: alias labels starting with "_" are accepted and can clobber a site's _wildcard proxy files (fixed in site-command #502, not merged). + @known-bug + Scenario: Alias domains with a label starting with an underscore are rejected + Given the html site "bh-site-command-68.test" exists + When I run 'bin/ee site update bh-site-command-68.test --add-alias-domains=_wildcard.bh-site-command-68.test' + Then the command should fail + And the "alias_domains" field of site "bh-site-command-68.test" in the database should be "bh-site-command-68.test" diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature new file mode 100644 index 00000000..b22b6730 --- /dev/null +++ b/features/update-ssl-php.feature @@ -0,0 +1,112 @@ +Feature: Update SSL and PHP of html sites + `ee site update --ssl[=...]`, `--wildcard` and `--php` on html sites. Let's Encrypt paths are in le.feature. + + Scenario: Enable self-signed SSL on a plain site + Given the html site "bh-site-command-70.test" exists + When I run 'bin/ee site update bh-site-command-70.test --ssl=self' + Then the command should succeed + And STDOUT should contain "Starting SSL update for: bh-site-command-70.test" + And STDOUT should contain "Enabled SSL for bh-site-command-70.test" + And the "site_ssl" field of site "bh-site-command-70.test" in the database should be "self" + And the file "{PROXY_DIR}/certs/bh-site-command-70.test.crt" should exist + And the file "{PROXY_DIR}/certs/bh-site-command-70.test.key" should exist + When I wait up to 60 seconds for "https://bh-site-command-70.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + And the TLS certificate served for "bh-site-command-70.test" should cover "bh-site-command-70.test" + When I send a GET request to "http://bh-site-command-70.test/" + Then the response code should be 301 + And the response header "Location" should be "https://bh-site-command-70.test/" + + Scenario: Enabling SSL twice fails + Given the html site "bh-site-command-72.test" exists + When I run 'bin/ee site update bh-site-command-72.test --ssl=self' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-72.test --ssl=self' + Then the command should fail + And STDERR should contain "SSL is already enabled on bh-site-command-72.test" + + Scenario: Disable SSL + Given the html site "bh-site-command-73.test" exists + When I run 'bin/ee site update bh-site-command-73.test --ssl=self' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-73.test --ssl=off' + Then the command should succeed + And STDOUT should contain "Disabled SSL for bh-site-command-73.test" + And the "site_ssl" field of site "bh-site-command-73.test" in the database should not be "self" + And the service "nginx" of site "bh-site-command-73.test" should have the environment variable "HTTPS_METHOD" containing "nohttps" + When I wait up to 60 seconds for "http://bh-site-command-73.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Disabling SSL on a plain site fails + Given the html site "bh-site-command-74.test" exists + When I run 'bin/ee site update bh-site-command-74.test --ssl=off' + Then the command should fail + And STDERR should contain "SSL is already disabled on bh-site-command-74.test" + + Scenario: Switching a normal site to wildcard SSL is refused + Given the html site "bh-site-command-74.test" exists + When I run 'bin/ee site update bh-site-command-74.test --ssl=self --wildcard' + Then the command should fail + And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." + And the "site_ssl" field of site "bh-site-command-74.test" in the database should be "" + + Scenario: An unknown SSL type is rejected + Given the html site "bh-site-command-74.test" exists + When I run 'bin/ee site update bh-site-command-74.test --ssl=bh-bogus' + Then the command should fail + And STDERR should contain "Invalid value specified for 'ssl'" + + Scenario: --wildcard cannot be combined with --ssl=off + Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site update bh-site-command-71.test --ssl=off --wildcard' + Then the command should fail + And STDERR should contain "You cannot use --wildcard flag with --ssl=off" + + Scenario: Inherit SSL from a self-signed parent through update + Given the site "bh-site-command-76.test" exists, created with "--type=html --ssl=self" + And the html site "sub.bh-site-command-76.test" exists + When I run 'bin/ee site update sub.bh-site-command-76.test --ssl=inherit' + Then the command should succeed + And the "site_ssl" field of site "sub.bh-site-command-76.test" in the database should be "inherit" + When I wait up to 60 seconds for "https://sub.bh-site-command-76.test/" to return 200 + Then the TLS certificate served for "sub.bh-site-command-76.test" should cover "sub.bh-site-command-76.test" + + Scenario: Changing the PHP version of an html site fails and leaves it serving + Given the html site "bh-site-command-77.test" exists + When I run 'bin/ee site update bh-site-command-77.test --php=8.3' + Then the command should fail + And STDERR should contain "Error:" + And the "php_version" field of site "bh-site-command-77.test" in the database should be "" + When I wait up to 60 seconds for "http://bh-site-command-77.test/" to return 200 + + Scenario: An unsupported PHP version is rejected + Given the html site "bh-site-command-77.test" exists + When I run 'bin/ee site update bh-site-command-77.test --php=9.9' + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + + # Suspected new bug: update_ssl() refuses --ssl=off when site_ssl_wildcard=1, and --ssl=self sets that flag on create, so SSL can never be turned off. + @known-bug + Scenario: SSL can be disabled on a site created with self-signed SSL + Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site update bh-site-command-71.test --ssl=off' + Then the command should succeed + And STDOUT should contain "Disabled SSL for bh-site-command-71.test" + + # Suspected new bug: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). + @known-bug + Scenario: Enabling self-signed SSL through update records it as wildcard like create does + Given the html site "bh-site-command-75.test" exists + When I run 'bin/ee site update bh-site-command-75.test --ssl=self' + Then the command should succeed + And the "site_ssl_wildcard" field of site "bh-site-command-75.test" in the database should be "1" + + # Suspected new bug: update --ssl=inherit skips check_parent_site_certs(), which create runs, so a site can "inherit" from a parent that does not exist. + @known-bug + Scenario: Inheriting SSL through update requires a wildcard parent + Given the site "bh-site-command-78.test" does not exist + And the html site "sub.bh-site-command-78.test" exists + When I run 'bin/ee site update sub.bh-site-command-78.test --ssl=inherit' + Then the command should fail + And STDERR should contain "Unable to find existing site: bh-site-command-78.test" + And the "site_ssl" field of site "sub.bh-site-command-78.test" in the database should be "" From 22cf632246dd780f77bd91b9d0f2229deeaf46d6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 07:07:22 +0530 Subject: [PATCH 05/37] test(site-command): cover backup, restore, clone and sync --- features/backup-restore.feature | 194 ++++++++++++++++++++++++++++++++ features/clone-sync.feature | 131 +++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 features/backup-restore.feature create mode 100644 features/clone-sync.feature diff --git a/features/backup-restore.feature b/features/backup-restore.feature new file mode 100644 index 00000000..1cb0e250 --- /dev/null +++ b/features/backup-restore.feature @@ -0,0 +1,194 @@ +@remote +Feature: Backup and restore + `ee site backup` / `ee site restore` against the REAL rclone remote from config.yml (rclone-path). + Only /bh-site-command-* is touched; it is purged after the feature. + + Background: + Given the EasyEngine config value "rclone-path" is set + + Scenario: Back up an html site + Given the html site "bh-site-command-140.test" exists + And there are no remote backups for site "bh-site-command-140.test" + And I write "bh-site-command-marker-140" to the file "{SITES_DIR}/bh-site-command-140.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-140.test' + Then the command should succeed + And STDOUT should contain "Backup uploaded to remote storage." + And STDOUT should contain "Backup created successfully." + And the remote backup storage should have 1 backup for site "bh-site-command-140.test" + And the directory "{BACKUP_DIR}/bh-site-command-140.test" should not exist + And the file "{BACKUP_DIR}/bh-site-command-140.test.lock" should not exist + When I run 'bin/ee site backup bh-site-command-140.test --list' + Then the command should succeed + And STDOUT should match "/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" + When I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "BACKUP_ID" + Then the remote backup "{BACKUP_ID}" of site "bh-site-command-140.test" should contain the file "metadata.json" + And the remote backup "{BACKUP_ID}" of site "bh-site-command-140.test" should contain the file "bh-site-command-140.test.zip" + And the remote backup "{BACKUP_ID}" of site "bh-site-command-140.test" should contain the file "conf.zip" + + Scenario: Restore the latest backup + Given the html site "bh-site-command-141.test" exists + And there are no remote backups for site "bh-site-command-141.test" + And I write "original" to the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-141.test' + Then the command should succeed + When I write "changed" to the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" + And I remove the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/index.html" + And I run 'bin/ee site restore bh-site-command-141.test' + Then the command should succeed + And STDOUT should contain "Site restored successfully." + And the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" should contain "original" + And the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/index.html" should exist + And the file "{BACKUP_DIR}/bh-site-command-141.test.lock" should not exist + When I wait up to 60 seconds for "http://bh-site-command-141.test/bh-marker.txt" to return 200 + Then the response body should contain "original" + + Scenario: Restore a specific backup by id + Given the html site "bh-site-command-142.test" exists + And there are no remote backups for site "bh-site-command-142.test" + And I write "first" to the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-142.test' + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-142.test --list' + And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "FIRST_ID" + And I write "second" to the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" + And I run 'bin/ee site backup bh-site-command-142.test' + Then the command should succeed + And the remote backup storage should have 2 backups for site "bh-site-command-142.test" + When I run 'bin/ee site restore bh-site-command-142.test --id={FIRST_ID}' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" should contain "first" + + Scenario: Restoring an unknown backup id fails + Given the html site "bh-site-command-140.test" exists + When I run 'bin/ee site restore bh-site-command-140.test --id=1000000000_2001-09-09-01-46-40' + Then the command should fail + And STDERR should contain "Invalid backup ID provided." + + Scenario: A disabled site cannot be backed up but its backups can be listed + Given the html site "bh-site-command-143.test" exists + And there are no remote backups for site "bh-site-command-143.test" + When I run 'bin/ee site backup bh-site-command-143.test' + Then the command should succeed + When I run 'bin/ee site disable bh-site-command-143.test' + And I run 'bin/ee site backup bh-site-command-143.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-143.test is not enabled." + When I run 'bin/ee site backup bh-site-command-143.test --list' + Then the command should succeed + And STDOUT should match "/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" + + # Suspected new bug: list_remote_backups() checks empty() on explode()'s [''] before filtering, so "No remote backups found." is never printed (and rclone may error on the missing path instead). + @known-bug + Scenario: Listing the backups of a site without backups says so + Given the html site "bh-site-command-149.test" exists + And there are no remote backups for site "bh-site-command-149.test" + When I run 'bin/ee site backup bh-site-command-149.test --list' + Then the command should succeed + And STDOUT should contain "No remote backups found." + + Scenario: A malformed --dash-auth is rejected + Given the html site "bh-site-command-140.test" exists + When I run 'bin/ee site backup bh-site-command-140.test --dash-auth=no-separator' + Then the command should fail + And STDERR should contain "Invalid --dash-auth format. Expected: backup-id:backup-verification-token" + + Scenario: --dash-auth needs ed-api-url in the config + Given the EasyEngine config value "ed-api-url" is not set + And the html site "bh-site-command-140.test" exists + When I run 'bin/ee site backup bh-site-command-140.test --dash-auth=bh-id:bh-token' + Then the command should fail + And STDERR should contain "ed-api-url is not configured." + + Scenario: A lock file of another backup blocks the backup + Given the html site "bh-site-command-144.test" exists + And I write "lock" to the file "{BACKUP_DIR}/bh-site-command-144.test.lock" + When I run 'bin/ee site backup bh-site-command-144.test' + Then the command should fail + And STDERR should contain "Another backup/restore process is running. Please wait for it to complete." + + # Suspected new bug: pre_backup_restore_checks() writes .lock, but a failing restore exits without removing it (the shutdown handler only sees the site command's own site_data), so every later backup/restore of the site is refused. + @known-bug + Scenario: A failed restore releases its lock + Given the html site "bh-site-command-145.test" exists + And there are no remote backups for site "bh-site-command-145.test" + When I run 'bin/ee site restore bh-site-command-145.test' + Then the command should fail + And the file "{BACKUP_DIR}/bh-site-command-145.test.lock" should not exist + + # BKP-1: a restore reuses files a previous restore left in the backup directory, even for another --id (fixed in site-command #482, not merged). + @known-bug + Scenario: Restoring by id ignores files left behind by an earlier restore + Given the html site "bh-site-command-146.test" exists + And there are no remote backups for site "bh-site-command-146.test" + And I write "A" to the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-146.test' + And I run 'bin/ee site backup bh-site-command-146.test --list' + And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "A_ID" + And I write "B" to the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" + And I run 'bin/ee site backup bh-site-command-146.test' + And I run 'bin/ee site backup bh-site-command-146.test --list' + And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "B_ID" + And I download the remote backup "{B_ID}" of site "bh-site-command-146.test" into the local backup directory + And I run 'bin/ee site restore bh-site-command-146.test --id={A_ID}' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" should contain "A" + + # Suspected new bug: cleanup_old_backups() only prunes when count > no-of-backups + 1, so one backup too many is kept. + @known-bug + Scenario: Old backups are pruned down to no-of-backups + Given the EasyEngine config value "no-of-backups" is "3" + And the html site "bh-site-command-147.test" exists + And there are no remote backups for site "bh-site-command-147.test" + When I run 'bin/ee site backup bh-site-command-147.test' + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-147.test' + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-147.test' + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-147.test' + Then the command should succeed + And the remote backup storage should have at most the configured "no-of-backups" backups for site "bh-site-command-147.test" + + @wp + Scenario: Back up and restore a WordPress site + Given "ee" is installed + And the site "bh-site-command-148.test" exists, created with "--type=wp" + And there are no remote backups for site "bh-site-command-148.test" + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp option update blogname bh-site-command-before'" + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-148.test' + Then the command should succeed + And STDOUT should contain "Backup created successfully." + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp option update blogname bh-site-command-after'" + And I run 'bin/ee site restore bh-site-command-148.test' + Then the command should succeed + And STDOUT should contain "Site restored successfully." + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp option get blogname'" + Then STDOUT should contain "bh-site-command-before" + When I wait up to 60 seconds for "http://bh-site-command-148.test/" to return 200 + + # LOG-3: restoring a WP site prints its DB password (wp config set output). + @wp @known-bug + Scenario: Restoring a WordPress site does not print its database password + Given "ee" is installed + And the site "bh-site-command-148.test" exists, created with "--type=wp" + When I run 'bin/ee site info bh-site-command-148.test --format=json' + And I save the JSON field "db_password" in STDOUT as "DB_PASSWORD" + And I run 'bin/ee site backup bh-site-command-148.test' + Then the command should succeed + When I run 'bin/ee site restore bh-site-command-148.test' + Then the command should succeed + And STDOUT should not contain "{DB_PASSWORD}" + And STDERR should not contain "{DB_PASSWORD}" + + # WP-1: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. + @wp @known-bug + Scenario: A restored WordPress core passes checksum verification + Given "ee" is installed + And the site "bh-site-command-148.test" exists, created with "--type=wp" + When I run 'bin/ee site backup bh-site-command-148.test' + Then the command should succeed + When I run 'bin/ee site restore bh-site-command-148.test' + Then the command should succeed + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp core verify-checksums'" + Then the command should succeed diff --git a/features/clone-sync.feature b/features/clone-sync.feature new file mode 100644 index 00000000..377abb5f --- /dev/null +++ b/features/clone-sync.feature @@ -0,0 +1,131 @@ +Feature: Clone and sync sites + `ee site clone` / `ee site sync` on the same host. The commands shell out to `ee` from PATH (not bin/ee), so PATH must point at the same install. + Remote (user@host:site) transfers need a second SSH-reachable EE host and are not covered. + + Background: + Given "ee" is installed + + Scenario: Source and destination cannot both be implicit + When I run 'bin/ee site clone . .' + Then the command should fail + And STDERR should contain "No sitename found in source and destination site." + + Scenario: Source and destination cannot both be remote + When I run 'bin/ee site clone root@bh-host-a:bh-site-command-150.test root@bh-host-b:bh-site-command-151.test' + Then the command should fail + And STDERR should contain "Both source and destination sites cannot be remote." + + Scenario: A site cannot be cloned onto itself + When I run 'bin/ee site clone bh-site-command-150.test bh-site-command-150.test' + Then the command should fail + And STDERR should contain "Cannot copy 'bh-site-command-150.test' on 'localhost' to 'bh-site-command-150.test' on 'localhost'" + + Scenario: A malformed remote location is rejected + When I run 'bin/ee site clone root@bh-site-command-150.test bh-site-command-151.test' + Then the command should fail + And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" + + Scenario: Cloning a site that does not exist fails + Given the site "bh-site-command-159.test" does not exist + And the site "bh-site-command-151.test" does not exist + When I run 'bin/ee site clone bh-site-command-159.test bh-site-command-151.test' + Then the command should fail + And STDERR should contain "Unable to find 'bh-site-command-159.test' on 'localhost'" + And the site "bh-site-command-151.test" should not exist + + Scenario: Cloning a disabled site fails + Given the html site "bh-site-command-152.test" exists + And the site "bh-site-command-152.test" is disabled + When I run 'bin/ee site clone bh-site-command-152.test bh-site-command-153.test' + Then the command should fail + And STDERR should contain "The site that you want to clone has been disabled. Please enable the site and clone again." + And the site "bh-site-command-153.test" should not exist + + Scenario: Only WordPress sites can be cloned + Given the html site "bh-site-command-154.test" exists + And the site "bh-site-command-155.test" does not exist + When I run 'bin/ee site clone bh-site-command-154.test bh-site-command-155.test' + Then the command should fail + And STDERR should contain "Only clone of WordPress sites is supported as of now." + And the site "bh-site-command-155.test" should not exist + + Scenario: Syncing to a site that does not exist fails + Given the html site "bh-site-command-154.test" exists + And the site "bh-site-command-159.test" does not exist + When I run 'bin/ee site sync bh-site-command-154.test bh-site-command-159.test' + Then the command should fail + And STDERR should contain "Unable to find 'bh-site-command-159.test' on 'localhost'" + + Scenario: Only WordPress sites can be synced + Given the html site "bh-site-command-154.test" exists + And the html site "bh-site-command-156.test" exists + When I run 'bin/ee site sync bh-site-command-154.test bh-site-command-156.test' + Then the command should fail + And STDERR should contain "Only Sync of WordPress sites is supported as of now." + + @wp + Scenario: Clone a WordPress site on the same host + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" does not exist + And I write "bh-site-command-clone-marker" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-marker.txt" + When I run "bin/ee shell bh-site-command-157.test --skip-tty --command='wp option update blogname bh-site-command-clone-source'" + Then the command should succeed + When I run 'bin/ee site clone bh-site-command-157.test bh-site-command-158.test' + Then the command should succeed + And STDOUT should contain "Site cloned successfully." + And the site "bh-site-command-158.test" should exist + And the "site_type" field of site "bh-site-command-158.test" in the database should be "wp" + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-marker.txt" should contain "bh-site-command-clone-marker" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get blogname'" + Then STDOUT should contain "bh-site-command-clone-source" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get siteurl'" + Then STDOUT should contain "bh-site-command-158.test" + When I wait up to 60 seconds for "http://bh-site-command-158.test/" to return 200 + + @wp + Scenario: Cloning onto an existing site fails and keeps that site + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + When I run 'bin/ee site clone bh-site-command-157.test bh-site-command-158.test' + Then the command should fail + And STDERR should contain "already exists on 'localhost'" + And the site "bh-site-command-158.test" should exist + And the site "bh-site-command-158.test" should be enabled + + @wp + Scenario: Sync only files + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + And I write "bh-site-command-files-v2" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-marker.txt" + When I run "bin/ee shell bh-site-command-157.test --skip-tty --command='wp option update blogname bh-site-command-files-only'" + And I run 'bin/ee site sync bh-site-command-157.test bh-site-command-158.test --files' + Then the command should succeed + And STDOUT should contain "Site synced successfully" + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-marker.txt" should contain "bh-site-command-files-v2" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get blogname'" + Then STDOUT should not contain "bh-site-command-files-only" + + @wp + Scenario: Sync only the database + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + And I write "bh-site-command-db-only-file" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-db-only.txt" + When I run "bin/ee shell bh-site-command-157.test --skip-tty --command='wp option update blogname bh-site-command-db-v2'" + And I run 'bin/ee site sync bh-site-command-157.test bh-site-command-158.test --db' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-db-only.txt" should not exist + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get blogname'" + Then STDOUT should contain "bh-site-command-db-v2" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get home'" + Then STDOUT should contain "bh-site-command-158.test" + + @wp + Scenario: Sync only uploads + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + And I write "bh-site-command-upload" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/wp-content/uploads/bh-upload.txt" + And I write "bh-site-command-not-an-upload" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-not-upload.txt" + When I run 'bin/ee site sync bh-site-command-157.test bh-site-command-158.test --uploads' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/wp-content/uploads/bh-upload.txt" should contain "bh-site-command-upload" + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-not-upload.txt" should not exist From f4eccae13eabafabc649e0413a2d4dc1f23a4912 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 07:21:55 +0530 Subject: [PATCH 06/37] test(site-command): cover let's encrypt issuance, renewal and aliases --- features/le.feature | 140 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 features/le.feature diff --git a/features/le.feature b/features/le.feature new file mode 100644 index 00000000..0f6ade1d --- /dev/null +++ b/features/le.feature @@ -0,0 +1,140 @@ +@le +Feature: Let's Encrypt certificates + Real issuance against public DNS (*.ee4-test.rt.gw points at the test host). Names include {RUN_ID} so repeated runs never hit + the duplicate-certificate limit. Budget per full run: 4 issuances (create, alias add, alias delete, update --ssl=le); the wildcard + and failing-alias scenarios create orders but issue nothing. Scenarios run in order and share LE1/LE2/LE3 to stay in budget; + run alone, a scenario issues its own certificate. + + Background: + Given the EasyEngine config value "le-mail" is set + And the variable "LE1" is "bh-site-command-le1-{RUN_ID}.ee4-test.rt.gw" + And the variable "LE2" is "bh-site-command-le2-{RUN_ID}.ee4-test.rt.gw" + And the variable "LE3" is "bh-site-command-le3-{RUN_ID}.ee4-test.rt.gw" + + Scenario: Create a site with a Let's Encrypt certificate + Given the site "{LE1}" does not exist + When I run 'bin/ee site create {LE1} --ssl=le' + Then the command should succeed + And the "site_ssl" field of site "{LE1}" in the database should be "le" + And the "site_ssl_wildcard" field of site "{LE1}" in the database should be "0" + And the file "{PROXY_DIR}/certs/{LE1}.crt" should exist + And the file "{PROXY_DIR}/certs/{LE1}.key" should exist + And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should exist + And the hosts file should not contain an entry for "{LE1}" + When I send a verified GET request to "https://{LE1}/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + And the TLS certificate served for "{LE1}" should cover "{LE1}" + And the TLS certificate served for "{LE1}" should cover "www.{LE1}" + And the TLS certificate served for "{LE1}" should be issued by "Let's Encrypt" + When I send a GET request to "http://{LE1}/" + Then the response code should be 301 + And the response header "Location" should be "https://{LE1}/" + When I send a verified GET request to "https://www.{LE1}/" + Then the response code should be 301 + And the response header "Location" should be "https://{LE1}/" + When I run 'bin/ee site ssl-info {LE1}' + Then the JSON field "0.ssl_type" in STDOUT should be "le" + And the JSON field "0.issued_by_CN" in STDOUT should be "Let's Encrypt" + And the JSON field "0.SANs" in STDOUT should contain "www.{LE1}" + + Scenario: ssl-renew leaves a certificate that is not due alone + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site ssl-info {LE1}' + And I save the JSON field "0.serial_number" in STDOUT as "SERIAL" + And I run 'bin/ee site ssl-renew {LE1}' + Then the command should succeed + And STDOUT should contain "SSL renewal completed." + When I run 'bin/ee site ssl-info {LE1}' + Then the JSON field "0.serial_number" in STDOUT should be "{SERIAL}" + + Scenario: Adding an alias reissues the certificate to cover it + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site update {LE1} --add-alias-domains=a1.{LE1}' + Then the command should succeed + And STDOUT should contain "Updating and force renewing SSL certificate to accomodated alias domain changes." + And STDOUT should contain "Alias domains updated on site {LE1}." + And the "alias_domains" field of site "{LE1}" in the database should be "{LE1},a1.{LE1}" + And the "site_ssl" field of site "{LE1}" in the database should be "le" + When I wait up to 60 seconds for "https://a1.{LE1}/" to return 200 + And I send a verified GET request to "https://a1.{LE1}/" + Then the response code should be 200 + And the TLS certificate served for "a1.{LE1}" should cover "a1.{LE1}" + And the TLS certificate served for "{LE1}" should cover "{LE1}" + + Scenario: Removing an alias reissues the certificate without it + Given the site "{LE1}" exists, created with "--type=html --ssl=le --alias-domains=a1.{LE1}" + When I run 'bin/ee site update {LE1} --delete-alias-domains=a1.{LE1}' + Then the command should succeed + And the "alias_domains" field of site "{LE1}" in the database should be "{LE1}" + When I wait up to 60 seconds for "https://{LE1}/" to return 200 + And I send a verified GET request to "https://{LE1}/" + Then the response code should be 200 + And the TLS certificate served for "{LE1}" should not cover "a1.{LE1}" + + Scenario: A wildcard certificate waits for the DNS challenge + Given the EasyEngine config value "cloudflare-api-key" is not set + And the site "{LE3}" does not exist + When I run 'bin/ee site create {LE3} --ssl=le --wildcard' + Then the command should succeed + And STDOUT should contain "Run `ee site ssl-verify {LE3}` once the DNS changes have propagated" + And the "site_ssl_wildcard" field of site "{LE3}" in the database should be "1" + When I run 'bin/ee site ssl-info {LE3}' + Then the JSON field "0.status" in STDOUT should be "Certificate file not found / yet to be issued." + When I run 'bin/ee site ssl-info {LE3} --get-dns-records' + Then the command should succeed + And the JSON field "0.dns_challenges" in STDOUT should contain "_acme-challenge.{LE3}" + + @destructive + Scenario: ssl-renew --all skips wildcard sites without Cloudflare credentials + Given the EasyEngine config value "cloudflare-api-key" is not set + And the site "{LE3}" exists, created with "--type=html --ssl=le --wildcard" + When I run 'bin/ee site ssl-renew --all' + Then the command should succeed + And STDERR should contain "Wildcard site found: {LE3}, skipping it" + + Scenario: Enable Let's Encrypt on an existing site + Given the html site "{LE2}" exists + When I run 'bin/ee site update {LE2} --ssl=le' + Then the command should succeed + And STDOUT should contain "Enabled SSL for {LE2}" + And the "site_ssl" field of site "{LE2}" in the database should be "le" + When I wait up to 60 seconds for "https://{LE2}/" to return 200 + And I send a verified GET request to "https://{LE2}/" + Then the response code should be 200 + And the TLS certificate served for "{LE2}" should be issued by "Let's Encrypt" + + # SSL-2: an alias that fails validation only warns; the alias is saved and SSL is marked disabled while the old cert keeps serving. + @known-bug + Scenario: An alias that fails validation is rejected and the site keeps its certificate + Given the site "{LE2}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site update {LE2} --add-alias-domains=bh-site-command-nx.rt.gw' + Then the command should fail + And the "alias_domains" field of site "{LE2}" in the database should be "{LE2}" + And the "site_ssl" field of site "{LE2}" in the database should be "le" + + # SSL-8: ssl-verify checks only [site] for an HTTP-01 cert that includes www, rotates the acme-conf key and fails with "has not yet been authorized". + @known-bug + Scenario: ssl-verify of a valid Let's Encrypt site succeeds + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site ssl-verify {LE1}' + Then the command should succeed + And STDOUT should contain "SSL verification completed." + + Scenario: Deleting a Let's Encrypt site revokes and removes its certificate + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site delete {LE1} --yes' + Then the command should succeed + And STDOUT should contain "Revoking certificate." + And the site "{LE1}" should not exist + And the file "{PROXY_DIR}/certs/{LE1}.crt" should not exist + And the file "{PROXY_DIR}/certs/{LE1}.key" should not exist + And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should not exist + And the directory "{PROXY_DIR}/acme-conf/var/{LE1}" should not exist + + # SSL-5 / SSL-9: deleting an LE site leaves acme-conf/var/www. (and alias dirs) behind. Run after the delete scenario above. + @known-bug + Scenario: Deleting a Let's Encrypt site leaves no ACME state behind + Given the site "{LE1}" does not exist + Then the directory "{PROXY_DIR}/acme-conf/var/www.{LE1}" should not exist + And the directory "{PROXY_DIR}/acme-conf/var/a1.{LE1}" should not exist From 9236a106a771ee164e74a77ca921d502b2175431 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 07:36:14 +0530 Subject: [PATCH 07/37] test(site-command): add coverage inventory for the behat suite --- features/COVERAGE.md | 234 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 features/COVERAGE.md diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 00000000..d94a7221 --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,234 @@ +# site-command Behat coverage + +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 180 scenarios (212 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. + +## Tags + +| Tag | Scenarios | Meaning | +|---|---|---| +| (none) | 157 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. | +| `@known-bug` | 24 | Exposes a bug from `issues.md` (ID in the comment above the scenario) or a suspected new bug ("Suspected new bug"). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | +| `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or renews every LE site on the host (`ssl-renew --all`). Every scenario leaves the proxy running. | +| `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`, purged after the feature. | +| `@le` | 11 | Real Let's Encrypt issuance on `*.ee4-test.rt.gw`. At most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. | +| `@wp` | 8 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | + +Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@known-bug&&~@wp'`. + +## Environment rules + +- Every site, alias, hosts entry and remote backup path contains `bh-site-command-`. `@AfterFeature` deletes only sites matching `(^|\.)bh-site-command-`, never other sites; `@remote` features also purge `/bh-site-command-*`. `@AfterScenario` removes hosts entries the scenario added, the scenario's `{TMP}` dir under `/var/tmp`, and `{BACKUP_DIR}/bh-site-command-*` leftovers. +- The file-writing, hosts, delete and rclone steps refuse names and paths without the prefix (or outside `{TMP}`). +- HTTP steps resolve the site name to 127.0.0.1 (whitelisted, so no HTTP auth, and no /etc/hosts entry needed); `through the public IP` goes through `hostname -I` instead; `verified` uses real DNS and full TLS verification (LE only). Redirects are never followed. +- Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps. +- Scenarios are independent: each one uses its own site or the idempotent `Given the html site "..." exists` / `exists, created with "..."`. The exception is `le.feature`, which shares three sites in file order to stay within the LE budget (each scenario still creates what it needs when run alone). +- Commands that can hang on a known bug (CACHE-1) run under `with a timeout of N seconds`. +- Commands that could prompt (le-mail) are guarded by `Given the EasyEngine config value "le-mail" is set`. + +## Estimated runtime (4 CPU test host) + +html create about 15-20 s, delete 5-10 s, proxy-cache toggle 10-15 s (proxy restart), html backup/restore 15-40 s, WordPress create 1.5-2.5 min, LE issuance 30-60 s. + +| Selection | Estimate | +|---|---| +| Default safe run | ~40 min | +| `@destructive` | ~7 min | +| `@remote` (html part) | ~7 min | +| `@wp` (clone/sync + WP backup/restore) | ~16 min | +| `@le` | ~6 min | +| Everything | ~75-80 min | + +## Inventory and mapping + +Legend: covered / partial / not covered (reason). Feature files are abbreviated: site, create, delete, list, info, en-dis (enable-disable), rr (restart-reload), alias (update-alias), ussl (update-ssl-php), pcache (proxy-cache), clean, share, ssl, redirect, labels, backup (backup-restore), clone (clone-sync), le. + +### Routing and global behaviour (`Site_Command::__invoke`, hooks.php) + +| Item | Status | Scenarios | +|---|---|---| +| `ee site` usage | covered | site: Check site command is present | +| `ee help site`, `ee help site create` | covered | site: Help lists every site subcommand; Help of site create | +| `ee help site --type=X` routing hook, unknown type error | covered | site: Help for an unregistered site type fails | +| `ee site cmd-dump` (hidden) | covered | site: cmd-dump prints the site command tree as JSON | +| `--type=X` on create, unregistered type error | covered | site: Creating a site of an unregistered type fails; create: Create an html site (`--type=html`) | +| Type auto-detection from the site name / from the cwd | covered | info: The site is detected from the working directory; site outlines (unknown site messages) | +| Wrong site name inside a site dir ("Did you mean") | covered | info: Another site name inside a site directory is rejected with a hint | +| Name normalisation: `http(s)://`, trailing slash, add `.test`, lower-case | covered | create: A site name without a dot...; The URL scheme and a trailing slash...; Upper-case site names...; info: The site name may be given as a URL | +| EE v3 compat flags (`--html`, `--wp`, `--le`, `--mysql`, ...) | partial | create: The EE v3 --html flag (`--wp`/`--php`/`--le` belong to site-type-* / LE budget) | +| Removed EE v3 flags error (`--w3tc`, ...) | covered (known bug) | site: Removed EE v3 cache flags give a clear error | +| Unknown parameter / too many positionals / missing required positional | covered | site: Unknown parameters are rejected; Extra positional arguments; Subcommands with a required site name print the usage | +| Missing site outside a site dir (auto_site_name) | covered | site: outline over info, update, enable, disable, restart, reload, clean, ssl-info, backup, restore; ssl-renew scenario | +| Unknown site (get_site_info) | covered | site: outlines over delete, refresh, ssl-verify, ssl, info, enable, disable, restart, update, share | +| Disabled site refused | covered | en-dis: outline over restart, reload, update, refresh, share, backup, restore | +| `site_cleanup` hooks (redis purge, share cleanup) | not covered | redis purge only fires for WP cache sites (site-type-wp suite); share cleanup needs a live ngrok share | +| Signal rollback (pcntl handlers) | not covered | dead code (MIG-9); needs killing ee mid-create | + +### create (html) + +| Item | Status | Scenarios | +|---|---|---| +| `` happy path: files, .env, main.conf, user.conf, index.html, compose file, nginx container, 3 labelled volumes, network, hosts entry, DB row, HTTP 200 | covered | create: Create an html site; labels: both scenarios | +| Duplicate site | covered | create: Creating a site that already exists fails and keeps the site | +| `--public-dir` | covered | create: Create a site with a custom public directory | +| `--skip-status-check` | covered | create: Create a site without the status check | +| `--alias-domains` (DB, server_name, VIRTUAL_HOST, HTTP on alias) | covered | create: Create a site with alias domains | +| Alias/site conflicts on create | covered | create: A domain that is an alias of another site...; An alias domain that belongs to another site...; known bug: The owner of an alias is reported correctly... | +| `--ssl=self` | covered | ssl: Create a site with a self-signed certificate; Info of a self-signed site | +| `--ssl=custom` + `--ssl-key/--ssl-crt`, validation, rollback | covered | ssl: Create a site with a custom certificate; Custom SSL needs both...; Custom SSL files must exist | +| `--ssl=inherit` (parent checks, `--wildcard` refusal) | covered | ssl: A subdomain site inherits...; Inheriting needs an existing parent / a parent with SSL / cannot be combined with --wildcard | +| `--ssl=le`, `--ssl=le --wildcard` | covered (@le) | le: Create a site with a Let's Encrypt certificate; A wildcard certificate waits for the DNS challenge | +| `--ssl` with an invalid value on create | not covered | `--ssl` is a flag on create, so no option validation; would only fail inside init_ssl after the containers are up | +| www redirect written only when www resolves; https redirect | covered | redirect: all scenarios | +| Rollback on failure (catch_clean) leaves no site/dir | covered | ssl: custom/inherit failure scenarios (site and dir absent) | +| Site name validation (SEC-1) | covered (known bug) | create: Site names that are not valid hostnames are rejected... | +| Global admin-tools auth init on create | not covered | belongs to auth-command / admin-tools-command suites | +| macOS paths (dnsmasq, IS_DARWIN) | not covered | Linux test host | + +### delete + +| Item | Status | Scenarios | +|---|---|---| +| `--yes`: containers, volumes, network, site root, hosts entry, redirect conf, DB row, clean STDERR | covered | delete: Delete an html site and everything that belongs to it | +| Second delete / unknown site | covered | delete: same scenario; site: outline | +| Prompt answered n / y | covered | delete: Answering no...; Answering yes... | +| Disabled site | covered | delete: A disabled site can be deleted | +| SSL files removed (self, LE + revoke) | covered | delete: Deleting a self-signed site removes its certificate; le: Deleting a Let's Encrypt site... | +| Proxy cache config removed | covered (@destructive) | pcache: Deleting a proxy-cached site removes its proxy config | +| Alias domains freed | covered | delete: Deleting a site frees its alias domains | +| LE leftovers (SSL-5/SSL-9) | covered (known bug) | le: Deleting a Let's Encrypt site leaves no ACME state behind | +| DB/user cleanup for external DB sites, cron rows | not covered | only php/wp sites have DBs and crons (site-type-*/cron-command suites) | + +### list / info + +| Item | Status | Scenarios | +|---|---|---| +| list `--format=table/text/json/csv/yaml/count`, invalid format | covered | list: all | +| `--enabled`, `--disabled`, both | covered | list: List only enabled/disabled; Both filters | +| "No sites found!" | not covered | depends on the whole host having no (enabled/disabled) sites | +| info table (Site, Site Root, Alias Domains, SSL, SSL Wildcard) | covered | info: Show site info as a table; ssl: Info of a self-signed site; alias: Add alias domains | +| info `--format=json`, invalid format | covered | info: Show site info as JSON; An unsupported format is rejected | +| info on a disabled site, URL-style name, cwd detection | covered | info: remaining scenarios | + +### enable / disable / refresh / restart / reload / clean + +| Item | Status | Scenarios | +|---|---|---| +| disable (containers removed, volumes kept, DB, proxy stops serving), idempotent | covered | en-dis: Disable a site; Disabling a disabled site is harmless | +| enable, already enabled, `--force`, `--refresh`, `--verify` | covered | en-dis: Enable...; Enabling an enabled site fails; Force-enable...; Enable with --refresh...; Enable with verification... | +| `refresh` subcommand | covered | en-dis: refresh is a shortcut for enable --refresh | +| enable/disable manage the www redirect | covered | redirect: Disable removes the redirect and enable restores it; known bug MIG-15 | +| enable post-configuration of postfix / admin-tools / mailhog | not covered | html sites have none (site-type-*, admin-tools, mailhog suites) | +| restart (no flag, `--nginx`, `--all`), unknown service flag | covered | rr: Restart the site containers (outline); Restart rejects services... | +| restart refuses a broken nginx config | covered | rr: Restart refuses a broken nginx config and keeps the site serving | +| reload (no flag, `--nginx`, `--all`), custom config applied | covered | rr: Reload nginx... (outline); Custom nginx config is applied on reload | +| reload with a broken config | covered (known bug) | rr: Reload fails when the nginx config test fails | +| `custom-compose` config branch of restart/enable | not covered | needs a custom compose setup (config change) | +| clean: no flags, `--object`, `--page`, `--proxy` without proxy cache | covered | clean: first four scenarios | +| clean `--proxy` with proxy cache | covered (@destructive, known bug CACHE-1 pattern) | clean: Cleaning the proxy cache empties it | +| clean without flags on a proxy-cached html site | covered (known bug) | clean: Cleaning without flags on a proxy-cached html site succeeds | +| clean object/page caches with redis | not covered | only WP cache sites have them (site-type-wp suite) | + +### update + +| Item | Status | Scenarios | +|---|---|---| +| `--add-alias-domains` (DB, env, HTTP, info) | covered | alias: Add alias domains | +| already present / partial overlap warning | covered | alias: Adding an alias that is already present fails; Adding present and new aliases... | +| `--delete-alias-domains`, primary, not a subset | covered | alias: Delete an alias domain; The primary domain cannot be deleted; Deleting an alias the site does not have fails | +| alias that is a site / alias of another site | covered | alias: Another site cannot be added...; An alias of another site cannot be added | +| add + delete in one call, wildcard alias | covered | alias: Add and delete aliases in one call; A wildcard alias serves every subdomain | +| alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; known bug SSL-2 | +| alias label validation (AUTH-1) | covered (known bug) | alias: Alias domains with a label starting with an underscore are rejected | +| alias change on custom SSL (warning) | not covered | low value; same code path as self apart from the warning text | +| `--ssl=self` on a plain site, twice, `--ssl=off`, off on plain | covered | ussl: first four scenarios | +| `--wildcard` refusals, invalid `--ssl`, `--ssl=off --wildcard` | covered | ussl: Switching a normal site to wildcard SSL is refused; An unknown SSL type is rejected; --wildcard cannot be combined with --ssl=off | +| `--ssl=inherit` via update (and the missing parent check) | covered | ussl: Inherit SSL from a self-signed parent through update; known bug: Inheriting SSL through update requires a wildcard parent | +| `--ssl=le` via update | covered (@le) | le: Enable Let's Encrypt on an existing site | +| `--ssl=custom` via update | not covered | update_ssl() has no --ssl-key/--ssl-crt handling; custom via update cannot work (flagged below) | +| off on a created self-signed site, wildcard flag consistency | covered (known bugs) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | +| `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected | +| `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | +| `--proxy-cache=on/off`, twice, custom sizes, invalid sizes, invalid switch | covered (@destructive) | pcache: all | +| proxy cache for aliases (existing and newly added) | covered (@destructive) | pcache: Alias domains get proxy cache locations... | +| proxy cache `subdom` location (`*.site_location`) | not covered | WP subdom only (site-type-wp suite) | + +### SSL commands + +| Item | Status | Scenarios | +|---|---|---| +| ssl-info: self, custom, inherit (parent cert file), no SSL, LE, pending wildcard | covered | ssl: ssl-info scenarios; le: create and wildcard scenarios | +| ssl-info `--format=yaml`, invalid format | covered | ssl: ssl-info supports other formats; ssl-info rejects an unsupported format | +| ssl-info `--get-dns-records`: non-DNS site, DNS-01 site | covered | ssl: ssl-info DNS records of a site that does not use DNS-01; le: A wildcard certificate waits... | +| ssl-renew guards: no SSL, self, inherit | covered | ssl: ssl-renew only renews Let's Encrypt certificates (outline); ssl-renew refuses inherited certificates | +| ssl-renew not due (no reissue), `--all` wildcard skip | covered (@le, `--all` also @destructive) | le: ssl-renew leaves a certificate that is not due alone; ssl-renew --all skips wildcard sites... | +| ssl-renew `--force`, expired-cert path | not covered | LE budget (a forced renew is one more issuance); expiry cannot be simulated without a fake CA | +| ssl-verify / `ssl` alias: unknown site | covered | site: outline | +| ssl-verify on a valid LE site | covered (known bug SSL-8) | le: ssl-verify of a valid Let's Encrypt site succeeds | +| ssl-verify completing a DNS-01 order, Cloudflare DNS solver | not covered | needs Cloudflare API credentials (not configured) | +| Self-signed root CA validity (SEC-3), alias coverage (SSL-4) | covered (known bugs) | ssl: last two scenarios | +| SSL policy (`ssl-policy` config) | not covered | global config change (@destructive) with little behaviour beyond the template | + +### share + +| Item | Status | Scenarios | +|---|---|---| +| `--disable` / `--refresh` without an active share, SSL site refused, required name | covered | share: all; site: usage outline | +| Actual share (ngrok download, tunnel, `--token`), one share at a time, share cleanup on delete | not covered | the ngrok v2 binary URL (bin.equinox.io) is retired and ngrok needs an account token; external network | + +### backup / restore (@remote) + +| Item | Status | Scenarios | +|---|---|---| +| backup html: upload, success output, remote files (metadata.json, .zip, conf.zip), local cleanup, lock released | covered | backup: Back up an html site | +| `--list` | covered | backup: Back up an html site; A disabled site cannot be backed up but its backups can be listed | +| `--list` with no backups | covered (known bug) | backup: Listing the backups of a site without backups says so | +| restore latest, restore `--id`, unknown id | covered | backup: Restore the latest backup; Restore a specific backup by id; Restoring an unknown backup id fails | +| disabled site | covered | backup: A disabled site cannot be backed up...; en-dis outline | +| `--dash-auth` format and ed-api-url check | covered | backup: A malformed --dash-auth is rejected; --dash-auth needs ed-api-url in the config | +| `--dash-auth` success/failure callbacks, rollback of the uploaded backup | not covered | needs an EasyDash API endpoint (`ed-api-url`) | +| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup; known bug: A failed restore releases its lock | +| BKP-1 stale download reuse | covered (known bug) | backup: Restoring by id ignores files left behind... | +| retention (`no-of-backups`) | covered (known bug) | backup: Old backups are pruned down to no-of-backups | +| WP backup/restore (DB, wp-content, core download), LOG-3, WP-1 | covered (@wp) | backup: three @wp scenarios | +| custom docker-compose files in backups | not covered | needs a custom compose setup | +| disk-space checks, rclone/7z missing, apt-get auto-install (BKP-3), global backup lock wait | not covered | needs a full disk, uninstalled tools or a concurrent backup; BKP-3 needs a pending kernel upgrade | +| BKP-2 partial SQL dump in webroot | not covered | needs a failing mysqldump (fault injection) | + +### clone / sync + +| Item | Status | Scenarios | +|---|---|---| +| Location validation (implicit names, both remote, same site, malformed remote) | covered | clone: first four scenarios | +| Missing source, disabled source, html source, missing destination, html sync | covered | clone: remaining validation scenarios | +| Local WP clone (files, DB, search-replace), existing destination kept | covered (@wp) | clone: Clone a WordPress site on the same host; Cloning onto an existing site fails and keeps that site | +| sync `--files`, `--db`, `--uploads` | covered (@wp) | clone: Sync only files / the database / uploads | +| clone `--ssl=...`, `--wildcard`, cert copy, admin-tools/mailhog replication | not covered | same-host clone of an SSL WP site is possible but slow; LE options need the LE budget | +| Remote clone/sync over SSH, production guard (`env: production`), CLONE-1, CLONE-2 | not covered | needs a second SSH-reachable EE host (CLONE-1 needs a fake ssh shim); the production guard needs a global config change | + +### Not in scope + +DB and container migrations under `migrations/` (core upgrade flow), the AcmePhp repository classes (only through LE scenarios), macOS-only code. + +## Coverage estimate + +Counting the rows above as items: 113 items, 88 covered (13 of them only through `@known-bug` scenarios), 1 partial, 24 not covered, i.e. about 78% of the inventory. Every user-facing subcommand and option of the html type has at least one scenario except `update --ssl=custom` (not implemented), `ssl-renew --force`, `clone --ssl/--wildcard` and a live `share`. + +## Suspected new bugs found while writing the suite + +Not in `issues.md` yet; each has a `@known-bug` scenario unless noted. + +1. `ltrim($site, 'www.')` strips characters, not the prefix (`add_site_redirects()`, `check_www_or_non_www_domain()`, `get_cert_domains()`): `www.w.x.test` is treated as `x.test`. redirect.feature. +2. `get_parent_of_alias()` uses `LIKE '%alias%'`, so the wrong site can be named as the owner of an alias. create.feature. +3. `update --ssl=off` is impossible on sites created with `--ssl=self` (create stores `site_ssl_wildcard=1`, and update_ssl() refuses wildcard -> normal), while `update --ssl=self` stores 0: the flag is inconsistent. update-ssl-php.feature (two scenarios). +4. `update --ssl=inherit` skips `check_parent_site_certs()`, so a site can inherit from a missing parent. update-ssl-php.feature. +5. `check_parent_site_certs()` builds its exception message with `var_dump()`, dumping the parent site object to STDOUT. ssl.feature. +6. `site reload` exits 0 when `nginx -t` fails (`reload_services()` ignores the exit code). restart-reload.feature. +7. `site clean` without flags always fails on html sites (object/page caches cannot exist), even after purging the proxy cache. clean.feature. +8. `convert_old_args_to_new_args()` checks `$args[1] === 'create'` (it is the site name), so the "no longer supported in EE v4" message for `--w3tc` & co. never shows. site.feature. +9. A failed restore (or backup failing after the pre-checks) leaves `/.lock`; later backups/restores of the site are refused until it is removed by hand. The shutdown handler only looks at the site command's own `site_data`. backup-restore.feature. +10. `cleanup_old_backups()` prunes only when `count > no-of-backups + 1`, keeping one backup too many. backup-restore.feature. +11. `list_remote_backups()` tests `empty()` on `explode()`'s `['']` before filtering, so "No remote backups found." is dead code (and a missing remote dir may surface as "Error listing remote backups"). backup-restore.feature. +12. `copy_site_db()` ignores the result of both `RevertableStepProcessor::execute()` calls, so a failed DB export/import during clone/sync still ends with "Site cloned/synced successfully" (no scenario: needs fault injection). +13. `update --php` on an html site fails only on a missing `config/php` directory (raw Symfony IOException message) after creating `.backup/config/php-`; it should be refused up front (scenario asserts only the failure). +14. `delete_site()`'s fallback `docker rm -f $(docker ps -q -f=label=created_by=EasyEngine -f=label=site_name=...)` uses labels EE never sets, so it never matches (no scenario). +15. `update --ssl=custom` has no `--ssl-key/--ssl-crt` options, so custom SSL can only be set at create time (no scenario). From 080e15a0509809ef95a3f7fc0874765b512bbeb9 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 07:48:34 +0530 Subject: [PATCH 08/37] test(site-command): list the remote before purging and clean up leftovers Purge a site's remote backups only after listing the rclone path and finding that exact directory, and fail if backups remain. After each feature, once no suite site is left, remove suite-named proxy certs, ACME state, site dirs and volumes that known bugs leave behind. Add a guard step for host-wide LE commands and keep /etc/hosts lines intact when the file lacks a trailing newline. --- features/bootstrap/FeatureContext.php | 84 +++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index eee2ccb3..0f6cd9b2 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -485,6 +485,8 @@ public static function cleanup(AfterFeatureScope $scope) EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); } + self::purge_own_leftovers(); + if ( in_array( 'remote', $scope->getFeature()->getTags(), true ) ) { self::purge_own_remote_backups(); } @@ -879,7 +881,9 @@ public function theHostsFileContainsAnEntryFor( $host ) $host = $this->v( $host ); $this->assert_own_name( $host ); if ( ! $this->hosts_has_entry( $host ) ) { - file_put_contents( '/etc/hosts', "127.0.0.1\t$host\n", FILE_APPEND ); + $hosts = file_get_contents( '/etc/hosts' ); + $lead = ( '' === $hosts || "\n" === substr( $hosts, -1 ) ) ? '' : "\n"; + file_put_contents( '/etc/hosts', "{$lead}127.0.0.1\t$host\n", FILE_APPEND ); $this->added_hosts[] = $host; } } @@ -1208,6 +1212,22 @@ public function theConfigValueIs( $key, $what, $value = null ) } } + /** + * Guard for host-wide commands (e.g. `ssl-renew --all`): refuses to run when other people's sites would be touched. + * + * @Given /^no enabled Let's Encrypt site other than this suite's exists$/ + */ + public function noForeignLetsEncryptSiteExists() + { + $sites = is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ? self::db()->query( "SELECT site_url FROM sites WHERE site_ssl = 'le' AND site_enabled = 1" )->fetchAll( PDO::FETCH_COLUMN ) : []; + $foreign = array_filter( $sites, function ( $site ) { + return ! $this->is_own_name( $site ); + } ); + if ( ! empty( $foreign ) ) { + throw new Exception( 'Refusing to run: these Let\'s Encrypt sites would be renewed too: ' . implode( ', ', $foreign ) ); + } + } + /* ----- remote backups (rclone) ----- */ /** @@ -1217,7 +1237,13 @@ public function thereAreNoRemoteBackupsForSite( $site ) { $site = $this->v( $site ); $this->assert_own_name( $site ); - EE::launch( 'rclone purge ' . escapeshellarg( $this->rclone_path() . '/' . $site ), false, true ); + // List first and purge only this exact site directory, never anything else on the real remote. + if ( in_array( $site, self::remote_dirs( $this->rclone_path() ), true ) ) { + EE::launch( 'rclone purge ' . escapeshellarg( $this->rclone_path() . '/' . $site ), false, true ); + } + if ( ! empty( $this->remote_backups( $site ) ) ) { + throw new Exception( "Could not remove the remote backups of $site." ); + } } /** @@ -1537,15 +1563,61 @@ private static function purge_own_remote_backups() { $context = new self(); $base = $context->rclone_path(); - $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $base ), false, true ); - foreach ( explode( "\n", trim( $run->stdout ) ) as $dir ) { - $dir = rtrim( trim( $dir ), '/' ); - if ( '' !== $dir && 0 === strpos( $dir, self::SITE_PREFIX ) ) { + foreach ( self::remote_dirs( $base ) as $dir ) { + if ( 0 === strpos( $dir, self::SITE_PREFIX ) ) { EE::launch( 'rclone purge ' . escapeshellarg( $base . '/' . $dir ), false, true ); } } } + /** + * Top-level directories under the rclone path that look like site names (no slashes or dot-segments). + */ + private static function remote_dirs( $base ) + { + $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $base ), false, true ); + if ( 0 !== $run->return_code ) { + return []; + } + $dirs = array_map( function ( $dir ) { + return rtrim( trim( $dir ), '/' ); + }, explode( "\n", $run->stdout ) ); + + return array_values( array_filter( $dirs, function ( $dir ) { + return 1 === preg_match( '/^[a-z0-9][a-z0-9.-]*$/', $dir ) && false === strpos( $dir, '..' ); + } ) ); + } + + /** + * Removes proxy certs/ACME state, site dirs and volumes named after this suite once none of its sites is left + * (known bugs such as SSL-2/SSL-5/SSL-9 or a failed create leave them behind), so runs do not pile up state. + */ + private static function purge_own_leftovers() + { + if ( ! empty( self::own_sites() ) ) { + return; + } + $own = function ( $name ) { + return 1 === preg_match( '/(^|\.)' . preg_quote( self::SITE_PREFIX, '/' ) . '/', $name ); + }; + $proxy = EE_ROOT_DIR . '/services/nginx-proxy'; + foreach ( [ "$proxy/certs", "$proxy/acme-conf/certs", "$proxy/acme-conf/var", EE_SITE_ROOT ] as $dir ) { + foreach ( glob( $dir . '/*' . self::SITE_PREFIX . '*' ) ?: [] as $path ) { + if ( $own( basename( $path ) ) ) { + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } + } + } + + $run = EE::launch( 'docker volume ls -q --filter label=org.label-schema.vendor=EasyEngine', false, true ); + foreach ( array_filter( explode( "\n", trim( $run->stdout ) ) ) as $volume ) { + $label = EE::launch( "docker volume inspect -f '{{index .Labels \"io.easyengine.site\"}}' " . escapeshellarg( $volume ), false, true ); + if ( $own( trim( $label->stdout ) ) ) { + EE::launch( 'docker volume rm ' . escapeshellarg( $volume ), false, true ); + } + } + } + private function http_request( $url, $method = 'GET', $options = [] ) { $options = array_merge( [ 'resolve_ip' => '127.0.0.1', 'verify' => false, 'host_header' => null ], $options ); From 300562a5622f8a8005a57033813255be97a8c787 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:01:17 +0530 Subject: [PATCH 09/37] test(site-command): harden backup, LE and reload scenarios - Hold the fake backup lock with flock(1) so the scenario also works once PR #480 switches to flock, and assert a failed restore through the next backup instead of the lock file. - Use a reserved .invalid name for the failing LE alias instead of a real rt.gw host outside the test subdomain. - Guard ssl-renew --all against other LE sites on the host. - Poll after reloading custom nginx config instead of one request. - Check SEC-1 leftovers by label and directory. - Add known-bug scenarios for update --php on html (SC-13) and update --ssl=custom (SC-15), and reference SC-* IDs and the open PRs that fix each known bug. --- features/backup-restore.feature | 24 +++++++++++++++--------- features/clean.feature | 4 ++-- features/create.feature | 8 ++++++-- features/le.feature | 5 ++++- features/redirect.feature | 2 +- features/restart-reload.feature | 7 +++---- features/site.feature | 2 +- features/ssl.feature | 2 +- features/update-alias.feature | 2 +- features/update-ssl-php.feature | 29 ++++++++++++++++++++++++++--- 10 files changed, 60 insertions(+), 25 deletions(-) diff --git a/features/backup-restore.feature b/features/backup-restore.feature index 1cb0e250..49106c67 100644 --- a/features/backup-restore.feature +++ b/features/backup-restore.feature @@ -16,6 +16,7 @@ Feature: Backup and restore And STDOUT should contain "Backup created successfully." And the remote backup storage should have 1 backup for site "bh-site-command-140.test" And the directory "{BACKUP_DIR}/bh-site-command-140.test" should not exist + # PR #480 keeps the lock file (it becomes an flock); drop this line when it merges. And the file "{BACKUP_DIR}/bh-site-command-140.test.lock" should not exist When I run 'bin/ee site backup bh-site-command-140.test --list' Then the command should succeed @@ -38,6 +39,7 @@ Feature: Backup and restore And STDOUT should contain "Site restored successfully." And the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" should contain "original" And the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/index.html" should exist + # PR #480 keeps the lock file (it becomes an flock); drop this line when it merges. And the file "{BACKUP_DIR}/bh-site-command-141.test.lock" should not exist When I wait up to 60 seconds for "http://bh-site-command-141.test/bh-marker.txt" to return 200 Then the response body should contain "original" @@ -77,7 +79,7 @@ Feature: Backup and restore Then the command should succeed And STDOUT should match "/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" - # Suspected new bug: list_remote_backups() checks empty() on explode()'s [''] before filtering, so "No remote backups found." is never printed (and rclone may error on the missing path instead). + # SC-11: list_remote_backups() checks empty() on explode()'s [''] before filtering, so "No remote backups found." is never printed. Fixed by PR #482. @known-bug Scenario: Listing the backups of a site without backups says so Given the html site "bh-site-command-149.test" exists @@ -99,23 +101,27 @@ Feature: Backup and restore Then the command should fail And STDERR should contain "ed-api-url is not configured." + # The lock file is also held with flock(1): develop only checks that the file exists, PR #480 only honours a held flock. Scenario: A lock file of another backup blocks the backup Given the html site "bh-site-command-144.test" exists And I write "lock" to the file "{BACKUP_DIR}/bh-site-command-144.test.lock" - When I run 'bin/ee site backup bh-site-command-144.test' + When I run 'flock {BACKUP_DIR}/bh-site-command-144.test.lock bin/ee site backup bh-site-command-144.test' Then the command should fail And STDERR should contain "Another backup/restore process is running. Please wait for it to complete." - # Suspected new bug: pre_backup_restore_checks() writes .lock, but a failing restore exits without removing it (the shutdown handler only sees the site command's own site_data), so every later backup/restore of the site is refused. + # SC-9: pre_backup_restore_checks() writes .lock, but a failing restore exits without removing it (the shutdown handler only sees the site command's own site_data), so every later backup/restore of the site is refused. + # Asserted through the next backup, not the file: PR #480 (flock) fixes the blocking but keeps the file. @known-bug - Scenario: A failed restore releases its lock + Scenario: A failed restore does not block later backups Given the html site "bh-site-command-145.test" exists And there are no remote backups for site "bh-site-command-145.test" When I run 'bin/ee site restore bh-site-command-145.test' Then the command should fail - And the file "{BACKUP_DIR}/bh-site-command-145.test.lock" should not exist + When I run 'bin/ee site backup bh-site-command-145.test' + Then the command should succeed + And STDERR should not contain "Another backup/restore process is running." - # BKP-1: a restore reuses files a previous restore left in the backup directory, even for another --id (fixed in site-command #482, not merged). + # BKP-1: a restore reuses files a previous restore left in the backup directory, even for another --id. Fixed by PR #482 (f372636). @known-bug Scenario: Restoring by id ignores files left behind by an earlier restore Given the html site "bh-site-command-146.test" exists @@ -133,7 +139,7 @@ Feature: Backup and restore Then the command should succeed And the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" should contain "A" - # Suspected new bug: cleanup_old_backups() only prunes when count > no-of-backups + 1, so one backup too many is kept. + # SC-10: cleanup_old_backups() only prunes when count > no-of-backups + 1, so one backup too many is kept. Fixed by PR #482. @known-bug Scenario: Old backups are pruned down to no-of-backups Given the EasyEngine config value "no-of-backups" is "3" @@ -167,7 +173,7 @@ Feature: Backup and restore Then STDOUT should contain "bh-site-command-before" When I wait up to 60 seconds for "http://bh-site-command-148.test/" to return 200 - # LOG-3: restoring a WP site prints its DB password (wp config set output). + # LOG-3: restoring a WP site prints its DB password (wp config set output). PR #484 fixes the success path only. @wp @known-bug Scenario: Restoring a WordPress site does not print its database password Given "ee" is installed @@ -181,7 +187,7 @@ Feature: Backup and restore And STDOUT should not contain "{DB_PASSWORD}" And STDERR should not contain "{DB_PASSWORD}" - # WP-1: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. + # WP-1: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. No PR yet (local fix only). @wp @known-bug Scenario: A restored WordPress core passes checksum verification Given "ee" is installed diff --git a/features/clean.feature b/features/clean.feature index aef72eb4..3140120d 100644 --- a/features/clean.feature +++ b/features/clean.feature @@ -27,7 +27,7 @@ Feature: Clean site caches Then the command should fail And STDERR should contain "Proxy cache is not enabled on site." - # CACHE-1 pattern: the proxy purge uses `docker exec -it` and ignores the exit code (no TTY: fails silently; nested ptys: can hang), hence the timeout. + # CACHE-1 pattern (see SC-7): the proxy purge uses `docker exec -it` and ignores the exit code (no TTY: fails silently; nested ptys: can hang), hence the timeout. No open PR. @destructive @known-bug Scenario: Cleaning the proxy cache empties it Given the html site "bh-site-command-89.test" exists @@ -46,7 +46,7 @@ Feature: Clean site caches """ And the docker container "services_global-nginx-proxy_1" should be running - # Suspected new bug: without flags, clean always tries object and page caches, which html sites cannot have, so it fails even after clearing the proxy cache. + # SC-7: without flags, clean always tries object and page caches, which html sites cannot have, so it fails even after clearing the proxy cache. No open PR. @destructive @known-bug Scenario: Cleaning without flags on a proxy-cached html site succeeds Given the html site "bh-site-command-89.test" exists diff --git a/features/create.feature b/features/create.feature index ba995428..fcd0ef9d 100644 --- a/features/create.feature +++ b/features/create.feature @@ -124,7 +124,7 @@ Feature: Create html sites Then the command should succeed And the "site_type" field of site "bh-site-command-18.test" in the database should be "html" - # Suspected new bug: get_parent_of_alias() finds the parent with LIKE '%alias%', so a longer alias of another site (ab.X contains b.X) is reported as the owner. + # SC-2: get_parent_of_alias() finds the parent with LIKE '%alias%', so a longer alias of another site (ab.X contains b.X) is reported as the owner. No open PR. @known-bug Scenario: The owner of an alias is reported correctly when another alias contains it Given the site "bh-site-command-20.test" exists, created with "--type=html --alias-domains=ab.bh-site-command-20.test" @@ -133,7 +133,9 @@ Feature: Create html sites Then the command should fail And STDERR should contain "already exists as an alias domain for site: bh-site-command-21.test." - # SEC-1: site names are not validated, and unescaped shell sinks run before the create fails. + # SEC-1: site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits + # `docker volume create ... bhsitecommand22;idtest_htdocs`, creating a volume "bhsitecommand22" and running "idtest_htdocs". + # PR #495 only escapes the self-signed sinks; the real fix is local (issues.md SEC-1). @known-bug Scenario: Site names that are not valid hostnames are rejected before anything runs Given the site "bh-site-command-22;id.test" does not exist @@ -141,4 +143,6 @@ Feature: Create html sites Then the command should fail And STDERR should contain "Invalid site name" And the site "bh-site-command-22;id.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-22;id.test" should not exist + And the site "bh-site-command-22;id.test" should have 0 docker volumes And the docker volume "bhsitecommand22" should not exist diff --git a/features/le.feature b/features/le.feature index 0f6ade1d..b4c0f5df 100644 --- a/features/le.feature +++ b/features/le.feature @@ -85,9 +85,11 @@ Feature: Let's Encrypt certificates Then the command should succeed And the JSON field "0.dns_challenges" in STDOUT should contain "_acme-challenge.{LE3}" + # --all walks every LE site on the host (and force-enables expired ones), so it only runs when all of them are ours. @destructive Scenario: ssl-renew --all skips wildcard sites without Cloudflare credentials Given the EasyEngine config value "cloudflare-api-key" is not set + And no enabled Let's Encrypt site other than this suite's exists And the site "{LE3}" exists, created with "--type=html --ssl=le --wildcard" When I run 'bin/ee site ssl-renew --all' Then the command should succeed @@ -105,10 +107,11 @@ Feature: Let's Encrypt certificates And the TLS certificate served for "{LE2}" should be issued by "Let's Encrypt" # SSL-2: an alias that fails validation only warns; the alias is saved and SSL is marked disabled while the old cert keeps serving. + # The alias is under the reserved .invalid TLD: the order is refused outright, so nothing outside *.ee4-test.rt.gw is validated. @known-bug Scenario: An alias that fails validation is rejected and the site keeps its certificate Given the site "{LE2}" exists, created with "--type=html --ssl=le" - When I run 'bin/ee site update {LE2} --add-alias-domains=bh-site-command-nx.rt.gw' + When I run 'bin/ee site update {LE2} --add-alias-domains=bh-site-command-nx-{RUN_ID}.invalid' Then the command should fail And the "alias_domains" field of site "{LE2}" in the database should be "{LE2}" And the "site_ssl" field of site "{LE2}" in the database should be "le" diff --git a/features/redirect.feature b/features/redirect.feature index df12a8a2..b0d2e475 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -59,7 +59,7 @@ Feature: Site Redirection Then the response code should be 301 And the response header "Location" should be "https://bh-site-command-126.test/" - # Suspected new bug: add_site_redirects()/check_www_or_non_www_domain()/get_cert_domains() use ltrim($site, 'www.'), which strips characters, not the prefix: www.w.x.test becomes x.test. + # SC-1: add_site_redirects()/check_www_or_non_www_domain()/get_cert_domains() use ltrim($site, 'www.'), which strips characters, not the prefix: www.w.x.test becomes x.test. No open PR. @known-bug Scenario: A www site whose next label starts with w redirects the right name Given the hosts file contains an entry for "w.bh-site-command-125.test" diff --git a/features/restart-reload.feature b/features/restart-reload.feature index dab7c6f8..e447ba5a 100644 --- a/features/restart-reload.feature +++ b/features/restart-reload.feature @@ -57,14 +57,13 @@ Feature: Restart and reload site services When I write "location = /bh-probe { return 200 'bh-site-command-probe'; }\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" And I run 'bin/ee site reload bh-site-command-58.test' Then the command should succeed - When I send a GET request to "http://bh-site-command-58.test/bh-probe" - Then the response code should be 200 - And the response body should contain "bh-site-command-probe" + When I wait up to 30 seconds for "http://bh-site-command-58.test/bh-probe" to return 200 + Then the response body should contain "bh-site-command-probe" When I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" And I run 'bin/ee site reload bh-site-command-58.test' Then the command should succeed - # Suspected new bug: reload_services() ignores the exit code of `nginx -t && nginx -s reload`, so a failed reload exits 0. + # SC-6: reload_services() ignores the exit code of `nginx -t && nginx -s reload`, so a failed reload exits 0. No open PR. @known-bug Scenario: Reload fails when the nginx config test fails Given the html site "bh-site-command-57.test" exists diff --git a/features/site.feature b/features/site.feature index 5f15b4b5..ca4ca83a 100644 --- a/features/site.feature +++ b/features/site.feature @@ -144,7 +144,7 @@ Feature: Site command basics Then the command should fail And STDERR should contain "Too many positional arguments" - # Suspected new bug: convert_old_args_to_new_args() checks $args[1] === 'create', but $args[1] is the site name, so the EE v3 flag message never shows. + # SC-8: convert_old_args_to_new_args() checks $args[1] === 'create', but $args[1] is the site name, so the EE v3 flag message never shows. No open PR. @known-bug Scenario: Removed EE v3 cache flags give a clear error Given the site "bh-site-command-3.test" does not exist diff --git a/features/ssl.feature b/features/ssl.feature index b98afd9a..557a0007 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -152,7 +152,7 @@ Feature: Self-signed, custom and inherited SSL And STDERR should contain "Cannot use --wildcard with --ssl=inherit" And the site "sub2.bh-site-command-105.test" should not exist - # Suspected new bug: check_parent_site_certs() builds its exception message with var_dump(), which prints the parent site object to STDOUT. + # SC-5: check_parent_site_certs() builds its exception message with var_dump(), which prints the parent site object to STDOUT. No open PR. @known-bug Scenario: A failed inherit does not dump the parent site object Given the html site "bh-site-command-107.test" exists diff --git a/features/update-alias.feature b/features/update-alias.feature index bdbd3cb2..6c8d2103 100644 --- a/features/update-alias.feature +++ b/features/update-alias.feature @@ -88,7 +88,7 @@ Feature: Update alias domains When I wait up to 60 seconds for "https://bh-site-command-69.test/" to return 200 Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" - # AUTH-1: alias labels starting with "_" are accepted and can clobber a site's _wildcard proxy files (fixed in site-command #502, not merged). + # AUTH-1: alias labels starting with "_" are accepted and can clobber a site's _wildcard proxy files. Fixed by PR #502 (38569df, draft). @known-bug Scenario: Alias domains with a label starting with an underscore are rejected Given the html site "bh-site-command-68.test" exists diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index b22b6730..7cf586ad 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -85,7 +85,7 @@ Feature: Update SSL and PHP of html sites Then the command should fail And STDERR should contain "Invalid value specified for 'php'" - # Suspected new bug: update_ssl() refuses --ssl=off when site_ssl_wildcard=1, and --ssl=self sets that flag on create, so SSL can never be turned off. + # SC-3: update_ssl() refuses --ssl=off when site_ssl_wildcard=1, and --ssl=self sets that flag on create, so SSL can never be turned off. No open PR. @known-bug Scenario: SSL can be disabled on a site created with self-signed SSL Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" @@ -93,7 +93,7 @@ Feature: Update SSL and PHP of html sites Then the command should succeed And STDOUT should contain "Disabled SSL for bh-site-command-71.test" - # Suspected new bug: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). + # SC-3: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). No open PR. @known-bug Scenario: Enabling self-signed SSL through update records it as wildcard like create does Given the html site "bh-site-command-75.test" exists @@ -101,7 +101,7 @@ Feature: Update SSL and PHP of html sites Then the command should succeed And the "site_ssl_wildcard" field of site "bh-site-command-75.test" in the database should be "1" - # Suspected new bug: update --ssl=inherit skips check_parent_site_certs(), which create runs, so a site can "inherit" from a parent that does not exist. + # SC-4: update --ssl=inherit skips check_parent_site_certs(), which create runs, so a site can "inherit" from a parent that does not exist. No open PR. @known-bug Scenario: Inheriting SSL through update requires a wildcard parent Given the site "bh-site-command-78.test" does not exist @@ -110,3 +110,26 @@ Feature: Update SSL and PHP of html sites Then the command should fail And STDERR should contain "Unable to find existing site: bh-site-command-78.test" And the "site_ssl" field of site "sub.bh-site-command-78.test" in the database should be "" + + # SC-13: update_php() never checks the site type; on html it mkdirs .backup/config/php- and then dies on mirror() of the + # missing config/php with Symfony's raw "The origin directory specified ... was not found." No open PR. + @known-bug + Scenario: Changing the PHP version of an html site is refused up front without leftovers + Given the html site "bh-site-command-79.test" exists + When I run 'bin/ee site update bh-site-command-79.test --php=8.3' + Then the command should fail + And STDERR should not contain "The origin directory specified" + And the directory "{SITES_DIR}/bh-site-command-79.test/.backup" should not exist + + # SC-15: update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. PR #489 adds both options. + @known-bug + Scenario: Enable custom SSL on an existing site + Given the html site "bh-site-command-108.test" exists + When I run "openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout {TMP}/bh.key -out {TMP}/bh.crt -subj '/CN=bh-site-command-108.test/O=bh-site-command-custom' -addext 'subjectAltName=DNS:bh-site-command-108.test'" + Then the command should succeed + When I run 'bin/ee site update bh-site-command-108.test --ssl=custom --ssl-key={TMP}/bh.key --ssl-crt={TMP}/bh.crt' + Then the command should succeed + And the "site_ssl" field of site "bh-site-command-108.test" in the database should be "custom" + And the file "{PROXY_DIR}/certs/bh-site-command-108.test.crt" should be identical to the file "{TMP}/bh.crt" + When I wait up to 60 seconds for "https://bh-site-command-108.test/" to return 200 + Then the TLS certificate served for "bh-site-command-108.test" should be issued by "bh-site-command-custom" From 73383cd62f8e9890cbfcabcf0efa2b249af62668 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:14:25 +0530 Subject: [PATCH 10/37] test(site-command): update coverage notes after review Correct the tag counts, document the remote listing, leftover cleanup and LE guards, map every known-bug scenario to its issues.md ID and the open PR that fixes it, and list the PRs that change what passing scenarios assert. --- features/COVERAGE.md | 76 +++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index d94a7221..35ff866c 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,15 +1,15 @@ # site-command Behat coverage -Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 180 scenarios (212 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 182 scenarios (214 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. ## Tags | Tag | Scenarios | Meaning | |---|---|---| -| (none) | 157 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. | -| `@known-bug` | 24 | Exposes a bug from `issues.md` (ID in the comment above the scenario) or a suspected new bug ("Suspected new bug"). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | -| `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or renews every LE site on the host (`ssl-renew --all`). Every scenario leaves the proxy running. | -| `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`, purged after the feature. | +| (none of the tags below) | 125 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | +| `@known-bug` | 26 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | +| `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | +| `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | | `@le` | 11 | Real Let's Encrypt issuance on `*.ee4-test.rt.gw`. At most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. | | `@wp` | 8 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | @@ -17,13 +17,14 @@ Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@known-bug&&~@wp'`. ## Environment rules -- Every site, alias, hosts entry and remote backup path contains `bh-site-command-`. `@AfterFeature` deletes only sites matching `(^|\.)bh-site-command-`, never other sites; `@remote` features also purge `/bh-site-command-*`. `@AfterScenario` removes hosts entries the scenario added, the scenario's `{TMP}` dir under `/var/tmp`, and `{BACKUP_DIR}/bh-site-command-*` leftovers. +- Every site, alias, hosts entry and remote backup path contains `bh-site-command-`. `@AfterFeature` deletes only sites matching `(^|\.)bh-site-command-`, never other sites; once none is left it also removes suite-named leftovers of known bugs (proxy `certs/`, `acme-conf/certs|var/`, site dirs, volumes labelled with a suite name); `@remote` features also purge the listed `/bh-site-command-*` directories. `@AfterScenario` removes hosts entries the scenario added, the scenario's `{TMP}` dir under `/var/tmp`, and `{BACKUP_DIR}/bh-site-command-*` leftovers. - The file-writing, hosts, delete and rclone steps refuse names and paths without the prefix (or outside `{TMP}`). - HTTP steps resolve the site name to 127.0.0.1 (whitelisted, so no HTTP auth, and no /etc/hosts entry needed); `through the public IP` goes through `hostname -I` instead; `verified` uses real DNS and full TLS verification (LE only). Redirects are never followed. - Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps. - Scenarios are independent: each one uses its own site or the idempotent `Given the html site "..." exists` / `exists, created with "..."`. The exception is `le.feature`, which shares three sites in file order to stay within the LE budget (each scenario still creates what it needs when run alone). - Commands that can hang on a known bug (CACHE-1) run under `with a timeout of N seconds`. - Commands that could prompt (le-mail) are guarded by `Given the EasyEngine config value "le-mail" is set`. +- The failing LE alias (SSL-2) uses a `*.invalid` name, so no validation ever reaches a host outside `*.ee4-test.rt.gw`. ## Estimated runtime (4 CPU test host) @@ -144,9 +145,9 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | `--wildcard` refusals, invalid `--ssl`, `--ssl=off --wildcard` | covered | ussl: Switching a normal site to wildcard SSL is refused; An unknown SSL type is rejected; --wildcard cannot be combined with --ssl=off | | `--ssl=inherit` via update (and the missing parent check) | covered | ussl: Inherit SSL from a self-signed parent through update; known bug: Inheriting SSL through update requires a wildcard parent | | `--ssl=le` via update | covered (@le) | le: Enable Let's Encrypt on an existing site | -| `--ssl=custom` via update | not covered | update_ssl() has no --ssl-key/--ssl-crt handling; custom via update cannot work (flagged below) | +| `--ssl=custom` via update | covered (known bug SC-15) | ussl: Enable custom SSL on an existing site (PR #489 adds `--ssl-key/--ssl-crt`) | | off on a created self-signed site, wildcard flag consistency | covered (known bugs) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | -| `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected | +| `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected; known bug SC-13: ...refused up front without leftovers | | `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | | `--proxy-cache=on/off`, twice, custom sizes, invalid sizes, invalid switch | covered (@destructive) | pcache: all | | proxy cache for aliases (existing and newly added) | covered (@destructive) | pcache: Alias domains get proxy cache locations... | @@ -186,7 +187,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | disabled site | covered | backup: A disabled site cannot be backed up...; en-dis outline | | `--dash-auth` format and ed-api-url check | covered | backup: A malformed --dash-auth is rejected; --dash-auth needs ed-api-url in the config | | `--dash-auth` success/failure callbacks, rollback of the uploaded backup | not covered | needs an EasyDash API endpoint (`ed-api-url`) | -| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup; known bug: A failed restore releases its lock | +| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup (held with flock(1), so it holds before and after PR #480); known bug SC-9: A failed restore does not block later backups | | BKP-1 stale download reuse | covered (known bug) | backup: Restoring by id ignores files left behind... | | retention (`no-of-backups`) | covered (known bug) | backup: Old backups are pruned down to no-of-backups | | WP backup/restore (DB, wp-content, core download), LOG-3, WP-1 | covered (@wp) | backup: three @wp scenarios | @@ -211,24 +212,39 @@ DB and container migrations under `migrations/` (core upgrade flow), the AcmePhp ## Coverage estimate -Counting the rows above as items: 113 items, 88 covered (13 of them only through `@known-bug` scenarios), 1 partial, 24 not covered, i.e. about 78% of the inventory. Every user-facing subcommand and option of the html type has at least one scenario except `update --ssl=custom` (not implemented), `ssl-renew --force`, `clone --ssl/--wildcard` and a live `share`. - -## Suspected new bugs found while writing the suite - -Not in `issues.md` yet; each has a `@known-bug` scenario unless noted. - -1. `ltrim($site, 'www.')` strips characters, not the prefix (`add_site_redirects()`, `check_www_or_non_www_domain()`, `get_cert_domains()`): `www.w.x.test` is treated as `x.test`. redirect.feature. -2. `get_parent_of_alias()` uses `LIKE '%alias%'`, so the wrong site can be named as the owner of an alias. create.feature. -3. `update --ssl=off` is impossible on sites created with `--ssl=self` (create stores `site_ssl_wildcard=1`, and update_ssl() refuses wildcard -> normal), while `update --ssl=self` stores 0: the flag is inconsistent. update-ssl-php.feature (two scenarios). -4. `update --ssl=inherit` skips `check_parent_site_certs()`, so a site can inherit from a missing parent. update-ssl-php.feature. -5. `check_parent_site_certs()` builds its exception message with `var_dump()`, dumping the parent site object to STDOUT. ssl.feature. -6. `site reload` exits 0 when `nginx -t` fails (`reload_services()` ignores the exit code). restart-reload.feature. -7. `site clean` without flags always fails on html sites (object/page caches cannot exist), even after purging the proxy cache. clean.feature. -8. `convert_old_args_to_new_args()` checks `$args[1] === 'create'` (it is the site name), so the "no longer supported in EE v4" message for `--w3tc` & co. never shows. site.feature. -9. A failed restore (or backup failing after the pre-checks) leaves `/.lock`; later backups/restores of the site are refused until it is removed by hand. The shutdown handler only looks at the site command's own `site_data`. backup-restore.feature. -10. `cleanup_old_backups()` prunes only when `count > no-of-backups + 1`, keeping one backup too many. backup-restore.feature. -11. `list_remote_backups()` tests `empty()` on `explode()`'s `['']` before filtering, so "No remote backups found." is dead code (and a missing remote dir may surface as "Error listing remote backups"). backup-restore.feature. -12. `copy_site_db()` ignores the result of both `RevertableStepProcessor::execute()` calls, so a failed DB export/import during clone/sync still ends with "Site cloned/synced successfully" (no scenario: needs fault injection). -13. `update --php` on an html site fails only on a missing `config/php` directory (raw Symfony IOException message) after creating `.backup/config/php-`; it should be refused up front (scenario asserts only the failure). -14. `delete_site()`'s fallback `docker rm -f $(docker ps -q -f=label=created_by=EasyEngine -f=label=site_name=...)` uses labels EE never sets, so it never matches (no scenario). -15. `update --ssl=custom` has no `--ssl-key/--ssl-crt` options, so custom SSL can only be set at create time (no scenario). +Counting the rows above as items: 113 items, 89 covered (14 of them only through `@known-bug` scenarios), 1 partial, 23 not covered, i.e. about 79% of the inventory (78% before the review). Every user-facing subcommand and option of the html type has at least one scenario except `ssl-renew --force`, `clone --ssl/--wildcard` and a live `share`. + +## Product bugs found while writing the suite (issues.md SC-1..15) + +Verified against develop 9dc44e1 in review; details, severity and locations are in `issues.md` ("site-command (from Behat authoring)"). + +| ID | Bug | Scenario | Open PR | +|---|---|---|---| +| SC-1 | `ltrim($site, 'www.')` strips characters, not the prefix | redirect: A www site whose next label starts with w... | none | +| SC-2 | `get_parent_of_alias()` uses `LIKE '%alias%'` | create: The owner of an alias is reported correctly... | none | +| SC-3 | SSL can't be turned off on a `--ssl=self` site; update stores `site_ssl_wildcard=0`, create 1 | ussl: two scenarios | none | +| SC-4 | `update --ssl=inherit` skips the parent check | ussl: Inheriting SSL through update requires a wildcard parent | none | +| SC-5 | inherit error message built with `var_dump()` | ssl: A failed inherit does not dump the parent site object | none | +| SC-6 | `site reload` exits 0 when `nginx -t` fails | rr: Reload fails when the nginx config test fails | none | +| SC-7 | `site clean` without flags always fails on html sites | clean: Cleaning without flags on a proxy-cached html site succeeds | none | +| SC-8 | EE v3 `--w3tc` message never shows | site: Removed EE v3 cache flags give a clear error | none | +| SC-9 | failed restore leaves `.lock` and blocks later backups | backup: A failed restore does not block later backups | #480 (flock; the file stays but no longer blocks) | +| SC-10 | retention keeps `no-of-backups + 1` | backup: Old backups are pruned down to no-of-backups | #482 | +| SC-11 | "No remote backups found." unreachable | backup: Listing the backups of a site without backups says so | #482 | +| SC-12 | `copy_site_db()` ignores failed export/import | none (needs fault injection) | none | +| SC-13 | `update --php` on html fails late, raw IOException, leaves `.backup/` | ussl: ...refused up front without leftovers | none | +| SC-14 | `delete_site()` `docker rm` fallback filters on labels EE never sets | none (dead fallback) | none | +| SC-15 | `update --ssl=custom` impossible (no `--ssl-key/--ssl-crt`) | ussl: Enable custom SSL on an existing site | #489 | + +## Other known bugs and the open PRs that fix them + +| Scenario | Issue | Open PR | +|---|---|---| +| backup: Restoring by id ignores files left behind... | BKP-1 | #482 (f372636) | +| backup: Restoring a WordPress site does not print its database password | LOG-3 | #484 (success path only) | +| backup: A restored WordPress core passes checksum verification | WP-1 | none (local fix only) | +| create: Site names that are not valid hostnames are rejected... | SEC-1 | #495 is partial (self-signed sinks only); full fix local | +| alias: Alias domains with a label starting with an underscore... | AUTH-1 | #502 (draft) | +| le: SSL-2, SSL-8, SSL-5/SSL-9; ssl: SEC-3, SSL-4; clean: CACHE-1 pattern; redirect: MIG-15 | as named | none (#486/#487/#493/#495/#498/#502 only touch these paths) | + +PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages, #486 changes the SSL-2 warning text, #487 makes `ssl-verify` fail for non-LE sites, #501 changes the clone messages on an empty destination host ("Unable to find '' on ''"), #502 adds alias validation messages. From 0d6678314d3d0a6a62a186653e1a7805e1bf1f75 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:22:07 +0530 Subject: [PATCH 11/37] test(site-command): take the expected LE issuer from EE_BEHAT_LE_ISSUER CI runs the @le scenarios against Pebble, whose intermediate CN differs per start. Defaults to Let's Encrypt. --- features/bootstrap/FeatureContext.php | 7 +++++-- features/le.feature | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 0f6cd9b2..dac80b3e 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -435,8 +435,8 @@ public function thereShouldBeContainersWithLabel($expected_running_containers, P * * Written to be copied as-is into other packages' contexts: only SITE_PREFIX changes. * Every string argument goes through v(), so {NAME} variables work everywhere: - * built-ins are {EE}, {EE_ROOT_DIR}, {SITES_DIR}, {PROXY_DIR}, {BACKUP_DIR}, {TMP}, {RUN_ID}, {PUBLIC_IP} - * and {RCLONE_PATH}; others come from the "I save ..." steps. + * built-ins are {EE}, {EE_ROOT_DIR}, {SITES_DIR}, {PROXY_DIR}, {BACKUP_DIR}, {TMP}, {RUN_ID}, {PUBLIC_IP}, + * {RCLONE_PATH} and {LE_ISSUER}; others come from the "I save ..." steps. * ===================================================================== */ /** @@ -1342,6 +1342,9 @@ private function variable( $name ) return $this->public_ip(); case 'RCLONE_PATH': return $this->rclone_path(); + case 'LE_ISSUER': + // CI runs @le against Pebble, whose intermediate CN is random per start. + return getenv( 'EE_BEHAT_LE_ISSUER' ) ?: "Let's Encrypt"; } return null; diff --git a/features/le.feature b/features/le.feature index b4c0f5df..31dadd67 100644 --- a/features/le.feature +++ b/features/le.feature @@ -26,7 +26,7 @@ Feature: Let's Encrypt certificates And the response body should contain "Your html site with EasyEngine" And the TLS certificate served for "{LE1}" should cover "{LE1}" And the TLS certificate served for "{LE1}" should cover "www.{LE1}" - And the TLS certificate served for "{LE1}" should be issued by "Let's Encrypt" + And the TLS certificate served for "{LE1}" should be issued by "{LE_ISSUER}" When I send a GET request to "http://{LE1}/" Then the response code should be 301 And the response header "Location" should be "https://{LE1}/" @@ -35,7 +35,7 @@ Feature: Let's Encrypt certificates And the response header "Location" should be "https://{LE1}/" When I run 'bin/ee site ssl-info {LE1}' Then the JSON field "0.ssl_type" in STDOUT should be "le" - And the JSON field "0.issued_by_CN" in STDOUT should be "Let's Encrypt" + And the JSON field "0.issued_by_CN" in STDOUT should be "{LE_ISSUER}" And the JSON field "0.SANs" in STDOUT should contain "www.{LE1}" Scenario: ssl-renew leaves a certificate that is not due alone @@ -104,7 +104,7 @@ Feature: Let's Encrypt certificates When I wait up to 60 seconds for "https://{LE2}/" to return 200 And I send a verified GET request to "https://{LE2}/" Then the response code should be 200 - And the TLS certificate served for "{LE2}" should be issued by "Let's Encrypt" + And the TLS certificate served for "{LE2}" should be issued by "{LE_ISSUER}" # SSL-2: an alias that fails validation only warns; the alias is saved and SSL is marked disabled while the old cert keeps serving. # The alias is under the reserved .invalid TLD: the order is refused outright, so nothing outside *.ee4-test.rt.gw is validated. From 6b81e04447ec2fd9f4a7a4b4f7857f374f3d2fd4 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:33:42 +0530 Subject: [PATCH 12/37] test(site-command): fix first CI run failures and tag new known bugs Table output needs SHELL_PIPE=0 without a TTY, and JSON is pretty-printed. The clone error scenario needs a site on the host (SC-17). The self-signed ssl-info DNS scenario (SC-16) and the invalid --proxy-cache value (SC-18) expose product bugs, so they're tagged @known-bug; a no-SSL site now covers the non-DNS branch. --- features/COVERAGE.md | 11 ++++++++--- features/clone-sync.feature | 4 +++- features/list.feature | 7 ++++--- features/proxy-cache.feature | 2 ++ features/ssl.feature | 10 +++++++++- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 35ff866c..bdc830c7 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,13 +1,13 @@ # site-command Behat coverage -Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 182 scenarios (214 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 183 scenarios (215 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. ## Tags | Tag | Scenarios | Meaning | |---|---|---| | (none of the tags below) | 125 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | -| `@known-bug` | 26 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | +| `@known-bug` | 28 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | | `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | | `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | | `@le` | 11 | Real Let's Encrypt issuance on `*.ee4-test.rt.gw`. At most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. | @@ -149,7 +149,8 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | off on a created self-signed site, wildcard flag consistency | covered (known bugs) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | | `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected; known bug SC-13: ...refused up front without leftovers | | `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | -| `--proxy-cache=on/off`, twice, custom sizes, invalid sizes, invalid switch | covered (@destructive) | pcache: all | +| `--proxy-cache=on/off`, twice, custom sizes, invalid sizes | covered (@destructive) | pcache: all but the invalid switch | +| invalid `--proxy-cache` value | covered (@destructive, known bug SC-18) | pcache: An invalid proxy cache switch is rejected | | proxy cache for aliases (existing and newly added) | covered (@destructive) | pcache: Alias domains get proxy cache locations... | | proxy cache `subdom` location (`*.site_location`) | not covered | WP subdom only (site-type-wp suite) | @@ -160,6 +161,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | ssl-info: self, custom, inherit (parent cert file), no SSL, LE, pending wildcard | covered | ssl: ssl-info scenarios; le: create and wildcard scenarios | | ssl-info `--format=yaml`, invalid format | covered | ssl: ssl-info supports other formats; ssl-info rejects an unsupported format | | ssl-info `--get-dns-records`: non-DNS site, DNS-01 site | covered | ssl: ssl-info DNS records of a site that does not use DNS-01; le: A wildcard certificate waits... | +| ssl-info `--get-dns-records` on a self-signed (wildcard) site | covered (known bug SC-16) | ssl: ssl-info DNS records of a self-signed site | | ssl-renew guards: no SSL, self, inherit | covered | ssl: ssl-renew only renews Let's Encrypt certificates (outline); ssl-renew refuses inherited certificates | | ssl-renew not due (no reissue), `--all` wildcard skip | covered (@le, `--all` also @destructive) | le: ssl-renew leaves a certificate that is not due alone; ssl-renew --all skips wildcard sites... | | ssl-renew `--force`, expired-cert path | not covered | LE budget (a forced renew is one more issuance); expiry cannot be simulated without a fake CA | @@ -235,6 +237,9 @@ Verified against develop 9dc44e1 in review; details, severity and locations are | SC-13 | `update --php` on html fails late, raw IOException, leaves `.backup/` | ussl: ...refused up front without leftovers | none | | SC-14 | `delete_site()` `docker rm` fallback filters on labels EE never sets | none (dead fallback) | none | | SC-15 | `update --ssl=custom` impossible (no `--ssl-key/--ssl-crt`) | ussl: Enable custom SSL on an existing site | #489 | +| SC-16 | `ssl-info --get-dns-records` treats self-signed (wildcard) sites as DNS-01 | ssl: ssl-info DNS records of a self-signed site | none | +| SC-17 | local clone/sync on a host without sites: "Unable to get site list" | none (needs a host with no site; clone: Cloning a site that does not exist fails keeps one site) | none | +| SC-18 | `update --proxy-cache=` accepted, non-`on` disables | pcache: An invalid proxy cache switch is rejected | none | ## Other known bugs and the open PRs that fix them diff --git a/features/clone-sync.feature b/features/clone-sync.feature index 377abb5f..c3561a1a 100644 --- a/features/clone-sync.feature +++ b/features/clone-sync.feature @@ -25,8 +25,10 @@ Feature: Clone and sync sites Then the command should fail And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" + # With no site on the host the error is "Unable to get site list" instead (SC-17), so keep one site around. Scenario: Cloning a site that does not exist fails - Given the site "bh-site-command-159.test" does not exist + Given the html site "bh-site-command-154.test" exists + And the site "bh-site-command-159.test" does not exist And the site "bh-site-command-151.test" does not exist When I run 'bin/ee site clone bh-site-command-159.test bh-site-command-151.test' Then the command should fail diff --git a/features/list.feature b/features/list.feature index 88be8361..393aa6f8 100644 --- a/features/list.feature +++ b/features/list.feature @@ -13,7 +13,8 @@ Feature: List sites And STDOUT should match "/^bh-site-command-41\.test$/m" Scenario: List sites as a table by default - When I run 'bin/ee site list' + # Without a TTY the default format is tab-separated; SHELL_PIPE=0 makes it render as it does in a terminal. + When I run 'SHELL_PIPE=0 bin/ee site list' Then the command should succeed And STDOUT should match "/\|\s*site\s*\|\s*status\s*\|/" And STDOUT should match "/\|\s*bh-site-command-40\.test\s*\|\s*enabled\s*\|/" @@ -23,8 +24,8 @@ Feature: List sites When I run 'bin/ee site list --format=json' Then the command should succeed And STDOUT should be valid JSON - And STDOUT should contain "{"site":"bh-site-command-40.test","status":"enabled"}" - And STDOUT should contain "{"site":"bh-site-command-41.test","status":"disabled"}" + And STDOUT should match "/\{\s*"site":\s*"bh-site-command-40\.test",\s*"status":\s*"enabled"\s*\}/" + And STDOUT should match "/\{\s*"site":\s*"bh-site-command-41\.test",\s*"status":\s*"disabled"\s*\}/" Scenario: List sites as CSV When I run 'bin/ee site list --format=csv' diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index 8be2d228..f8a7150c 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -61,6 +61,8 @@ Feature: Proxy cache Then the command should fail And STDERR should contain "Site bh-site-command-85.test already has proxy cache: off" + # SC-18: any value other than "on" is accepted and runs the disable path (exit 0). + @known-bug Scenario: An invalid proxy cache switch is rejected Given the html site "bh-site-command-85.test" exists When I run 'bin/ee site update bh-site-command-85.test --proxy-cache=maybe' diff --git a/features/ssl.feature b/features/ssl.feature index 557a0007..c2a83f84 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -60,6 +60,14 @@ Feature: Self-signed, custom and inherited SSL And the JSON field "0.status" in STDOUT should be "SSL is not enabled for this site." Scenario: ssl-info DNS records of a site that does not use DNS-01 + Given the html site "bh-site-command-101.test" exists + When I run 'bin/ee site ssl-info bh-site-command-101.test --get-dns-records' + Then the command should succeed + And the JSON field "0.warnings" in STDOUT should contain "This site does not use DNS-based (DNS-01) SSL challenge." + + # SC-16: self-signed sites are stored as wildcard, so ssl-info treats them as DNS-01 and reports missing challenges. + @known-bug + Scenario: ssl-info DNS records of a self-signed site Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" When I run 'bin/ee site ssl-info bh-site-command-100.test --get-dns-records' Then the command should succeed @@ -161,7 +169,7 @@ Feature: Self-signed, custom and inherited SSL Then the command should fail And STDOUT should not contain "object(" - # SEC-3: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3 rejects every leaf it signed (error 79). + # SEC-3: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). Passes on the OpenSSL 3.0 of ubuntu-24.04 runners. @known-bug Scenario: Self-signed site certificates verify against the EasyEngine root CA Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" From f4d6d860895a80bcb19fd4fc8f22fce2bfc84b93 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:42:52 +0530 Subject: [PATCH 13/37] test(site-command): keep back-to-back backups in separate seconds Backup ids have one-second resolution (BKP-5). On a fast local remote two backups landed in the same directory, so the retention scenario passed without testing anything and the by-id restore scenarios could compare a backup with itself. --- features/COVERAGE.md | 2 +- features/backup-restore.feature | 5 +++++ features/bootstrap/FeatureContext.php | 11 +++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index bdc830c7..55ada32b 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -20,7 +20,7 @@ Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@known-bug&&~@wp'`. - Every site, alias, hosts entry and remote backup path contains `bh-site-command-`. `@AfterFeature` deletes only sites matching `(^|\.)bh-site-command-`, never other sites; once none is left it also removes suite-named leftovers of known bugs (proxy `certs/`, `acme-conf/certs|var/`, site dirs, volumes labelled with a suite name); `@remote` features also purge the listed `/bh-site-command-*` directories. `@AfterScenario` removes hosts entries the scenario added, the scenario's `{TMP}` dir under `/var/tmp`, and `{BACKUP_DIR}/bh-site-command-*` leftovers. - The file-writing, hosts, delete and rclone steps refuse names and paths without the prefix (or outside `{TMP}`). - HTTP steps resolve the site name to 127.0.0.1 (whitelisted, so no HTTP auth, and no /etc/hosts entry needed); `through the public IP` goes through `hostname -I` instead; `verified` uses real DNS and full TLS verification (LE only). Redirects are never followed. -- Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps. +- Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps, except `I wait for the next second` between back-to-back backups of one site (backup ids have one-second resolution, BKP-5). - Scenarios are independent: each one uses its own site or the idempotent `Given the html site "..." exists` / `exists, created with "..."`. The exception is `le.feature`, which shares three sites in file order to stay within the LE budget (each scenario still creates what it needs when run alone). - Commands that can hang on a known bug (CACHE-1) run under `with a timeout of N seconds`. - Commands that could prompt (le-mail) are guarded by `Given the EasyEngine config value "le-mail" is set`. diff --git a/features/backup-restore.feature b/features/backup-restore.feature index 49106c67..dc3c670e 100644 --- a/features/backup-restore.feature +++ b/features/backup-restore.feature @@ -53,6 +53,7 @@ Feature: Backup and restore When I run 'bin/ee site backup bh-site-command-142.test --list' And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "FIRST_ID" And I write "second" to the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" + And I wait for the next second And I run 'bin/ee site backup bh-site-command-142.test' Then the command should succeed And the remote backup storage should have 2 backups for site "bh-site-command-142.test" @@ -131,6 +132,7 @@ Feature: Backup and restore And I run 'bin/ee site backup bh-site-command-146.test --list' And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "A_ID" And I write "B" to the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" + And I wait for the next second And I run 'bin/ee site backup bh-site-command-146.test' And I run 'bin/ee site backup bh-site-command-146.test --list' And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "B_ID" @@ -147,10 +149,13 @@ Feature: Backup and restore And there are no remote backups for site "bh-site-command-147.test" When I run 'bin/ee site backup bh-site-command-147.test' Then the command should succeed + And I wait for the next second When I run 'bin/ee site backup bh-site-command-147.test' Then the command should succeed + And I wait for the next second When I run 'bin/ee site backup bh-site-command-147.test' Then the command should succeed + And I wait for the next second When I run 'bin/ee site backup bh-site-command-147.test' Then the command should succeed And the remote backup storage should have at most the configured "no-of-backups" backups for site "bh-site-command-147.test" diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index dac80b3e..b0194a76 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -217,6 +217,17 @@ public function afterDelayOfSeconds( $time ) sleep( $time ); } + /** + * Backup ids have one-second resolution (BKP-5): back-to-back backups need a second boundary between them. + * + * @When I wait for the next second + */ + public function iWaitForTheNextSecond() + { + $now = microtime( true ); + usleep( (int) ( ( floor( $now ) + 1 - $now ) * 1000000 ) + 1000 ); + } + /** * @Then /^(STDOUT|STDERR) should return exactly$/ */ From 25b0f91e5b8725015cfbfcde15c941b46af3e240 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:48:36 +0530 Subject: [PATCH 14/37] test(site-command): wait for the proxy reload before the first LE HTTPS check --- features/le.feature | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/features/le.feature b/features/le.feature index 31dadd67..0d12b921 100644 --- a/features/le.feature +++ b/features/le.feature @@ -21,7 +21,9 @@ Feature: Let's Encrypt certificates And the file "{PROXY_DIR}/certs/{LE1}.key" should exist And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should exist And the hosts file should not contain an entry for "{LE1}" - When I send a verified GET request to "https://{LE1}/" + # The proxy reload that picks up the new certificate is asynchronous. + When I wait up to 60 seconds for "https://{LE1}/" to return 200 + And I send a verified GET request to "https://{LE1}/" Then the response code should be 200 And the response body should contain "Your html site with EasyEngine" And the TLS certificate served for "{LE1}" should cover "{LE1}" From 673fcbc2875beda90fdf7d19238c1c29041d45ea Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 08:54:32 +0530 Subject: [PATCH 15/37] test(site-command): document the Pebble lane for the @le scenarios --- features/COVERAGE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 55ada32b..0df7900b 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -10,7 +10,8 @@ Suite for `easyengine/site-command` (the `ee site` command and the html site typ | `@known-bug` | 28 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | | `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | | `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | -| `@le` | 11 | Real Let's Encrypt issuance on `*.ee4-test.rt.gw`. At most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. | +| `@le` | 11 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 11 run against Pebble (`behat/ci/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`); 8 pass, the 3 `@known-bug` ones fail as expected (SSL-2, SSL-8, SSL-9). | +| `@le-real` | 0 | Needs the public LE service itself (rate limits, the real chain); never runs on CI. None so far: every `@le` scenario works on Pebble. | | `@wp` | 8 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@known-bug&&~@wp'`. From 5927ba1a5fc2957016835eb7d1eb8e9b9500b7ca Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:01:19 +0530 Subject: [PATCH 16/37] test(site-command): clear the stat cache before file existence checks On PHP 7.4 is_file() answers from the stat cache for the last path checked, so a file ee deleted in between still exists: "Deleting a self-signed site removes its certificate" and both redirect removal scenarios failed on the 7.4 runner, and the MIG-15 known-bug scenario passed vacuously there. --- features/bootstrap/FeatureContext.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index b0194a76..a902ece9 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -804,6 +804,8 @@ public function theFieldOfSiteShouldBe( $field, $site, $not, $expected ) */ public function thePathShouldExist( $type, $path, $not = '' ) { + // PHP 7.4 serves is_file() of the last path it stat'ed from its stat cache, even after ee deleted it. + clearstatcache(); $path = $this->v( $path ); $exists = 'file' === $type ? is_file( $path ) : ( 'directory' === $type ? is_dir( $path ) : is_link( $path ) ); if ( $exists === (bool) $not ) { From e30f8e1ccfa0954f5f919fbf00669b2394918b87 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:11:14 +0530 Subject: [PATCH 17/37] test(site-command): follow the merged alias domain validation (#502) AUTH-1 is fixed on develop, so the underscore alias scenario is a regular regression check. "Delete an alias domain" now waits for the remaining alias to answer 200 before checking the deleted one: the recreated nginx container drops all of the site's domains from the proxy until docker-gen catches up, and on develop the command returns inside that gap. --- features/COVERAGE.md | 5 ++--- features/update-alias.feature | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 0df7900b..7383a7dd 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -140,7 +140,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | alias that is a site / alias of another site | covered | alias: Another site cannot be added...; An alias of another site cannot be added | | add + delete in one call, wildcard alias | covered | alias: Add and delete aliases in one call; A wildcard alias serves every subdomain | | alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; known bug SSL-2 | -| alias label validation (AUTH-1) | covered (known bug) | alias: Alias domains with a label starting with an underscore are rejected | +| alias label validation (AUTH-1) | covered (regression; fixed by #502, merged 2026-09-25) | alias: Alias domains with a label starting with an underscore are rejected | | alias change on custom SSL (warning) | not covered | low value; same code path as self apart from the warning text | | `--ssl=self` on a plain site, twice, `--ssl=off`, off on plain | covered | ussl: first four scenarios | | `--wildcard` refusals, invalid `--ssl`, `--ssl=off --wildcard` | covered | ussl: Switching a normal site to wildcard SSL is refused; An unknown SSL type is rejected; --wildcard cannot be combined with --ssl=off | @@ -250,7 +250,6 @@ Verified against develop 9dc44e1 in review; details, severity and locations are | backup: Restoring a WordPress site does not print its database password | LOG-3 | #484 (success path only) | | backup: A restored WordPress core passes checksum verification | WP-1 | none (local fix only) | | create: Site names that are not valid hostnames are rejected... | SEC-1 | #495 is partial (self-signed sinks only); full fix local | -| alias: Alias domains with a label starting with an underscore... | AUTH-1 | #502 (draft) | | le: SSL-2, SSL-8, SSL-5/SSL-9; ssl: SEC-3, SSL-4; clean: CACHE-1 pattern; redirect: MIG-15 | as named | none (#486/#487/#493/#495/#498/#502 only touch these paths) | -PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages, #486 changes the SSL-2 warning text, #487 makes `ssl-verify` fail for non-LE sites, #501 changes the clone messages on an empty destination host ("Unable to find '' on ''"), #502 adds alias validation messages. +PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages, #486 changes the SSL-2 warning text, #487 makes `ssl-verify` fail for non-LE sites, #501 changes the clone messages on an empty destination host ("Unable to find '' on ''"). #502 merged on 2026-09-25: AUTH-1 is a regular scenario since, and "Delete an alias domain" waits for the remaining alias to answer 200 (the recreated nginx container briefly drops all of the site's domains from the proxy; https://github.com/mrrobot47/site-command/actions/runs/36099145415). diff --git a/features/update-alias.feature b/features/update-alias.feature index 6c8d2103..4b5bb93d 100644 --- a/features/update-alias.feature +++ b/features/update-alias.feature @@ -34,9 +34,9 @@ Feature: Update alias domains And STDOUT should contain "Alias domains updated on site bh-site-command-62.test." And the "alias_domains" field of site "bh-site-command-62.test" in the database should be "bh-site-command-62.test,bh-site-command-62-a.test" And the service "nginx" of site "bh-site-command-62.test" should not have the environment variable "VIRTUAL_HOST" containing "bh-site-command-62-b.test" - When I wait up to 60 seconds for "http://bh-site-command-62-b.test/" to not return 200 - And I send a GET request to "http://bh-site-command-62-a.test/" - Then the response code should be 200 + # The site's nginx container is recreated, so the proxy briefly drops every domain of the site until docker-gen catches up. + When I wait up to 60 seconds for "http://bh-site-command-62-a.test/" to return 200 + And I wait up to 60 seconds for "http://bh-site-command-62-b.test/" to not return 200 Scenario: The primary domain cannot be deleted Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" @@ -88,8 +88,7 @@ Feature: Update alias domains When I wait up to 60 seconds for "https://bh-site-command-69.test/" to return 200 Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" - # AUTH-1: alias labels starting with "_" are accepted and can clobber a site's _wildcard proxy files. Fixed by PR #502 (38569df, draft). - @known-bug + # AUTH-1 regression: alias labels starting with "_" could clobber a site's _wildcard proxy files (fixed by #502, 38569df). Scenario: Alias domains with a label starting with an underscore are rejected Given the html site "bh-site-command-68.test" exists When I run 'bin/ee site update bh-site-command-68.test --add-alias-domains=_wildcard.bh-site-command-68.test' From 1335aea0e79235441d3a310eaadba743a42a35cb Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:21:41 +0530 Subject: [PATCH 18/37] test(site-command): cover the refused docker volume and compose project collision An html site whose name maps to the volumes and compose project of an existing site (bh-site-command-160x.test vs bh-site-command-160.x.test) is refused and leaves the existing site untouched. Needs the fix/site-name-collisions branch of site-command. --- features/COVERAGE.md | 1 + features/create.feature | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 7383a7dd..a94132bf 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -71,6 +71,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: |---|---|---| | `` happy path: files, .env, main.conf, user.conf, index.html, compose file, nginx container, 3 labelled volumes, network, hosts entry, DB row, HTTP 200 | covered | create: Create an html site; labels: both scenarios | | Duplicate site | covered | create: Creating a site that already exists fails and keeps the site | +| Docker volume / compose project collision with another site (PHPT-4) | covered | create: A site whose docker volumes and compose project collide with an existing site is refused (needs the `fix/site-name-collisions` branch of site-command, 2026-09-26, local) | | `--public-dir` | covered | create: Create a site with a custom public directory | | `--skip-status-check` | covered | create: Create a site without the status check | | `--alias-domains` (DB, server_name, VIRTUAL_HOST, HTTP on alias) | covered | create: Create a site with alias domains | diff --git a/features/create.feature b/features/create.feature index fcd0ef9d..78d366ba 100644 --- a/features/create.feature +++ b/features/create.feature @@ -31,6 +31,19 @@ Feature: Create html sites Then the response code should be 200 And the response body should contain "Your html site with EasyEngine" + # PHPT-4: volume names drop "." and "-" and the compose project drops ".", so both names map to the volumes + # bhsitecommand160xtest_* and the compose project bh-site-command-160xtest. The second create is refused. + Scenario: A site whose docker volumes and compose project collide with an existing site is refused + Given the html site "bh-site-command-160.x.test" exists + When I run 'bin/ee site create bh-site-command-160x.test --type=html' + Then the command should fail + And STDERR should contain "Site bh-site-command-160x.test would share docker volumes (bhsitecommand160xtest_*) with the existing site bh-site-command-160.x.test." + And the site "bh-site-command-160x.test" should not exist + And the site "bh-site-command-160.x.test" should have 3 docker volumes + And the service "nginx" of site "bh-site-command-160.x.test" should be running + When I send a GET request to "http://bh-site-command-160.x.test/" + Then the response code should be 200 + Scenario: The old site assertions still work on a created site Given the html site "bh-site-command-10.test" exists Then The site 'bh-site-command-10.test' should have webroot From cac48e17ceb3278f57e3cbae096e710867865c37 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:33:05 +0530 Subject: [PATCH 19/37] test(site-command): cover the proxy cache rollback and LE alias fixes Regular now (fail on develop until fix/proxy-cache-rollback-and-le-alias merges): - SSL-2: a failing LE alias is rejected, the DB, certificate and renewals are unchanged. - SSL-5/SSL-9: deleting an LE site leaves no ACME state. - SC-3: `--ssl=off` works on a self-signed site; it also removes the certificate and the redirect's HTTPS block (WPT-12). New: deleting a site without SSL removes a leftover certificate, `--ssl=off` on an LE site removes the certificate and ACME state, deleting an alias removes its proxy cache location, and a failed proxy cache alias sync (PRX-6) leaves a config nginx can test and restart with. --- features/COVERAGE.md | 22 +++++++++++----------- features/delete.feature | 10 ++++++++++ features/le.feature | 27 +++++++++++++++++++++++---- features/proxy-cache.feature | 30 ++++++++++++++++++++++++++++++ features/update-ssl-php.feature | 11 +++++++++-- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index a94132bf..67ed8913 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,16 +1,16 @@ # site-command Behat coverage -Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 183 scenarios (215 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 187 scenarios (220 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. ## Tags | Tag | Scenarios | Meaning | |---|---|---| -| (none of the tags below) | 125 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | -| `@known-bug` | 28 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | -| `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | +| (none of the tags below) | 127 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | +| `@known-bug` | 25 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | +| `@destructive` | 14 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | | `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | -| `@le` | 11 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 11 run against Pebble (`behat/ci/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`); 8 pass, the 3 `@known-bug` ones fail as expected (SSL-2, SSL-8, SSL-9). | +| `@le` | 12 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 12 run against Pebble (`behat/ci/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`); 11 pass, the `@known-bug` one fails as expected (SSL-8). SSL-2 and SSL-5/SSL-9 are regular scenarios since the fixes on `fix/proxy-cache-rollback-and-le-alias` (they fail on develop until those merge). | | `@le-real` | 0 | Needs the public LE service itself (rate limits, the real chain); never runs on CI. None so far: every `@le` scenario works on Pebble. | | `@wp` | 8 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | @@ -95,10 +95,10 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | Second delete / unknown site | covered | delete: same scenario; site: outline | | Prompt answered n / y | covered | delete: Answering no...; Answering yes... | | Disabled site | covered | delete: A disabled site can be deleted | -| SSL files removed (self, LE + revoke) | covered | delete: Deleting a self-signed site removes its certificate; le: Deleting a Let's Encrypt site... | +| SSL files removed (self, LE + revoke, leftover cert of a site without SSL, WPT-12) | covered | delete: Deleting a self-signed site removes its certificate; Deleting a site without SSL removes a leftover certificate; le: Deleting a Let's Encrypt site... | | Proxy cache config removed | covered (@destructive) | pcache: Deleting a proxy-cached site removes its proxy config | | Alias domains freed | covered | delete: Deleting a site frees its alias domains | -| LE leftovers (SSL-5/SSL-9) | covered (known bug) | le: Deleting a Let's Encrypt site leaves no ACME state behind | +| LE leftovers (SSL-5/SSL-9) | covered | le: Deleting a Let's Encrypt site leaves no ACME state behind | | DB/user cleanup for external DB sites, cron rows | not covered | only php/wp sites have DBs and crons (site-type-*/cron-command suites) | ### list / info @@ -140,7 +140,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | `--delete-alias-domains`, primary, not a subset | covered | alias: Delete an alias domain; The primary domain cannot be deleted; Deleting an alias the site does not have fails | | alias that is a site / alias of another site | covered | alias: Another site cannot be added...; An alias of another site cannot be added | | add + delete in one call, wildcard alias | covered | alias: Add and delete aliases in one call; A wildcard alias serves every subdomain | -| alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; known bug SSL-2 | +| alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation (rolled back, SSL-2) | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; An alias that fails validation is rejected... | | alias label validation (AUTH-1) | covered (regression; fixed by #502, merged 2026-09-25) | alias: Alias domains with a label starting with an underscore are rejected | | alias change on custom SSL (warning) | not covered | low value; same code path as self apart from the warning text | | `--ssl=self` on a plain site, twice, `--ssl=off`, off on plain | covered | ussl: first four scenarios | @@ -153,7 +153,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | | `--proxy-cache=on/off`, twice, custom sizes, invalid sizes | covered (@destructive) | pcache: all but the invalid switch | | invalid `--proxy-cache` value | covered (@destructive, known bug SC-18) | pcache: An invalid proxy cache switch is rejected | -| proxy cache for aliases (existing and newly added) | covered (@destructive) | pcache: Alias domains get proxy cache locations... | +| proxy cache for aliases (existing, newly added, deleted), failed alias sync leaves a loadable config (PRX-6) | covered (@destructive) | pcache: Alias domains get proxy cache locations...; Deleting an alias removes its proxy cache location; A failed proxy cache alias sync leaves a config nginx can load | | proxy cache `subdom` location (`*.site_location`) | not covered | WP subdom only (site-type-wp suite) | ### SSL commands @@ -226,7 +226,7 @@ Verified against develop 9dc44e1 in review; details, severity and locations are |---|---|---|---| | SC-1 | `ltrim($site, 'www.')` strips characters, not the prefix | redirect: A www site whose next label starts with w... | none | | SC-2 | `get_parent_of_alias()` uses `LIKE '%alias%'` | create: The owner of an alias is reported correctly... | none | -| SC-3 | SSL can't be turned off on a `--ssl=self` site; update stores `site_ssl_wildcard=0`, create 1 | ussl: two scenarios | none | +| SC-3 | SSL can't be turned off on a `--ssl=self` site (fixed on `fix/proxy-cache-rollback-and-le-alias`, now a regular scenario); update stores `site_ssl_wildcard=0`, create 1 (still `@known-bug`) | ussl: two scenarios | none | | SC-4 | `update --ssl=inherit` skips the parent check | ussl: Inheriting SSL through update requires a wildcard parent | none | | SC-5 | inherit error message built with `var_dump()` | ssl: A failed inherit does not dump the parent site object | none | | SC-6 | `site reload` exits 0 when `nginx -t` fails | rr: Reload fails when the nginx config test fails | none | @@ -251,6 +251,6 @@ Verified against develop 9dc44e1 in review; details, severity and locations are | backup: Restoring a WordPress site does not print its database password | LOG-3 | #484 (success path only) | | backup: A restored WordPress core passes checksum verification | WP-1 | none (local fix only) | | create: Site names that are not valid hostnames are rejected... | SEC-1 | #495 is partial (self-signed sinks only); full fix local | -| le: SSL-2, SSL-8, SSL-5/SSL-9; ssl: SEC-3, SSL-4; clean: CACHE-1 pattern; redirect: MIG-15 | as named | none (#486/#487/#493/#495/#498/#502 only touch these paths) | +| le: SSL-8; ssl: SEC-3, SSL-4; clean: CACHE-1 pattern; redirect: MIG-15 | as named | none (#486/#487/#493/#495/#498/#502 only touch these paths) | PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages, #486 changes the SSL-2 warning text, #487 makes `ssl-verify` fail for non-LE sites, #501 changes the clone messages on an empty destination host ("Unable to find '' on ''"). #502 merged on 2026-09-25: AUTH-1 is a regular scenario since, and "Delete an alias domain" waits for the remaining alias to answer 200 (the recreated nginx container briefly drops all of the site's domains from the proxy; https://github.com/mrrobot47/site-command/actions/runs/36099145415). diff --git a/features/delete.feature b/features/delete.feature index 4b57b7d7..5a9b8ba7 100644 --- a/features/delete.feature +++ b/features/delete.feature @@ -55,6 +55,16 @@ Feature: Delete sites And the file "{PROXY_DIR}/certs/bh-site-command-33.test.crt" should not exist And the file "{PROXY_DIR}/certs/bh-site-command-33.test.key" should not exist + # WPT-12: delete removes the site's certificate files even when site_ssl is empty (SSL turned off or lost), since nginx-proxy keeps matching them. + Scenario: Deleting a site without SSL removes a leftover certificate + Given the html site "bh-site-command-161.test" exists + When I write "leftover" to the file "{PROXY_DIR}/certs/bh-site-command-161.test.crt" + And I write "leftover" to the file "{PROXY_DIR}/certs/bh-site-command-161.test.key" + And I run 'bin/ee site delete bh-site-command-161.test --yes' + Then the command should succeed + And the file "{PROXY_DIR}/certs/bh-site-command-161.test.crt" should not exist + And the file "{PROXY_DIR}/certs/bh-site-command-161.test.key" should not exist + Scenario: Deleting a site frees its alias domains Given the site "bh-site-command-34.test" exists, created with "--type=html --alias-domains=bh-site-command-34-alias.test" And the site "bh-site-command-34-alias.test" does not exist diff --git a/features/le.feature b/features/le.feature index 0d12b921..715db294 100644 --- a/features/le.feature +++ b/features/le.feature @@ -108,15 +108,35 @@ Feature: Let's Encrypt certificates Then the response code should be 200 And the TLS certificate served for "{LE2}" should be issued by "{LE_ISSUER}" - # SSL-2: an alias that fails validation only warns; the alias is saved and SSL is marked disabled while the old cert keeps serving. + # SSL-2 (fixed on fix/proxy-cache-rollback-and-le-alias): a failed issuance used to only warn, save the alias with SSL "Not Enabled" and revoke the served cert. # The alias is under the reserved .invalid TLD: the order is refused outright, so nothing outside *.ee4-test.rt.gw is validated. - @known-bug Scenario: An alias that fails validation is rejected and the site keeps its certificate Given the site "{LE2}" exists, created with "--type=html --ssl=le" When I run 'bin/ee site update {LE2} --add-alias-domains=bh-site-command-nx-{RUN_ID}.invalid' Then the command should fail + And STDERR should contain "Alias domains of {LE2} were not changed" + And STDOUT should not contain "Alias domains updated" And the "alias_domains" field of site "{LE2}" in the database should be "{LE2}" And the "site_ssl" field of site "{LE2}" in the database should be "le" + And the directory "{PROXY_DIR}/acme-conf/var/bh-site-command-nx-{RUN_ID}.invalid" should not exist + When I wait up to 60 seconds for "https://{LE2}/" to return 200 + And I send a verified GET request to "https://{LE2}/" + Then the response code should be 200 + And the TLS certificate served for "{LE2}" should cover "{LE2}" + When I run 'bin/ee site ssl-renew {LE2}' + Then the command should succeed + And STDERR should not contain "Only Letsencrypt certificate renewal is supported." + + # WPT-12: turning SSL off removes the certificate and ACME state (no issuance: runs on the site of the scenario above). + Scenario: Turning Let's Encrypt off removes the certificate + Given the site "{LE2}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site update {LE2} --ssl=off' + Then the command should succeed + And the file "{PROXY_DIR}/certs/{LE2}.crt" should not exist + And the file "{PROXY_DIR}/certs/{LE2}.key" should not exist + And the directory "{PROXY_DIR}/acme-conf/certs/{LE2}" should not exist + And the directory "{PROXY_DIR}/acme-conf/var/{LE2}" should not exist + When I wait up to 60 seconds for "http://{LE2}/" to return 200 # SSL-8: ssl-verify checks only [site] for an HTTP-01 cert that includes www, rotates the acme-conf key and fails with "has not yet been authorized". @known-bug @@ -137,8 +157,7 @@ Feature: Let's Encrypt certificates And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should not exist And the directory "{PROXY_DIR}/acme-conf/var/{LE1}" should not exist - # SSL-5 / SSL-9: deleting an LE site leaves acme-conf/var/www. (and alias dirs) behind. Run after the delete scenario above. - @known-bug + # SSL-5 / SSL-9 (fixed on fix/proxy-cache-rollback-and-le-alias): delete used to leave acme-conf/var/www. and alias dirs behind. Run after the delete scenario above. Scenario: Deleting a Let's Encrypt site leaves no ACME state behind Given the site "{LE1}" does not exist Then the directory "{PROXY_DIR}/acme-conf/var/www.{LE1}" should not exist diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index f8a7150c..5ff85f78 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -80,6 +80,36 @@ Feature: Proxy cache And the file "{PROXY_DIR}/vhost.d/bh-site-command-86-b.test_location" should exist And the docker container "services_global-nginx-proxy_1" should be running + Scenario: Deleting an alias removes its proxy cache location + Given the site "bh-site-command-162.test" exists, created with "--type=html --alias-domains=bh-site-command-162-a.test,bh-site-command-162-b.test" + When I run 'bin/ee site update bh-site-command-162.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-162.test --delete-alias-domains=bh-site-command-162-a.test' + Then the command should succeed + And the file "{PROXY_DIR}/vhost.d/bh-site-command-162-a.test_location" should not exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-162-b.test_location" should exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-162.test_location" should exist + + # PRX-6 (fixed on fix/proxy-cache-rollback-and-le-alias): a failed nginx -t during the alias sync used to remove the cache zone but keep the locations using it, so nginx never passed -t again and could not start after a restart. + # The broken include is removed right after the update, before any assertion can fail. + Scenario: A failed proxy cache alias sync leaves a config nginx can load + Given the site "bh-site-command-163.test" exists, created with "--type=html --alias-domains=bh-site-command-163-a.test" + When I run 'bin/ee site update bh-site-command-163.test --proxy-cache=on' + Then the command should succeed + When I write "bh_broken_directive;" to the file "{PROXY_DIR}/conf.d/bh-site-command-163-broken.conf" + And I run 'bin/ee site update bh-site-command-163.test --add-alias-domains=bh-site-command-163-b.test' + And I remove the file "{PROXY_DIR}/conf.d/bh-site-command-163-broken.conf" + Then the command should succeed + And STDERR should contain "Could not enable proxy cache on bh-site-command-163.test, nginx config test failed. The previous proxy config was restored." + And the file "{PROXY_DIR}/conf.d/bh-site-command-163.test.conf" should exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-163-b.test_location" should not exist + When I run 'docker exec services_global-nginx-proxy_1 nginx -t' + Then the command should succeed + When I run 'docker restart services_global-nginx-proxy_1' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-163.test/" to return 200 + Then the docker container "services_global-nginx-proxy_1" should be running + Scenario: Deleting a proxy-cached site removes its proxy config Given the html site "bh-site-command-87.test" exists When I run 'bin/ee site update bh-site-command-87.test --proxy-cache=on' diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index 7cf586ad..45e97957 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -85,13 +85,20 @@ Feature: Update SSL and PHP of html sites Then the command should fail And STDERR should contain "Invalid value specified for 'php'" - # SC-3: update_ssl() refuses --ssl=off when site_ssl_wildcard=1, and --ssl=self sets that flag on create, so SSL can never be turned off. No open PR. - @known-bug + # SC-3 (fixed on fix/proxy-cache-rollback-and-le-alias): --ssl=off was refused for sites stored with site_ssl_wildcard=1, which create --ssl=self sets. + # WPT-12: --ssl=off also removes the certificate, and the www redirect loses its HTTPS block that loads it. Scenario: SSL can be disabled on a site created with self-signed SSL Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" When I run 'bin/ee site update bh-site-command-71.test --ssl=off' Then the command should succeed And STDOUT should contain "Disabled SSL for bh-site-command-71.test" + And the "site_ssl" field of site "bh-site-command-71.test" in the database should be "" + And the file "{PROXY_DIR}/certs/bh-site-command-71.test.crt" should not exist + And the file "{PROXY_DIR}/certs/bh-site-command-71.test.key" should not exist + And the file "{PROXY_DIR}/conf.d/bh-site-command-71.test-redirect.conf" should not contain "ssl_certificate" + When I run 'docker exec services_global-nginx-proxy_1 nginx -t' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-71.test/" to return 200 # SC-3: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). No open PR. @known-bug From 637f0a183277be2052a8d4b2226a01759c380a8c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:42:06 +0530 Subject: [PATCH 20/37] test(site-command): refuse --ssl=off on a parent whose certificate is inherited --- features/COVERAGE.md | 4 ++-- features/update-ssl-php.feature | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 67ed8913..cc23d6ff 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,12 +1,12 @@ # site-command Behat coverage -Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 187 scenarios (220 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 188 scenarios (221 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. ## Tags | Tag | Scenarios | Meaning | |---|---|---| -| (none of the tags below) | 127 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | +| (none of the tags below) | 128 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | | `@known-bug` | 25 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | | `@destructive` | 14 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | | `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index 45e97957..c35dcbd7 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -71,6 +71,17 @@ Feature: Update SSL and PHP of html sites When I wait up to 60 seconds for "https://sub.bh-site-command-76.test/" to return 200 Then the TLS certificate served for "sub.bh-site-command-76.test" should cover "sub.bh-site-command-76.test" + # --ssl=off on the parent would remove the certificate that the child's www redirect loads, and nginx could not load its config. + Scenario: SSL can't be turned off on a parent whose certificate is inherited + Given the site "bh-site-command-76.test" exists, created with "--type=html --ssl=self" + And the html site "sub.bh-site-command-76.test" exists + When I run 'bin/ee site update sub.bh-site-command-76.test --ssl=inherit' + And I run 'bin/ee site update bh-site-command-76.test --ssl=off' + Then the command should fail + And STDERR should contain "Cannot disable SSL on bh-site-command-76.test: sub.bh-site-command-76.test inherit its certificate." + And the "site_ssl" field of site "bh-site-command-76.test" in the database should be "self" + And the file "{PROXY_DIR}/certs/bh-site-command-76.test.crt" should exist + Scenario: Changing the PHP version of an html site fails and leaves it serving Given the html site "bh-site-command-77.test" exists When I run 'bin/ee site update bh-site-command-77.test --php=8.3' From 71fb92ff237f9ce4c94c11aab7c8b0d7dc3dd404 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:56:37 +0530 Subject: [PATCH 21/37] test(site-command): cover backup retention, per-host remote layout and restore scope Known-bug scenarios for BKP-7 (invalid no-of-backups), BKP-8 (hosts sharing rclone-path, via two backup-host-id config copies), BKP-9 (WordPress webroot files), BKP-10 (symlinked uploads), BKP-12 (restore without backups) and BKP-14 (dotfiles left by a restore). The remote-backup steps now also find backups under ///. --- features/COVERAGE.md | 6 ++ features/backup-restore.feature | 98 +++++++++++++++++++++++++++ features/bootstrap/FeatureContext.php | 61 +++++++++++++++-- 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index cc23d6ff..05d9b68f 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -198,6 +198,12 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | custom docker-compose files in backups | not covered | needs a custom compose setup | | disk-space checks, rclone/7z missing, apt-get auto-install (BKP-3), global backup lock wait | not covered | needs a full disk, uninstalled tools or a concurrent backup; BKP-3 needs a pending kernel upgrade | | BKP-2 partial SQL dump in webroot | not covered | needs a failing mysqldump (fault injection) | +| invalid `no-of-backups` (BKP-7) | covered (known bug) | backup: An invalid no-of-backups keeps the backups and warns (config copy via `EE_CONFIG_PATH`) | +| per-host remote layout, hosts sharing `rclone-path` (BKP-8) | covered (known bug) | backup: Hosts that share a remote keep their backups apart (two `backup-host-id` config copies) | +| restore without any backup (BKP-12) | covered (known bug) | backup: Restoring a site without backups says so | +| files and dotfiles not in the backup (BKP-14) | covered (known bug) | backup: A restore removes files that are not in the backup, dotfiles too | +| WP webroot files beside WordPress (BKP-9), symlinked uploads (BKP-10) | covered (@wp, known bug) | backup: A WordPress backup holds the files next to WordPress; Symlinked uploads are backed up and restored without a loop | +| restore rollback, disk-full extraction (BKP-6) | not covered | needs fault injection or a small filesystem; isolated harness `/var/tmp/fix-backup-test/harness` | ### clone / sync diff --git a/features/backup-restore.feature b/features/backup-restore.feature index dc3c670e..69979caf 100644 --- a/features/backup-restore.feature +++ b/features/backup-restore.feature @@ -203,3 +203,101 @@ Feature: Backup and restore Then the command should succeed When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp core verify-checksums'" Then the command should succeed + + # BKP-7: `no-of-backups: 0` (or a typo) made the prune delete every backup, the one just uploaded included. + @known-bug + Scenario: An invalid no-of-backups keeps the backups and warns + Given the html site "bh-site-command-150.test" exists + And there are no remote backups for site "bh-site-command-150.test" + When I run 'sed "/^no-of-backups:/d" {EE_ROOT_DIR}/config/config.yml > {TMP}/config.yml && echo "no-of-backups: 0" >> {TMP}/config.yml' + And I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site backup bh-site-command-150.test' + Then the command should succeed + When I wait for the next second + And I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site backup bh-site-command-150.test' + Then the command should succeed + And STDERR should contain "Invalid no-of-backups value" + And the remote backup storage should have 2 backups for site "bh-site-command-150.test" + + # BKP-8: backups had no host identity, so hosts sharing rclone-path and a site name restored (and pruned) each other's. + @known-bug + Scenario: Hosts that share a remote keep their backups apart + Given the html site "bh-site-command-151.test" exists + And there are no remote backups for site "bh-site-command-151.test" + When I run 'sed "/^backup-host-id:/d" {EE_ROOT_DIR}/config/config.yml > {TMP}/a.yml && cp {TMP}/a.yml {TMP}/b.yml && echo "backup-host-id: bh-site-command-host-a" >> {TMP}/a.yml && echo "backup-host-id: bh-site-command-host-b" >> {TMP}/b.yml' + And I write "host-a" to the file "{SITES_DIR}/bh-site-command-151.test/app/htdocs/bh-marker.txt" + And I run 'EE_CONFIG_PATH={TMP}/a.yml bin/ee site backup bh-site-command-151.test' + Then the command should succeed + When I write "host-b" to the file "{SITES_DIR}/bh-site-command-151.test/app/htdocs/bh-marker.txt" + And I wait for the next second + And I run 'EE_CONFIG_PATH={TMP}/b.yml bin/ee site backup bh-site-command-151.test' + Then the command should succeed + When I run 'EE_CONFIG_PATH={TMP}/a.yml bin/ee site restore bh-site-command-151.test' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-151.test/app/htdocs/bh-marker.txt" should contain "host-a" + When I run 'EE_CONFIG_PATH={TMP}/a.yml bin/ee site backup bh-site-command-151.test --list' + Then the command should succeed + And STDOUT should match "/^bh-site-command-host-b\/\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" + + # BKP-12: without any backup the restore failed with "Failed to get remote backup size." (local remotes). + @known-bug + Scenario: Restoring a site without backups says so + Given the html site "bh-site-command-155.test" exists + And there are no remote backups for site "bh-site-command-155.test" + When I run 'bin/ee site restore bh-site-command-155.test' + Then the command should fail + And STDERR should contain "No backups found for bh-site-command-155.test." + + # BKP-14: `rm -rf app/*` skipped dotfiles, so files that are not in the backup survived the restore. + @known-bug + Scenario: A restore removes files that are not in the backup, dotfiles too + Given the html site "bh-site-command-154.test" exists + And there are no remote backups for site "bh-site-command-154.test" + When I run 'bin/ee site backup bh-site-command-154.test' + Then the command should succeed + When I write "stale" to the file "{SITES_DIR}/bh-site-command-154.test/app/.bh-stale" + And I write "stale" to the file "{SITES_DIR}/bh-site-command-154.test/app/htdocs/.bh-stale" + And I run 'bin/ee site restore bh-site-command-154.test' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-154.test/app/.bh-stale" should not exist + And the file "{SITES_DIR}/bh-site-command-154.test/app/htdocs/.bh-stale" should not exist + And the file "{SITES_DIR}/bh-site-command-154.test/app/htdocs/index.html" should exist + + # BKP-9: a WordPress backup held only wp-config.php, wp-content and the DB; other webroot files were lost. + @wp @known-bug + Scenario: A WordPress backup holds the files next to WordPress + Given "ee" is installed + And the site "bh-site-command-152.test" exists, created with "--type=wp" + And there are no remote backups for site "bh-site-command-152.test" + And I write "User-agent: bh" to the file "{SITES_DIR}/bh-site-command-152.test/app/htdocs/robots.txt" + And I write " bh-shared/uploads/bh.txt && chown -R www-data:www-data bh-shared && chown -h www-data:www-data wp-content/uploads' + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-153.test' + Then the command should succeed + When I remove the file "{SITES_DIR}/bh-site-command-153.test/app/htdocs/bh-shared/uploads/bh.txt" + And I run 'bin/ee site restore bh-site-command-153.test' + Then the command should succeed + And the symlink "{SITES_DIR}/bh-site-command-153.test/app/htdocs/wp-content/uploads" should exist + And the file "{SITES_DIR}/bh-site-command-153.test/app/htdocs/bh-shared/uploads/bh.txt" should contain "bh-upload" + And the symlink "{SITES_DIR}/bh-site-command-153.test/app/htdocs/bh-shared/uploads/uploads" should not exist diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index a902ece9..ff4cf0ab 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1288,7 +1288,7 @@ public function theRemoteStorageShouldRespectRetention( $site ) public function theRemoteBackupShouldContainTheFile( $backup_id, $site, $file ) { $site = $this->v( $site ); - $run = EE::launch( 'rclone lsf ' . escapeshellarg( $this->rclone_path() . '/' . $site . '/' . $this->v( $backup_id ) ), false, true ); + $run = EE::launch( 'rclone lsf ' . escapeshellarg( $this->remote_backup_path( $site, $this->v( $backup_id ) ) ), false, true ); if ( ! in_array( $this->v( $file ), explode( "\n", trim( $run->stdout ) ), true ) ) { throw new Exception( "Remote backup does not contain $file.\n" . $this->describe( $run ) ); } @@ -1303,7 +1303,7 @@ public function iDownloadTheRemoteBackup( $backup_id, $site ) { $site = $this->v( $site ); $this->assert_own_name( $site ); - $run = EE::launch( sprintf( 'rclone copy %s %s', escapeshellarg( $this->rclone_path() . '/' . $site . '/' . $this->v( $backup_id ) ), escapeshellarg( EE_ROOT_DIR . '/.backup/' . $site ) ), false, true ); + $run = EE::launch( sprintf( 'rclone copy %s %s', escapeshellarg( $this->remote_backup_path( $site, $this->v( $backup_id ) ) ), escapeshellarg( EE_ROOT_DIR . '/.backup/' . $site ) ), false, true ); if ( 0 !== $run->return_code ) { throw new Exception( "rclone copy failed.\n" . $this->describe( $run ) ); } @@ -1564,15 +1564,64 @@ private function rclone_path() return rtrim( (string) ( $this->config_value( 'rclone-path' ) ?: 'easyengine:easyengine' ), '/' ); } + /** + * Backup ids of a site: // (develop) and /// (per-host layout, BKP-8). + */ private function remote_backups( $site ) { - $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $this->rclone_path() . '/' . $site ), false, true ); + $ids = []; + foreach ( $this->remote_backup_paths( $site ) as $path ) { + $ids[] = basename( $path ); + } + + return $ids; + } + + /** + * @return string[] Remote path of each backup of $site. + */ + private function remote_backup_paths( $site ) + { + $paths = []; + $root = $this->rclone_path() . '/' . $site; + foreach ( self::list_remote_dirs( $root ) as $dir ) { + if ( self::is_backup_id( $dir ) ) { + $paths[] = "$root/$dir"; + continue; + } + foreach ( self::list_remote_dirs( "$root/$dir" ) as $id ) { + if ( self::is_backup_id( $id ) ) { + $paths[] = "$root/$dir/$id"; + } + } + } + + return $paths; + } + + private function remote_backup_path( $site, $backup_id ) + { + foreach ( $this->remote_backup_paths( $site ) as $path ) { + if ( basename( $path ) === basename( $backup_id ) ) { + return $path; + } + } + + return $this->rclone_path() . '/' . $site . '/' . $backup_id; + } + + private static function list_remote_dirs( $path ) + { + $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $path ), false, true ); return array_values( array_filter( array_map( function ( $line ) { return rtrim( trim( $line ), '/' ); - }, explode( "\n", $run->stdout ) ), function ( $line ) { - return 1 === preg_match( '/\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}/', $line ); - } ) ); + }, explode( "\n", $run->stdout ) ), 'strlen' ) ); + } + + private static function is_backup_id( $name ) + { + return 1 === preg_match( '/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/', $name ); } private static function purge_own_remote_backups() From 89af4a8e39b91560dcd9e087c7fd73bea578b6fb Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 10:09:11 +0530 Subject: [PATCH 22/37] test(site-command): cover clone and sync location, name and option checks Adds @known-bug scenarios for CLONE-3, CLONE-7 and CLONE-8 (fixed on fix/clone-sync-safety): a loopback address as the same host, ssh options in a remote location, a remote `.`, a site name with shell syntax, the sync help, --ssl=custom without --ssl-crt, and admin passwords with shell syntax or characters the destination shell would expand. --- features/clone-sync.feature | 66 +++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/features/clone-sync.feature b/features/clone-sync.feature index c3561a1a..7aa60661 100644 --- a/features/clone-sync.feature +++ b/features/clone-sync.feature @@ -25,6 +25,41 @@ Feature: Clone and sync sites Then the command should fail And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" + # CLONE-7: the same-site guard only knows the literal "localhost", so a loopback address syncs a site onto itself. Fixed on fix/clone-sync-safety. + @known-bug + Scenario: A site cannot be synced onto itself through a loopback address + When I run 'bin/ee site sync bh-site-command-150.test root@127.0.0.1:bh-site-command-150.test' + Then the command should fail + And STDERR should contain "Cannot copy 'bh-site-command-150.test' on 'localhost' to 'bh-site-command-150.test' on '127.0.0.1'" + + # CLONE-7: the host part accepts spaces, so ssh options pass through. Fixed on fix/clone-sync-safety. + @known-bug + Scenario: ssh options in a remote location are rejected + When I run "bin/ee site clone 'root@bh-host-a -oProxyCommand=true:bh-site-command-150.test' bh-site-command-151.test" + Then the command should fail + And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" + + # CLONE-7: `user@host:.` gives the site name "." instead of "same name as the other side". Fixed on fix/clone-sync-safety. + @known-bug + Scenario: A remote dot means the name of the other side + When I run 'bin/ee site clone root@bh-host-a:. .' + Then the command should fail + And STDERR should contain "No sitename found in source and destination site." + + # CLONE-3/CLONE-7: site names go into shell commands unchecked. Fixed on fix/clone-sync-safety. + @known-bug + Scenario: A site name with shell syntax is rejected + When I run "bin/ee site clone 'bh-site-command-150.test;true' bh-site-command-151.test" + Then the command should fail + And STDERR should contain "Invalid site name: bh-site-command-150.test;true" + + # CLONE-8: the sync help shows `--ssl=le --wildcard`, which sync doesn't accept. Fixed on fix/clone-sync-safety. + @known-bug + Scenario: The sync help has no SSL example + When I run 'bin/ee help site sync' + Then the command should succeed + And STDOUT should not contain "--wildcard" + # With no site on the host the error is "Unable to get site list" instead (SC-17), so keep one site around. Scenario: Cloning a site that does not exist fails Given the html site "bh-site-command-154.test" exists @@ -131,3 +166,34 @@ Feature: Clone and sync sites Then the command should succeed And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/wp-content/uploads/bh-upload.txt" should contain "bh-site-command-upload" And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-not-upload.txt" should not exist + + # CLONE-8: `?? false && … ?? false` precedence lets a missing --ssl-crt through to is_file(null). Fixed on fix/clone-sync-safety. + @wp @known-bug + Scenario: Cloning with --ssl=custom needs both certificate files + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-170.test" does not exist + When I run 'bin/ee site clone bh-site-command-157.test bh-site-command-170.test --ssl=custom --ssl-key=/etc/hostname' + Then the command should fail + And STDERR should contain "You need to specify --ssl-crt and --ssl-key with --ssl=custom" + And STDERR should not contain "is_file" + And the site "bh-site-command-170.test" should not exist + + # CLONE-3: the admin password goes unquoted into `ee site create`, so a space, ; or & breaks the create. Fixed on fix/clone-sync-safety. + @wp @known-bug + Scenario: Clone copies an admin password with spaces and shell operators + Given the site "bh-site-command-171.test" exists, created with "--type=wp --admin-pass='bh pw;1&x'" + And the site "bh-site-command-172.test" does not exist + When I run 'bin/ee site clone bh-site-command-171.test bh-site-command-172.test' + Then the command should succeed + And STDOUT should contain "Site cloned successfully." + And the "app_admin_password" field of site "bh-site-command-172.test" in the database should be "bh pw;1&x" + + # CLONE-3: values with $ or quotes would be expanded by site-type-wp's double-quoted `bash -c`, so clone leaves them out. Fixed on fix/clone-sync-safety. + @wp @known-bug + Scenario: Clone does not pass an admin password the destination shell would expand + Given the site "bh-site-command-174.test" exists, created with "--type=wp --admin-pass='bh$pw1xyz'" + And the site "bh-site-command-173.test" does not exist + When I run 'bin/ee site clone bh-site-command-174.test bh-site-command-173.test' + Then the command should succeed + And STDERR should contain "Not copying --admin-pass of bh-site-command-174.test" + And the "app_admin_password" field of site "bh-site-command-173.test" in the database should not be "bh$pw1xyz" From 4739cae21271e7a383884f56aff6debbd19000c1 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 10:15:17 +0530 Subject: [PATCH 23/37] test(site-command): match how --ssl=off stores site_ssl update_ssl() saves false, which the DB stores as 0; assert it is no longer self, like the Disable SSL scenario. --- features/update-ssl-php.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index c35dcbd7..69a677c6 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -103,7 +103,7 @@ Feature: Update SSL and PHP of html sites When I run 'bin/ee site update bh-site-command-71.test --ssl=off' Then the command should succeed And STDOUT should contain "Disabled SSL for bh-site-command-71.test" - And the "site_ssl" field of site "bh-site-command-71.test" in the database should be "" + And the "site_ssl" field of site "bh-site-command-71.test" in the database should not be "self" And the file "{PROXY_DIR}/certs/bh-site-command-71.test.crt" should not exist And the file "{PROXY_DIR}/certs/bh-site-command-71.test.key" should not exist And the file "{PROXY_DIR}/conf.d/bh-site-command-71.test-redirect.conf" should not contain "ssl_certificate" From 490a81211dad356a25466a617af58f6d1372a8f3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 10:23:26 +0530 Subject: [PATCH 24/37] test(site-command): don't expect a www redirect where www doesn't resolve --- features/update-ssl-php.feature | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index 69a677c6..fd99978a 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -97,7 +97,7 @@ Feature: Update SSL and PHP of html sites And STDERR should contain "Invalid value specified for 'php'" # SC-3 (fixed on fix/proxy-cache-rollback-and-le-alias): --ssl=off was refused for sites stored with site_ssl_wildcard=1, which create --ssl=self sets. - # WPT-12: --ssl=off also removes the certificate, and the www redirect loses its HTTPS block that loads it. + # WPT-12: --ssl=off also removes the certificate (the www redirect's HTTPS block is only written when www resolves, so it isn't checked here). Scenario: SSL can be disabled on a site created with self-signed SSL Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" When I run 'bin/ee site update bh-site-command-71.test --ssl=off' @@ -106,7 +106,6 @@ Feature: Update SSL and PHP of html sites And the "site_ssl" field of site "bh-site-command-71.test" in the database should not be "self" And the file "{PROXY_DIR}/certs/bh-site-command-71.test.crt" should not exist And the file "{PROXY_DIR}/certs/bh-site-command-71.test.key" should not exist - And the file "{PROXY_DIR}/conf.d/bh-site-command-71.test-redirect.conf" should not contain "ssl_certificate" When I run 'docker exec services_global-nginx-proxy_1 nginx -t' Then the command should succeed When I wait up to 60 seconds for "http://bh-site-command-71.test/" to return 200 From cb559603943f62ba8d767f0f8d9c243e76e06233 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 10:35:12 +0530 Subject: [PATCH 25/37] test(site-command): run the scenarios fixed in v4.13.1 and v4.13.2 as regular ones WP-1, SC-3, SC-15, CACHE-1, CLONE-3/7/8, SEC-1 and SEC-3 pass on develop. The wildcard-update refusal is checked with --ssl=le, since self-signed certificates are always wildcard (5c34be8). --- features/COVERAGE.md | 4 ++++ features/backup-restore.feature | 4 ++-- features/clean.feature | 4 ++-- features/clone-sync.feature | 27 +++++++++++---------------- features/create.feature | 4 +--- features/ssl.feature | 3 +-- features/update-ssl-php.feature | 9 ++++----- 7 files changed, 25 insertions(+), 30 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 05d9b68f..8e2dd341 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -249,6 +249,10 @@ Verified against develop 9dc44e1 in review; details, severity and locations are | SC-17 | local clone/sync on a host without sites: "Unable to get site list" | none (needs a host with no site; clone: Cloning a site that does not exist fails keeps one site) | none | | SC-18 | `update --proxy-cache=` accepted, non-`on` disables | pcache: An invalid proxy cache switch is rejected | none | +## Known bugs fixed by v4.13.1 / v4.13.2 (Behat r2, 2026-10-19) + +These scenarios passed in the `known-bug` lane against develop after the v4.13.2 release and are regular now (the comment above each names the fix): WP-1 (#504), SC-3 update part (#505), SC-15 (#489), CACHE-1 proxy purge (276d118), CLONE-3/7/8 (8 scenarios, #510), SEC-1 (#509), SEC-3 (#513). The tables above and below predate this and still list them as open. `@known-bug` count after r2: 24. + ## Other known bugs and the open PRs that fix them | Scenario | Issue | Open PR | diff --git a/features/backup-restore.feature b/features/backup-restore.feature index 69979caf..73a748b4 100644 --- a/features/backup-restore.feature +++ b/features/backup-restore.feature @@ -192,8 +192,8 @@ Feature: Backup and restore And STDOUT should not contain "{DB_PASSWORD}" And STDERR should not contain "{DB_PASSWORD}" - # WP-1: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. No PR yet (local fix only). - @wp @known-bug + # WP-1 (fixed in v4.13.1, site-command#504), regression check: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. + @wp Scenario: A restored WordPress core passes checksum verification Given "ee" is installed And the site "bh-site-command-148.test" exists, created with "--type=wp" diff --git a/features/clean.feature b/features/clean.feature index 3140120d..9ab9d939 100644 --- a/features/clean.feature +++ b/features/clean.feature @@ -27,8 +27,8 @@ Feature: Clean site caches Then the command should fail And STDERR should contain "Proxy cache is not enabled on site." - # CACHE-1 pattern (see SC-7): the proxy purge uses `docker exec -it` and ignores the exit code (no TTY: fails silently; nested ptys: can hang), hence the timeout. No open PR. - @destructive @known-bug + # CACHE-1 pattern (see SC-7, fixed in v4.13.2 by site-command 276d118), regression check: the proxy purge used `docker exec -it` and ignored the exit code (no TTY: failed silently; nested ptys: could hang), hence the timeout. + @destructive Scenario: Cleaning the proxy cache empties it Given the html site "bh-site-command-89.test" exists When I run 'bin/ee site update bh-site-command-89.test --proxy-cache=on' diff --git a/features/clone-sync.feature b/features/clone-sync.feature index 7aa60661..b581caac 100644 --- a/features/clone-sync.feature +++ b/features/clone-sync.feature @@ -25,36 +25,31 @@ Feature: Clone and sync sites Then the command should fail And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" - # CLONE-7: the same-site guard only knows the literal "localhost", so a loopback address syncs a site onto itself. Fixed on fix/clone-sync-safety. - @known-bug + # CLONE-7 (fixed in v4.13.2, site-command#510), regression check: the same-site guard only knows the literal "localhost", so a loopback address syncs a site onto itself. Scenario: A site cannot be synced onto itself through a loopback address When I run 'bin/ee site sync bh-site-command-150.test root@127.0.0.1:bh-site-command-150.test' Then the command should fail And STDERR should contain "Cannot copy 'bh-site-command-150.test' on 'localhost' to 'bh-site-command-150.test' on '127.0.0.1'" - # CLONE-7: the host part accepts spaces, so ssh options pass through. Fixed on fix/clone-sync-safety. - @known-bug + # CLONE-7 (fixed in v4.13.2, site-command#510), regression check: the host part accepts spaces, so ssh options pass through. Scenario: ssh options in a remote location are rejected When I run "bin/ee site clone 'root@bh-host-a -oProxyCommand=true:bh-site-command-150.test' bh-site-command-151.test" Then the command should fail And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" - # CLONE-7: `user@host:.` gives the site name "." instead of "same name as the other side". Fixed on fix/clone-sync-safety. - @known-bug + # CLONE-7 (fixed in v4.13.2, site-command#510), regression check: `user@host:.` gives the site name "." instead of "same name as the other side". Scenario: A remote dot means the name of the other side When I run 'bin/ee site clone root@bh-host-a:. .' Then the command should fail And STDERR should contain "No sitename found in source and destination site." - # CLONE-3/CLONE-7: site names go into shell commands unchecked. Fixed on fix/clone-sync-safety. - @known-bug + # CLONE-3/CLONE-7 (fixed in v4.13.2, site-command#510), regression check: site names go into shell commands unchecked. Scenario: A site name with shell syntax is rejected When I run "bin/ee site clone 'bh-site-command-150.test;true' bh-site-command-151.test" Then the command should fail And STDERR should contain "Invalid site name: bh-site-command-150.test;true" - # CLONE-8: the sync help shows `--ssl=le --wildcard`, which sync doesn't accept. Fixed on fix/clone-sync-safety. - @known-bug + # CLONE-8 (fixed in v4.13.2, site-command#510), regression check: the sync help shows `--ssl=le --wildcard`, which sync doesn't accept. Scenario: The sync help has no SSL example When I run 'bin/ee help site sync' Then the command should succeed @@ -167,8 +162,8 @@ Feature: Clone and sync sites And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/wp-content/uploads/bh-upload.txt" should contain "bh-site-command-upload" And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-not-upload.txt" should not exist - # CLONE-8: `?? false && … ?? false` precedence lets a missing --ssl-crt through to is_file(null). Fixed on fix/clone-sync-safety. - @wp @known-bug + # CLONE-8 (fixed in v4.13.2, site-command#510), regression check: `?? false && … ?? false` precedence lets a missing --ssl-crt through to is_file(null). + @wp Scenario: Cloning with --ssl=custom needs both certificate files Given the site "bh-site-command-157.test" exists, created with "--type=wp" And the site "bh-site-command-170.test" does not exist @@ -178,8 +173,8 @@ Feature: Clone and sync sites And STDERR should not contain "is_file" And the site "bh-site-command-170.test" should not exist - # CLONE-3: the admin password goes unquoted into `ee site create`, so a space, ; or & breaks the create. Fixed on fix/clone-sync-safety. - @wp @known-bug + # CLONE-3 (fixed in v4.13.2, site-command#510), regression check: the admin password goes unquoted into `ee site create`, so a space, ; or & breaks the create. + @wp Scenario: Clone copies an admin password with spaces and shell operators Given the site "bh-site-command-171.test" exists, created with "--type=wp --admin-pass='bh pw;1&x'" And the site "bh-site-command-172.test" does not exist @@ -188,8 +183,8 @@ Feature: Clone and sync sites And STDOUT should contain "Site cloned successfully." And the "app_admin_password" field of site "bh-site-command-172.test" in the database should be "bh pw;1&x" - # CLONE-3: values with $ or quotes would be expanded by site-type-wp's double-quoted `bash -c`, so clone leaves them out. Fixed on fix/clone-sync-safety. - @wp @known-bug + # CLONE-3 (fixed in v4.13.2, site-command#510), regression check: values with $ or quotes would be expanded by site-type-wp's double-quoted `bash -c`, so clone leaves them out. + @wp Scenario: Clone does not pass an admin password the destination shell would expand Given the site "bh-site-command-174.test" exists, created with "--type=wp --admin-pass='bh$pw1xyz'" And the site "bh-site-command-173.test" does not exist diff --git a/features/create.feature b/features/create.feature index 78d366ba..3f31b275 100644 --- a/features/create.feature +++ b/features/create.feature @@ -146,10 +146,8 @@ Feature: Create html sites Then the command should fail And STDERR should contain "already exists as an alias domain for site: bh-site-command-21.test." - # SEC-1: site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits + # SEC-1 (fixed in v4.13.2, site-command#509), regression check: site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits # `docker volume create ... bhsitecommand22;idtest_htdocs`, creating a volume "bhsitecommand22" and running "idtest_htdocs". - # PR #495 only escapes the self-signed sinks; the real fix is local (issues.md SEC-1). - @known-bug Scenario: Site names that are not valid hostnames are rejected before anything runs Given the site "bh-site-command-22;id.test" does not exist When I run "bin/ee site create 'bh-site-command-22;id.test'" diff --git a/features/ssl.feature b/features/ssl.feature index c2a83f84..e8ee4fd0 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -169,8 +169,7 @@ Feature: Self-signed, custom and inherited SSL Then the command should fail And STDOUT should not contain "object(" - # SEC-3: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). Passes on the OpenSSL 3.0 of ubuntu-24.04 runners. - @known-bug + # SEC-3 (fixed in v4.13.2, site-command#513), regression check: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). Scenario: Self-signed site certificates verify against the EasyEngine root CA Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" When I run 'openssl verify -CAfile {PROXY_DIR}/self-signed-certs/rootCA.pem {PROXY_DIR}/certs/bh-site-command-100.test.crt' diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index fd99978a..9197419b 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -43,9 +43,10 @@ Feature: Update SSL and PHP of html sites Then the command should fail And STDERR should contain "SSL is already disabled on bh-site-command-74.test" + # Self-signed certificates are always wildcard (site-command 5c34be8, v3.8.1), so the refusal is checked with LE; it fails before any ACME call. Scenario: Switching a normal site to wildcard SSL is refused Given the html site "bh-site-command-74.test" exists - When I run 'bin/ee site update bh-site-command-74.test --ssl=self --wildcard' + When I run 'bin/ee site update bh-site-command-74.test --ssl=le --wildcard' Then the command should fail And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." And the "site_ssl" field of site "bh-site-command-74.test" in the database should be "" @@ -110,8 +111,7 @@ Feature: Update SSL and PHP of html sites Then the command should succeed When I wait up to 60 seconds for "http://bh-site-command-71.test/" to return 200 - # SC-3: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). No open PR. - @known-bug + # SC-3 (fixed in v4.13.1, site-command#505), regression check: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). Scenario: Enabling self-signed SSL through update records it as wildcard like create does Given the html site "bh-site-command-75.test" exists When I run 'bin/ee site update bh-site-command-75.test --ssl=self' @@ -138,8 +138,7 @@ Feature: Update SSL and PHP of html sites And STDERR should not contain "The origin directory specified" And the directory "{SITES_DIR}/bh-site-command-79.test/.backup" should not exist - # SC-15: update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. PR #489 adds both options. - @known-bug + # SC-15 (fixed in v4.13.1, site-command#489), regression check: update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. Scenario: Enable custom SSL on an existing site Given the html site "bh-site-command-108.test" exists When I run "openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout {TMP}/bh.key -out {TMP}/bh.crt -subj '/CN=bh-site-command-108.test/O=bh-site-command-custom' -addext 'subjectAltName=DNS:bh-site-command-108.test'" From 2bd530b08a05591a89686f59ca87a9a3d3ec574b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 10:45:12 +0530 Subject: [PATCH 26/37] test(site-command): wait for the alias-updated proxy to serve the site certificate On PHP 7.4 the proxy still served its default certificate right after the HTTPS check returned 200 (PRX-7). --- features/bootstrap/FeatureContext.php | 21 +++++++++++++++++++++ features/update-alias.feature | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index ff4cf0ab..0c19ae47 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1186,6 +1186,27 @@ public function theTlsCertificateShouldCover( $host, $not, $domain ) } } + /** + * The proxy can serve its default certificate for a moment while it regenerates after an alias change (PRX-7). + * + * @Then /^the TLS certificate served for "([^"]*)" should cover "([^"]*)" within (\d+) seconds$/ + */ + public function theTlsCertificateShouldCoverWithin( $host, $domain, $seconds ) + { + $deadline = time() + (int) $seconds; + while ( true ) { + try { + $this->theTlsCertificateShouldCover( $host, '', $domain ); + return; + } catch ( Exception $e ) { + if ( time() >= $deadline ) { + throw $e; + } + sleep( 2 ); + } + } + } + /** * @Then /^the TLS certificate served for "([^"]*)" should be issued by "(.*)"$/ */ diff --git a/features/update-alias.feature b/features/update-alias.feature index 4b5bb93d..cc2558f1 100644 --- a/features/update-alias.feature +++ b/features/update-alias.feature @@ -86,7 +86,7 @@ Feature: Update alias domains And STDOUT should contain "No SSL certificate action needed for self SSL on alias domain change." And the service "nginx" of site "bh-site-command-69.test" should not have the environment variable "HTTPS_METHOD" containing "nohttps" When I wait up to 60 seconds for "https://bh-site-command-69.test/" to return 200 - Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" + Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" within 30 seconds # AUTH-1 regression: alias labels starting with "_" could clobber a site's _wildcard proxy files (fixed by #502, 38569df). Scenario: Alias domains with a label starting with an underscore are rejected From 996747719a2c0daecd0c4c036f3fc4ff6cdc3acc Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 10:56:16 +0530 Subject: [PATCH 27/37] test(site-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. Both something-like steps only used the first line of the expected text. --- features/bootstrap/FeatureContext.php | 6 ++---- features/bootstrap/support.php | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 0c19ae47..123fb32e 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -263,8 +263,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); } } @@ -277,8 +276,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 ed9a5b56..a5f1a1e6 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 ec2cfc67cf72028c66efe4b923b835830c26f5c3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 11:09:41 +0530 Subject: [PATCH 28/37] 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 | 169 ++++++++++++++++++++++++++++++++++++ 1 file changed, 169 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 00000000..18dfbe00 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,169 @@ +# 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: 'eetest:ee-backups' + 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, wp, known-bug] + include: + - lane: main + tags: '~@known-bug&&~@wp' + - lane: wp + tags: '@wp&&~@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 6009ba616daeddd3b688a9ff30f9ad69c614c8d1 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 11:25:19 +0530 Subject: [PATCH 29/37] ci: run the @le behat scenarios against Pebble EE's hardcoded Let's Encrypt endpoint is redirected to a local Pebble on the runner (hosts entry, throwaway CA, resolved routing for the test domain). No product code changes. --- .github/behat/pebble-setup.sh | 368 +++++++++++++++++++++++++++++++++ .github/workflows/behat-le.yml | 127 ++++++++++++ 2 files changed, 495 insertions(+) create mode 100755 .github/behat/pebble-setup.sh create mode 100644 .github/workflows/behat-le.yml diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh new file mode 100755 index 00000000..3a60473b --- /dev/null +++ b/.github/behat/pebble-setup.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Redirects EasyEngine's hardcoded Let's Encrypt endpoint to a local Pebble on a throwaway CI runner, so the @le Behat +# scenarios run without public DNS or real certificates. Test-only: no product code changes. +# +# pebble-setup.sh setup start Pebble + DNS, redirect and trust, block real LE, then run `verify` +# pebble-setup.sh verify prove EE's HTTP stack reaches Pebble (and cannot reach real LE) +# pebble-setup.sh reach after the EE proxy is up: prove Pebble's VA can fetch http:/// (HTTP-01 path) +# pebble-setup.sh logs print the Pebble, proxy and DNS logs +# pebble-setup.sh teardown undo everything setup did +# +# Needs root, docker, openssl, curl, php, iptables and systemd-resolved (GitHub's ubuntu runners have all of them). +# Pebble's root is trusted system-wide while it runs, so never leave it set up on a machine used for anything else. +set -euo pipefail + +PEBBLE_DIR=${PEBBLE_DIR:-/tmp/ee-pebble} +# 2.10+ also offers the token-less draft dns-persist-01 challenge, which EE's acmephp 1.3 client can't parse (SSL-10). +PEBBLE_VERSION=${PEBBLE_VERSION:-2.9.0} +PEBBLE_NET=${PEBBLE_NET:-ee-pebble} +PEBBLE_BRIDGE=${PEBBLE_BRIDGE:-ee-pebble0} +PEBBLE_SUBNET=${PEBBLE_SUBNET:-172.30.0.0/24} +PEBBLE_GATEWAY=${PEBBLE_GATEWAY:-172.30.0.1} +# EE's host resolves to the front proxy: Pebble serves its directory at /dir, EE requests /directory. +PEBBLE_IP=${PEBBLE_IP:-172.30.0.10} +PEBBLE_ACME_IP=${PEBBLE_ACME_IP:-172.30.0.12} +PEBBLE_PROXY_IMAGE=${PEBBLE_PROXY_IMAGE:-nginx:1.29-alpine} +PEBBLE_DNS_IP=${PEBBLE_DNS_IP:-172.30.0.11} +PEBBLE_DNS_IMAGE=${PEBBLE_DNS_IMAGE:-alpine:3.22} +# Every name under this domain resolves to PEBBLE_TARGET_IP, on the runner and for Pebble's VA. +PEBBLE_DOMAIN=${PEBBLE_DOMAIN:-ee4-test.rt.gw} +# The gateway is a host address, so both the runner and the VA reach nginx-proxy's published :80 through it. +PEBBLE_TARGET_IP=${PEBBLE_TARGET_IP:-$PEBBLE_GATEWAY} +ACME_HOST=acme-v02.api.letsencrypt.org +TOS_MARKER='Do%20what%20thou%20wilt' +PUBLIC_PROBE=api.wordpress.org +MARK='# ee-pebble' +RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/ee-pebble.conf +CA_LINK=/usr/local/share/ca-certificates/ee-pebble-ca.crt +ROOT_LINK=/usr/local/share/ca-certificates/ee-pebble-root.crt +STATE="$PEBBLE_DIR/state" + +log() { printf '[pebble] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +need_root() { [[ $EUID -eq 0 ]] || die 'run as root'; } + +# Records an undo command (one per line) for teardown. +remember() { printf '%s\n' "$*" >>"$STATE"; } + +public_ips() { + # $1 = ahostsv4|ahostsv6, $2 = name; resolved before any override is installed. + getent "$1" "$2" 2>/dev/null | awk '{print $1}' | sort -u || true +} + +block_ip() { + local ip=$1 tool=iptables + [[ $ip == *:* ]] && tool=ip6tables + command -v "$tool" >/dev/null || return 0 + "$tool" -I OUTPUT -d "$ip" -j REJECT + remember "$tool -D OUTPUT -d $ip -j REJECT" + if "$tool" -n -L DOCKER-USER >/dev/null 2>&1; then + "$tool" -I DOCKER-USER -d "$ip" -j REJECT + remember "$tool -D DOCKER-USER -d $ip -j REJECT" + fi + log "blocked egress to $ip" +} + +make_certs() { + local d=$PEBBLE_DIR + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj '/CN=EE Behat Pebble listener CA' \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign' \ + -keyout "$d/ca.key" -out "$d/ca.pem" 2>/dev/null + openssl req -newkey rsa:2048 -nodes -subj "/CN=$ACME_HOST" -keyout "$d/key.pem" -out "$d/cert.csr" 2>/dev/null + printf 'subjectAltName=DNS:%s,IP:%s\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\n' \ + "$ACME_HOST" "$PEBBLE_IP,IP:$PEBBLE_ACME_IP" >"$d/cert.ext" + openssl x509 -req -in "$d/cert.csr" -CA "$d/ca.pem" -CAkey "$d/ca.key" -CAcreateserial -days 2 \ + -extfile "$d/cert.ext" -out "$d/cert.pem" 2>/dev/null + rm -f "$d/ca.key" "$d/cert.csr" "$d/ca.srl" + # Pebble runs as root in a scratch image; the key only has to be readable by it. + chmod 644 "$d/key.pem" +} + +write_config() { + # The blocklist mirrors LE refusing reserved TLDs. + cat >"$PEBBLE_DIR/pebble-config.json" <<-EOF + { + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "/pebble/cert.pem", + "privateKey": "/pebble/key.pem", + "httpPort": 80, + "tlsPort": 443, + "ocspResponderURL": "", + "externalAccountBindingRequired": false, + "domainBlocklist": ["invalid", "test", "example", "localhost", "local"], + "retryAfter": { "authz": 3, "order": 5 }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { "description": "LE classic: 90 days, first name as CN", "validityPeriod": 7776000, "promoteCommonName": true } + } + } + } + EOF +} + +write_front_config() { + # Pebble builds its URLs from Host and X-Forwarded-Proto, so they come back as https://$ACME_HOST/... + cat >"$PEBBLE_DIR/front.conf" <<-EOF + server { + listen 443 ssl; + ssl_certificate /pebble/cert.pem; + ssl_certificate_key /pebble/key.pem; + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_redirect off; + proxy_ssl_server_name on; + proxy_ssl_name $ACME_HOST; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /pebble/ca.pem; + location = /directory { proxy_pass https://$PEBBLE_ACME_IP:14000/dir; } + # Boulder puts a blank line between chain PEMs and acmephp splits on it; Pebble puts none. + location /certZ/ { + proxy_pass https://$PEBBLE_ACME_IP:14000; + # A location-level proxy_set_header drops the inherited ones; Pebble checks the kid against Host. + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Accept-Encoding ""; + sub_filter_types application/pem-certificate-chain; + sub_filter_once off; + sub_filter "-----END CERTIFICATE----- + -----BEGIN CERTIFICATE-----" "-----END CERTIFICATE----- + + -----BEGIN CERTIFICATE-----"; + } + location / { proxy_pass https://$PEBBLE_ACME_IP:14000; } + } + EOF +} + +wait_for() { + # $1 = description, rest = command; up to 60 s. + local what=$1 i + shift + for ((i = 0; i < 60; i++)); do + "$@" >/dev/null 2>&1 && return 0 + sleep 1 + done + die "timed out waiting for $what" +} + +start_containers() { + docker network create --driver bridge --subnet "$PEBBLE_SUBNET" --gateway "$PEBBLE_GATEWAY" \ + -o "com.docker.network.bridge.name=$PEBBLE_BRIDGE" "$PEBBLE_NET" >/dev/null + remember "docker network rm $PEBBLE_NET" + # dnsmasq, not challtestsrv: it answers only the test domain and forwards everything else, because resolved's + # global DNS also ends up in the containers' resolv.conf (challtestsrv answers every name, even wordpress.org). + local upstream + upstream=$(awk '$1 == "nameserver" && $2 !~ /^127\./ {print $2; exit}' /run/systemd/resolve/resolv.conf 2>/dev/null) + [[ -n $upstream ]] || die 'no upstream DNS server found in /run/systemd/resolve/resolv.conf' + docker run -d --name ee-pebble-dns --network "$PEBBLE_NET" --ip "$PEBBLE_DNS_IP" "$PEBBLE_DNS_IMAGE" \ + sh -c "apk add -q --no-cache dnsmasq && exec dnsmasq -k --no-hosts --no-resolv --log-queries --log-facility=- \ + --server=$upstream --address=/$PEBBLE_DOMAIN/$PEBBLE_TARGET_IP" >/dev/null + remember 'docker rm -f ee-pebble-dns' + docker run -d --name ee-pebble --network "$PEBBLE_NET" --ip "$PEBBLE_ACME_IP" \ + -e PEBBLE_VA_NOSLEEP=1 -e PEBBLE_WFE_NONCEREJECT=0 -e PEBBLE_AUTHZREUSE=100 \ + -v "$PEBBLE_DIR:/pebble:ro" "ghcr.io/letsencrypt/pebble:$PEBBLE_VERSION" \ + -config /pebble/pebble-config.json -dnsserver "$PEBBLE_DNS_IP:53" >/dev/null + remember 'docker rm -f ee-pebble' + docker run -d --name ee-pebble-front --network "$PEBBLE_NET" --ip "$PEBBLE_IP" \ + -v "$PEBBLE_DIR:/pebble:ro" -v "$PEBBLE_DIR/front.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$PEBBLE_PROXY_IMAGE" >/dev/null + remember 'docker rm -f ee-pebble-front' +} + +allow_va_forwarding() { + # Docker's inter-network isolation may drop the VA's DNATed request to nginx-proxy's bridge; DOCKER-USER runs first. + iptables -n -L DOCKER-USER >/dev/null 2>&1 || return 0 + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -p tcp --dport 80 -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -p tcp --dport 80 -j ACCEPT" + # Site containers get the resolver from resolved's resolv.conf, which now lists the Pebble DNS. + local proto + for proto in udp tcp; do + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -d "$PEBBLE_DNS_IP" -p "$proto" --dport 53 -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -d $PEBBLE_DNS_IP -p $proto --dport 53 -j ACCEPT" + done +} + +redirect_acme_host() { + cp /etc/hosts "$PEBBLE_DIR/hosts.bak" + printf '%s\t%s %s\n' "$PEBBLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" +} + +route_test_domain() { + systemctl is-active --quiet systemd-resolved || die 'systemd-resolved is not active; cannot route *.'"$PEBBLE_DOMAIN" + mkdir -p "$(dirname "$RESOLVED_DROPIN")" + printf '[Resolve]\nDNS=%s\nDomains=~%s\n' "$PEBBLE_DNS_IP" "$PEBBLE_DOMAIN" >"$RESOLVED_DROPIN" + remember "rm -f $RESOLVED_DROPIN; systemctl restart systemd-resolved" + systemctl restart systemd-resolved + resolvectl flush-caches 2>/dev/null || true +} + +php_cafiles() { + # CA files PHP is configured to use instead of the system bundle, if any. + php <<-'PHP' | sort -u + /dev/null" + update-ca-certificates >/dev/null + # Guzzle (verify=true) and PHP streams fall back to these when set, so they must trust the CAs too. + while IFS= read -r f; do + [[ -f $f ]] || continue + [[ $(readlink -f "$f") == "$(readlink -f /etc/ssl/certs/ca-certificates.crt)" ]] && continue + cp "$f" "$f.ee-pebble.bak" + remember "mv -f '$f.ee-pebble.bak' '$f'" + for pem in "$PEBBLE_DIR/ca.pem" "$PEBBLE_DIR/pebble-root.pem"; do cat "$pem" >>"$f"; done + log "appended the CAs to PHP's $f" + done < <(php_cafiles) +} + +write_env() { + local issuer + issuer=$(openssl x509 -in "$PEBBLE_DIR/pebble-intermediate.pem" -noout -subject -nameopt multiline | + sed -n 's/^ *commonName *= *//p') + [[ -n $issuer ]] || die 'could not read the Pebble intermediate CN' + printf 'EE_BEHAT_LE_ISSUER=%s\nEE_BEHAT_LE_DNS_SERVER=127.0.0.53\nEE_BEHAT_PEBBLE_ROOT=%s\n' \ + "$issuer" "$PEBBLE_DIR/pebble-root.pem" >"$PEBBLE_DIR/env" + if [[ -n ${GITHUB_ENV:-} ]]; then + cat "$PEBBLE_DIR/env" >>"$GITHUB_ENV" + fi + log "issuer for the scenarios: $issuer" +} + +cmd_setup() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip + # Resolve before the overrides so the real addresses get blocked. + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") \ + $(public_ips ahostsv4 "$PEBBLE_DOMAIN") $(public_ips ahostsv6 "$PEBBLE_DOMAIN"); do + block_ip "$ip" + done + + make_certs + write_config + write_front_config + start_containers + allow_va_forwarding + redirect_acme_host + + wait_for 'the Pebble directory' curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$ACME_HOST/directory" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/roots/0" -o "$PEBBLE_DIR/pebble-root.pem" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/intermediates/0" -o "$PEBBLE_DIR/pebble-intermediate.pem" + + trust_cas + wait_for 'the Pebble DNS' docker exec ee-pebble-dns pidof dnsmasq + route_test_domain + write_env + cmd_verify + log 'setup done' +} + +cmd_verify() { + local probe resolved ip body + resolved=$(getent ahostsv4 "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not Pebble ($PEBBLE_IP)" + + body=$(curl -fsS "https://$ACME_HOST/directory") || die 'curl with the system trust store cannot reach Pebble' + [[ $body == *"$TOS_MARKER"* ]] || die "the directory at $ACME_HOST is not Pebble's: $body" + [[ $body == *"https://$ACME_HOST/"* ]] || die "Pebble's directory does not use $ACME_HOST in its URLs: $body" + + # The same two paths EE's ACME client can take: Guzzle over ext-curl (verify=true, no CA option) and PHP streams. + # Pebble rejects requests without a User-Agent (Guzzle always sends one). + ACME_HOST=$ACME_HOST TOS_MARKER=$TOS_MARKER php <<-'PHP' >&2 || die 'PHP does not reach Pebble with default TLS verification' + true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'ee-pebble-verify']); + $a = curl_exec($c); + if (false === $a || false === strpos($a, $m)) { + fwrite(STDERR, 'php curl: ' . json_encode(['error' => curl_error($c), 'url' => $u, 'marker' => $m, 'code' => curl_getinfo($c, CURLINFO_RESPONSE_CODE), 'ip' => curl_getinfo($c, CURLINFO_PRIMARY_IP), 'body' => substr((string) $a, 0, 300)]) . "\n"); + exit(1); + } + $b = @file_get_contents($u, false, stream_context_create(['http' => ['user_agent' => 'ee-pebble-verify']])); + if (false === $b || false === strpos($b, $m)) { fwrite(STDERR, 'php stream: ' . json_encode([error_get_last(), substr((string) $b, 0, 300)]) . "\n"); exit(1); } + echo 'php curl + stream reach Pebble; ', json_encode(['curl.cainfo' => ini_get('curl.cainfo'), 'openssl' => openssl_get_cert_locations()['default_cert_file'], 'ssl' => curl_version()['ssl_version']]), "\n"; + PHP + + probe="probe-$RANDOM.$PEBBLE_DOMAIN" + resolved=$(getent ahostsv4 "$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + resolved=$(getent ahostsv4 "www.$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "www.$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + + # Everything else must still resolve normally, on the host and in containers on other networks (like EE's sites). + resolved=$(getent ahostsv4 "$PUBLIC_PROBE" | awk 'NR==1{print $1}') + [[ -n $resolved && $resolved != "$PEBBLE_TARGET_IP" ]] || die "$PUBLIC_PROBE resolves to '$resolved' on the host" + log "resolved upstreams for containers: $(grep '^nameserver' /run/systemd/resolve/resolv.conf | tr '\n' ' ')" + docker network create ee-pebble-probe >/dev/null + body=$(docker run --rm --network ee-pebble-probe "$PEBBLE_DNS_IMAGE" sh -c \ + "nslookup $probe | tail -n +3; wget -q -O /dev/null https://$PUBLIC_PROBE/ && echo public-https-ok" 2>&1) || true + docker network rm ee-pebble-probe >/dev/null + log "container view of $probe: $(tr '\n' ' ' <<<"$body")" + [[ $body == *public-https-ok* ]] || die "containers can no longer reach https://$PUBLIC_PROBE/: $body" + + while read -r _ ip; do + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> Pebble $PEBBLE_IP (system trust), *.$PEBBLE_DOMAIN -> $PEBBLE_TARGET_IP, real LE blocked" +} + +cmd_reach() { + local code + code=$(docker run --rm --network "$PEBBLE_NET" "curlimages/curl:8.11.1" -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 10 "http://$PEBBLE_TARGET_IP/.well-known/acme-challenge/ee-pebble-probe") || + die "a container on $PEBBLE_NET cannot reach http://$PEBBLE_TARGET_IP/ (HTTP-01 would fail)" + log "container on $PEBBLE_NET -> http://$PEBBLE_TARGET_IP/ answered HTTP $code" +} + +cmd_logs() { + local c + for c in ee-pebble ee-pebble-front ee-pebble-dns; do + echo "===== $c" + docker logs "$c" 2>&1 || true + done +} + +cmd_teardown() { + need_root + [[ -f $STATE ]] || { log 'nothing to tear down'; return 0; } + local line + # Undo in reverse order; keep going on errors so a partial setup is still cleaned up. + while IFS= read -r line; do + bash -c "$line" >/dev/null 2>&1 || log "undo failed (ignored): $line" + done < <(tac "$STATE") + resolvectl flush-caches 2>/dev/null || true + rm -rf "$PEBBLE_DIR" + log 'teardown done' +} + +case ${1:-} in + setup) cmd_setup ;; + verify) cmd_verify ;; + reach) cmd_reach ;; + logs) cmd_logs ;; + teardown) cmd_teardown ;; + *) + sed -n '2,12p' "$0" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/behat-le.yml b/.github/workflows/behat-le.yml new file mode 100644 index 00000000..30ee54ab --- /dev/null +++ b/.github/workflows/behat-le.yml @@ -0,0 +1,127 @@ +# @le Behat scenarios against Pebble: EE's hardcoded Let's Encrypt host is redirected to it on the runner (test-only). +name: Behat LE (Pebble) + +on: + pull_request: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-le-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + COMPOSE_INTERACTIVE_NO_CLI: 1 + EE_BEHAT_DESTRUCTIVE: 1 + # Scenarios that need the public LE service itself are tagged @le-real and never run here. + LE_TAGS: '@le&&~@le-real' + +jobs: + behat-le: + name: Behat LE ${{ matrix.lane }} (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + 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' + # The whole feature: some known-bug scenarios check what an earlier scenario left behind. + - lane: known-bug + tags: '@le' + 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: Prepare the host + run: | + sudo systemctl stop nginx apache2 mysql 2>/dev/null || 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}}' + + - name: Set up EasyEngine core + run: | + git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine + cd easyengine + 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");' + composer update --prefer-dist --no-progress --no-interaction + rm -rf features + cp -R ../pkg/features features + sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee + + - name: Initialise EasyEngine + working-directory: easyengine + run: | + sudo -E bin/ee cli info + sudo -E bin/ee site list || true + sudo -E bin/ee config set le-mail behat-ci@example.org + + - name: Start Pebble and redirect Let's Encrypt to it + run: | + sudo -E pkg/.github/behat/pebble-setup.sh setup + # Diagnostic only: nginx-proxy may not be up before the first site exists. + sudo docker ps --format '{{.Names}} {{.Ports}}' + sudo pkg/.github/behat/pebble-setup.sh reach || true + + - name: Run Behat (@le) + 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="${LE_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 pkg/.github/behat/pebble-setup.sh logs > "$out/pebble.log" 2>&1 || true + sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true + sudo chown -R "$USER" "$out" + tail -n 60 "$out/pebble.log" || true + + - name: Tear down Pebble + if: always() + run: sudo pkg/.github/behat/pebble-setup.sh teardown + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: behat-le-${{ matrix.lane }}-php${{ matrix.php }} + path: ${{ runner.temp }}/behat + retention-days: 14 From 3e036becdda603d6c7746285e86e8769de45daec Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 11:39:47 +0530 Subject: [PATCH 30/37] 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. The @le lanes against Pebble move into behat.yml (lanes named le*), replacing behat-le.yml. Diagnostics: the host addresses and routes, and the docker-compose version after the first run (MIG-20). --- .github/workflows/behat-le.yml | 127 --------------------------------- .github/workflows/behat.yml | 59 ++++++++++++--- 2 files changed, 48 insertions(+), 138 deletions(-) delete mode 100644 .github/workflows/behat-le.yml diff --git a/.github/workflows/behat-le.yml b/.github/workflows/behat-le.yml deleted file mode 100644 index 30ee54ab..00000000 --- a/.github/workflows/behat-le.yml +++ /dev/null @@ -1,127 +0,0 @@ -# @le Behat scenarios against Pebble: EE's hardcoded Let's Encrypt host is redirected to it on the runner (test-only). -name: Behat LE (Pebble) - -on: - pull_request: - push: - branches: - - develop - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: behat-le-${{ github.ref }} - cancel-in-progress: true - -env: - CORE_REF: develop - COMPOSE_VERSION: v5.5.1 - COMPOSE_INTERACTIVE_NO_CLI: 1 - EE_BEHAT_DESTRUCTIVE: 1 - # Scenarios that need the public LE service itself are tagged @le-real and never run here. - LE_TAGS: '@le&&~@le-real' - -jobs: - behat-le: - name: Behat LE ${{ matrix.lane }} (PHP ${{ matrix.php }}) - runs-on: ubuntu-latest - timeout-minutes: 60 - 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' - # The whole feature: some known-bug scenarios check what an earlier scenario left behind. - - lane: known-bug - tags: '@le' - 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: Prepare the host - run: | - sudo systemctl stop nginx apache2 mysql 2>/dev/null || 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}}' - - - name: Set up EasyEngine core - run: | - git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine - cd easyengine - 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");' - composer update --prefer-dist --no-progress --no-interaction - rm -rf features - cp -R ../pkg/features features - sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee - - - name: Initialise EasyEngine - working-directory: easyengine - run: | - sudo -E bin/ee cli info - sudo -E bin/ee site list || true - sudo -E bin/ee config set le-mail behat-ci@example.org - - - name: Start Pebble and redirect Let's Encrypt to it - run: | - sudo -E pkg/.github/behat/pebble-setup.sh setup - # Diagnostic only: nginx-proxy may not be up before the first site exists. - sudo docker ps --format '{{.Names}} {{.Ports}}' - sudo pkg/.github/behat/pebble-setup.sh reach || true - - - name: Run Behat (@le) - 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="${LE_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 pkg/.github/behat/pebble-setup.sh logs > "$out/pebble.log" 2>&1 || true - sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true - sudo chown -R "$USER" "$out" - tail -n 60 "$out/pebble.log" || true - - - name: Tear down Pebble - if: always() - run: sudo pkg/.github/behat/pebble-setup.sh teardown - - - name: Upload results - if: always() - uses: actions/upload-artifact@v7 - with: - name: behat-le-${{ matrix.lane }}-php${{ matrix.php }} - path: ${{ runner.temp }}/behat - retention-days: 14 diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 18dfbe00..74f5f7f2 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -22,7 +22,9 @@ 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. + # le* lanes run @le against a local Pebble instead (.github/behat/pebble-setup.sh); @le-real needs the real service. + LE_TAGS: '@le&&~@le-real&&~@remote-real' + # Local rclone alias written to EE's rclone-path, for suites with @remote scenarios ('eetest:ee-backups'); empty = no rclone. EE_RCLONE_PATH: 'eetest:ee-backups' COMPOSE_INTERACTIVE_NO_CLI: 1 RCLONE_CONFIG: /etc/rclone/rclone.conf @@ -34,12 +36,12 @@ 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'] - lane: [main, wp, known-bug] + php: ['7.4', '8.5'] + lane: [main, wp, known-bug, le, le-known-bug] include: - lane: main tags: '~@known-bug&&~@wp' @@ -47,6 +49,12 @@ jobs: tags: '@wp&&~@known-bug' - lane: known-bug tags: '@known-bug' + # @le against Pebble (.github/behat/pebble-setup.sh). + - lane: le + tags: '~@known-bug' + # The whole @le feature: some known-bug scenarios check what an earlier one left behind. + - lane: le-known-bug + tags: '@le' steps: - name: Check out the package uses: actions/checkout@v7 @@ -83,6 +91,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" @@ -120,22 +131,38 @@ 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: Start Pebble and redirect Let's Encrypt to it + if: startsWith(matrix.lane, 'le') + run: | + sudo -E pkg/.github/behat/pebble-setup.sh setup + # Diagnostic only: nginx-proxy may not be up before the first site exists. + sudo docker ps --format '{{.Names}} {{.Ports}}' + sudo pkg/.github/behat/pebble-setup.sh reach || true - 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" + case "${{ matrix.lane }}" in le*) base="$LE_TAGS" ;; esac + 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() @@ -145,14 +172,20 @@ jobs: 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 + if [[ "${{ matrix.lane }}" == le* ]]; then + sudo pkg/.github/behat/pebble-setup.sh logs > "$out/pebble.log" 2>&1 || true + tail -n 60 "$out/pebble.log" || true + fi 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') @@ -160,6 +193,10 @@ jobs: PY head -c 20000 "$out/failures.txt" + - name: Tear down Pebble + if: always() && startsWith(matrix.lane, 'le') + run: sudo pkg/.github/behat/pebble-setup.sh teardown + - name: Upload results if: always() uses: actions/upload-artifact@v7 From 36834d86bd3ad69b9ecabbfb29f3cdc05ff07696 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 11:46:52 +0530 Subject: [PATCH 31/37] 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 74f5f7f2..8abaaeaf 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -137,7 +137,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: Start Pebble and redirect Let's Encrypt to it From 1e707346488b4acea45914c5182fd1d579f4ea75 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 11:53:19 +0530 Subject: [PATCH 32/37] ci(site-command): run the behat lanes on every supported PHP version Runs every lane on PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 and 8.5 instead of only the floor and ceiling; the jobs run in parallel. --- .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 8abaaeaf..be31e0cc 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -40,7 +40,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, wp, known-bug, le, le-known-bug] include: - lane: main From f39ab691b7cd7fbd4417b418a5861c58ebcbdf10 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 12:04:31 +0530 Subject: [PATCH 33/37] test(site-command): refuse to run against the real Let's Encrypt on CI --- features/bootstrap/FeatureContext.php | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 123fb32e..2e186336 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -483,6 +483,44 @@ public function cleanupScenario(AfterScenarioScope $scope) } } + /** + * On CI, EE's hardcoded Let's Encrypt host must point at Pebble or a blackhole (.github/behat/pebble-setup.sh), never the real service. + * + * @BeforeSuite + */ + public static function refuseRealAcmeOnCi() + { + if ( getenv( 'CI' ) && ! self::acme_host_is_local() ) { + throw new Exception( 'acme-v02.api.letsencrypt.org resolves to ' . gethostbyname( 'acme-v02.api.letsencrypt.org' ) . ' on CI; run `.github/behat/pebble-setup.sh setup` (le lanes) or `block` (every other lane) first.' ); + } + } + + /** + * @le issues certificates: only against Pebble, or on a real test host with EE_BEHAT_LE_REAL=1 (ignored on CI). + * + * @BeforeScenario @le + */ + public function requireTestAcme() + { + if ( getenv( 'EE_BEHAT_LE_ISSUER' ) && self::acme_host_is_local() ) { + return; + } + if ( '1' === getenv( 'EE_BEHAT_LE_REAL' ) && ! getenv( 'CI' ) ) { + return; + } + throw new Exception( '@le needs Pebble (.github/behat/pebble-setup.sh setup), or EE_BEHAT_LE_REAL=1 on a host with real DNS for *.ee4-test.rt.gw.' ); + } + + /** + * True when the LE host resolves to a private, loopback or TEST-NET address (Pebble's front proxy or the blackhole). + */ + private static function acme_host_is_local() + { + $ip = gethostbyname( 'acme-v02.api.letsencrypt.org' ); + + return (bool) preg_match( '/^(10\.|127\.|192\.168\.|192\.0\.2\.|172\.(1[6-9]|2\d|3[01])\.)/', $ip ); + } + /** * Deletes only the sites (and remote backups) of this suite; never touches other sites. * From b00fcc416fff921814c3b338f13dfaa338e48bb8 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 12:17:21 +0530 Subject: [PATCH 34/37] ci: block the real Let's Encrypt and replace test_and_build.yml with the behat workflow --- .github/behat/pebble-setup.sh | 36 ++++++++++- .github/workflows/behat.yml | 22 ++++++- .github/workflows/test_and_build.yml | 94 ---------------------------- 3 files changed, 53 insertions(+), 99 deletions(-) delete mode 100644 .github/workflows/test_and_build.yml diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh index 3a60473b..696ce1c9 100755 --- a/.github/behat/pebble-setup.sh +++ b/.github/behat/pebble-setup.sh @@ -3,10 +3,11 @@ # scenarios run without public DNS or real certificates. Test-only: no product code changes. # # pebble-setup.sh setup start Pebble + DNS, redirect and trust, block real LE, then run `verify` +# pebble-setup.sh block lanes without Pebble: point the LE host at a blackhole and block real LE # pebble-setup.sh verify prove EE's HTTP stack reaches Pebble (and cannot reach real LE) # pebble-setup.sh reach after the EE proxy is up: prove Pebble's VA can fetch http:/// (HTTP-01 path) # pebble-setup.sh logs print the Pebble, proxy and DNS logs -# pebble-setup.sh teardown undo everything setup did +# pebble-setup.sh teardown undo everything setup or block did # # Needs root, docker, openssl, curl, php, iptables and systemd-resolved (GitHub's ubuntu runners have all of them). # Pebble's root is trusted system-wide while it runs, so never leave it set up on a machine used for anything else. @@ -31,6 +32,8 @@ PEBBLE_DOMAIN=${PEBBLE_DOMAIN:-ee4-test.rt.gw} PEBBLE_TARGET_IP=${PEBBLE_TARGET_IP:-$PEBBLE_GATEWAY} ACME_HOST=acme-v02.api.letsencrypt.org TOS_MARKER='Do%20what%20thou%20wilt' +# TEST-NET-1: never routed, and rejected locally so a request fails at once instead of timing out. +BLACKHOLE_IP=${BLACKHOLE_IP:-192.0.2.1} PUBLIC_PROBE=api.wordpress.org MARK='# ee-pebble' RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/ee-pebble.conf @@ -326,6 +329,34 @@ cmd_verify() { log "verified: $ACME_HOST -> Pebble $PEBBLE_IP (system trust), *.$PEBBLE_DOMAIN -> $PEBBLE_TARGET_IP, real LE blocked" } +cmd_block() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip resolved + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") "$BLACKHOLE_IP"; do + block_ip "$ip" + done + printf '%s\t%s %s\n' "$BLACKHOLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" + + resolved=$(getent ahosts "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$BLACKHOLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not the blackhole $BLACKHOLE_IP" + if curl -sS --connect-timeout 5 -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "$ACME_HOST is still reachable" + fi + while read -r _ ip; do + [[ $ip == "$BLACKHOLE_IP" ]] && continue + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> $BLACKHOLE_IP (rejected), real LE blocked" +} + cmd_reach() { local code code=$(docker run --rm --network "$PEBBLE_NET" "curlimages/curl:8.11.1" -sS -o /dev/null -w '%{http_code}' \ @@ -357,12 +388,13 @@ cmd_teardown() { case ${1:-} in setup) cmd_setup ;; + block) cmd_block ;; verify) cmd_verify ;; reach) cmd_reach ;; logs) cmd_logs ;; teardown) cmd_teardown ;; *) - sed -n '2,12p' "$0" >&2 + sed -n '2,13p' "$0" >&2 exit 2 ;; esac diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index be31e0cc..d994aa09 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -1,4 +1,5 @@ # Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +# The main lane's job is named "Behat Tests - PHP " to match the required checks; update those with the matrix. name: Behat on: @@ -6,6 +7,7 @@ on: push: branches: - develop + - master workflow_dispatch: permissions: @@ -32,7 +34,7 @@ env: jobs: behat: - name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) + name: ${{ matrix.lane == 'main' && format('Behat Tests - PHP {0}', matrix.php) || format('Behat {0} (PHP {1})', matrix.lane, matrix.php) }} runs-on: ubuntu-latest timeout-minutes: 150 # Known bugs are expected to fail; they're reported, not gating. @@ -103,6 +105,11 @@ jobs: sudo -E rclone listremotes fi + # Every lane without Pebble points EE's hardcoded Let's Encrypt host at a blackhole, so no scenario can reach the real service. + - name: Block the real Let's Encrypt + if: ${{ !startsWith(matrix.lane, 'le') }} + run: sudo -E pkg/.github/behat/pebble-setup.sh block + - name: Set up EasyEngine core run: | git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine @@ -144,6 +151,15 @@ jobs: docker-compose version fi + # The suites run bin/ee; this keeps the release build (no dev autoloading) covered. + - name: Build the phar + if: matrix.lane == 'main' + working-directory: easyengine + run: | + composer dump-autoload --no-dev -q + php -dphar.readonly=0 utils/make-phar.php easyengine.phar --quiet + composer dump-autoload -q + sudo php easyengine.phar cli version - name: Start Pebble and redirect Let's Encrypt to it if: startsWith(matrix.lane, 'le') @@ -198,8 +214,8 @@ jobs: PY head -c 20000 "$out/failures.txt" - - name: Tear down Pebble - if: always() && startsWith(matrix.lane, 'le') + - name: Tear down Pebble and the Let's Encrypt block + if: always() run: sudo pkg/.github/behat/pebble-setup.sh teardown - name: Upload results diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml deleted file mode 100644 index f8fce960..00000000 --- a/.github/workflows/test_and_build.yml +++ /dev/null @@ -1,94 +0,0 @@ -on: - pull_request: - push: - branches: - - master - - develop - -name: Test 👨‍🔧 - -jobs: - - test: #---------------------------------------------------------------------- - runs-on: ubuntu-latest - name: Behat Tests - PHP ${{ matrix.php }} - strategy: - fail-fast: false - matrix: - php: ['7.4', '8.0'] - steps: - - name: Check out source code - uses: actions/checkout@v2 - - - name: Get Composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Set up Composer caching - uses: actions/cache@v4 - env: - cache-name: cache-composer-dependencies - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - 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: Update docker - run: | - sudo apt purge nginx nginx-common docker docker-engine docker.io docker-ce containerd runc - curl -fsSL https://get.docker.com/ | sudo bash - sudo systemctl restart docker.service - - - name: Install docker-compose - run: | - VERSION=$(curl --silent "https://api.github.com/repos/docker/compose/releases/latest" | - grep '"tag_name":' | - sed -E 's/.*"([^"]+)".*/\1/' - ) - sudo curl -L "https://github.com/docker/compose/releases/download/$VERSION/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - sudo chmod +x /usr/local/bin/docker-compose - - - name: Install dependencies - run: | - cd "$GITHUB_WORKSPACE/.." - git clone https://github.com/EasyEngine/easyengine.git easyengine --depth=1 - cd easyengine - rm -rf features - cp -R $GITHUB_WORKSPACE/features . - # Run composer install for master else update. - if [[ "$GITHUB_REF" = "refs/heads/master" ]]; then - composer install --prefer-dist --no-progress --no-interaction - else - sed -i 's/\(easyengine\/.*\):\ \".*\"/\1:\ \"dev-develop\"/' composer.json - composer update --prefer-dist --no-progress --no-interaction - fi - composer dump-autoload --no-dev - php -dphar.readonly=0 ./utils/make-phar.php easyengine.phar --quite > /dev/null - composer dump-autoload - sudo php easyengine.phar cli info - - - name: Test - shell: 'script -q -e -c "bash {0}"' - run: | - set -e - cd "$GITHUB_WORKSPACE/../easyengine" - sudo -E ./vendor/bin/behat - env: - COMPOSE_INTERACTIVE_NO_CLI: 1 - - - name: Output logs - if: ${{ always() }} - run: | - # The logs directory is readable by root only. - sudo test -f /opt/easyengine/logs/install.log && sudo cat /opt/easyengine/logs/install.log || echo 'No install log.' - sudo test -f /opt/easyengine/logs/ee.log && sudo cat /opt/easyengine/logs/ee.log || echo 'No run log.' - From e4f331d37fb21394393a50836dd3f7072c3ddc91 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 12:32:59 +0530 Subject: [PATCH 35/37] test(behat): leave the known-bug scenarios out until their fixes land --- .github/workflows/behat.yml | 9 +- features/COVERAGE.md | 101 +++++++------------ features/backup-restore.feature | 171 -------------------------------- features/clean.feature | 9 -- features/create.feature | 9 -- features/le.feature | 8 -- features/proxy-cache.feature | 8 -- features/redirect.feature | 23 ----- features/restart-reload.feature | 9 -- features/site.feature | 9 -- features/ssl.feature | 24 ----- features/update-ssl-php.feature | 20 ---- 12 files changed, 35 insertions(+), 365 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index d994aa09..ae9a7d24 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -37,26 +37,19 @@ jobs: name: ${{ matrix.lane == 'main' && format('Behat Tests - PHP {0}', matrix.php) || format('Behat {0} (PHP {1})', matrix.lane, 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.0', '8.1', '8.2', '8.3', '8.4', '8.5'] - lane: [main, wp, known-bug, le, le-known-bug] + lane: [main, wp, le] include: - lane: main tags: '~@known-bug&&~@wp' - lane: wp tags: '@wp&&~@known-bug' - - lane: known-bug - tags: '@known-bug' # @le against Pebble (.github/behat/pebble-setup.sh). - lane: le tags: '~@known-bug' - # The whole @le feature: some known-bug scenarios check what an earlier one left behind. - - lane: le-known-bug - tags: '@le' steps: - name: Check out the package uses: actions/checkout@v7 diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 8e2dd341..dedfb6b9 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,20 +1,20 @@ # site-command Behat coverage -Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 188 scenarios (221 with outlines expanded). Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 179 scenarios (211 with outlines expanded): main lane 190, `@wp` 10, `@le` 11. Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. ## Tags | Tag | Scenarios | Meaning | |---|---|---| -| (none of the tags below) | 128 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | -| `@known-bug` | 25 | Exposes a bug from `issues.md` (ID in the comment above the scenario, `SC-*` for the ones found while writing this suite; the comment also names the open PR that fixes it, if any). Expected to fail until fixed. Exclude with `--tags='~@known-bug'`. | -| `@destructive` | 14 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | -| `@remote` | 15 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | -| `@le` | 12 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 12 run against Pebble (`behat/ci/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`); 11 pass, the `@known-bug` one fails as expected (SSL-8). SSL-2 and SSL-5/SSL-9 are regular scenarios since the fixes on `fix/proxy-cache-rollback-and-le-alias` (they fail on develop until those merge). | +| (none of the tags below) | 171 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | +| `@known-bug` | 0 | Scenarios that expose open bugs are kept outside this suite and come back with their fixes. The CI lanes still exclude the tag. | +| `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | +| `@remote` | 10 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | +| `@le` | 11 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 11 run against Pebble (`behat/ci/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`). SSL-2 and SSL-5/SSL-9 are regular scenarios since the fixes on `fix/proxy-cache-rollback-and-le-alias` (they fail on develop until those merge). | | `@le-real` | 0 | Needs the public LE service itself (rate limits, the real chain); never runs on CI. None so far: every `@le` scenario works on Pebble. | -| `@wp` | 8 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | +| `@wp` | 10 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | -Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@known-bug&&~@wp'`. +Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@wp'`. ## Environment rules @@ -57,7 +57,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | Wrong site name inside a site dir ("Did you mean") | covered | info: Another site name inside a site directory is rejected with a hint | | Name normalisation: `http(s)://`, trailing slash, add `.test`, lower-case | covered | create: A site name without a dot...; The URL scheme and a trailing slash...; Upper-case site names...; info: The site name may be given as a URL | | EE v3 compat flags (`--html`, `--wp`, `--le`, `--mysql`, ...) | partial | create: The EE v3 --html flag (`--wp`/`--php`/`--le` belong to site-type-* / LE budget) | -| Removed EE v3 flags error (`--w3tc`, ...) | covered (known bug) | site: Removed EE v3 cache flags give a clear error | +| Removed EE v3 flags error (`--w3tc`, ...) | not covered yet | the removed-flag message never shows (its scenario comes back with the fix) | | Unknown parameter / too many positionals / missing required positional | covered | site: Unknown parameters are rejected; Extra positional arguments; Subcommands with a required site name print the usage | | Missing site outside a site dir (auto_site_name) | covered | site: outline over info, update, enable, disable, restart, reload, clean, ssl-info, backup, restore; ssl-renew scenario | | Unknown site (get_site_info) | covered | site: outlines over delete, refresh, ssl-verify, ssl, info, enable, disable, restart, update, share | @@ -75,7 +75,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | `--public-dir` | covered | create: Create a site with a custom public directory | | `--skip-status-check` | covered | create: Create a site without the status check | | `--alias-domains` (DB, server_name, VIRTUAL_HOST, HTTP on alias) | covered | create: Create a site with alias domains | -| Alias/site conflicts on create | covered | create: A domain that is an alias of another site...; An alias domain that belongs to another site...; known bug: The owner of an alias is reported correctly... | +| Alias/site conflicts on create | covered | create: A domain that is an alias of another site...; An alias domain that belongs to another site...; not covered yet: the owner lookup matches any alias containing the name (its scenario comes back with the fix) | | `--ssl=self` | covered | ssl: Create a site with a self-signed certificate; Info of a self-signed site | | `--ssl=custom` + `--ssl-key/--ssl-crt`, validation, rollback | covered | ssl: Create a site with a custom certificate; Custom SSL needs both...; Custom SSL files must exist | | `--ssl=inherit` (parent checks, `--wildcard` refusal) | covered | ssl: A subdomain site inherits...; Inheriting needs an existing parent / a parent with SSL / cannot be combined with --wildcard | @@ -124,11 +124,11 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | restart (no flag, `--nginx`, `--all`), unknown service flag | covered | rr: Restart the site containers (outline); Restart rejects services... | | restart refuses a broken nginx config | covered | rr: Restart refuses a broken nginx config and keeps the site serving | | reload (no flag, `--nginx`, `--all`), custom config applied | covered | rr: Reload nginx... (outline); Custom nginx config is applied on reload | -| reload with a broken config | covered (known bug) | rr: Reload fails when the nginx config test fails | +| reload with a broken config | not covered yet | reload exits 0 when `nginx -t` fails (its scenario comes back with the fix) | | `custom-compose` config branch of restart/enable | not covered | needs a custom compose setup (config change) | | clean: no flags, `--object`, `--page`, `--proxy` without proxy cache | covered | clean: first four scenarios | | clean `--proxy` with proxy cache | covered (@destructive, known bug CACHE-1 pattern) | clean: Cleaning the proxy cache empties it | -| clean without flags on a proxy-cached html site | covered (known bug) | clean: Cleaning without flags on a proxy-cached html site succeeds | +| clean without flags on a proxy-cached html site | not covered yet | clean without flags always fails on html sites (its scenario comes back with the fix) | | clean object/page caches with redis | not covered | only WP cache sites have them (site-type-wp suite) | ### update @@ -145,14 +145,14 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | alias change on custom SSL (warning) | not covered | low value; same code path as self apart from the warning text | | `--ssl=self` on a plain site, twice, `--ssl=off`, off on plain | covered | ussl: first four scenarios | | `--wildcard` refusals, invalid `--ssl`, `--ssl=off --wildcard` | covered | ussl: Switching a normal site to wildcard SSL is refused; An unknown SSL type is rejected; --wildcard cannot be combined with --ssl=off | -| `--ssl=inherit` via update (and the missing parent check) | covered | ussl: Inherit SSL from a self-signed parent through update; known bug: Inheriting SSL through update requires a wildcard parent | +| `--ssl=inherit` via update | covered | ussl: Inherit SSL from a self-signed parent through update; not covered yet: update skips the wildcard parent check (its scenario comes back with the fix) | | `--ssl=le` via update | covered (@le) | le: Enable Let's Encrypt on an existing site | | `--ssl=custom` via update | covered (known bug SC-15) | ussl: Enable custom SSL on an existing site (PR #489 adds `--ssl-key/--ssl-crt`) | | off on a created self-signed site, wildcard flag consistency | covered (known bugs) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | -| `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected; known bug SC-13: ...refused up front without leftovers | +| `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected; not covered yet: the refusal comes late, with a raw IOException and a leftover `.backup/` (its scenario comes back with the fix) | | `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | | `--proxy-cache=on/off`, twice, custom sizes, invalid sizes | covered (@destructive) | pcache: all but the invalid switch | -| invalid `--proxy-cache` value | covered (@destructive, known bug SC-18) | pcache: An invalid proxy cache switch is rejected | +| invalid `--proxy-cache` value | not covered yet | any value is accepted and anything but `on` disables the cache (its scenario comes back with the fix) | | proxy cache for aliases (existing, newly added, deleted), failed alias sync leaves a loadable config (PRX-6) | covered (@destructive) | pcache: Alias domains get proxy cache locations...; Deleting an alias removes its proxy cache location; A failed proxy cache alias sync leaves a config nginx can load | | proxy cache `subdom` location (`*.site_location`) | not covered | WP subdom only (site-type-wp suite) | @@ -163,14 +163,16 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | ssl-info: self, custom, inherit (parent cert file), no SSL, LE, pending wildcard | covered | ssl: ssl-info scenarios; le: create and wildcard scenarios | | ssl-info `--format=yaml`, invalid format | covered | ssl: ssl-info supports other formats; ssl-info rejects an unsupported format | | ssl-info `--get-dns-records`: non-DNS site, DNS-01 site | covered | ssl: ssl-info DNS records of a site that does not use DNS-01; le: A wildcard certificate waits... | -| ssl-info `--get-dns-records` on a self-signed (wildcard) site | covered (known bug SC-16) | ssl: ssl-info DNS records of a self-signed site | +| ssl-info `--get-dns-records` on a self-signed (wildcard) site | not covered yet | self-signed wildcard sites are treated as DNS-01 (its scenario comes back with the fix) | | ssl-renew guards: no SSL, self, inherit | covered | ssl: ssl-renew only renews Let's Encrypt certificates (outline); ssl-renew refuses inherited certificates | | ssl-renew not due (no reissue), `--all` wildcard skip | covered (@le, `--all` also @destructive) | le: ssl-renew leaves a certificate that is not due alone; ssl-renew --all skips wildcard sites... | | ssl-renew `--force`, expired-cert path | not covered | LE budget (a forced renew is one more issuance); expiry cannot be simulated without a fake CA | | ssl-verify / `ssl` alias: unknown site | covered | site: outline | -| ssl-verify on a valid LE site | covered (known bug SSL-8) | le: ssl-verify of a valid Let's Encrypt site succeeds | +| ssl-verify on a valid LE site | not covered yet | ssl-verify checks only the site name of an HTTP-01 certificate that includes www and fails (its scenario comes back with the fix) | | ssl-verify completing a DNS-01 order, Cloudflare DNS solver | not covered | needs Cloudflare API credentials (not configured) | -| Self-signed root CA validity (SEC-3), alias coverage (SSL-4) | covered (known bugs) | ssl: last two scenarios | +| Self-signed root CA validity (SEC-3) | covered | ssl: Self-signed site certificates verify against the EasyEngine root CA | +| Self-signed certificate covering alias domains | not covered yet | the certificate leaves the alias domains out (its scenario comes back with the fix) | +| Failed inherit error message | not covered yet | the message dumps the parent site object (its scenario comes back with the fix) | | SSL policy (`ssl-policy` config) | not covered | global config change (@destructive) with little behaviour beyond the template | ### share @@ -186,23 +188,23 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: |---|---|---| | backup html: upload, success output, remote files (metadata.json, .zip, conf.zip), local cleanup, lock released | covered | backup: Back up an html site | | `--list` | covered | backup: Back up an html site; A disabled site cannot be backed up but its backups can be listed | -| `--list` with no backups | covered (known bug) | backup: Listing the backups of a site without backups says so | +| `--list` with no backups | not covered yet | the "No remote backups found." message is never printed (its scenario comes back with the fix) | | restore latest, restore `--id`, unknown id | covered | backup: Restore the latest backup; Restore a specific backup by id; Restoring an unknown backup id fails | | disabled site | covered | backup: A disabled site cannot be backed up...; en-dis outline | | `--dash-auth` format and ed-api-url check | covered | backup: A malformed --dash-auth is rejected; --dash-auth needs ed-api-url in the config | | `--dash-auth` success/failure callbacks, rollback of the uploaded backup | not covered | needs an EasyDash API endpoint (`ed-api-url`) | -| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup (held with flock(1), so it holds before and after PR #480); known bug SC-9: A failed restore does not block later backups | -| BKP-1 stale download reuse | covered (known bug) | backup: Restoring by id ignores files left behind... | -| retention (`no-of-backups`) | covered (known bug) | backup: Old backups are pruned down to no-of-backups | -| WP backup/restore (DB, wp-content, core download), LOG-3, WP-1 | covered (@wp) | backup: three @wp scenarios | +| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup (held with flock(1), so it holds before and after PR #480); not covered yet: a failed restore leaves the lock file and blocks later backups (its scenario comes back with the fix) | +| stale download reuse | not covered yet | a restore by id reuses files an earlier restore left behind (its scenario comes back with the fix) | +| retention (`no-of-backups`) | not covered yet | retention keeps one backup more than `no-of-backups` (its scenario comes back with the fix) | +| WP backup/restore (DB, wp-content, core download), WP-1 | covered (@wp) | backup: Back up and restore a WordPress site; A restored WordPress core passes checksum verification; not covered yet: the restore prints the database password (its scenario comes back with the fix) | | custom docker-compose files in backups | not covered | needs a custom compose setup | | disk-space checks, rclone/7z missing, apt-get auto-install (BKP-3), global backup lock wait | not covered | needs a full disk, uninstalled tools or a concurrent backup; BKP-3 needs a pending kernel upgrade | | BKP-2 partial SQL dump in webroot | not covered | needs a failing mysqldump (fault injection) | -| invalid `no-of-backups` (BKP-7) | covered (known bug) | backup: An invalid no-of-backups keeps the backups and warns (config copy via `EE_CONFIG_PATH`) | -| per-host remote layout, hosts sharing `rclone-path` (BKP-8) | covered (known bug) | backup: Hosts that share a remote keep their backups apart (two `backup-host-id` config copies) | -| restore without any backup (BKP-12) | covered (known bug) | backup: Restoring a site without backups says so | -| files and dotfiles not in the backup (BKP-14) | covered (known bug) | backup: A restore removes files that are not in the backup, dotfiles too | -| WP webroot files beside WordPress (BKP-9), symlinked uploads (BKP-10) | covered (@wp, known bug) | backup: A WordPress backup holds the files next to WordPress; Symlinked uploads are backed up and restored without a loop | +| invalid `no-of-backups` | not covered yet | an invalid value deletes backups without a warning (its scenario comes back with the fix) | +| per-host remote layout, hosts sharing `rclone-path` | not covered yet | hosts that share a remote mix their backups (its scenario comes back with the fix) | +| restore without any backup | not covered yet | the restore doesn't say that there is no backup (its scenario comes back with the fix) | +| files and dotfiles not in the backup | not covered yet | a restore keeps files that are not in the backup (its scenario comes back with the fix) | +| WP webroot files beside WordPress, symlinked uploads | not covered yet | files next to WordPress are left out and symlinked uploads loop (their scenarios come back with the fixes) | | restore rollback, disk-full extraction (BKP-6) | not covered | needs fault injection or a small filesystem; isolated harness `/var/tmp/fix-backup-test/harness` | ### clone / sync @@ -222,45 +224,10 @@ DB and container migrations under `migrations/` (core upgrade flow), the AcmePhp ## Coverage estimate -Counting the rows above as items: 113 items, 89 covered (14 of them only through `@known-bug` scenarios), 1 partial, 23 not covered, i.e. about 79% of the inventory (78% before the review). Every user-facing subcommand and option of the html type has at least one scenario except `ssl-renew --force`, `clone --ssl/--wildcard` and a live `share`. +Counting the rows above as items: 124 items, 83 covered, 1 partial, 40 not covered (16 of them, marked "not covered yet", are open bugs whose scenarios come back with their fixes), i.e. about 67% of the inventory. Every user-facing subcommand and option of the html type has at least one scenario except `ssl-renew --force`, `clone --ssl/--wildcard` and a live `share`. -## Product bugs found while writing the suite (issues.md SC-1..15) +## Known bugs left out -Verified against develop 9dc44e1 in review; details, severity and locations are in `issues.md` ("site-command (from Behat authoring)"). +24 scenarios that expose open bugs are not part of this suite; each comes back with the PR that fixes its bug. They cover: the www redirect of a site whose next label starts with `w`, and the redirect after re-enabling a site when the www check fails; the owner lookup of an alias that contains another alias; `update --ssl=inherit` skipping the parent check; the inherit error message dumping the parent site object; the self-signed certificate leaving out alias domains; `ssl-info --get-dns-records` on self-signed sites; `ssl-verify` on a valid Let's Encrypt site; `site reload` exiting 0 when `nginx -t` fails; `site clean` without flags on html sites; invalid `--proxy-cache` values; the removed EE v3 cache flags message; `update --php` on html sites failing late; and in backup/restore: `--list` and restore without backups, a failed restore blocking later backups, stale downloads reused by a restore, retention, invalid `no-of-backups`, hosts sharing a remote, files left over by a restore, the database password printed by a WordPress restore, and WordPress webroot files and symlinked uploads. -| ID | Bug | Scenario | Open PR | -|---|---|---|---| -| SC-1 | `ltrim($site, 'www.')` strips characters, not the prefix | redirect: A www site whose next label starts with w... | none | -| SC-2 | `get_parent_of_alias()` uses `LIKE '%alias%'` | create: The owner of an alias is reported correctly... | none | -| SC-3 | SSL can't be turned off on a `--ssl=self` site (fixed on `fix/proxy-cache-rollback-and-le-alias`, now a regular scenario); update stores `site_ssl_wildcard=0`, create 1 (still `@known-bug`) | ussl: two scenarios | none | -| SC-4 | `update --ssl=inherit` skips the parent check | ussl: Inheriting SSL through update requires a wildcard parent | none | -| SC-5 | inherit error message built with `var_dump()` | ssl: A failed inherit does not dump the parent site object | none | -| SC-6 | `site reload` exits 0 when `nginx -t` fails | rr: Reload fails when the nginx config test fails | none | -| SC-7 | `site clean` without flags always fails on html sites | clean: Cleaning without flags on a proxy-cached html site succeeds | none | -| SC-8 | EE v3 `--w3tc` message never shows | site: Removed EE v3 cache flags give a clear error | none | -| SC-9 | failed restore leaves `.lock` and blocks later backups | backup: A failed restore does not block later backups | #480 (flock; the file stays but no longer blocks) | -| SC-10 | retention keeps `no-of-backups + 1` | backup: Old backups are pruned down to no-of-backups | #482 | -| SC-11 | "No remote backups found." unreachable | backup: Listing the backups of a site without backups says so | #482 | -| SC-12 | `copy_site_db()` ignores failed export/import | none (needs fault injection) | none | -| SC-13 | `update --php` on html fails late, raw IOException, leaves `.backup/` | ussl: ...refused up front without leftovers | none | -| SC-14 | `delete_site()` `docker rm` fallback filters on labels EE never sets | none (dead fallback) | none | -| SC-15 | `update --ssl=custom` impossible (no `--ssl-key/--ssl-crt`) | ussl: Enable custom SSL on an existing site | #489 | -| SC-16 | `ssl-info --get-dns-records` treats self-signed (wildcard) sites as DNS-01 | ssl: ssl-info DNS records of a self-signed site | none | -| SC-17 | local clone/sync on a host without sites: "Unable to get site list" | none (needs a host with no site; clone: Cloning a site that does not exist fails keeps one site) | none | -| SC-18 | `update --proxy-cache=` accepted, non-`on` disables | pcache: An invalid proxy cache switch is rejected | none | - -## Known bugs fixed by v4.13.1 / v4.13.2 (Behat r2, 2026-10-19) - -These scenarios passed in the `known-bug` lane against develop after the v4.13.2 release and are regular now (the comment above each names the fix): WP-1 (#504), SC-3 update part (#505), SC-15 (#489), CACHE-1 proxy purge (276d118), CLONE-3/7/8 (8 scenarios, #510), SEC-1 (#509), SEC-3 (#513). The tables above and below predate this and still list them as open. `@known-bug` count after r2: 24. - -## Other known bugs and the open PRs that fix them - -| Scenario | Issue | Open PR | -|---|---|---| -| backup: Restoring by id ignores files left behind... | BKP-1 | #482 (f372636) | -| backup: Restoring a WordPress site does not print its database password | LOG-3 | #484 (success path only) | -| backup: A restored WordPress core passes checksum verification | WP-1 | none (local fix only) | -| create: Site names that are not valid hostnames are rejected... | SEC-1 | #495 is partial (self-signed sinks only); full fix local | -| le: SSL-8; ssl: SEC-3, SSL-4; clean: CACHE-1 pattern; redirect: MIG-15 | as named | none (#486/#487/#493/#495/#498/#502 only touch these paths) | - -PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages, #486 changes the SSL-2 warning text, #487 makes `ssl-verify` fail for non-LE sites, #501 changes the clone messages on an empty destination host ("Unable to find '' on ''"). #502 merged on 2026-09-25: AUTH-1 is a regular scenario since, and "Delete an alias domain" waits for the remaining alias to answer 200 (the recreated nginx container briefly drops all of the site's domains from the proxy; https://github.com/mrrobot47/site-command/actions/runs/36099145415). +PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages. diff --git a/features/backup-restore.feature b/features/backup-restore.feature index 73a748b4..432156fb 100644 --- a/features/backup-restore.feature +++ b/features/backup-restore.feature @@ -80,15 +80,6 @@ Feature: Backup and restore Then the command should succeed And STDOUT should match "/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" - # SC-11: list_remote_backups() checks empty() on explode()'s [''] before filtering, so "No remote backups found." is never printed. Fixed by PR #482. - @known-bug - Scenario: Listing the backups of a site without backups says so - Given the html site "bh-site-command-149.test" exists - And there are no remote backups for site "bh-site-command-149.test" - When I run 'bin/ee site backup bh-site-command-149.test --list' - Then the command should succeed - And STDOUT should contain "No remote backups found." - Scenario: A malformed --dash-auth is rejected Given the html site "bh-site-command-140.test" exists When I run 'bin/ee site backup bh-site-command-140.test --dash-auth=no-separator' @@ -110,56 +101,6 @@ Feature: Backup and restore Then the command should fail And STDERR should contain "Another backup/restore process is running. Please wait for it to complete." - # SC-9: pre_backup_restore_checks() writes .lock, but a failing restore exits without removing it (the shutdown handler only sees the site command's own site_data), so every later backup/restore of the site is refused. - # Asserted through the next backup, not the file: PR #480 (flock) fixes the blocking but keeps the file. - @known-bug - Scenario: A failed restore does not block later backups - Given the html site "bh-site-command-145.test" exists - And there are no remote backups for site "bh-site-command-145.test" - When I run 'bin/ee site restore bh-site-command-145.test' - Then the command should fail - When I run 'bin/ee site backup bh-site-command-145.test' - Then the command should succeed - And STDERR should not contain "Another backup/restore process is running." - - # BKP-1: a restore reuses files a previous restore left in the backup directory, even for another --id. Fixed by PR #482 (f372636). - @known-bug - Scenario: Restoring by id ignores files left behind by an earlier restore - Given the html site "bh-site-command-146.test" exists - And there are no remote backups for site "bh-site-command-146.test" - And I write "A" to the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" - When I run 'bin/ee site backup bh-site-command-146.test' - And I run 'bin/ee site backup bh-site-command-146.test --list' - And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "A_ID" - And I write "B" to the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" - And I wait for the next second - And I run 'bin/ee site backup bh-site-command-146.test' - And I run 'bin/ee site backup bh-site-command-146.test --list' - And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "B_ID" - And I download the remote backup "{B_ID}" of site "bh-site-command-146.test" into the local backup directory - And I run 'bin/ee site restore bh-site-command-146.test --id={A_ID}' - Then the command should succeed - And the file "{SITES_DIR}/bh-site-command-146.test/app/htdocs/bh-marker.txt" should contain "A" - - # SC-10: cleanup_old_backups() only prunes when count > no-of-backups + 1, so one backup too many is kept. Fixed by PR #482. - @known-bug - Scenario: Old backups are pruned down to no-of-backups - Given the EasyEngine config value "no-of-backups" is "3" - And the html site "bh-site-command-147.test" exists - And there are no remote backups for site "bh-site-command-147.test" - When I run 'bin/ee site backup bh-site-command-147.test' - Then the command should succeed - And I wait for the next second - When I run 'bin/ee site backup bh-site-command-147.test' - Then the command should succeed - And I wait for the next second - When I run 'bin/ee site backup bh-site-command-147.test' - Then the command should succeed - And I wait for the next second - When I run 'bin/ee site backup bh-site-command-147.test' - Then the command should succeed - And the remote backup storage should have at most the configured "no-of-backups" backups for site "bh-site-command-147.test" - @wp Scenario: Back up and restore a WordPress site Given "ee" is installed @@ -178,20 +119,6 @@ Feature: Backup and restore Then STDOUT should contain "bh-site-command-before" When I wait up to 60 seconds for "http://bh-site-command-148.test/" to return 200 - # LOG-3: restoring a WP site prints its DB password (wp config set output). PR #484 fixes the success path only. - @wp @known-bug - Scenario: Restoring a WordPress site does not print its database password - Given "ee" is installed - And the site "bh-site-command-148.test" exists, created with "--type=wp" - When I run 'bin/ee site info bh-site-command-148.test --format=json' - And I save the JSON field "db_password" in STDOUT as "DB_PASSWORD" - And I run 'bin/ee site backup bh-site-command-148.test' - Then the command should succeed - When I run 'bin/ee site restore bh-site-command-148.test' - Then the command should succeed - And STDOUT should not contain "{DB_PASSWORD}" - And STDERR should not contain "{DB_PASSWORD}" - # WP-1 (fixed in v4.13.1, site-command#504), regression check: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. @wp Scenario: A restored WordPress core passes checksum verification @@ -203,101 +130,3 @@ Feature: Backup and restore Then the command should succeed When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp core verify-checksums'" Then the command should succeed - - # BKP-7: `no-of-backups: 0` (or a typo) made the prune delete every backup, the one just uploaded included. - @known-bug - Scenario: An invalid no-of-backups keeps the backups and warns - Given the html site "bh-site-command-150.test" exists - And there are no remote backups for site "bh-site-command-150.test" - When I run 'sed "/^no-of-backups:/d" {EE_ROOT_DIR}/config/config.yml > {TMP}/config.yml && echo "no-of-backups: 0" >> {TMP}/config.yml' - And I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site backup bh-site-command-150.test' - Then the command should succeed - When I wait for the next second - And I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site backup bh-site-command-150.test' - Then the command should succeed - And STDERR should contain "Invalid no-of-backups value" - And the remote backup storage should have 2 backups for site "bh-site-command-150.test" - - # BKP-8: backups had no host identity, so hosts sharing rclone-path and a site name restored (and pruned) each other's. - @known-bug - Scenario: Hosts that share a remote keep their backups apart - Given the html site "bh-site-command-151.test" exists - And there are no remote backups for site "bh-site-command-151.test" - When I run 'sed "/^backup-host-id:/d" {EE_ROOT_DIR}/config/config.yml > {TMP}/a.yml && cp {TMP}/a.yml {TMP}/b.yml && echo "backup-host-id: bh-site-command-host-a" >> {TMP}/a.yml && echo "backup-host-id: bh-site-command-host-b" >> {TMP}/b.yml' - And I write "host-a" to the file "{SITES_DIR}/bh-site-command-151.test/app/htdocs/bh-marker.txt" - And I run 'EE_CONFIG_PATH={TMP}/a.yml bin/ee site backup bh-site-command-151.test' - Then the command should succeed - When I write "host-b" to the file "{SITES_DIR}/bh-site-command-151.test/app/htdocs/bh-marker.txt" - And I wait for the next second - And I run 'EE_CONFIG_PATH={TMP}/b.yml bin/ee site backup bh-site-command-151.test' - Then the command should succeed - When I run 'EE_CONFIG_PATH={TMP}/a.yml bin/ee site restore bh-site-command-151.test' - Then the command should succeed - And the file "{SITES_DIR}/bh-site-command-151.test/app/htdocs/bh-marker.txt" should contain "host-a" - When I run 'EE_CONFIG_PATH={TMP}/a.yml bin/ee site backup bh-site-command-151.test --list' - Then the command should succeed - And STDOUT should match "/^bh-site-command-host-b\/\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" - - # BKP-12: without any backup the restore failed with "Failed to get remote backup size." (local remotes). - @known-bug - Scenario: Restoring a site without backups says so - Given the html site "bh-site-command-155.test" exists - And there are no remote backups for site "bh-site-command-155.test" - When I run 'bin/ee site restore bh-site-command-155.test' - Then the command should fail - And STDERR should contain "No backups found for bh-site-command-155.test." - - # BKP-14: `rm -rf app/*` skipped dotfiles, so files that are not in the backup survived the restore. - @known-bug - Scenario: A restore removes files that are not in the backup, dotfiles too - Given the html site "bh-site-command-154.test" exists - And there are no remote backups for site "bh-site-command-154.test" - When I run 'bin/ee site backup bh-site-command-154.test' - Then the command should succeed - When I write "stale" to the file "{SITES_DIR}/bh-site-command-154.test/app/.bh-stale" - And I write "stale" to the file "{SITES_DIR}/bh-site-command-154.test/app/htdocs/.bh-stale" - And I run 'bin/ee site restore bh-site-command-154.test' - Then the command should succeed - And the file "{SITES_DIR}/bh-site-command-154.test/app/.bh-stale" should not exist - And the file "{SITES_DIR}/bh-site-command-154.test/app/htdocs/.bh-stale" should not exist - And the file "{SITES_DIR}/bh-site-command-154.test/app/htdocs/index.html" should exist - - # BKP-9: a WordPress backup held only wp-config.php, wp-content and the DB; other webroot files were lost. - @wp @known-bug - Scenario: A WordPress backup holds the files next to WordPress - Given "ee" is installed - And the site "bh-site-command-152.test" exists, created with "--type=wp" - And there are no remote backups for site "bh-site-command-152.test" - And I write "User-agent: bh" to the file "{SITES_DIR}/bh-site-command-152.test/app/htdocs/robots.txt" - And I write " bh-shared/uploads/bh.txt && chown -R www-data:www-data bh-shared && chown -h www-data:www-data wp-content/uploads' - Then the command should succeed - When I run 'bin/ee site backup bh-site-command-153.test' - Then the command should succeed - When I remove the file "{SITES_DIR}/bh-site-command-153.test/app/htdocs/bh-shared/uploads/bh.txt" - And I run 'bin/ee site restore bh-site-command-153.test' - Then the command should succeed - And the symlink "{SITES_DIR}/bh-site-command-153.test/app/htdocs/wp-content/uploads" should exist - And the file "{SITES_DIR}/bh-site-command-153.test/app/htdocs/bh-shared/uploads/bh.txt" should contain "bh-upload" - And the symlink "{SITES_DIR}/bh-site-command-153.test/app/htdocs/bh-shared/uploads/uploads" should not exist diff --git a/features/clean.feature b/features/clean.feature index 9ab9d939..0c02d754 100644 --- a/features/clean.feature +++ b/features/clean.feature @@ -45,12 +45,3 @@ Feature: Clean site caches 0 """ And the docker container "services_global-nginx-proxy_1" should be running - - # SC-7: without flags, clean always tries object and page caches, which html sites cannot have, so it fails even after clearing the proxy cache. No open PR. - @destructive @known-bug - Scenario: Cleaning without flags on a proxy-cached html site succeeds - Given the html site "bh-site-command-89.test" exists - When I run 'bin/ee site update bh-site-command-89.test --proxy-cache=on' with a timeout of 300 seconds - And I run 'bin/ee site clean bh-site-command-89.test' with a timeout of 120 seconds - Then the command should succeed - And STDOUT should contain "Proxy cache cleared for bh-site-command-89.test" diff --git a/features/create.feature b/features/create.feature index 3f31b275..0816e81c 100644 --- a/features/create.feature +++ b/features/create.feature @@ -137,15 +137,6 @@ Feature: Create html sites Then the command should succeed And the "site_type" field of site "bh-site-command-18.test" in the database should be "html" - # SC-2: get_parent_of_alias() finds the parent with LIKE '%alias%', so a longer alias of another site (ab.X contains b.X) is reported as the owner. No open PR. - @known-bug - Scenario: The owner of an alias is reported correctly when another alias contains it - Given the site "bh-site-command-20.test" exists, created with "--type=html --alias-domains=ab.bh-site-command-20.test" - And the site "bh-site-command-21.test" exists, created with "--type=html --alias-domains=b.bh-site-command-20.test" - When I run 'bin/ee site create b.bh-site-command-20.test' - Then the command should fail - And STDERR should contain "already exists as an alias domain for site: bh-site-command-21.test." - # SEC-1 (fixed in v4.13.2, site-command#509), regression check: site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits # `docker volume create ... bhsitecommand22;idtest_htdocs`, creating a volume "bhsitecommand22" and running "idtest_htdocs". Scenario: Site names that are not valid hostnames are rejected before anything runs diff --git a/features/le.feature b/features/le.feature index 715db294..b669c8a6 100644 --- a/features/le.feature +++ b/features/le.feature @@ -138,14 +138,6 @@ Feature: Let's Encrypt certificates And the directory "{PROXY_DIR}/acme-conf/var/{LE2}" should not exist When I wait up to 60 seconds for "http://{LE2}/" to return 200 - # SSL-8: ssl-verify checks only [site] for an HTTP-01 cert that includes www, rotates the acme-conf key and fails with "has not yet been authorized". - @known-bug - Scenario: ssl-verify of a valid Let's Encrypt site succeeds - Given the site "{LE1}" exists, created with "--type=html --ssl=le" - When I run 'bin/ee site ssl-verify {LE1}' - Then the command should succeed - And STDOUT should contain "SSL verification completed." - Scenario: Deleting a Let's Encrypt site revokes and removes its certificate Given the site "{LE1}" exists, created with "--type=html --ssl=le" When I run 'bin/ee site delete {LE1} --yes' diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index 5ff85f78..63146ea3 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -61,14 +61,6 @@ Feature: Proxy cache Then the command should fail And STDERR should contain "Site bh-site-command-85.test already has proxy cache: off" - # SC-18: any value other than "on" is accepted and runs the disable path (exit 0). - @known-bug - Scenario: An invalid proxy cache switch is rejected - Given the html site "bh-site-command-85.test" exists - When I run 'bin/ee site update bh-site-command-85.test --proxy-cache=maybe' - Then the command should fail - And STDERR should contain "Invalid value specified for 'proxy-cache'" - Scenario: Alias domains get proxy cache locations, also when added later Given the site "bh-site-command-86.test" exists, created with "--type=html --alias-domains=bh-site-command-86-a.test" When I run 'bin/ee site update bh-site-command-86.test --proxy-cache=on' diff --git a/features/redirect.feature b/features/redirect.feature index b0d2e475..cdb34faa 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -58,26 +58,3 @@ Feature: Site Redirection When I send a GET request to "http://www.bh-site-command-126.test/" Then the response code should be 301 And the response header "Location" should be "https://bh-site-command-126.test/" - - # SC-1: add_site_redirects()/check_www_or_non_www_domain()/get_cert_domains() use ltrim($site, 'www.'), which strips characters, not the prefix: www.w.x.test becomes x.test. No open PR. - @known-bug - Scenario: A www site whose next label starts with w redirects the right name - Given the hosts file contains an entry for "w.bh-site-command-125.test" - And the site "www.w.bh-site-command-125.test" does not exist - When I run 'bin/ee site create www.w.bh-site-command-125.test' - Then the command should succeed - And the file "{PROXY_DIR}/conf.d/www.w.bh-site-command-125.test-redirect.conf" should contain "server_name w.bh-site-command-125.test;" - When I wait up to 60 seconds for "http://w.bh-site-command-125.test/" to return 301 - Then the response header "Location" should be "http://www.w.bh-site-command-125.test/" - - # MIG-15: enable (also run by the image migration) re-checks www with a real DNS lookup and deletes the redirect when the check fails. - @known-bug - Scenario: Re-enabling a site keeps its www redirect when the www check fails - Given the hosts file contains an entry for "www.bh-site-command-127.test" - And the site "bh-site-command-127.test" does not exist - When I run 'bin/ee site create bh-site-command-127.test' - Then the file "{PROXY_DIR}/conf.d/bh-site-command-127.test-redirect.conf" should exist - When I remove the hosts entry for "www.bh-site-command-127.test" - And I run 'bin/ee site enable bh-site-command-127.test --force' - Then the command should succeed - And the file "{PROXY_DIR}/conf.d/bh-site-command-127.test-redirect.conf" should exist diff --git a/features/restart-reload.feature b/features/restart-reload.feature index e447ba5a..788b61a7 100644 --- a/features/restart-reload.feature +++ b/features/restart-reload.feature @@ -62,12 +62,3 @@ Feature: Restart and reload site services When I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" And I run 'bin/ee site reload bh-site-command-58.test' Then the command should succeed - - # SC-6: reload_services() ignores the exit code of `nginx -t && nginx -s reload`, so a failed reload exits 0. No open PR. - @known-bug - Scenario: Reload fails when the nginx config test fails - Given the html site "bh-site-command-57.test" exists - And I write "# Custom Nginx configuration can be put here\nbh_invalid_directive on;\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" - When I run 'bin/ee site reload bh-site-command-57.test' - And I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" - Then the command should fail diff --git a/features/site.feature b/features/site.feature index ca4ca83a..ea57232c 100644 --- a/features/site.feature +++ b/features/site.feature @@ -143,12 +143,3 @@ Feature: Site command basics When I run 'bin/ee site info bh-site-command-2.test bh-site-command-2.test' Then the command should fail And STDERR should contain "Too many positional arguments" - - # SC-8: convert_old_args_to_new_args() checks $args[1] === 'create', but $args[1] is the site name, so the EE v3 flag message never shows. No open PR. - @known-bug - Scenario: Removed EE v3 cache flags give a clear error - Given the site "bh-site-command-3.test" does not exist - When I run 'bin/ee site create bh-site-command-3.test --w3tc' - Then the command should fail - And STDERR should contain "flag/s is/are no longer supported in EE v4" - And the site "bh-site-command-3.test" should not exist diff --git a/features/ssl.feature b/features/ssl.feature index e8ee4fd0..c88166e5 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -65,14 +65,6 @@ Feature: Self-signed, custom and inherited SSL Then the command should succeed And the JSON field "0.warnings" in STDOUT should contain "This site does not use DNS-based (DNS-01) SSL challenge." - # SC-16: self-signed sites are stored as wildcard, so ssl-info treats them as DNS-01 and reports missing challenges. - @known-bug - Scenario: ssl-info DNS records of a self-signed site - Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" - When I run 'bin/ee site ssl-info bh-site-command-100.test --get-dns-records' - Then the command should succeed - And the JSON field "0.warnings" in STDOUT should contain "This site does not use DNS-based (DNS-01) SSL challenge." - Scenario Outline: ssl-renew only renews Let's Encrypt certificates Given the EasyEngine config value "le-mail" is set And the site "" exists, created with "" @@ -160,24 +152,8 @@ Feature: Self-signed, custom and inherited SSL And STDERR should contain "Cannot use --wildcard with --ssl=inherit" And the site "sub2.bh-site-command-105.test" should not exist - # SC-5: check_parent_site_certs() builds its exception message with var_dump(), which prints the parent site object to STDOUT. No open PR. - @known-bug - Scenario: A failed inherit does not dump the parent site object - Given the html site "bh-site-command-107.test" exists - And the site "sub.bh-site-command-107.test" does not exist - When I run 'bin/ee site create sub.bh-site-command-107.test --ssl=inherit' - Then the command should fail - And STDOUT should not contain "object(" - # SEC-3 (fixed in v4.13.2, site-command#513), regression check: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). Scenario: Self-signed site certificates verify against the EasyEngine root CA Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" When I run 'openssl verify -CAfile {PROXY_DIR}/self-signed-certs/rootCA.pem {PROXY_DIR}/certs/bh-site-command-100.test.crt' Then the command should succeed - - # SSL-4: self-signed certs cover only the site and *.site, alias domains get a mismatched cert. - @known-bug - Scenario: The self-signed certificate covers alias domains - Given the site "bh-site-command-102.test" exists, created with "--type=html --ssl=self --alias-domains=bh-site-command-102-a.test" - When I wait up to 60 seconds for "https://bh-site-command-102-a.test/" to return 200 - Then the TLS certificate served for "bh-site-command-102-a.test" should cover "bh-site-command-102-a.test" diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index 9197419b..db17f33a 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -118,26 +118,6 @@ Feature: Update SSL and PHP of html sites Then the command should succeed And the "site_ssl_wildcard" field of site "bh-site-command-75.test" in the database should be "1" - # SC-4: update --ssl=inherit skips check_parent_site_certs(), which create runs, so a site can "inherit" from a parent that does not exist. No open PR. - @known-bug - Scenario: Inheriting SSL through update requires a wildcard parent - Given the site "bh-site-command-78.test" does not exist - And the html site "sub.bh-site-command-78.test" exists - When I run 'bin/ee site update sub.bh-site-command-78.test --ssl=inherit' - Then the command should fail - And STDERR should contain "Unable to find existing site: bh-site-command-78.test" - And the "site_ssl" field of site "sub.bh-site-command-78.test" in the database should be "" - - # SC-13: update_php() never checks the site type; on html it mkdirs .backup/config/php- and then dies on mirror() of the - # missing config/php with Symfony's raw "The origin directory specified ... was not found." No open PR. - @known-bug - Scenario: Changing the PHP version of an html site is refused up front without leftovers - Given the html site "bh-site-command-79.test" exists - When I run 'bin/ee site update bh-site-command-79.test --php=8.3' - Then the command should fail - And STDERR should not contain "The origin directory specified" - And the directory "{SITES_DIR}/bh-site-command-79.test/.backup" should not exist - # SC-15 (fixed in v4.13.1, site-command#489), regression check: update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. Scenario: Enable custom SSL on an existing site Given the html site "bh-site-command-108.test" exists From c2134f51b26ae5aad5b2fc9cf636e7ccd96f80bd Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 12:46:20 +0530 Subject: [PATCH 36/37] docs(site-command): describe coverage gaps without workspace references --- .github/behat/pebble-setup.sh | 2 +- .github/workflows/behat.yml | 4 +-- features/COVERAGE.md | 50 +++++++++++++-------------- features/backup-restore.feature | 2 +- features/bootstrap/FeatureContext.php | 12 +++---- features/clean.feature | 2 +- features/clone-sync.feature | 18 +++++----- features/create.feature | 4 +-- features/delete.feature | 2 +- features/le.feature | 6 ++-- features/proxy-cache.feature | 2 +- features/ssl.feature | 2 +- features/update-alias.feature | 2 +- features/update-ssl-php.feature | 8 ++--- 14 files changed, 58 insertions(+), 58 deletions(-) diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh index 696ce1c9..33664693 100755 --- a/.github/behat/pebble-setup.sh +++ b/.github/behat/pebble-setup.sh @@ -14,7 +14,7 @@ set -euo pipefail PEBBLE_DIR=${PEBBLE_DIR:-/tmp/ee-pebble} -# 2.10+ also offers the token-less draft dns-persist-01 challenge, which EE's acmephp 1.3 client can't parse (SSL-10). +# 2.10+ also offers the token-less draft dns-persist-01 challenge, which EE's acmephp 1.3 client can't parse. PEBBLE_VERSION=${PEBBLE_VERSION:-2.9.0} PEBBLE_NET=${PEBBLE_NET:-ee-pebble} PEBBLE_BRIDGE=${PEBBLE_BRIDGE:-ee-pebble0} diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index ae9a7d24..c89b0cd0 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -136,8 +136,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 dedfb6b9..2f4ced49 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -1,6 +1,6 @@ # site-command Behat coverage -Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 179 scenarios (211 with outlines expanded): main lane 190, `@wp` 10, `@le` 11. Run it the CI way (`tools/run-behat.sh site-command`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 179 scenarios (211 with outlines expanded): main lane 190, `@wp` 10, `@le` 11. Run it the CI way (`.github/workflows/behat.yml`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. ## Tags @@ -10,7 +10,7 @@ Suite for `easyengine/site-command` (the `ee site` command and the html site typ | `@known-bug` | 0 | Scenarios that expose open bugs are kept outside this suite and come back with their fixes. The CI lanes still exclude the tag. | | `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | | `@remote` | 10 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | -| `@le` | 11 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 11 run against Pebble (`behat/ci/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`). SSL-2 and SSL-5/SSL-9 are regular scenarios since the fixes on `fix/proxy-cache-rollback-and-le-alias` (they fail on develop until those merge). | +| `@le` | 11 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 11 run against Pebble (`.github/behat/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`). The failed-alias rollback and the ACME cleanup on delete are regression checks for EasyEngine/site-command#505. | | `@le-real` | 0 | Needs the public LE service itself (rate limits, the real chain); never runs on CI. None so far: every `@le` scenario works on Pebble. | | `@wp` | 10 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | @@ -21,11 +21,11 @@ Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@wp'`. - Every site, alias, hosts entry and remote backup path contains `bh-site-command-`. `@AfterFeature` deletes only sites matching `(^|\.)bh-site-command-`, never other sites; once none is left it also removes suite-named leftovers of known bugs (proxy `certs/`, `acme-conf/certs|var/`, site dirs, volumes labelled with a suite name); `@remote` features also purge the listed `/bh-site-command-*` directories. `@AfterScenario` removes hosts entries the scenario added, the scenario's `{TMP}` dir under `/var/tmp`, and `{BACKUP_DIR}/bh-site-command-*` leftovers. - The file-writing, hosts, delete and rclone steps refuse names and paths without the prefix (or outside `{TMP}`). - HTTP steps resolve the site name to 127.0.0.1 (whitelisted, so no HTTP auth, and no /etc/hosts entry needed); `through the public IP` goes through `hostname -I` instead; `verified` uses real DNS and full TLS verification (LE only). Redirects are never followed. -- Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps, except `I wait for the next second` between back-to-back backups of one site (backup ids have one-second resolution, BKP-5). +- Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps, except `I wait for the next second` between back-to-back backups of one site (backup ids have one-second resolution). - Scenarios are independent: each one uses its own site or the idempotent `Given the html site "..." exists` / `exists, created with "..."`. The exception is `le.feature`, which shares three sites in file order to stay within the LE budget (each scenario still creates what it needs when run alone). -- Commands that can hang on a known bug (CACHE-1) run under `with a timeout of N seconds`. +- Commands that could hang (the proxy cache purge used to, without a terminal) run under `with a timeout of N seconds`. - Commands that could prompt (le-mail) are guarded by `Given the EasyEngine config value "le-mail" is set`. -- The failing LE alias (SSL-2) uses a `*.invalid` name, so no validation ever reaches a host outside `*.ee4-test.rt.gw`. +- The failing LE alias uses a `*.invalid` name, so no validation ever reaches a host outside `*.ee4-test.rt.gw`. ## Estimated runtime (4 CPU test host) @@ -63,7 +63,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | Unknown site (get_site_info) | covered | site: outlines over delete, refresh, ssl-verify, ssl, info, enable, disable, restart, update, share | | Disabled site refused | covered | en-dis: outline over restart, reload, update, refresh, share, backup, restore | | `site_cleanup` hooks (redis purge, share cleanup) | not covered | redis purge only fires for WP cache sites (site-type-wp suite); share cleanup needs a live ngrok share | -| Signal rollback (pcntl handlers) | not covered | dead code (MIG-9); needs killing ee mid-create | +| Signal rollback (pcntl handlers) | not covered | the handlers are never dispatched; needs killing ee mid-create | ### create (html) @@ -71,7 +71,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: |---|---|---| | `` happy path: files, .env, main.conf, user.conf, index.html, compose file, nginx container, 3 labelled volumes, network, hosts entry, DB row, HTTP 200 | covered | create: Create an html site; labels: both scenarios | | Duplicate site | covered | create: Creating a site that already exists fails and keeps the site | -| Docker volume / compose project collision with another site (PHPT-4) | covered | create: A site whose docker volumes and compose project collide with an existing site is refused (needs the `fix/site-name-collisions` branch of site-command, 2026-09-26, local) | +| Docker volume / compose project collision with another site | covered | create: A site whose docker volumes and compose project collide with an existing site is refused (EasyEngine/site-command#503) | | `--public-dir` | covered | create: Create a site with a custom public directory | | `--skip-status-check` | covered | create: Create a site without the status check | | `--alias-domains` (DB, server_name, VIRTUAL_HOST, HTTP on alias) | covered | create: Create a site with alias domains | @@ -83,7 +83,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | `--ssl` with an invalid value on create | not covered | `--ssl` is a flag on create, so no option validation; would only fail inside init_ssl after the containers are up | | www redirect written only when www resolves; https redirect | covered | redirect: all scenarios | | Rollback on failure (catch_clean) leaves no site/dir | covered | ssl: custom/inherit failure scenarios (site and dir absent) | -| Site name validation (SEC-1) | covered (known bug) | create: Site names that are not valid hostnames are rejected... | +| Site name validation | covered (regression check, EasyEngine/site-command#509) | create: Site names that are not valid hostnames are rejected... | | Global admin-tools auth init on create | not covered | belongs to auth-command / admin-tools-command suites | | macOS paths (dnsmasq, IS_DARWIN) | not covered | Linux test host | @@ -95,10 +95,10 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | Second delete / unknown site | covered | delete: same scenario; site: outline | | Prompt answered n / y | covered | delete: Answering no...; Answering yes... | | Disabled site | covered | delete: A disabled site can be deleted | -| SSL files removed (self, LE + revoke, leftover cert of a site without SSL, WPT-12) | covered | delete: Deleting a self-signed site removes its certificate; Deleting a site without SSL removes a leftover certificate; le: Deleting a Let's Encrypt site... | +| SSL files removed (self, LE + revoke, leftover cert of a site without SSL) | covered | delete: Deleting a self-signed site removes its certificate; Deleting a site without SSL removes a leftover certificate; le: Deleting a Let's Encrypt site... | | Proxy cache config removed | covered (@destructive) | pcache: Deleting a proxy-cached site removes its proxy config | | Alias domains freed | covered | delete: Deleting a site frees its alias domains | -| LE leftovers (SSL-5/SSL-9) | covered | le: Deleting a Let's Encrypt site leaves no ACME state behind | +| LE leftovers (www and alias ACME state) | covered | le: Deleting a Let's Encrypt site leaves no ACME state behind | | DB/user cleanup for external DB sites, cron rows | not covered | only php/wp sites have DBs and crons (site-type-*/cron-command suites) | ### list / info @@ -119,7 +119,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | disable (containers removed, volumes kept, DB, proxy stops serving), idempotent | covered | en-dis: Disable a site; Disabling a disabled site is harmless | | enable, already enabled, `--force`, `--refresh`, `--verify` | covered | en-dis: Enable...; Enabling an enabled site fails; Force-enable...; Enable with --refresh...; Enable with verification... | | `refresh` subcommand | covered | en-dis: refresh is a shortcut for enable --refresh | -| enable/disable manage the www redirect | covered | redirect: Disable removes the redirect and enable restores it; known bug MIG-15 | +| enable/disable manage the www redirect | covered | redirect: Disable removes the redirect and enable restores it; not covered yet: re-enabling drops the www redirect when the www check fails (its scenario comes back with the fix) | | enable post-configuration of postfix / admin-tools / mailhog | not covered | html sites have none (site-type-*, admin-tools, mailhog suites) | | restart (no flag, `--nginx`, `--all`), unknown service flag | covered | rr: Restart the site containers (outline); Restart rejects services... | | restart refuses a broken nginx config | covered | rr: Restart refuses a broken nginx config and keeps the site serving | @@ -127,7 +127,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | reload with a broken config | not covered yet | reload exits 0 when `nginx -t` fails (its scenario comes back with the fix) | | `custom-compose` config branch of restart/enable | not covered | needs a custom compose setup (config change) | | clean: no flags, `--object`, `--page`, `--proxy` without proxy cache | covered | clean: first four scenarios | -| clean `--proxy` with proxy cache | covered (@destructive, known bug CACHE-1 pattern) | clean: Cleaning the proxy cache empties it | +| clean `--proxy` with proxy cache | covered (@destructive, regression check for the purge without a terminal) | clean: Cleaning the proxy cache empties it | | clean without flags on a proxy-cached html site | not covered yet | clean without flags always fails on html sites (its scenario comes back with the fix) | | clean object/page caches with redis | not covered | only WP cache sites have them (site-type-wp suite) | @@ -140,20 +140,20 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | `--delete-alias-domains`, primary, not a subset | covered | alias: Delete an alias domain; The primary domain cannot be deleted; Deleting an alias the site does not have fails | | alias that is a site / alias of another site | covered | alias: Another site cannot be added...; An alias of another site cannot be added | | add + delete in one call, wildcard alias | covered | alias: Add and delete aliases in one call; A wildcard alias serves every subdomain | -| alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation (rolled back, SSL-2) | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; An alias that fails validation is rejected... | -| alias label validation (AUTH-1) | covered (regression; fixed by #502, merged 2026-09-25) | alias: Alias domains with a label starting with an underscore are rejected | +| alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation (rolled back) | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; An alias that fails validation is rejected... | +| alias label validation | covered (regression check, EasyEngine/site-command#502) | alias: Alias domains with a label starting with an underscore are rejected | | alias change on custom SSL (warning) | not covered | low value; same code path as self apart from the warning text | | `--ssl=self` on a plain site, twice, `--ssl=off`, off on plain | covered | ussl: first four scenarios | | `--wildcard` refusals, invalid `--ssl`, `--ssl=off --wildcard` | covered | ussl: Switching a normal site to wildcard SSL is refused; An unknown SSL type is rejected; --wildcard cannot be combined with --ssl=off | | `--ssl=inherit` via update | covered | ussl: Inherit SSL from a self-signed parent through update; not covered yet: update skips the wildcard parent check (its scenario comes back with the fix) | | `--ssl=le` via update | covered (@le) | le: Enable Let's Encrypt on an existing site | -| `--ssl=custom` via update | covered (known bug SC-15) | ussl: Enable custom SSL on an existing site (PR #489 adds `--ssl-key/--ssl-crt`) | -| off on a created self-signed site, wildcard flag consistency | covered (known bugs) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | +| `--ssl=custom` via update | covered (regression check, EasyEngine/site-command#489) | ussl: Enable custom SSL on an existing site | +| off on a created self-signed site, wildcard flag consistency | covered (regression checks, EasyEngine/site-command#505) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | | `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected; not covered yet: the refusal comes late, with a raw IOException and a leftover `.backup/` (its scenario comes back with the fix) | | `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | | `--proxy-cache=on/off`, twice, custom sizes, invalid sizes | covered (@destructive) | pcache: all but the invalid switch | | invalid `--proxy-cache` value | not covered yet | any value is accepted and anything but `on` disables the cache (its scenario comes back with the fix) | -| proxy cache for aliases (existing, newly added, deleted), failed alias sync leaves a loadable config (PRX-6) | covered (@destructive) | pcache: Alias domains get proxy cache locations...; Deleting an alias removes its proxy cache location; A failed proxy cache alias sync leaves a config nginx can load | +| proxy cache for aliases (existing, newly added, deleted), failed alias sync leaves a loadable config | covered (@destructive) | pcache: Alias domains get proxy cache locations...; Deleting an alias removes its proxy cache location; A failed proxy cache alias sync leaves a config nginx can load | | proxy cache `subdom` location (`*.site_location`) | not covered | WP subdom only (site-type-wp suite) | ### SSL commands @@ -170,7 +170,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | ssl-verify / `ssl` alias: unknown site | covered | site: outline | | ssl-verify on a valid LE site | not covered yet | ssl-verify checks only the site name of an HTTP-01 certificate that includes www and fails (its scenario comes back with the fix) | | ssl-verify completing a DNS-01 order, Cloudflare DNS solver | not covered | needs Cloudflare API credentials (not configured) | -| Self-signed root CA validity (SEC-3) | covered | ssl: Self-signed site certificates verify against the EasyEngine root CA | +| Self-signed root CA validity | covered (regression check, EasyEngine/site-command#513) | ssl: Self-signed site certificates verify against the EasyEngine root CA | | Self-signed certificate covering alias domains | not covered yet | the certificate leaves the alias domains out (its scenario comes back with the fix) | | Failed inherit error message | not covered yet | the message dumps the parent site object (its scenario comes back with the fix) | | SSL policy (`ssl-policy` config) | not covered | global config change (@destructive) with little behaviour beyond the template | @@ -193,19 +193,19 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | disabled site | covered | backup: A disabled site cannot be backed up...; en-dis outline | | `--dash-auth` format and ed-api-url check | covered | backup: A malformed --dash-auth is rejected; --dash-auth needs ed-api-url in the config | | `--dash-auth` success/failure callbacks, rollback of the uploaded backup | not covered | needs an EasyDash API endpoint (`ed-api-url`) | -| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup (held with flock(1), so it holds before and after PR #480); not covered yet: a failed restore leaves the lock file and blocks later backups (its scenario comes back with the fix) | +| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup (held with flock(1), so it holds with or without EasyEngine/site-command#480); not covered yet: a failed restore leaves the lock file and blocks later backups (its scenario comes back with the fix) | | stale download reuse | not covered yet | a restore by id reuses files an earlier restore left behind (its scenario comes back with the fix) | | retention (`no-of-backups`) | not covered yet | retention keeps one backup more than `no-of-backups` (its scenario comes back with the fix) | -| WP backup/restore (DB, wp-content, core download), WP-1 | covered (@wp) | backup: Back up and restore a WordPress site; A restored WordPress core passes checksum verification; not covered yet: the restore prints the database password (its scenario comes back with the fix) | +| WP backup/restore (DB, wp-content, core download) | covered (@wp; core checksums are a regression check, EasyEngine/site-command#504) | backup: Back up and restore a WordPress site; A restored WordPress core passes checksum verification; not covered yet: the restore prints the database password (its scenario comes back with the fix) | | custom docker-compose files in backups | not covered | needs a custom compose setup | -| disk-space checks, rclone/7z missing, apt-get auto-install (BKP-3), global backup lock wait | not covered | needs a full disk, uninstalled tools or a concurrent backup; BKP-3 needs a pending kernel upgrade | -| BKP-2 partial SQL dump in webroot | not covered | needs a failing mysqldump (fault injection) | +| disk-space checks, rclone/7z missing, apt-get auto-install, global backup lock wait | not covered | needs a full disk, uninstalled tools or a concurrent backup; the auto-install path needs a pending kernel upgrade | +| partial SQL dump left in the webroot after a failed dump | not covered | needs a failing mysqldump (fault injection) | | invalid `no-of-backups` | not covered yet | an invalid value deletes backups without a warning (its scenario comes back with the fix) | | per-host remote layout, hosts sharing `rclone-path` | not covered yet | hosts that share a remote mix their backups (its scenario comes back with the fix) | | restore without any backup | not covered yet | the restore doesn't say that there is no backup (its scenario comes back with the fix) | | files and dotfiles not in the backup | not covered yet | a restore keeps files that are not in the backup (its scenario comes back with the fix) | | WP webroot files beside WordPress, symlinked uploads | not covered yet | files next to WordPress are left out and symlinked uploads loop (their scenarios come back with the fixes) | -| restore rollback, disk-full extraction (BKP-6) | not covered | needs fault injection or a small filesystem; isolated harness `/var/tmp/fix-backup-test/harness` | +| restore rollback, disk-full extraction | not covered | needs fault injection or a small filesystem | ### clone / sync @@ -216,7 +216,7 @@ Legend: covered / partial / not covered (reason). Feature files are abbreviated: | Local WP clone (files, DB, search-replace), existing destination kept | covered (@wp) | clone: Clone a WordPress site on the same host; Cloning onto an existing site fails and keeps that site | | sync `--files`, `--db`, `--uploads` | covered (@wp) | clone: Sync only files / the database / uploads | | clone `--ssl=...`, `--wildcard`, cert copy, admin-tools/mailhog replication | not covered | same-host clone of an SSL WP site is possible but slow; LE options need the LE budget | -| Remote clone/sync over SSH, production guard (`env: production`), CLONE-1, CLONE-2 | not covered | needs a second SSH-reachable EE host (CLONE-1 needs a fake ssh shim); the production guard needs a global config change | +| Remote clone/sync over SSH, production guard (`env: production`), non-JSON site list output, inherit-site checks on the other host | not covered | needs a second SSH-reachable EE host (or a fake ssh shim); the production guard needs a global config change | ### Not in scope @@ -230,4 +230,4 @@ Counting the rows above as items: 124 items, 83 covered, 1 partial, 40 not cover 24 scenarios that expose open bugs are not part of this suite; each comes back with the PR that fixes its bug. They cover: the www redirect of a site whose next label starts with `w`, and the redirect after re-enabling a site when the www check fails; the owner lookup of an alias that contains another alias; `update --ssl=inherit` skipping the parent check; the inherit error message dumping the parent site object; the self-signed certificate leaving out alias domains; `ssl-info --get-dns-records` on self-signed sites; `ssl-verify` on a valid Let's Encrypt site; `site reload` exiting 0 when `nginx -t` fails; `site clean` without flags on html sites; invalid `--proxy-cache` values; the removed EE v3 cache flags message; `update --php` on html sites failing late; and in backup/restore: `--list` and restore without backups, a failed restore blocking later backups, stale downloads reused by a restore, retention, invalid `no-of-backups`, hosts sharing a remote, files left over by a restore, the database password printed by a WordPress restore, and WordPress webroot files and symlinked uploads. -PRs that change what passing scenarios assert, to revisit when they merge: #480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), #482 adds a `--list` "No remote backups found." line and new disk-space errors, #484/#483 add new restore/backup abort messages. +PRs that change what passing scenarios assert, to revisit when they merge: EasyEngine/site-command#480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), EasyEngine/site-command#482 adds a `--list` "No remote backups found." line and new disk-space errors, EasyEngine/site-command#484 and #483 add new restore/backup abort messages. diff --git a/features/backup-restore.feature b/features/backup-restore.feature index 432156fb..b2142139 100644 --- a/features/backup-restore.feature +++ b/features/backup-restore.feature @@ -119,7 +119,7 @@ Feature: Backup and restore Then STDOUT should contain "bh-site-command-before" When I wait up to 60 seconds for "http://bh-site-command-148.test/" to return 200 - # WP-1 (fixed in v4.13.1, site-command#504), regression check: `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. + # Regression check (fixed in v4.13.1, EasyEngine/site-command#504): `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. @wp Scenario: A restored WordPress core passes checksum verification Given "ee" is installed diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 2e186336..1c188de3 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -218,7 +218,7 @@ public function afterDelayOfSeconds( $time ) } /** - * Backup ids have one-second resolution (BKP-5): back-to-back backups need a second boundary between them. + * Backup ids have one-second resolution: back-to-back backups need a second boundary between them. * * @When I wait for the next second */ @@ -549,7 +549,7 @@ public static function cleanup(AfterFeatureScope $scope) /* ----- command execution and output ----- */ /** - * Runs a command, killing it after the given time (for commands with known hangs, e.g. CACHE-1). + * Runs a command, killing it after the given time (for commands that could hang, e.g. the proxy cache purge without a terminal). * * @When /^I run (['"])(.*)\1 with a timeout of (\d+) seconds$/ */ @@ -1223,7 +1223,7 @@ public function theTlsCertificateShouldCover( $host, $not, $domain ) } /** - * The proxy can serve its default certificate for a moment while it regenerates after an alias change (PRX-7). + * The proxy can serve its default certificate for a moment while it regenerates after an alias change. * * @Then /^the TLS certificate served for "([^"]*)" should cover "([^"]*)" within (\d+) seconds$/ */ @@ -1352,7 +1352,7 @@ public function theRemoteBackupShouldContainTheFile( $backup_id, $site, $file ) } /** - * Simulates a restore that died after downloading (BKP-1). + * Simulates a restore that died after downloading. * * @When /^I download the remote backup "([^"]*)" of site "([^"]*)" into the local backup directory$/ */ @@ -1622,7 +1622,7 @@ private function rclone_path() } /** - * Backup ids of a site: // (develop) and /// (per-host layout, BKP-8). + * Backup ids of a site: // (develop) and /// (per-host layout). */ private function remote_backups( $site ) { @@ -1712,7 +1712,7 @@ private static function remote_dirs( $base ) /** * Removes proxy certs/ACME state, site dirs and volumes named after this suite once none of its sites is left - * (known bugs such as SSL-2/SSL-5/SSL-9 or a failed create leave them behind), so runs do not pile up state. + * (known bugs or a failed create can leave them behind), so runs do not pile up state. */ private static function purge_own_leftovers() { diff --git a/features/clean.feature b/features/clean.feature index 0c02d754..b56de51b 100644 --- a/features/clean.feature +++ b/features/clean.feature @@ -27,7 +27,7 @@ Feature: Clean site caches Then the command should fail And STDERR should contain "Proxy cache is not enabled on site." - # CACHE-1 pattern (see SC-7, fixed in v4.13.2 by site-command 276d118), regression check: the proxy purge used `docker exec -it` and ignored the exit code (no TTY: failed silently; nested ptys: could hang), hence the timeout. + # Regression check (fixed in v4.13.2 by site-command 276d118): the proxy purge used `docker exec -it` and ignored the exit code (no TTY: failed silently; nested ptys: could hang), hence the timeout. @destructive Scenario: Cleaning the proxy cache empties it Given the html site "bh-site-command-89.test" exists diff --git a/features/clone-sync.feature b/features/clone-sync.feature index b581caac..d3f02d6d 100644 --- a/features/clone-sync.feature +++ b/features/clone-sync.feature @@ -25,37 +25,37 @@ Feature: Clone and sync sites Then the command should fail And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" - # CLONE-7 (fixed in v4.13.2, site-command#510), regression check: the same-site guard only knows the literal "localhost", so a loopback address syncs a site onto itself. + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the same-site guard only knows the literal "localhost", so a loopback address syncs a site onto itself. Scenario: A site cannot be synced onto itself through a loopback address When I run 'bin/ee site sync bh-site-command-150.test root@127.0.0.1:bh-site-command-150.test' Then the command should fail And STDERR should contain "Cannot copy 'bh-site-command-150.test' on 'localhost' to 'bh-site-command-150.test' on '127.0.0.1'" - # CLONE-7 (fixed in v4.13.2, site-command#510), regression check: the host part accepts spaces, so ssh options pass through. + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the host part accepts spaces, so ssh options pass through. Scenario: ssh options in a remote location are rejected When I run "bin/ee site clone 'root@bh-host-a -oProxyCommand=true:bh-site-command-150.test' bh-site-command-151.test" Then the command should fail And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" - # CLONE-7 (fixed in v4.13.2, site-command#510), regression check: `user@host:.` gives the site name "." instead of "same name as the other side". + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): `user@host:.` gives the site name "." instead of "same name as the other side". Scenario: A remote dot means the name of the other side When I run 'bin/ee site clone root@bh-host-a:. .' Then the command should fail And STDERR should contain "No sitename found in source and destination site." - # CLONE-3/CLONE-7 (fixed in v4.13.2, site-command#510), regression check: site names go into shell commands unchecked. + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): site names go into shell commands unchecked. Scenario: A site name with shell syntax is rejected When I run "bin/ee site clone 'bh-site-command-150.test;true' bh-site-command-151.test" Then the command should fail And STDERR should contain "Invalid site name: bh-site-command-150.test;true" - # CLONE-8 (fixed in v4.13.2, site-command#510), regression check: the sync help shows `--ssl=le --wildcard`, which sync doesn't accept. + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the sync help shows `--ssl=le --wildcard`, which sync doesn't accept. Scenario: The sync help has no SSL example When I run 'bin/ee help site sync' Then the command should succeed And STDOUT should not contain "--wildcard" - # With no site on the host the error is "Unable to get site list" instead (SC-17), so keep one site around. + # With no site on the host the error is "Unable to get site list" instead, so keep one site around. Scenario: Cloning a site that does not exist fails Given the html site "bh-site-command-154.test" exists And the site "bh-site-command-159.test" does not exist @@ -162,7 +162,7 @@ Feature: Clone and sync sites And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/wp-content/uploads/bh-upload.txt" should contain "bh-site-command-upload" And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-not-upload.txt" should not exist - # CLONE-8 (fixed in v4.13.2, site-command#510), regression check: `?? false && … ?? false` precedence lets a missing --ssl-crt through to is_file(null). + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): `?? false && … ?? false` precedence lets a missing --ssl-crt through to is_file(null). @wp Scenario: Cloning with --ssl=custom needs both certificate files Given the site "bh-site-command-157.test" exists, created with "--type=wp" @@ -173,7 +173,7 @@ Feature: Clone and sync sites And STDERR should not contain "is_file" And the site "bh-site-command-170.test" should not exist - # CLONE-3 (fixed in v4.13.2, site-command#510), regression check: the admin password goes unquoted into `ee site create`, so a space, ; or & breaks the create. + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the admin password goes unquoted into `ee site create`, so a space, ; or & breaks the create. @wp Scenario: Clone copies an admin password with spaces and shell operators Given the site "bh-site-command-171.test" exists, created with "--type=wp --admin-pass='bh pw;1&x'" @@ -183,7 +183,7 @@ Feature: Clone and sync sites And STDOUT should contain "Site cloned successfully." And the "app_admin_password" field of site "bh-site-command-172.test" in the database should be "bh pw;1&x" - # CLONE-3 (fixed in v4.13.2, site-command#510), regression check: values with $ or quotes would be expanded by site-type-wp's double-quoted `bash -c`, so clone leaves them out. + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): values with $ or quotes would be expanded by site-type-wp's double-quoted `bash -c`, so clone leaves them out. @wp Scenario: Clone does not pass an admin password the destination shell would expand Given the site "bh-site-command-174.test" exists, created with "--type=wp --admin-pass='bh$pw1xyz'" diff --git a/features/create.feature b/features/create.feature index 0816e81c..e06e9fd7 100644 --- a/features/create.feature +++ b/features/create.feature @@ -31,7 +31,7 @@ Feature: Create html sites Then the response code should be 200 And the response body should contain "Your html site with EasyEngine" - # PHPT-4: volume names drop "." and "-" and the compose project drops ".", so both names map to the volumes + # EasyEngine/site-command#503: volume names drop "." and "-" and the compose project drops ".", so both names map to the volumes # bhsitecommand160xtest_* and the compose project bh-site-command-160xtest. The second create is refused. Scenario: A site whose docker volumes and compose project collide with an existing site is refused Given the html site "bh-site-command-160.x.test" exists @@ -137,7 +137,7 @@ Feature: Create html sites Then the command should succeed And the "site_type" field of site "bh-site-command-18.test" in the database should be "html" - # SEC-1 (fixed in v4.13.2, site-command#509), regression check: site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits + # Regression check (fixed in v4.13.2, EasyEngine/site-command#509): site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits # `docker volume create ... bhsitecommand22;idtest_htdocs`, creating a volume "bhsitecommand22" and running "idtest_htdocs". Scenario: Site names that are not valid hostnames are rejected before anything runs Given the site "bh-site-command-22;id.test" does not exist diff --git a/features/delete.feature b/features/delete.feature index 5a9b8ba7..9c6b11e8 100644 --- a/features/delete.feature +++ b/features/delete.feature @@ -55,7 +55,7 @@ Feature: Delete sites And the file "{PROXY_DIR}/certs/bh-site-command-33.test.crt" should not exist And the file "{PROXY_DIR}/certs/bh-site-command-33.test.key" should not exist - # WPT-12: delete removes the site's certificate files even when site_ssl is empty (SSL turned off or lost), since nginx-proxy keeps matching them. + # Delete removes the site's certificate files even when site_ssl is empty (SSL turned off or lost), since nginx-proxy keeps matching them. Scenario: Deleting a site without SSL removes a leftover certificate Given the html site "bh-site-command-161.test" exists When I write "leftover" to the file "{PROXY_DIR}/certs/bh-site-command-161.test.crt" diff --git a/features/le.feature b/features/le.feature index b669c8a6..2cb3b88d 100644 --- a/features/le.feature +++ b/features/le.feature @@ -108,7 +108,7 @@ Feature: Let's Encrypt certificates Then the response code should be 200 And the TLS certificate served for "{LE2}" should be issued by "{LE_ISSUER}" - # SSL-2 (fixed on fix/proxy-cache-rollback-and-le-alias): a failed issuance used to only warn, save the alias with SSL "Not Enabled" and revoke the served cert. + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): a failed issuance used to only warn, save the alias with SSL "Not Enabled" and revoke the served cert. # The alias is under the reserved .invalid TLD: the order is refused outright, so nothing outside *.ee4-test.rt.gw is validated. Scenario: An alias that fails validation is rejected and the site keeps its certificate Given the site "{LE2}" exists, created with "--type=html --ssl=le" @@ -127,7 +127,7 @@ Feature: Let's Encrypt certificates Then the command should succeed And STDERR should not contain "Only Letsencrypt certificate renewal is supported." - # WPT-12: turning SSL off removes the certificate and ACME state (no issuance: runs on the site of the scenario above). + # Turning SSL off removes the certificate and ACME state (no issuance: runs on the site of the scenario above). Scenario: Turning Let's Encrypt off removes the certificate Given the site "{LE2}" exists, created with "--type=html --ssl=le" When I run 'bin/ee site update {LE2} --ssl=off' @@ -149,7 +149,7 @@ Feature: Let's Encrypt certificates And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should not exist And the directory "{PROXY_DIR}/acme-conf/var/{LE1}" should not exist - # SSL-5 / SSL-9 (fixed on fix/proxy-cache-rollback-and-le-alias): delete used to leave acme-conf/var/www. and alias dirs behind. Run after the delete scenario above. + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): delete used to leave acme-conf/var/www. and alias dirs behind. Run after the delete scenario above. Scenario: Deleting a Let's Encrypt site leaves no ACME state behind Given the site "{LE1}" does not exist Then the directory "{PROXY_DIR}/acme-conf/var/www.{LE1}" should not exist diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index 63146ea3..8790e735 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -82,7 +82,7 @@ Feature: Proxy cache And the file "{PROXY_DIR}/vhost.d/bh-site-command-162-b.test_location" should exist And the file "{PROXY_DIR}/vhost.d/bh-site-command-162.test_location" should exist - # PRX-6 (fixed on fix/proxy-cache-rollback-and-le-alias): a failed nginx -t during the alias sync used to remove the cache zone but keep the locations using it, so nginx never passed -t again and could not start after a restart. + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): a failed nginx -t during the alias sync used to remove the cache zone but keep the locations using it, so nginx never passed -t again and could not start after a restart. # The broken include is removed right after the update, before any assertion can fail. Scenario: A failed proxy cache alias sync leaves a config nginx can load Given the site "bh-site-command-163.test" exists, created with "--type=html --alias-domains=bh-site-command-163-a.test" diff --git a/features/ssl.feature b/features/ssl.feature index c88166e5..6775fdef 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -152,7 +152,7 @@ Feature: Self-signed, custom and inherited SSL And STDERR should contain "Cannot use --wildcard with --ssl=inherit" And the site "sub2.bh-site-command-105.test" should not exist - # SEC-3 (fixed in v4.13.2, site-command#513), regression check: the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). + # Regression check (fixed in v4.13.2, EasyEngine/site-command#513): the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). Scenario: Self-signed site certificates verify against the EasyEngine root CA Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" When I run 'openssl verify -CAfile {PROXY_DIR}/self-signed-certs/rootCA.pem {PROXY_DIR}/certs/bh-site-command-100.test.crt' diff --git a/features/update-alias.feature b/features/update-alias.feature index cc2558f1..543a5b56 100644 --- a/features/update-alias.feature +++ b/features/update-alias.feature @@ -88,7 +88,7 @@ Feature: Update alias domains When I wait up to 60 seconds for "https://bh-site-command-69.test/" to return 200 Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" within 30 seconds - # AUTH-1 regression: alias labels starting with "_" could clobber a site's _wildcard proxy files (fixed by #502, 38569df). + # Regression check (fixed by EasyEngine/site-command#502): alias labels starting with "_" could clobber a site's _wildcard proxy files. Scenario: Alias domains with a label starting with an underscore are rejected Given the html site "bh-site-command-68.test" exists When I run 'bin/ee site update bh-site-command-68.test --add-alias-domains=_wildcard.bh-site-command-68.test' diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature index db17f33a..e2ec1ee6 100644 --- a/features/update-ssl-php.feature +++ b/features/update-ssl-php.feature @@ -97,8 +97,8 @@ Feature: Update SSL and PHP of html sites Then the command should fail And STDERR should contain "Invalid value specified for 'php'" - # SC-3 (fixed on fix/proxy-cache-rollback-and-le-alias): --ssl=off was refused for sites stored with site_ssl_wildcard=1, which create --ssl=self sets. - # WPT-12: --ssl=off also removes the certificate (the www redirect's HTTPS block is only written when www resolves, so it isn't checked here). + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): --ssl=off was refused for sites stored with site_ssl_wildcard=1, which create --ssl=self sets. + # --ssl=off also removes the certificate (the www redirect's HTTPS block is only written when www resolves, so it isn't checked here). Scenario: SSL can be disabled on a site created with self-signed SSL Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" When I run 'bin/ee site update bh-site-command-71.test --ssl=off' @@ -111,14 +111,14 @@ Feature: Update SSL and PHP of html sites Then the command should succeed When I wait up to 60 seconds for "http://bh-site-command-71.test/" to return 200 - # SC-3 (fixed in v4.13.1, site-command#505), regression check: create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). Scenario: Enabling self-signed SSL through update records it as wildcard like create does Given the html site "bh-site-command-75.test" exists When I run 'bin/ee site update bh-site-command-75.test --ssl=self' Then the command should succeed And the "site_ssl_wildcard" field of site "bh-site-command-75.test" in the database should be "1" - # SC-15 (fixed in v4.13.1, site-command#489), regression check: update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. + # Regression check (fixed in v4.13.1, EasyEngine/site-command#489): update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. Scenario: Enable custom SSL on an existing site Given the html site "bh-site-command-108.test" exists When I run "openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout {TMP}/bh.key -out {TMP}/bh.crt -subj '/CN=bh-site-command-108.test/O=bh-site-command-custom' -addext 'subjectAltName=DNS:bh-site-command-108.test'" From cfbaa0db806146300db6324e4a3d72be2fb92623 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 13:32:04 +0530 Subject: [PATCH 37/37] test(site-command): match certificate names by their DNS SANs before the Common Name --- features/bootstrap/FeatureContext.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 1c188de3..1c3d8582 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1825,9 +1825,6 @@ private function certificate_names( $cert ) { $parsed = openssl_x509_parse( $cert ); $names = []; - if ( ! empty( $parsed['subject']['CN'] ) ) { - $names[] = $parsed['subject']['CN']; - } if ( ! empty( $parsed['extensions']['subjectAltName'] ) ) { foreach ( explode( ',', $parsed['extensions']['subjectAltName'] ) as $san ) { $san = trim( $san ); @@ -1836,6 +1833,10 @@ private function certificate_names( $cert ) } } } + // Clients ignore the Common Name when DNS SANs are present (RFC 6125). + if ( ! $names && ! empty( $parsed['subject']['CN'] ) ) { + $names[] = $parsed['subject']['CN']; + } return array_values( array_unique( $names ) ); }