From cd6a55291ddd84933565536dd476ca37f8635b2d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 16:00:48 +0530 Subject: [PATCH 01/36] test(site-type-wp): add behat context with shared steps Fix upstream steps that could never fail (unanchored I run pattern, inverted label count, compose v1 container names, swapped subsite arguments) and replace the delete-all cleanup with prefix-scoped cleanup of sites, docker objects, global DB objects, redis keys and hosts entries. Add generic steps for command results, site info, EE database, files, containers, networks, volumes, global DB/redis, HTTP and hosts. --- features/bootstrap/FeatureContext.php | 1347 +++++++++++++++++++++++-- 1 file changed, 1239 insertions(+), 108 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 94c34d8..8954c7f 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -14,7 +14,18 @@ define( 'EE', true ); define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); -define( 'EE_ROOT_DIR', '/opt/easyengine' ); + +$root_alias = '/opt'; +if ( 'Darwin' === php_uname( 's' ) ) { + $sys_name = posix_getpwuid(posix_geteuid())['name']; + if ( ! empty( $sys_name ) ) { + $root_alias = '/Users/' . $sys_name; + } +} +// Core's init-ee.php (pulled in by the includes above) may already have defined it. +if ( ! defined( 'EE_ROOT_DIR' ) ) { + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -47,6 +58,7 @@ use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\AfterFeatureScope; use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Behat\Hook\Scope\BeforeFeatureScope; use Behat\Gherkin\Node\PyStringNode, Behat\Gherkin\Node\TableNode; @@ -55,10 +67,37 @@ class FeatureContext implements Context { + /** + * Every site, docker object, DB name/user, hosts line and temp path created by this suite contains this marker. + * Cleanup only ever touches things that contain it, so suites of other packages are never affected. + */ + const TEST_PREFIX = 'bh-site-type-wp-'; + + /** Global DB/user names are derived from site names with `.` and `-` replaced by `_`. */ + const TEST_DB_PREFIX = 'bh_site_type_wp_'; + + /** Scratch directory for generated certificates and similar fixtures. */ + const TEST_TMP_DIR = '/var/tmp/bh-site-type-wp'; + + const GLOBAL_DB_CONTAINER = 'services_global-db_1'; + const GLOBAL_REDIS_CONTAINER = 'services_global-redis_1'; + public $command; public $webroot_path; public $ee_path; + /** @var array Results (EE\ProcessRun) of every command run in the current scenario. */ + public $commands = []; + + /** @var array|null Last HTTP response: code, headers, body, url. */ + private $response; + + /** @var array Lines appended to /etc/hosts by the current scenario. */ + private $added_hosts_lines = []; + + /** @var array Container start times noted by the current scenario, keyed by "site/service". */ + private $noted_start_times = []; + /** * Initializes context. */ @@ -102,11 +141,15 @@ public function isInstalled($command) } /** - * @When /I run '(.*)'|"(.*)"/ + * The upstream pattern `/I run '(.*)'|"(.*)"/` is unanchored, so it matched every step containing a double-quoted + * string and passed an empty command for the double-quoted form. Split into two anchored definitions. + * + * @When /^I run '(.*)'$/ + * @When /^I run "(.*)"$/ */ public function iRun($command) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = EE::launch( $this->replace_placeholders( $command ), false, true ); } /** @@ -114,9 +157,9 @@ public function iRun($command) */ public function iCreateSubsiteInOfType($subsite, $site ) { - $php_container = implode( explode( '.', $subsite ) ) . '_php_1'; - - EE::launch( "docker exec -it --user='www-data' $php_container sh -c 'wp site create --slug=$site'", false, true ); + // Upstream swapped the arguments and used `docker exec -it`, so it never created a subsite. + $this->run_wp_cli( $site, 'site create --slug=' . escapeshellarg( $subsite ) ); + $this->assert_last_command_succeeded(); } /** @@ -125,17 +168,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_command( $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_command( $this->commands[ $index - 1 ] )); } } @@ -148,7 +191,7 @@ public function afterDelayOfSeconds( $time ) } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * @Then /^(STDOUT|STDERR) should return exactly$/ */ public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) { @@ -162,7 +205,7 @@ 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) { @@ -170,14 +213,13 @@ public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStr $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if ( $expected_out !== trim($command_output)) { + if ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) should return something like$/ */ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) { @@ -189,9 +231,8 @@ 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) { @@ -208,11 +249,11 @@ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, */ public function theSiteShouldBeMultisite( $site, $type ) { - $result = EE::launch("cd " . EE_SITE_ROOT . "/$site && docker-compose exec --user='www-data' php sh -c 'wp config get SUBDOMAIN_INSTALL'", false, true ); + $result = $this->run_wp_cli( $site, 'config get SUBDOMAIN_INSTALL' ); -// if( $result->stderr ) { -// throw new Exception("Found error while executing command: $result->stderr"); -// } + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not read SUBDOMAIN_INSTALL:\n" . $this->describe_command( $result ) ); + } if($type === 'subdomain' && trim($result->stdout) !== '1') { throw new Exception("Expecting SUBDOMAIN_INSTALL to be 1. Got: $result->stdout"); @@ -230,11 +271,9 @@ public function theSiteShouldBeMultisite( $site, $type ) */ public function theDbEntryShouldBeRemoved($site) { - $out = shell_exec("sudo bin/ee site list"); - if (strpos($out, $site) !== false) { + if ( $this->site_row( $site ) ) { throw new Exception("$site db entry not been removed!"); } - } /** @@ -252,17 +291,11 @@ 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!"); + // Upstream looked up `__1`, which never matches compose v2 names, so it always passed. + foreach ( $table->getHash() as $container ) { + $id = $this->site_container_id( $site, $container['container'] ); + if ( '' !== $id ) { + throw new Exception( "The {$container['container']} container of $site has not been removed (id $id)." ); } } } @@ -282,7 +315,7 @@ public function theSiteShouldHaveWebroot($site) */ public function theSiteShouldHaveWordpress($site) { - if ( ! file_exists( EE_SITE_ROOT . '/' . $site . "/app/wp-config.php" ) ) { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site . '/app/wp-config.php' ) ) { throw new Exception("WordPress data not found!"); } } @@ -292,25 +325,8 @@ 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); - - $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); - } - } + $response = $this->http_request( 'http://' . $site . '/', null, true ); + $this->assert_headers_contain_rows( $response['headers'], $table ); } /** @@ -318,25 +334,9 @@ public function requestOnShouldContainFollowingHeaders($site, TableNode $table) */ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) { - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $host); - curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); - curl_setopt($ch, CURLOPT_NOBODY, true); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); - $headers = curl_exec($ch); - - curl_close($ch); - - $rows = $table->getHash(); - - foreach ($rows as $row) { - if (strpos($headers, $row['header']) === false) { - throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); - } - } + $url = preg_match( '#^https?://#', $host ) ? $host : 'http://' . $host . '/'; + $response = $this->http_request( $url, null, false, [ $header ] ); + $this->assert_headers_contain_rows( $response['headers'], $table ); } /** @@ -344,7 +344,6 @@ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, */ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) { - $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $host); curl_setopt($ch, CURLOPT_HEADER, true); @@ -353,17 +352,10 @@ 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); - $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); - } - } + $this->assert_headers_contain_rows( (string) $headers, $table ); } /** @@ -371,7 +363,10 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ */ public function cleanupScenario(AfterScenarioScope $scope) { - $this->commands = []; + $this->remove_added_hosts_lines(); + $this->commands = []; + $this->response = null; + $this->noted_start_times = []; chdir($this->ee_path); } @@ -380,41 +375,38 @@ public function cleanupScenario(AfterScenarioScope $scope) */ public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) { - $labels = $pyStringNode->getStrings(); - $label_string = implode(' -f label=',$labels); + // Upstream compared a string to an int with `===` and threw on equality, so it could never fail. + $filters = ''; + foreach ( $pyStringNode->getStrings() as $label ) { + $filters .= ' --filter ' . escapeshellarg( 'label=' . trim( $label ) ); + } - $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); - $running_containers = (int) trim($result->stdout); + $result = EE::launch( "docker ps -q $filters | wc -l", false, true ); + $running_containers = (int) trim( $result->stdout ); - if($expected_running_containers === $running_containers) { - throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception( "Expected $expected_running_containers running containers. Found: $running_containers" ); } } /** + * Removes leftovers of an aborted earlier run before a feature starts. + * + * @BeforeFeature + */ + public static function prepareFeature(BeforeFeatureScope $scope) + { + self::cleanup_test_artifacts(); + } + + /** + * Deletes only this suite's sites (upstream deleted a fixed list of generic names) and restores shared state. + * * @AfterFeature */ public static function cleanup(AfterFeatureScope $scope) { - $test_sites = [ - 'wp.test', - 'wpsubdom.test', - 'wpsubdir.test', - 'example.test', - 'www.example1.test', - 'example2.test', - 'www.example3.test', - 'labels.test', - 'wpcache.test', - ]; - - $result = EE::launch( 'sudo bin/ee site list --format=text',false, true ); - $running_sites = explode( "\n", $result->stdout ); - $sites_to_delete = array_intersect( $test_sites, $running_sites ); - - foreach ( $sites_to_delete as $site ) { - exec("sudo bin/ee site delete $site --yes" ); - } + self::cleanup_test_artifacts(); if(file_exists('ee.phar')) { unlink('ee.phar'); @@ -423,4 +415,1143 @@ public static function cleanup(AfterFeatureScope $scope) unlink('ee-old.phar'); } } + + /* ------------------------------------------------------------------------------------------------------------ + * Shared generic steps. + * + * Generic steps meant to be shared across the EasyEngine package suites. Assertions operate on the LAST command + * run in the scenario (the upstream steps above only look at the first one) and print the actual output on failure. + * Placeholders usable in commands, paths and expected values: {EE_ROOT_DIR}, {SITES_DIR}, {TMP_DIR}, {DOCKER_BRIDGE_IP}. + * --------------------------------------------------------------------------------------------------------- */ + + /** + * @Given a WordPress site :site exists + */ + public function aWordpressSiteExists( $site ) + { + $this->aWordpressSiteExistsCreatedWith( $site, '' ); + } + + /** + * Creates the site only when it is missing, so scenarios stay independent but can reuse a site within a feature. + * + * @Given a WordPress site :site exists created with :flags + */ + public function aWordpressSiteExistsCreatedWith( $site, $flags ) + { + if ( $this->site_row( $site ) ) { + return; + } + $command = sprintf( 'bin/ee site create %s --type=wp %s', escapeshellarg( $site ), $this->replace_placeholders( $flags ) ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not create prerequisite site $site:\n" . $this->describe_command( $result ) ); + } + } + + /** + * Runs a command with the given working directory; a leading `bin/ee` is resolved against the EE checkout. + * + * @When From the directory :dir I run :command + */ + public function fromTheDirectoryIRun( $dir, $command ) + { + $command = $this->replace_placeholders( $command ); + if ( 0 === strpos( $command, 'bin/ee' ) ) { + $command = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ) . substr( $command, strlen( 'bin/ee' ) ); + } + $this->commands[] = EE::launch( 'cd ' . escapeshellarg( $this->replace_placeholders( $dir ) ) . ' && ' . $command, false, true ); + } + + /** + * @When I run WP-CLI :command on site :site + */ + public function iRunWpCliOnSite( $command, $site ) + { + $this->run_wp_cli( $site, $this->replace_placeholders( $command ) ); + } + + /** + * @When In the :service container of site :site I run :command + */ + public function inTheContainerOfSiteIRun( $service, $site, $command ) + { + $id = $this->site_container_id( $site, $service, false ); + if ( '' === $id ) { + throw new Exception( "No running $service container found for $site." ); + } + $this->commands[] = EE::launch( sprintf( 'docker exec %s sh -c %s', $id, escapeshellarg( $this->replace_placeholders( $command ) ) ), false, true ); + } + + /** + * @When In the docker container :name I run :command + */ + public function inTheDockerContainerIRun( $name, $command ) + { + $this->commands[] = EE::launch( sprintf( 'docker exec %s sh -c %s', escapeshellarg( $name ), escapeshellarg( $this->replace_placeholders( $command ) ) ), false, true ); + } + + /** + * @When I wait until :command succeeds within :seconds seconds + */ + public function iWaitUntilSucceedsWithin( $command, $seconds ) + { + $command = $this->replace_placeholders( $command ); + $deadline = time() + (int) $seconds; + do { + $result = EE::launch( $command, false, true ); + if ( 0 === $result->return_code ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "`$command` did not succeed within $seconds seconds. Last attempt:\n" . $this->describe_command( $result ) ); + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $this->assert_last_command_succeeded(); + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $result = $this->last_command(); + if ( 0 === $result->return_code ) { + throw new Exception( "Expected a non-zero exit code.\n" . $this->describe_command( $result ) ); + } + } + + /** + * Every non-empty line of the PyString must appear in the stream (order-insensitive substring match). + * + * @Then /^(STDOUT|STDERR) should contain:$/ + */ + public function streamShouldContain( $stream, PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( $stream ), $expected, true, $stream ); + } + + /** + * @Then /^(STDOUT|STDERR) should not contain:$/ + */ + public function streamShouldNotContain( $stream, PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( $stream ), $expected, false, $stream ); + } + + /** + * STDOUT and STDERR combined; use it where EE's choice of stream is not what is being tested. + * + * @Then the output should contain: + */ + public function theOutputShouldContain( PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( 'BOTH' ), $expected, true, 'output' ); + } + + /** + * @Then the output should not contain: + */ + public function theOutputShouldNotContain( PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( 'BOTH' ), $expected, false, 'output' ); + } + + /** + * @Then STDOUT should be valid json + */ + public function stdoutShouldBeValidJson() + { + json_decode( trim( $this->last_command()->stdout ) ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + throw new Exception( 'STDOUT is not valid JSON (' . json_last_error_msg() . "):\n" . $this->last_command()->stdout ); + } + } + + /** + * @Then STDOUT should match :regex + */ + public function stdoutShouldMatch( $regex ) + { + if ( ! preg_match( $regex, $this->last_command()->stdout ) ) { + throw new Exception( "STDOUT does not match $regex:\n" . $this->last_command()->stdout ); + } + } + + /** + * @Then the site :site should exist + */ + public function theSiteShouldExist( $site ) + { + if ( ! $this->site_row( $site ) ) { + throw new Exception( "Site $site has no row in the EE database." ); + } + } + + /** + * @Then the site :site should not exist + */ + public function theSiteShouldNotExist( $site ) + { + if ( $this->site_row( $site ) ) { + throw new Exception( "Site $site still has a row in the EE database." ); + } + } + + /** + * @Then the site :site should be enabled + */ + public function theSiteShouldBeEnabled( $site ) + { + $this->assert_site_field( $site, 'site_enabled', '1' ); + } + + /** + * @Then the site :site should be disabled + */ + public function theSiteShouldBeDisabled( $site ) + { + $this->assert_site_field( $site, 'site_enabled', '0' ); + } + + /** + * Compares `ee site info --format=json` fields. Values: literal, ``, `` or `/regex/`. + * + * @Then the site :site info should contain: + */ + public function theSiteInfoShouldContain( $site, TableNode $table ) + { + $result = EE::launch( 'bin/ee site info ' . escapeshellarg( $site ) . ' --format=json', false, true ); + $info = json_decode( trim( $result->stdout ), true ); + if ( ! is_array( $info ) ) { + throw new Exception( "Could not read site info of $site:\n" . $this->describe_command( $result ) ); + } + + $errors = []; + foreach ( $table->getHash() as $row ) { + $field = $row['field']; + if ( ! array_key_exists( $field, $info ) ) { + $errors[] = "field `$field` is missing"; + continue; + } + $actual = $this->scalar_to_string( $info[ $field ] ); + if ( ! $this->value_matches( $actual, $this->replace_placeholders( $row['value'] ) ) ) { + $errors[] = "`$field` is " . var_export( $actual, true ) . ', expected ' . $row['value']; + } + } + + if ( $errors ) { + throw new Exception( "Site info mismatch for $site:\n - " . implode( "\n - ", $errors ) . "\nFull info: " . trim( $result->stdout ) ); + } + } + + /** + * @Then the EE database table :table should have :count rows where :column is :value + */ + public function theEeDatabaseTableShouldHaveRowsWhere( $table, $count, $column, $value ) + { + $this->assert_identifier( $table ); + $this->assert_identifier( $column ); + $statement = $this->ee_db()->prepare( "SELECT COUNT(*) FROM $table WHERE $column = ?" ); + $statement->execute( [ $this->replace_placeholders( $value ) ] ); + $actual = (int) $statement->fetchColumn(); + if ( (int) $count !== $actual ) { + throw new Exception( "Expected $count rows in `$table` where $column = $value, found $actual." ); + } + } + + /** + * @Then the site :site should have the site meta :key set to :value + */ + public function theSiteShouldHaveTheSiteMetaSetTo( $site, $key, $value ) + { + $statement = $this->ee_db()->prepare( 'SELECT m.meta_value FROM site_meta m JOIN sites s ON s.id = m.site_id WHERE s.site_url = ? AND m.meta_key = ?' ); + $statement->execute( [ $site, $key ] ); + $values = $statement->fetchAll( PDO::FETCH_COLUMN ); + if ( 1 !== count( $values ) || ! $this->value_matches( (string) $values[0], $value ) ) { + throw new Exception( "Site meta `$key` of $site is " . var_export( $values, true ) . ", expected $value." ); + } + } + + /** + * @Then the file :path should exist + */ + public function theFileShouldExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + } + + /** + * @Then the file :path should not exist + */ + public function theFileShouldNotExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( file_exists( $path ) ) { + throw new Exception( "File $path exists." ); + } + } + + /** + * @Then the directory :path should exist + */ + public function theDirectoryShouldExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( ! is_dir( $path ) ) { + throw new Exception( "Directory $path does not exist." ); + } + } + + /** + * @Then the directory :path should not exist + */ + public function theDirectoryShouldNotExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( file_exists( $path ) ) { + throw new Exception( "Directory $path exists." ); + } + } + + /** + * @Then the file :path should contain: + */ + public function theFileShouldContain( $path, PyStringNode $expected ) + { + $this->assert_output_lines( $this->read_file( $path ), $expected, true, "file $path" ); + } + + /** + * @Then the file :path should not contain: + */ + public function theFileShouldNotContain( $path, PyStringNode $expected ) + { + $this->assert_output_lines( $this->read_file( $path ), $expected, false, "file $path" ); + } + + /** + * @Then the files :first and :second should be identical + */ + public function theFilesShouldBeIdentical( $first, $second ) + { + if ( $this->read_file( $first ) !== $this->read_file( $second ) ) { + throw new Exception( "$first and $second differ." ); + } + } + + /** + * @Given a self-signed certificate for :cn exists in :dir + */ + public function aSelfSignedCertificateForExistsIn( $cn, $dir ) + { + $dir = $this->replace_placeholders( $dir ); + if ( ! is_dir( $dir ) && ! mkdir( $dir, 0700, true ) ) { + throw new Exception( "Could not create $dir." ); + } + $command = sprintf( + 'openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj %s -addext %s -keyout %s -out %s', + escapeshellarg( '/CN=' . $cn ), + escapeshellarg( 'subjectAltName=DNS:' . $cn ), + escapeshellarg( "$dir/$cn.key" ), + escapeshellarg( "$dir/$cn.crt" ) + ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not generate a certificate:\n" . $this->describe_command( $result ) ); + } + } + + /** + * @Then the :service container of site :site should be running + */ + public function theContainerOfSiteShouldBeRunning( $service, $site ) + { + $id = $this->site_container_id( $site, $service ); + if ( '' === $id ) { + throw new Exception( "No $service container exists for $site." ); + } + $state = trim( EE::launch( "docker inspect -f '{{.State.Status}}' $id", false, true )->stdout ); + if ( 'running' !== $state ) { + throw new Exception( "The $service container of $site is $state, expected running." ); + } + } + + /** + * @Then the :service container of site :site should not exist + */ + public function theContainerOfSiteShouldNotExist( $service, $site ) + { + $id = $this->site_container_id( $site, $service ); + if ( '' !== $id ) { + throw new Exception( "The $service container of $site exists (id $id)." ); + } + } + + /** + * @Then the site :site should have :count running containers + */ + public function theSiteShouldHaveRunningContainers( $site, $count ) + { + $result = EE::launch( 'docker ps --format "{{.Names}}" --filter ' . escapeshellarg( "label=io.easyengine.site=$site" ), false, true ); + $names = array_filter( explode( "\n", trim( $result->stdout ) ) ); + if ( (int) $count !== count( $names ) ) { + throw new Exception( "Expected $count running containers for $site, found " . count( $names ) . ': ' . implode( ', ', $names ) ); + } + } + + /** + * @Then the :service container of site :site should use image :image + */ + public function theContainerOfSiteShouldUseImage( $service, $site, $image ) + { + $actual = $this->inspect_site_container( $site, $service, '{{.Config.Image}}' ); + if ( 0 !== strpos( $actual, $image ) ) { + throw new Exception( "The $service container of $site uses image $actual, expected $image*." ); + } + } + + /** + * @Then the :service container of site :site should have restart policy :policy + */ + public function theContainerOfSiteShouldHaveRestartPolicy( $service, $site, $policy ) + { + $actual = $this->inspect_site_container( $site, $service, '{{.HostConfig.RestartPolicy.Name}}' ); + if ( $policy !== $actual ) { + throw new Exception( "The $service container of $site has restart policy '$actual', expected '$policy'." ); + } + } + + /** + * @Then the :service container of site :site should be connected to network :network + */ + public function theContainerOfSiteShouldBeConnectedToNetwork( $service, $site, $network ) + { + $networks = $this->container_networks( $site, $service ); + if ( ! in_array( $network, $networks, true ) ) { + throw new Exception( "The $service container of $site is not on $network; networks: " . implode( ', ', $networks ) ); + } + } + + /** + * @Then the :service container of site :site should not be connected to network :network + */ + public function theContainerOfSiteShouldNotBeConnectedToNetwork( $service, $site, $network ) + { + $networks = $this->container_networks( $site, $service ); + if ( in_array( $network, $networks, true ) ) { + throw new Exception( "The $service container of $site is connected to $network." ); + } + } + + /** + * @Given I note the start time of the :service container of site :site + */ + public function iNoteTheStartTimeOfTheContainerOfSite( $service, $site ) + { + $this->noted_start_times[ "$site/$service" ] = $this->inspect_site_container( $site, $service, '{{.State.StartedAt}}' ); + } + + /** + * @Then the :service container of site :site should have been restarted + */ + public function theContainerOfSiteShouldHaveBeenRestarted( $service, $site ) + { + if ( ! isset( $this->noted_start_times[ "$site/$service" ] ) ) { + throw new Exception( "No start time was noted for the $service container of $site." ); + } + $now = $this->inspect_site_container( $site, $service, '{{.State.StartedAt}}' ); + if ( $now === $this->noted_start_times[ "$site/$service" ] ) { + throw new Exception( "The $service container of $site was not restarted (started at $now)." ); + } + } + + /** + * @Then the docker network :network should exist + */ + public function theDockerNetworkShouldExist( $network ) + { + if ( 0 !== EE::launch( 'docker network inspect ' . escapeshellarg( $network ), false, true )->return_code ) { + throw new Exception( "Docker network $network does not exist." ); + } + } + + /** + * @Then the docker network :network should not exist + */ + public function theDockerNetworkShouldNotExist( $network ) + { + if ( 0 === EE::launch( 'docker network inspect ' . escapeshellarg( $network ), false, true )->return_code ) { + throw new Exception( "Docker network $network exists." ); + } + } + + /** + * @Then the site :site should have :count docker volumes + */ + public function theSiteShouldHaveDockerVolumes( $site, $count ) + { + $volumes = $this->site_volumes( $site ); + if ( (int) $count !== count( $volumes ) ) { + throw new Exception( "Expected $count volumes labelled for $site, found " . count( $volumes ) . ': ' . implode( ', ', $volumes ) ); + } + } + + /** + * Runs a throwaway MariaDB (the image EE uses) on the host network, bound to one address only. + * + * @Given a MariaDB server :name is listening on :address port :port with root password :password + */ + public function aMariadbServerIsListening( $name, $address, $port, $password ) + { + $this->assert_test_name( $name ); + $address = $this->replace_placeholders( $address ); + EE::launch( 'docker rm -f -v ' . escapeshellarg( $name ), false, true ); + + $versions = json_decode( file_get_contents( EE_ROOT . '/img-versions.json' ), true ); + $command = sprintf( + 'docker run -d --name %s --network host -e MYSQL_ROOT_PASSWORD=%s easyengine/mariadb:%s --port=%d --bind-address=%s', + escapeshellarg( $name ), + escapeshellarg( $password ), + escapeshellarg( $versions['easyengine/mariadb'] ), + (int) $port, + escapeshellarg( $address ) + ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not start $name:\n" . $this->describe_command( $result ) ); + } + + $this->iWaitUntilSucceedsWithin( + sprintf( 'docker exec %s mysql -h%s -P%d -uroot -p%s -e "SELECT 1"', escapeshellarg( $name ), escapeshellarg( $address ), (int) $port, escapeshellarg( $password ) ), + 120 + ); + } + + /** + * @Then the global database should have a database named :name + */ + public function theGlobalDatabaseShouldHaveADatabaseNamed( $name ) + { + if ( ! in_array( $name, $this->global_db_lines( 'SHOW DATABASES' ), true ) ) { + throw new Exception( "The global database has no database named $name." ); + } + } + + /** + * @Then the global database should not have a database named :name + */ + public function theGlobalDatabaseShouldNotHaveADatabaseNamed( $name ) + { + if ( in_array( $name, $this->global_db_lines( 'SHOW DATABASES' ), true ) ) { + throw new Exception( "The global database still has a database named $name." ); + } + } + + /** + * @Then the global database should have a user named :user + */ + public function theGlobalDatabaseShouldHaveAUserNamed( $user ) + { + if ( ! in_array( $user, $this->global_db_lines( 'SELECT User FROM mysql.user' ), true ) ) { + throw new Exception( "The global database has no user named $user." ); + } + } + + /** + * @Then the global database should not have a user named :user + */ + public function theGlobalDatabaseShouldNotHaveAUserNamed( $user ) + { + if ( in_array( $user, $this->global_db_lines( 'SELECT User FROM mysql.user' ), true ) ) { + throw new Exception( "The global database still has a user named $user." ); + } + } + + /** + * @Then the global database :database should have a table named :table + */ + public function theGlobalDatabaseShouldHaveATableNamed( $database, $table ) + { + $tables = $this->global_db_lines( sprintf( 'SHOW TABLES FROM `%s`', str_replace( '`', '', $database ) ) ); + if ( ! in_array( $table, $tables, true ) ) { + throw new Exception( "Database $database has no table $table; tables: " . implode( ', ', $tables ) ); + } + } + + /** + * The DB user of a site is random unless --dbuser was passed, so it is read from the EE database. + * + * @Then the global database should have the user of site :site + */ + public function theGlobalDatabaseShouldHaveTheUserOfSite( $site ) + { + $this->theGlobalDatabaseShouldHaveAUserNamed( $this->site_field( $site, 'db_user' ) ); + } + + /** + * @Given I store the key :key in the global redis + */ + public function iStoreTheKeyInTheGlobalRedis( $key ) + { + $result = EE::launch( sprintf( 'docker exec %s redis-cli SET %s bh-value', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( $key ) ), false, true ); + if ( 0 !== $result->return_code || false === strpos( $result->stdout, 'OK' ) ) { + throw new Exception( "Could not store $key in global redis:\n" . $this->describe_command( $result ) ); + } + } + + /** + * @Then the global redis should have keys matching :pattern + */ + public function theGlobalRedisShouldHaveKeysMatching( $pattern ) + { + if ( ! $this->global_redis_keys( $pattern ) ) { + throw new Exception( "Global redis has no key matching $pattern." ); + } + } + + /** + * @Then the global redis should not have keys matching :pattern + */ + public function theGlobalRedisShouldNotHaveKeysMatching( $pattern ) + { + $keys = $this->global_redis_keys( $pattern ); + if ( $keys ) { + throw new Exception( "Global redis still has keys matching $pattern: " . implode( ', ', array_slice( $keys, 0, 10 ) ) ); + } + } + + /** + * Requests go to 127.0.0.1 (the host part of the URL is resolved there), so no /etc/hosts entry is needed. + * + * @When I send a GET request to :url + */ + public function iSendAGetRequestTo( $url ) + { + $this->http_request( $this->replace_placeholders( $url ), null, true ); + } + + /** + * @Then a GET request to :url should return :code + */ + public function aGetRequestToShouldReturn( $url, $code ) + { + $this->assert_response_code( $this->http_request( $this->replace_placeholders( $url ), null, true ), $code ); + } + + /** + * @Then a GET request to :url with Host :host should return :code + */ + public function aGetRequestToWithHostShouldReturn( $url, $host, $code ) + { + $this->assert_response_code( $this->http_request( $this->replace_placeholders( $url ), $host, false ), $code ); + } + + /** + * @Then a GET request to :url should return :code within :seconds seconds + */ + public function aGetRequestToShouldReturnWithin( $url, $code, $seconds ) + { + $url = $this->replace_placeholders( $url ); + $deadline = time() + (int) $seconds; + do { + $response = $this->http_request( $url, null, true ); + if ( (int) $code === $response['code'] ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + $this->assert_response_code( $response, $code ); + } + + /** + * @Then a GET request to :url should not return :code + */ + public function aGetRequestToShouldNotReturn( $url, $code ) + { + $response = $this->http_request( $this->replace_placeholders( $url ), null, true ); + if ( (int) $code === $response['code'] ) { + throw new Exception( "GET $url returned $code.\n" . $response['headers'] ); + } + } + + /** + * Polls until a response carries the header line, e.g. `X-SRCache-Fetch-Status: HIT`. + * + * @Then a GET request to :url should have header :header within :seconds seconds + */ + public function aGetRequestToShouldHaveHeaderWithin( $url, $header, $seconds ) + { + $url = $this->replace_placeholders( $url ); + $deadline = time() + (int) $seconds; + do { + $response = $this->http_request( $url, null, true ); + if ( $this->headers_contain( $response['headers'], $header ) ) { + return; + } + sleep( 1 ); + } while ( time() < $deadline ); + + throw new Exception( "GET $url never returned header `$header` within $seconds seconds. Last headers:\n" . $response['headers'] ); + } + + /** + * @Then the response should have header :header + */ + public function theResponseShouldHaveHeader( $header ) + { + $response = $this->last_response(); + if ( ! $this->headers_contain( $response['headers'], $header ) ) { + throw new Exception( "Header `$header` not found in:\n" . $response['headers'] ); + } + } + + /** + * @Then the response should not have header :name + */ + public function theResponseShouldNotHaveHeader( $name ) + { + $response = $this->last_response(); + if ( preg_match( '/^' . preg_quote( $name, '/' ) . '\s*:/mi', $response['headers'] ) ) { + throw new Exception( "Header `$name` found in:\n" . $response['headers'] ); + } + } + + /** + * @Then the response should redirect to :location + */ + public function theResponseShouldRedirectTo( $location ) + { + $response = $this->last_response(); + if ( ! in_array( $response['code'], [ 301, 302, 307, 308 ], true ) || ! preg_match( '/^Location:\s*' . preg_quote( $location, '/' ) . '\s*$/mi', $response['headers'] ) ) { + throw new Exception( "Expected a redirect to $location, got:\n" . $response['headers'] ); + } + } + + /** + * @Then the response body should contain :text + */ + public function theResponseBodyShouldContain( $text ) + { + $response = $this->last_response(); + if ( false === strpos( $response['body'], $text ) ) { + throw new Exception( "`$text` not found in the body of {$response['url']}:\n" . substr( $response['body'], 0, 2000 ) ); + } + } + + /** + * @Given I add a hosts entry for :host + */ + public function iAddAHostsEntryFor( $host ) + { + $this->assert_test_name( $host ); + $line = "127.0.0.1\t$host"; + if ( false === file_put_contents( '/etc/hosts', PHP_EOL . $line . PHP_EOL, FILE_APPEND ) ) { + throw new Exception( 'Could not write /etc/hosts.' ); + } + $this->added_hosts_lines[] = $line; + } + + /** + * @Then the hosts file should contain :host + */ + public function theHostsFileShouldContain( $host ) + { + if ( ! preg_match( '/\s' . preg_quote( $host, '/' ) . '$/m', file_get_contents( '/etc/hosts' ) ) ) { + throw new Exception( "/etc/hosts has no entry for $host." ); + } + } + + /** + * @Then the hosts file should not contain :host + */ + public function theHostsFileShouldNotContain( $host ) + { + if ( preg_match( '/\s' . preg_quote( $host, '/' ) . '$/m', file_get_contents( '/etc/hosts' ) ) ) { + throw new Exception( "/etc/hosts still has an entry for $host." ); + } + } + + /* ------------------------------------------------------------------------------------------------------------ + * Helpers. + * --------------------------------------------------------------------------------------------------------- */ + + private function replace_placeholders( $text ) { + $map = [ + '{EE_ROOT_DIR}' => EE_ROOT_DIR, + '{SITES_DIR}' => EE_SITE_ROOT, + '{TMP_DIR}' => self::TEST_TMP_DIR, + ]; + if ( false !== strpos( $text, '{DOCKER_BRIDGE_IP}' ) ) { + $map['{DOCKER_BRIDGE_IP}'] = self::docker_bridge_ip(); + } + + return strtr( $text, $map ); + } + + private static function docker_bridge_ip() { + $result = EE::launch( "docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'", false, true ); + $ip = trim( $result->stdout ); + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + throw new Exception( "Could not determine the docker bridge gateway:\n" . $result->stdout . $result->stderr ); + } + + return $ip; + } + + 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 assert_last_command_succeeded() { + $result = $this->last_command(); + if ( 0 !== $result->return_code ) { + throw new Exception( "Expected exit code 0.\n" . $this->describe_command( $result ) ); + } + } + + private function describe_command( $result ) { + return sprintf( "Command: %s\nExit code: %s\nSTDOUT:\n%s\nSTDERR:\n%s", $result->command, $result->return_code, $result->stdout, $result->stderr ); + } + + private function stream_output( $stream ) { + $result = $this->last_command(); + if ( 'STDOUT' === $stream ) { + $output = $result->stdout; + } elseif ( 'STDERR' === $stream ) { + $output = $result->stderr; + } else { + $output = $result->stdout . "\n" . $result->stderr; + } + + return str_replace( [ "\033[1;31m", "\033[0m", "\033[32m", "\033[33m", "\033[1;33m", "\033[1;32m" ], '', $output ); + } + + private function assert_output_lines( $haystack, PyStringNode $expected, $should_contain, $label ) { + foreach ( $expected->getStrings() as $line ) { + $line = $this->replace_placeholders( trim( $line ) ); + if ( '' === $line ) { + continue; + } + $found = false !== strpos( $haystack, $line ); + if ( $found !== $should_contain ) { + $context = empty( $this->commands ) ? '' : "\n\n" . $this->describe_command( $this->last_command() ); + throw new Exception( sprintf( "%s %s `%s`. Actual %s:\n%s%s", ucfirst( $label ), $should_contain ? 'does not contain' : 'contains', $line, $label, $haystack, 0 === strpos( $label, 'file' ) ? '' : $context ) ); + } + } + } + + private function read_file( $path ) { + $path = $this->replace_placeholders( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + + return file_get_contents( $path ); + } + + private function scalar_to_string( $value ) { + if ( is_bool( $value ) ) { + return $value ? '1' : '0'; + } + + return null === $value ? '' : (string) $value; + } + + private function value_matches( $actual, $expected ) { + if ( '' === $expected ) { + return '' === $actual || '0' === $actual; + } + if ( '' === $expected ) { + return '' !== $actual; + } + if ( strlen( $expected ) > 2 && '/' === $expected[0] && '/' === substr( $expected, -1 ) ) { + return (bool) preg_match( $expected, $actual ); + } + + return $expected === $actual; + } + + private function ee_db() { + $pdo = new PDO( 'sqlite:' . EE_ROOT_DIR . '/db/ee.sqlite' ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + return $pdo; + } + + private function assert_identifier( $name ) { + if ( ! preg_match( '/^[a-z_][a-z0-9_]*$/i', $name ) ) { + throw new Exception( "Invalid SQL identifier: $name" ); + } + } + + private function site_row( $site ) { + $statement = $this->ee_db()->prepare( 'SELECT * FROM sites WHERE site_url = ?' ); + $statement->execute( [ $site ] ); + + return $statement->fetch( PDO::FETCH_ASSOC ); + } + + private function site_field( $site, $field ) { + $row = $this->site_row( $site ); + if ( ! $row || ! array_key_exists( $field, $row ) ) { + throw new Exception( "Site $site or its field $field not found in the EE database." ); + } + + return $this->scalar_to_string( $row[ $field ] ); + } + + private function assert_site_field( $site, $field, $expected ) { + $actual = $this->site_field( $site, $field ); + if ( $expected !== $actual ) { + throw new Exception( "`$field` of $site is '$actual', expected '$expected'." ); + } + } + + private function run_wp_cli( $site, $command ) { + $command = sprintf( + 'cd %s && docker-compose exec -T --user=www-data php sh -c %s', + escapeshellarg( EE_SITE_ROOT . '/' . $site ), + escapeshellarg( 'wp ' . $command ) + ); + $result = EE::launch( $command, false, true ); + $this->commands[] = $result; + + return $result; + } + + private function site_container_id( $site, $service, $include_stopped = true ) { + $command = sprintf( + 'docker ps %s -q --filter %s --filter %s', + $include_stopped ? '-a' : '', + escapeshellarg( "label=io.easyengine.site=$site" ), + escapeshellarg( "label=com.docker.compose.service=$service" ) + ); + $ids = array_filter( explode( "\n", trim( EE::launch( $command, false, true )->stdout ) ) ); + + return $ids ? reset( $ids ) : ''; + } + + private function inspect_site_container( $site, $service, $format ) { + $id = $this->site_container_id( $site, $service ); + if ( '' === $id ) { + throw new Exception( "No $service container exists for $site." ); + } + + return trim( EE::launch( 'docker inspect -f ' . escapeshellarg( $format ) . " $id", false, true )->stdout ); + } + + private function container_networks( $site, $service ) { + $networks = json_decode( $this->inspect_site_container( $site, $service, '{{json .NetworkSettings.Networks}}' ), true ); + + return is_array( $networks ) ? array_keys( $networks ) : []; + } + + private static function site_volumes( $site ) { + $result = EE::launch( 'docker volume ls -q --filter ' . escapeshellarg( "label=io.easyengine.site=$site" ), false, true ); + + return array_values( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + } + + private static function global_db_query( $sql ) { + $script = 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B -e ' . escapeshellarg( $sql ); + + return EE::launch( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_DB_CONTAINER, escapeshellarg( $script ) ), false, true ); + } + + private function global_db_lines( $sql ) { + $result = self::global_db_query( $sql ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Global DB query failed:\n" . $this->describe_command( $result ) ); + } + + return array_map( 'trim', array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + } + + private function global_redis_keys( $pattern ) { + $result = EE::launch( sprintf( 'docker exec %s redis-cli --scan --pattern %s', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( $pattern ) ), false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not scan global redis:\n" . $this->describe_command( $result ) ); + } + + return array_values( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + } + + /** + * @param string $url Full URL. With $resolve the URL's host is pinned to 127.0.0.1 (keeps SNI correct). + * @param string|null $host Explicit Host header (for URLs pointing at an IP). + * @param bool $resolve Resolve the URL host to 127.0.0.1. + * @param array $headers Extra raw request headers. + */ + private function http_request( $url, $host = null, $resolve = true, $headers = [] ) { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $url ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 60 ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 ); + if ( $resolve ) { + $url_host = parse_url( $url, PHP_URL_HOST ); + $port = parse_url( $url, PHP_URL_PORT ) ?: ( 'https' === parse_url( $url, PHP_URL_SCHEME ) ? 443 : 80 ); + curl_setopt( $ch, CURLOPT_RESOLVE, [ "$url_host:$port:127.0.0.1" ] ); + } + if ( null !== $host ) { + $headers[] = 'Host: ' . $host; + } + if ( $headers ) { + curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); + } + $raw = curl_exec( $ch ); + $code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); + $header_size = (int) curl_getinfo( $ch, CURLINFO_HEADER_SIZE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + $this->response = [ + 'url' => $url, + 'code' => $code, + 'headers' => false === $raw ? "(request failed: $error)" : substr( $raw, 0, $header_size ), + 'body' => false === $raw ? '' : (string) substr( $raw, $header_size ), + ]; + + return $this->response; + } + + 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 assert_response_code( $response, $code ) { + if ( (int) $code !== $response['code'] ) { + throw new Exception( "GET {$response['url']} returned {$response['code']}, expected $code.\n" . $response['headers'] . substr( $response['body'], 0, 1000 ) ); + } + } + + private function headers_contain( $headers, $header ) { + return false !== stripos( $headers, $header ); + } + + private function assert_headers_contain_rows( $headers, TableNode $table ) { + foreach ( $table->getHash() as $row ) { + if ( ! $this->headers_contain( $headers, $row['header'] ) ) { + throw new Exception( 'Unable to find ' . $row['header'] . "\nActual output is : " . $headers ); + } + } + } + + private function assert_test_name( $name ) { + if ( false === strpos( $name, self::TEST_PREFIX ) ) { + throw new Exception( "Refusing to touch `$name`: test fixtures must contain " . self::TEST_PREFIX ); + } + } + + private function remove_added_hosts_lines() { + if ( empty( $this->added_hosts_lines ) ) { + return; + } + $lines = file( '/etc/hosts', FILE_IGNORE_NEW_LINES ); + foreach ( $this->added_hosts_lines as $added ) { + $index = array_search( $added, $lines, true ); + if ( false !== $index ) { + unset( $lines[ $index ] ); + } + } + file_put_contents( '/etc/hosts', implode( PHP_EOL, $lines ) . PHP_EOL ); + $this->added_hosts_lines = []; + } + + /** + * Deletes everything this suite may have left behind; each item is matched by the test prefix. + */ + private static function cleanup_test_artifacts() { + $ee = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ); + + $pdo = new PDO( 'sqlite:' . EE_ROOT_DIR . '/db/ee.sqlite' ); + $sites = $pdo->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ); + foreach ( $sites as $site ) { + if ( false !== strpos( $site, self::TEST_PREFIX ) ) { + EE::launch( "timeout 600 $ee site delete " . escapeshellarg( $site ) . ' --yes', false, true ); + } + } + + // Webroots left without a DB row (failed creates, fixtures). + foreach ( glob( EE_SITE_ROOT . '/*' . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $dir ) { + EE::launch( 'rm -rf ' . escapeshellarg( $dir ), false, true ); + } + + // Containers (external DB fixtures and orphans of failed creates), then volumes and networks. + $containers = EE::launch( 'docker ps -a --format "{{.ID}} {{.Names}} {{.Label \"io.easyengine.site\"}}"', false, true )->stdout; + foreach ( array_filter( explode( "\n", trim( $containers ) ) ) as $line ) { + if ( false !== strpos( $line, self::TEST_PREFIX ) || false !== strpos( $line, str_replace( '-', '', self::TEST_PREFIX ) ) ) { + EE::launch( 'docker rm -f -v ' . strtok( $line, ' ' ), false, true ); + } + } + $volumes = EE::launch( 'docker volume ls --filter label=org.label-schema.vendor=EasyEngine --format "{{.Name}} {{.Label \"io.easyengine.site\"}}"', false, true )->stdout; + foreach ( array_filter( explode( "\n", trim( $volumes ) ) ) as $line ) { + if ( false !== strpos( $line, self::TEST_PREFIX ) ) { + EE::launch( 'docker volume rm ' . escapeshellarg( strtok( $line, ' ' ) ), false, true ); + } + } + $networks = EE::launch( 'docker network ls --format "{{.Name}}"', false, true )->stdout; + foreach ( array_filter( explode( "\n", trim( $networks ) ) ) as $network ) { + if ( false !== strpos( $network, self::TEST_PREFIX ) ) { + EE::launch( 'docker network rm ' . escapeshellarg( $network ), false, true ); + } + } + + // Global DB databases and users (also leaked by failed creates). + $databases = self::global_db_query( 'SHOW DATABASES' ); + foreach ( array_filter( explode( "\n", trim( $databases->stdout ) ) ) as $database ) { + if ( false !== strpos( $database, self::TEST_DB_PREFIX ) ) { + self::global_db_query( sprintf( 'DROP DATABASE `%s`', $database ) ); + } + } + $users = self::global_db_query( 'SELECT User FROM mysql.user' ); + foreach ( array_filter( explode( "\n", trim( $users->stdout ) ) ) as $user ) { + if ( false !== strpos( $user, self::TEST_PREFIX ) || false !== strpos( $user, self::TEST_DB_PREFIX ) ) { + self::global_db_query( sprintf( "DROP USER '%s'@'%%'", str_replace( "'", '', $user ) ) ); + } + } + + // Global redis keys written by cache scenarios. + EE::launch( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( "redis-cli --scan --pattern '" . self::TEST_PREFIX . "*' | xargs -r redis-cli DEL" ) ), false, true ); + + // Hosts entries (EE's own and the ones added by scenarios). + $hosts = file( '/etc/hosts', FILE_IGNORE_NEW_LINES ); + if ( false !== $hosts ) { + $kept = array_filter( $hosts, function ( $line ) { + return false === strpos( $line, self::TEST_PREFIX ); + } ); + if ( count( $kept ) !== count( $hosts ) ) { + file_put_contents( '/etc/hosts', implode( PHP_EOL, $kept ) . PHP_EOL ); + } + } + + // Backups written by `ee site update --ssl` search-replace, fixtures and injection markers. + foreach ( glob( EE_ROOT_DIR . '/.backup/*' . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $dir ) { + EE::launch( 'rm -rf ' . escapeshellarg( $dir ), false, true ); + } + EE::launch( 'rm -rf ' . escapeshellarg( self::TEST_TMP_DIR ) . ' /root/bhsitetypewpsec1pwned', false, true ); + } } From 4330f86bac6474eb6a3778c124e8c60d8a6f5798 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 16:06:25 +0530 Subject: [PATCH 02/36] test(site-type-wp): cover site lifecycle, labels and redirects --- features/labels.feature | 30 ++- features/redirect.feature | 47 ++-- features/site.feature | 533 +++++++++++++++++++++++++++++++++----- 3 files changed, 522 insertions(+), 88 deletions(-) diff --git a/features/labels.feature b/features/labels.feature index f4cacf9..fa11248 100644 --- a/features/labels.feature +++ b/features/labels.feature @@ -1,9 +1,27 @@ -Feature: Container Labels +Feature: Container, volume and network labels of WordPress sites - - Scenario: All easyengine containers are tagged - Given I run "bin/ee site create labels.test --type=wp" - Then There should be 5 containers with labels + Scenario: All easyengine containers, volumes and the site network are tagged + Given a WordPress site 'bh-site-type-wp-labels.test' exists created with '--skip-install' + Then There should be 3 containers with labels """ - io.easyengine.site=labels.test + io.easyengine.site=bh-site-type-wp-labels.test """ + And the site 'bh-site-type-wp-labels.test' should have 8 docker volumes + When I run 'docker network inspect -f "{{json .Labels}}" bh-site-type-wp-labels.test' + Then STDOUT should contain: + """ + "io.easyengine.site":"bh-site-type-wp-labels.test" + "org.label-schema.vendor":"EasyEngine" + """ + When I run 'docker volume ls --filter label=io.easyengine.site=bh-site-type-wp-labels.test --filter label=org.label-schema.vendor=EasyEngine --format "{{.Name}}"' + Then STDOUT should contain: + """ + bhsitetypewplabelstest_htdocs + bhsitetypewplabelstest_config_nginx + bhsitetypewplabelstest_config_php + bhsitetypewplabelstest_log_nginx + bhsitetypewplabelstest_log_php + bhsitetypewplabelstest_data_postfix + bhsitetypewplabelstest_ssl_postfix + bhsitetypewplabelstest_config_postfix + """ diff --git a/features/redirect.feature b/features/redirect.feature index da76c45..df4a125 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,19 +1,36 @@ -Feature: Site Redirection +Feature: www / non-www redirection of WordPress sites + # The www check curls the other host name from the host, so it needs a hosts entry before the site is created. 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 --type=wp' - Then After delay of 5 seconds - And 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 I add a hosts entry for 'www.bh-site-type-wp-redir.test' + When I run 'bin/ee site create bh-site-type-wp-redir.test --type=wp --skip-install' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should exist + And a GET request to 'http://www.bh-site-type-wp-redir.test/' should return 301 within 30 seconds + And the response should redirect to 'http://bh-site-type-wp-redir.test/' + And Request on 'localhost' with header 'Host: www.bh-site-type-wp-redir.test' should contain following headers: + | header | + | HTTP/1.1 301 Moved Permanently | + | Location: http://bh-site-type-wp-redir.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 --type=wp' - Then After delay of 5 seconds - And 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 I add a hosts entry for 'bh-site-type-wp-redir2.test' + When I run 'bin/ee site create www.bh-site-type-wp-redir2.test --type=wp --skip-install' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/www.bh-site-type-wp-redir2.test-redirect.conf' should exist + And a GET request to 'http://bh-site-type-wp-redir2.test/' should return 301 within 30 seconds + And the response should redirect to 'http://www.bh-site-type-wp-redir2.test/' + + Scenario: No redirect config when the www host does not resolve + When I run 'bin/ee site create bh-site-type-wp-redir3.test --type=wp --skip-install' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir3.test-redirect.conf' should not exist + And a GET request to 'http://www.bh-site-type-wp-redir3.test/' should return 503 + + Scenario: Deleting the site removes its redirect config + Given I add a hosts entry for 'www.bh-site-type-wp-redir.test' + And a WordPress site 'bh-site-type-wp-redir.test' exists created with '--skip-install' + When I run 'bin/ee site delete bh-site-type-wp-redir.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should not exist + And a GET request to 'http://www.bh-site-type-wp-redir.test/' should return 503 within 30 seconds diff --git a/features/site.feature b/features/site.feature index f996305..acf3480 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1,4 +1,4 @@ -Feature: Site Command +Feature: WordPress site lifecycle (create, info, list, enable/disable, restart/reload, delete) Scenario: ee executable is command working correctly Given 'bin/ee' is installed @@ -17,71 +17,470 @@ Feature: Site Command usage: ee site """ - Scenario: Create wp site successfully - When I run 'bin/ee site create wp.test --type=wp' - Then After delay of 5 seconds - And The site 'wp.test' should have webroot - And The site 'wp.test' should have WordPress - And Request on 'wp.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: Create wp site successfully - When I run 'bin/ee site create wpcache.test --type=wp --cache' - Then After delay of 5 seconds - And The site 'wpcache.test' should have webroot - And The site 'wpcache.test' should have WordPress - And Request on 'wpcache.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: Create wpsubdir site successfully - When I run 'bin/ee site create wpsubdir.test --type=wp --mu=subdir' - And I create subsite '1' in 'wpsubdir.test' - Then After delay of 5 seconds - And The site 'wpsubdir.test' should have webroot - And The site 'wpsubdir.test' should have WordPress - And The site 'wpsubdir.test' should be 'subdir' multisite - And Request on 'wpsubdir.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: Create wpsubdom site successfully - When I run 'bin/ee site create wpsubdom.test --type=wp --mu=subdom' - And I create subsite '1' in 'wpsubdom.test' - Then After delay of 5 seconds - And The site 'wpsubdom.test' should have webroot - And The site 'wpsubdom.test' should have WordPress - And The site 'wpsubdom.test' should be 'subdomain' multisite - And Request on 'wpsubdom.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: List the sites + Scenario: Help of the wp site type documents every wp create flag + When I run 'bin/ee help site create --type=wp' + Then the command should succeed + And STDOUT should contain: + """ + Runs the standard WordPress Site installation. + [--cache] + [--vip] + [--mu=] + [--alias-domains=] + [--title=] + [--admin-user=<admin-user>] + [--admin-email=<admin-email>] + [--admin-pass=<admin-pass>] + [--allow-insecure-pass] + [--local-db] + [--with-local-redis] + [--public-dir] + [--php=<php-version>] + [--dbhost=<dbhost>] + [--dbprefix=<dbprefix>] + [--skip-check] + [--version=<version>] + [--skip-content] + [--skip-install] + [--skip-status-check] + [--proxy-cache=<on-or-off>] + [--force] + """ + + Scenario: Create without a site name prints the usage + When I run 'bin/ee site create --type=wp' + Then the command should fail + And the output should contain: + """ + usage: ee site create <site-name> + """ + + Scenario: Create a default WordPress site + When I run 'bin/ee site create bh-site-type-wp-basic.test --type=wp' + Then the command should succeed + And STDOUT should contain: + """ + Starting site creation. + Configuring project. + Creating WordPress site bh-site-type-wp-basic.test + Downloading and configuring WordPress. + Checking and verifying site-up status. + Installing WordPress site. + Success: http://bh-site-type-wp-basic.test has been created successfully! + Site entry created. + Creating cron entry + """ + And STDERR should not contain: + """ + Error: + """ + And the site 'bh-site-type-wp-basic.test' should exist + And the site 'bh-site-type-wp-basic.test' should be enabled + And the site 'bh-site-type-wp-basic.test' info should contain: + | field | value | + | site_type | wp | + | app_sub_type | wp | + | site_fs_path | {SITES_DIR}/bh-site-type-wp-basic.test | + | site_container_fs_path | /var/www/htdocs | + | site_ssl | <empty> | + | site_ssl_wildcard | 0 | + | cache_nginx_browser | 0 | + | cache_nginx_fullpage | 0 | + | cache_mysql_query | 0 | + | cache_app_object | 0 | + | cache_host | <empty> | + | php_version | 8.4 | + | db_host | global-db | + | db_port | 3306 | + | db_name | bh_site_type_wp_basic_test | + | db_user | /^bh-site-type-wp-basic\.test-.{6}$/ | + | db_password | <not empty> | + | db_root_password | <empty> | + | app_admin_url | bh-site-type-wp-basic.test | + | app_admin_email | admin@bh-site-type-wp-basic.test | + | app_admin_username | /^wp-user-\w{4,6}$/ | + | app_admin_password | /^.{18}$/ | + | app_mail | postfix | + | alias_domains | bh-site-type-wp-basic.test | + | mailhog_enabled | 0 | + # Webroot layout and config files. + And The site 'bh-site-type-wp-basic.test' should have webroot + And The site 'bh-site-type-wp-basic.test' should have WordPress + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/wp-load.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/wp-config.php' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/check.html' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/wp-cli.yml' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/conf.d/main.conf' should contain: + """ + server bh-site-type-wp-basic.test_php:9000; + server_name bh-site-type-wp-basic.test; + root /var/www/htdocs; + # PHP NGINX CONFIGURATION + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/conf.d/main.conf' should not contain: + """ + # Redis NGINX CONFIGURATION + # WPSUBDIRECTORY NGINX CONFIGURATION + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/custom/user.conf' should exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/custom/admin-tools.conf' should exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/php/php/conf.d/custom.ini' should contain: + """ + upload_max_filesize = 100M + sendmail_path = /usr/bin/msmtp -t + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/.env' should contain: + """ + VIRTUAL_HOST=bh-site-type-wp-basic.test + WORDPRESS_DB_HOST=global-db:3306 + WORDPRESS_DB_NAME=bh_site_type_wp_basic_test + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/.env' should not contain: + """ + MYSQL_ROOT_PASSWORD + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml' should contain: + """ + image: easyengine/php8.4: + - VIRTUAL_HOST + - VIRTUAL_PATH=/ + - HSTS=off + global-backend-network: + HTTPS_METHOD=nohttps + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml' should not contain: + """ + easyengine/mariadb + easyengine/redis + """ + # Containers, volumes and networks. + And the site 'bh-site-type-wp-basic.test' should have 3 running containers + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should be running + And the 'php' container of site 'bh-site-type-wp-basic.test' should be running + And the 'postfix' container of site 'bh-site-type-wp-basic.test' should be running + And the 'mailhog' container of site 'bh-site-type-wp-basic.test' should not exist + And the 'db' container of site 'bh-site-type-wp-basic.test' should not exist + And the 'redis' container of site 'bh-site-type-wp-basic.test' should not exist + And the 'php' container of site 'bh-site-type-wp-basic.test' should use image 'easyengine/php8.4:' + And the 'php' container of site 'bh-site-type-wp-basic.test' should have restart policy 'always' + And the 'php' container of site 'bh-site-type-wp-basic.test' should be connected to network 'ee-global-backend-network' + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should be connected to network 'ee-global-frontend-network' + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should not be connected to network 'ee-global-backend-network' + And the docker network 'bh-site-type-wp-basic.test' should exist + And the site 'bh-site-type-wp-basic.test' should have 8 docker volumes + # Global DB, hosts entry, cron and proxy side effects. + And the global database should have a database named 'bh_site_type_wp_basic_test' + And the global database should have the user of site 'bh-site-type-wp-basic.test' + And the hosts file should contain 'bh-site-type-wp-basic.test' + And the EE database table 'cron' should have 1 rows where 'site_url' is 'bh-site-type-wp-basic.test' + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-basic.test-redirect.conf' should not exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-basic.test.conf' should not exist + # WordPress itself. + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 + And the response body should contain 'bh-site-type-wp-basic.test' + And a GET request to 'http://bh-site-type-wp-basic.test/wp-login.php' should return 200 + And a GET request to 'http://www.bh-site-type-wp-basic.test/' should return 503 + + Scenario: WordPress configuration written by create + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run WP-CLI 'core is-installed' on site 'bh-site-type-wp-basic.test' + Then the command should succeed + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-basic.test + """ + When I run WP-CLI 'option get permalink_structure' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + /%year%/%monthnum%/%day%/%postname%/ + """ + When I run WP-CLI 'config get DB_HOST' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + global-db:3306 + """ + When I run WP-CLI 'config get DB_NAME' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + bh_site_type_wp_basic_test + """ + When I run WP-CLI 'config get WP_DEBUG_DISPLAY' on site 'bh-site-type-wp-basic.test' + Then the command should succeed + When I run WP-CLI 'config has COOKIE_DOMAIN' on site 'bh-site-type-wp-basic.test' + Then the command should fail + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/wp-config.php' should contain: + """ + $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https' + define( 'WP_DEBUG_LOG', false ); + define( 'SCRIPT_DEBUG', false ); + """ + When I run WP-CLI 'user list --role=administrator --field=user_login' on site 'bh-site-type-wp-basic.test' + Then STDOUT should match '/^wp-user-\w{4,6}$/m' + + Scenario: WordPress hardening rules of the generated nginx config + Given a WordPress site 'bh-site-type-wp-basic.test' exists + Then a GET request to 'http://bh-site-type-wp-basic.test/readme.html' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/license.txt' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/wp-config.txt' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/.htaccess' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/backup.sql' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/wp-content/uploads/evil.php' should return 403 + + Scenario: Creating an existing site fails and leaves it untouched + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site create bh-site-type-wp-basic.test --type=wp' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-basic.test already exists. + """ + And the site 'bh-site-type-wp-basic.test' should be enabled + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 + + Scenario: Site info as a table + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site info bh-site-type-wp-basic.test' + Then the command should succeed + And STDOUT should contain: + """ + http://bh-site-type-wp-basic.test + {SITES_DIR}/bh-site-type-wp-basic.test + Site Title + WordPress Username + WordPress Password + Alias Domains + None + global-db + bh_site_type_wp_basic_test + admin@bh-site-type-wp-basic.test + Not Enabled + Proxy Cache + Off + """ + And STDOUT should not contain: + """ + DB Root Password + SSL Wildcard + Access admin-tools + """ + + Scenario: Site info as JSON + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site info bh-site-type-wp-basic.test --format=json' + Then the command should succeed + And STDOUT should be valid json + And STDOUT should contain: + """ + "site_url":"bh-site-type-wp-basic.test" + "site_type":"wp" + """ + + Scenario: Site info detects the site from the current directory + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When From the directory '{SITES_DIR}/bh-site-type-wp-basic.test/app' I run 'bin/ee site info' + Then the command should succeed + And STDOUT should contain: + """ + http://bh-site-type-wp-basic.test + """ + + Scenario: Site info of an unknown site fails + When I run 'bin/ee site info bh-site-type-wp-unknown.test' + Then the command should fail + And STDERR should contain: + """ + Could not find the site you wish to run site info command on. + """ + + Scenario: Update of an unknown site fails + When I run 'bin/ee site update bh-site-type-wp-unknown.test --php=8.3' + Then the command should fail + And STDERR should contain: + """ + Could not find the site you wish to run site update command on. + """ + + Scenario: Invalid info format is rejected + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site info bh-site-type-wp-basic.test --format=yaml' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'format' + """ + + Scenario: List shows the site in every format + Given a WordPress site 'bh-site-type-wp-basic.test' exists When I run 'bin/ee site list --format=text' - Then STDOUT should return exactly - """ - wp.test - wpcache.test - wpsubdir.test - wpsubdom.test - """ + Then STDOUT should contain: + """ + bh-site-type-wp-basic.test + """ + When I run 'bin/ee site list --format=json' + Then STDOUT should be valid json + And STDOUT should contain: + """ + {"site":"bh-site-type-wp-basic.test","status":"enabled"} + """ + When I run 'bin/ee site list --enabled --format=text' + Then STDOUT should contain: + """ + bh-site-type-wp-basic.test + """ - Scenario: Delete the sites - When I run 'bin/ee site delete wp.test --yes' - Then STDOUT should return something like - """ - Site wp.test deleted. - """ - And STDERR should return exactly - """ - """ - And The 'wp.test' db entry should be removed - And The 'wp.test' webroot should be removed - And Following containers of site 'wp.test' should be removed: - | container | - | nginx | - | php | - | db | - | redis | - | phpmyadmin | + Scenario: Cache clean on a site without cache fails + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site clean bh-site-type-wp-basic.test' + Then the command should fail + And STDERR should contain: + """ + Site object cache is not enabled. + Site page cache is not enabled. + """ + When I run 'bin/ee site clean bh-site-type-wp-basic.test --proxy' + Then the command should fail + And STDERR should contain: + """ + Proxy cache is not enabled on site. + """ + + Scenario: Share without an active share fails before touching WordPress + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site share bh-site-type-wp-basic.test --disable' + Then the command should fail + And STDERR should contain: + """ + bh-site-type-wp-basic.test does not have active share running. + """ + And STDOUT should not contain: + """ + Running additional WordPress configurations. + """ + + Scenario: Restart and reload the WordPress containers + Given a WordPress site 'bh-site-type-wp-basic.test' exists + And I note the start time of the 'php' container of site 'bh-site-type-wp-basic.test' + And I note the start time of the 'nginx' container of site 'bh-site-type-wp-basic.test' + When I run 'bin/ee site restart bh-site-type-wp-basic.test --php' + Then the command should succeed + And the 'php' container of site 'bh-site-type-wp-basic.test' should have been restarted + When I run 'bin/ee site restart bh-site-type-wp-basic.test --nginx' + Then the command should succeed + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should have been restarted + When I run 'bin/ee site restart bh-site-type-wp-basic.test' + Then the command should succeed + When I run 'bin/ee site restart bh-site-type-wp-basic.test --all' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test --php' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test --nginx' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test --all' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 within 60 seconds + + Scenario: Refresh regenerates docker-compose.yml and keeps the site working + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run '/bin/bash -c "echo \# stale >> {SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml"' + And I run 'bin/ee site refresh bh-site-type-wp-basic.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml' should not contain: + """ + # stale + """ + And the site 'bh-site-type-wp-basic.test' should have 3 running containers + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 within 60 seconds + + Scenario: Disable and enable a WordPress site + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site disable bh-site-type-wp-basic.test' + Then the command should succeed + And STDOUT should contain: + """ + Site bh-site-type-wp-basic.test disabled. + """ + And the site 'bh-site-type-wp-basic.test' should be disabled + And the site 'bh-site-type-wp-basic.test' should have 0 running containers + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 503 within 30 seconds + When I run 'bin/ee site list --disabled --format=text' + Then STDOUT should contain: + """ + bh-site-type-wp-basic.test + """ + # Info works on a disabled site, update/restart/reload refuse it. + When I run 'bin/ee site info bh-site-type-wp-basic.test' + Then the command should succeed + When I run 'bin/ee site update bh-site-type-wp-basic.test --php=8.3' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-basic.test is not enabled. Use `ee site enable bh-site-type-wp-basic.test` to enable it. + """ + When I run 'bin/ee site restart bh-site-type-wp-basic.test' + Then the command should fail + When I run 'bin/ee site reload bh-site-type-wp-basic.test' + Then the command should fail + When I run 'bin/ee site enable bh-site-type-wp-basic.test' + Then the command should succeed + And STDOUT should contain: + """ + Site bh-site-type-wp-basic.test enabled. + Post enable configurations complete. + """ + And the site 'bh-site-type-wp-basic.test' should be enabled + And the site 'bh-site-type-wp-basic.test' should have 3 running containers + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 within 60 seconds + When I run 'bin/ee site enable bh-site-type-wp-basic.test' + Then the command should fail + And STDERR should contain: + """ + bh-site-type-wp-basic.test is already enabled! + """ + + Scenario: Declining the delete confirmation keeps the site + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run '/bin/bash -c "echo n | bin/ee site delete bh-site-type-wp-basic.test"' + Then STDOUT should contain: + """ + Are you sure you want to delete bh-site-type-wp-basic.test? + """ + And the site 'bh-site-type-wp-basic.test' should exist + And the 'php' container of site 'bh-site-type-wp-basic.test' should be running + + Scenario: Delete removes every trace of the site + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site delete bh-site-type-wp-basic.test --yes' + Then the command should succeed + And STDOUT should contain: + """ + Site bh-site-type-wp-basic.test deleted. + """ + And STDERR should not contain: + """ + Error: + """ + And The 'bh-site-type-wp-basic.test' db entry should be removed + And The 'bh-site-type-wp-basic.test' webroot should be removed + And Following containers of site 'bh-site-type-wp-basic.test' should be removed: + | container | + | nginx | + | php | + | postfix | + | mailhog | + And the site 'bh-site-type-wp-basic.test' should have 0 docker volumes + And the docker network 'bh-site-type-wp-basic.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_basic_test' + And the hosts file should not contain 'bh-site-type-wp-basic.test' + And the EE database table 'cron' should have 0 rows where 'site_url' is 'bh-site-type-wp-basic.test' + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 503 + + Scenario: Deleting a missing site fails + When I run 'bin/ee site delete bh-site-type-wp-basic.test --yes' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-basic.test does not exist. + """ From a3add4a2d550f07dda0e11a148c335b453baf145 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:11:00 +0530 Subject: [PATCH 03/36] test(site-type-wp): cover multisite, cache and proxy cache --- features/cache.feature | 151 +++++++++++++++++++++++++++++++++++ features/multisite.feature | 119 +++++++++++++++++++++++++++ features/proxy-cache.feature | 133 ++++++++++++++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 features/cache.feature create mode 100644 features/multisite.feature create mode 100644 features/proxy-cache.feature diff --git a/features/cache.feature b/features/cache.feature new file mode 100644 index 0000000..579bbe9 --- /dev/null +++ b/features/cache.feature @@ -0,0 +1,151 @@ +Feature: WordPress redis cache (--cache, --with-local-redis) + + Scenario: Create a site with page and object cache on the global redis + When I run 'bin/ee site create bh-site-type-wp-cache.test --type=wp --cache' + Then the command should succeed + And the site 'bh-site-type-wp-cache.test' info should contain: + | field | value | + | cache_nginx_browser | 1 | + | cache_nginx_fullpage | 1 | + | cache_mysql_query | 1 | + | cache_app_object | 1 | + | cache_host | global-redis | + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/config/nginx/conf.d/main.conf' should contain: + """ + server global-redis:6379; + # Redis NGINX CONFIGURATION + set $key "bh-site-type-wp-cache.test_page:http$request_method$host$request_uri"; + """ + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/config/nginx/conf.d/main.conf' should not contain: + """ + # PHP NGINX CONFIGURATION + """ + And the 'redis' container of site 'bh-site-type-wp-cache.test' should not exist + And the 'php' container of site 'bh-site-type-wp-cache.test' should be connected to network 'ee-global-backend-network' + And the 'nginx' container of site 'bh-site-type-wp-cache.test' should be connected to network 'ee-global-backend-network' + When I run WP-CLI 'plugin is-active nginx-helper' on site 'bh-site-type-wp-cache.test' + Then the command should succeed + When I run WP-CLI 'plugin is-active wp-redis' on site 'bh-site-type-wp-cache.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/app/htdocs/wp-content/object-cache.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/app/wp-config.php' should contain: + """ + $redis_server = array('host'=> 'global-redis','port'=> 6379,); + define( 'RT_WP_NGINX_HELPER_REDIS_HOSTNAME', 'global-redis' ); + define( 'RT_WP_NGINX_HELPER_REDIS_PORT', '6379' ); + define( 'RT_WP_NGINX_HELPER_REDIS_PREFIX', 'bh-site-type-wp-cache.test_page:' ); + define( 'WP_CACHE_KEY_SALT', 'bh-site-type-wp-cache.test_obj:' ); + define( 'WP_REDIS_MAXTTL', '14400' ); + """ + When I run WP-CLI 'option get rt_wp_nginx_helper_options --format=json' on site 'bh-site-type-wp-cache.test' + Then STDOUT should contain: + """ + "redis_hostname":"global-redis" + "redis_prefix":"bh-site-type-wp-cache.test_page:" + "cache_method":"enable_redis" + """ + When I run 'bin/ee site info bh-site-type-wp-cache.test' + Then STDOUT should contain: + """ + Enabled + """ + + Scenario: The page cache is served from redis + Given a WordPress site 'bh-site-type-wp-cache.test' exists created with '--cache' + When I send a GET request to 'http://bh-site-type-wp-cache.test/' + Then the response should have header 'X-SRCache-Fetch-Status' + And a GET request to 'http://bh-site-type-wp-cache.test/' should have header 'X-SRCache-Fetch-Status: HIT' within 20 seconds + And the global redis should have keys matching 'bh-site-type-wp-cache.test_page:*' + # Logged-in cookies and query strings bypass the cache. + When I send a GET request to 'http://bh-site-type-wp-cache.test/?bh=1' + Then the response should have header 'X-SRCache-Fetch-Status: BYPASS' + + # CACHE-1: clean_site_cache() runs `docker exec -it`, which fails without a TTY (and can hang under nested ptys), yet `ee site clean` still prints Success. + @known-bug + Scenario: ee site clean purges the page and object cache keys + Given a WordPress site 'bh-site-type-wp-cacheclean.test' exists created with '--cache --skip-install' + And I store the key 'bh-site-type-wp-cacheclean.test_page:httpGET/bh' in the global redis + And I store the key 'bh-site-type-wp-cacheclean.test_obj:bh' in the global redis + When I run 'bin/ee site clean bh-site-type-wp-cacheclean.test --page' + Then the command should succeed + And STDOUT should contain: + """ + Page cache cleared for bh-site-type-wp-cacheclean.test + """ + And the global redis should not have keys matching 'bh-site-type-wp-cacheclean.test_page:*' + And the global redis should have keys matching 'bh-site-type-wp-cacheclean.test_obj:*' + When I run 'bin/ee site clean bh-site-type-wp-cacheclean.test' + Then the command should succeed + And STDOUT should contain: + """ + Page cache cleared for bh-site-type-wp-cacheclean.test + Object cache cleared for bh-site-type-wp-cacheclean.test + """ + And the global redis should not have keys matching 'bh-site-type-wp-cacheclean.test_obj:*' + + Scenario: Cache clean without proxy cache rejects --proxy + Given a WordPress site 'bh-site-type-wp-cache.test' exists created with '--cache' + When I run 'bin/ee site clean bh-site-type-wp-cache.test --proxy' + Then the command should fail + And STDERR should contain: + """ + Proxy cache is not enabled on site. + """ + + Scenario: Create a site with cache on a local redis container + When I run 'bin/ee site create bh-site-type-wp-lredis.test --type=wp --cache --with-local-redis' + Then the command should succeed + And the site 'bh-site-type-wp-lredis.test' info should contain: + | field | value | + | cache_nginx_fullpage | 1 | + | cache_host | redis | + And the site 'bh-site-type-wp-lredis.test' should have 4 running containers + And the 'redis' container of site 'bh-site-type-wp-lredis.test' should be running + And the 'redis' container of site 'bh-site-type-wp-lredis.test' should use image 'easyengine/redis:' + And the 'nginx' container of site 'bh-site-type-wp-lredis.test' should not be connected to network 'ee-global-backend-network' + And the file '{SITES_DIR}/bh-site-type-wp-lredis.test/config/nginx/conf.d/main.conf' should contain: + """ + server redis:6379; + """ + And the file '{SITES_DIR}/bh-site-type-wp-lredis.test/docker-compose.yml' should contain: + """ + image: easyengine/redis: + - redis + """ + And the file '{SITES_DIR}/bh-site-type-wp-lredis.test/app/wp-config.php' should contain: + """ + define( 'RT_WP_NGINX_HELPER_REDIS_HOSTNAME', 'redis' ); + $redis_server = array('host'=> 'redis','port'=> 6379,); + """ + And a GET request to 'http://bh-site-type-wp-lredis.test/' should have header 'X-SRCache-Fetch-Status: HIT' within 20 seconds + And the global redis should not have keys matching 'bh-site-type-wp-lredis.test_page:*' + When I run 'bin/ee site delete bh-site-type-wp-lredis.test --yes' + Then the command should succeed + And the 'redis' container of site 'bh-site-type-wp-lredis.test' should not exist + + Scenario: Subdomain multisite with cache stores the nginx-helper options as network meta + When I run 'bin/ee site create bh-site-type-wp-mucache.test --type=wp --mu=subdom --cache' + Then the command should succeed + When I run WP-CLI 'network meta get 1 rt_wp_nginx_helper_options --format=json' on site 'bh-site-type-wp-mucache.test' + Then the command should succeed + And STDOUT should contain: + """ + "redis_hostname":"global-redis" + """ + And a GET request to 'http://bh-site-type-wp-mucache.test/' should return 200 + + # MIG-16: the local redis service has no restart policy, unlike every other service. + @known-bug + Scenario: The local redis container restarts with the site + Given a WordPress site 'bh-site-type-wp-mig16.test' exists created with '--cache --with-local-redis --skip-install' + Then the 'redis' container of site 'bh-site-type-wp-mig16.test' should have restart policy 'always' + + # Suspected bug (new): --with-local-redis alone is silently ignored (no redis container, no cache) although the help says "Enable cache with local redis container". + @known-bug + Scenario: --with-local-redis without --cache enables the local redis cache + When I run 'bin/ee site create bh-site-type-wp-lredisonly.test --type=wp --with-local-redis --skip-install' + Then the command should succeed + And the 'redis' container of site 'bh-site-type-wp-lredisonly.test' should be running + And the site 'bh-site-type-wp-lredisonly.test' info should contain: + | field | value | + | cache_host | redis | diff --git a/features/multisite.feature b/features/multisite.feature new file mode 100644 index 0000000..8c8b89b --- /dev/null +++ b/features/multisite.feature @@ -0,0 +1,119 @@ +Feature: WordPress multisite (--mu=subdir|subdom) + + Scenario: Create a subdirectory multisite + When I run 'bin/ee site create bh-site-type-wp-subdir.test --type=wp --mu=subdir' + Then the command should succeed + And the site 'bh-site-type-wp-subdir.test' info should contain: + | field | value | + | app_sub_type | subdir | + | site_ssl_wildcard | 0 | + | alias_domains | bh-site-type-wp-subdir.test | + And The site 'bh-site-type-wp-subdir.test' should be 'subdir' multisite + And the file '{SITES_DIR}/bh-site-type-wp-subdir.test/config/nginx/conf.d/main.conf' should contain: + """ + # WPSUBDIRECTORY NGINX CONFIGURATION + rewrite ^(/[^/]+)?(/wp-.*) $2 last; + server_name bh-site-type-wp-subdir.test; + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdir.test/app/wp-config.php' should contain: + """ + define( 'COOKIE_DOMAIN', false ); + """ + When I run WP-CLI 'config get MULTISITE' on site 'bh-site-type-wp-subdir.test' + Then STDOUT should contain: + """ + 1 + """ + When I create subsite 'bhsub1' in 'bh-site-type-wp-subdir.test' + Then a GET request to 'http://bh-site-type-wp-subdir.test/' should return 200 + And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/' should return 200 + And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/wp-login.php' should return 200 + And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/wp-admin' should return 301 + When I run WP-CLI 'site list --field=url' on site 'bh-site-type-wp-subdir.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-subdir.test/bhsub1/ + """ + + Scenario: Create a subdomain multisite + When I run 'bin/ee site create bh-site-type-wp-subdom.test --type=wp --mu=subdom' + Then the command should succeed + # The implicit wildcard alias marks the site as wildcard even without SSL. + And the site 'bh-site-type-wp-subdom.test' info should contain: + | field | value | + | app_sub_type | subdom | + | site_ssl_wildcard | 1 | + | alias_domains | bh-site-type-wp-subdom.test,*.bh-site-type-wp-subdom.test | + And The site 'bh-site-type-wp-subdom.test' should be 'subdomain' multisite + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/docker-compose.yml' should contain: + """ + VIRTUAL_HOST=${VIRTUAL_HOST},*.${VIRTUAL_HOST} + CERT_NAME=${VIRTUAL_HOST} + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/config/nginx/conf.d/main.conf' should contain: + """ + server_name bh-site-type-wp-subdom.test *.bh-site-type-wp-subdom.test; + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/config/nginx/conf.d/main.conf' should not contain: + """ + # WPSUBDIRECTORY NGINX CONFIGURATION + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/app/wp-config.php' should contain: + """ + define( 'COOKIE_DOMAIN', false ); + """ + When I create subsite 'bhsub1' in 'bh-site-type-wp-subdom.test' + Then a GET request to 'http://bh-site-type-wp-subdom.test/' should return 200 + And a GET request to 'http://bhsub1.bh-site-type-wp-subdom.test/' should return 200 within 30 seconds + And the response body should contain 'bhsub1.bh-site-type-wp-subdom.test' + When I run 'bin/ee site info bh-site-type-wp-subdom.test' + Then STDOUT should contain: + """ + *.bh-site-type-wp-subdom.test + """ + + Scenario: Unknown multisite type is rejected before anything is created + When I run 'bin/ee site create bh-site-type-wp-mubad.test --type=wp --mu=subfoo' + Then the command should fail + And STDERR should contain: + """ + Unrecognized multi-site parameter: subfoo. Only `--mu=subdom` and `--mu=subdir` are supported. + """ + And the site 'bh-site-type-wp-mubad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-mubad.test' should not exist + + Scenario: Subdomain multisite cannot inherit SSL + When I run 'bin/ee site create bh-site-type-wp-muinh.test --type=wp --mu=subdom --ssl=inherit' + Then the command should fail + And STDERR should contain: + """ + --wildcard or --mu=subdom flag can not be passed together with --ssl=inherit flag. + """ + And the site 'bh-site-type-wp-muinh.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-muinh.test' should not exist + + # Suspected bug (new): the inherit/wildcard check runs after create_user_in_db() and exits via EE::error() without cleanup, so the global DB and user leak. + @known-bug + Scenario: A rejected inherit + subdom create leaves nothing in the global database + When I run 'bin/ee site create bh-site-type-wp-muinh2.test --type=wp --mu=subdom --ssl=inherit' + Then the command should fail + And the global database should not have a database named 'bh_site_type_wp_muinh2_test' + + # Suspected bug (new): `--mu` without a value only warns "--mu parameter needs a value" and silently creates a single site. + @known-bug + Scenario: --mu without a value is rejected + When I run 'bin/ee site create bh-site-type-wp-muempty.test --type=wp --mu --skip-install' + Then the command should fail + And the site 'bh-site-type-wp-muempty.test' should not exist + + # AUTH-5: subdom create doesn't check its implicit *.site against existing aliases (fixed in site-type-wp #239, not on develop). + @known-bug + Scenario: Subdomain multisite whose wildcard is already an alias of another site is rejected + Given a WordPress site 'bh-site-type-wp-auth5owner.test' exists created with "--skip-install --alias-domains='*.bh-site-type-wp-auth5.test'" + When I run 'bin/ee site create bh-site-type-wp-auth5.test --type=wp --mu=subdom --skip-install' + Then the command should fail + And STDERR should contain: + """ + is already an alias domain of site bh-site-type-wp-auth5owner.test + """ + And the site 'bh-site-type-wp-auth5.test' should not exist diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature new file mode 100644 index 0000000..6baeadd --- /dev/null +++ b/features/proxy-cache.feature @@ -0,0 +1,133 @@ +# Enabling or disabling proxy cache runs `docker restart` on the global nginx-proxy, which briefly interrupts every site on the host. +Feature: Proxy cache of WordPress sites (--proxy-cache) + + @destructive + Scenario: Create a site with proxy cache and custom limits + When I run 'bin/ee site create bh-site-type-wp-proxy.test --type=wp --proxy-cache=on --proxy-cache-max-time=30s --proxy-cache-max-size=bad' + Then the command should succeed + And STDERR should contain: + """ + Wrong value `bad` supplied to param: `proxy-cache-max-size`, replacing it with default:256m + """ + And STDOUT should contain: + """ + Proxy cache update on site bh-site-type-wp-proxy.test completed. + """ + # Proxy cache implies the redis page/object cache. + And the site 'bh-site-type-wp-proxy.test' info should contain: + | field | value | + | proxy_cache | on | + | cache_nginx_fullpage | 1 | + | cache_host | global-redis | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxy.test.conf' should contain: + """ + proxy_cache_path /var/cache/nginx/bh-site-type-wp-proxy.test levels=1:2 keys_zone=bh-site-type-wp-proxy-test:10m max_size=256m; + """ + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxy.test_location' should contain: + """ + proxy_cache bh-site-type-wp-proxy-test; + proxy_cache_valid 200 301 302 404 30s; + add_header X-Proxy-Cache $upstream_cache_status; + """ + And a GET request to 'http://bh-site-type-wp-proxy.test/' should return 200 within 60 seconds + And a GET request to 'http://bh-site-type-wp-proxy.test/' should have header 'X-Proxy-Cache: HIT' within 20 seconds + When I run 'bin/ee site info bh-site-type-wp-proxy.test' + Then STDOUT should contain: + """ + Proxy Cache + Enabled + """ + + @destructive + Scenario: Proxy cache can be turned off and is not turned on twice + Given a WordPress site 'bh-site-type-wp-proxy.test' exists created with '--proxy-cache=on' + When I run 'bin/ee site update bh-site-type-wp-proxy.test --proxy-cache=on' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-proxy.test already has proxy cache: on + """ + When I run 'bin/ee site update bh-site-type-wp-proxy.test --proxy-cache=off' + Then the command should succeed + And the site 'bh-site-type-wp-proxy.test' info should contain: + | field | value | + | proxy_cache | off | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxy.test.conf' should not exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxy.test_location' should not exist + And a GET request to 'http://bh-site-type-wp-proxy.test/' should return 200 within 60 seconds + And the response should not have header 'X-Proxy-Cache' + + @destructive + Scenario: Enable proxy cache on an existing site with update + Given a WordPress site 'bh-site-type-wp-proxyupd.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-proxyupd.test --proxy-cache=on --proxy-cache-max-size=1g --proxy-cache-max-time=xyz --proxy-cache-key-zone-size=20m' + Then the command should succeed + And STDERR should contain: + """ + Wrong value `xyz` supplied to param: `proxy-cache-max-time`, replacing it with default:1s + """ + And the site 'bh-site-type-wp-proxyupd.test' info should contain: + | field | value | + | proxy_cache | on | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should contain: + """ + keys_zone=bh-site-type-wp-proxyupd-test:20m max_size=1g; + """ + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should contain: + """ + proxy_cache_valid 200 301 302 404 1s; + """ + When I run 'bin/ee site delete bh-site-type-wp-proxyupd.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should not exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should not exist + + # Suspected bug (new): the synopsis options `on`/`off` are parsed by YAML as booleans, so any non-empty value passes validation; update then runs the disable path. + @known-bug @destructive + Scenario: Invalid proxy-cache value on update is rejected + Given a WordPress site 'bh-site-type-wp-proxyval.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-proxyval.test --proxy-cache=maybe' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'proxy-cache' + """ + + # Suspected bug (new): same YAML boolean parsing on create; the invalid value is silently ignored. + @known-bug + Scenario: Invalid proxy-cache value on create is rejected + When I run 'bin/ee site create bh-site-type-wp-proxybad.test --type=wp --proxy-cache=maybe --skip-install' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'proxy-cache' + """ + And the site 'bh-site-type-wp-proxybad.test' should not exist + + # Suspected bug (new): without --proxy-cache the YAML default `off` becomes boolean false and is stored as '' instead of 'off', so `--proxy-cache=off` is not detected as a no-op (and restarts the global proxy). + @known-bug @destructive + Scenario: A new site stores proxy_cache off and refuses to turn it off again + Given a WordPress site 'bh-site-type-wp-proxyoff.test' exists created with '--skip-install' + Then the site 'bh-site-type-wp-proxyoff.test' info should contain: + | field | value | + | proxy_cache | off | + When I run 'bin/ee site update bh-site-type-wp-proxyoff.test --proxy-cache=off' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-proxyoff.test already has proxy cache: off + """ + + # CACHE-1: the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. + @known-bug @destructive + Scenario: ee site clean --proxy empties the proxy cache directory + Given a WordPress site 'bh-site-type-wp-proxyclean.test' exists created with '--proxy-cache=on --skip-install' + And I run 'docker exec services_global-nginx-proxy_1 sh -c "mkdir -p /var/cache/nginx/bh-site-type-wp-proxyclean.test/x && touch /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry"' + When I run 'bin/ee site clean bh-site-type-wp-proxyclean.test --proxy' + Then the command should succeed + And STDOUT should contain: + """ + Proxy cache cleared for bh-site-type-wp-proxyclean.test + """ + When I run 'docker exec services_global-nginx-proxy_1 test -e /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry' + Then the command should fail From 77e4094f4468803b2eadfb67f106407b192da695 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:15:50 +0530 Subject: [PATCH 04/36] test(site-type-wp): cover php versions and database options --- features/database.feature | 169 +++++++++++++++++++++++++++++++++++ features/external-db.feature | 89 ++++++++++++++++++ features/php-version.feature | 140 +++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 features/database.feature create mode 100644 features/external-db.feature create mode 100644 features/php-version.feature diff --git a/features/database.feature b/features/database.feature new file mode 100644 index 0000000..e72909f --- /dev/null +++ b/features/database.feature @@ -0,0 +1,169 @@ +Feature: Database options of WordPress sites (global DB flags, --local-db) + + Scenario: Custom database name, user, password, prefix, charset and collation on the global DB + When I run 'bin/ee site create bh-site-type-wp-dbopts.test --type=wp --dbname=bh_site_type_wp_customdb --dbuser=bh_site_type_wp_customuser --dbpass=BhDbPass123 --dbprefix=bhp_ --dbcharset=utf8 --dbcollate=utf8_general_ci --skip-check' + Then the command should succeed + And the site 'bh-site-type-wp-dbopts.test' info should contain: + | field | value | + | db_host | global-db | + | db_name | bh_site_type_wp_customdb | + | db_user | bh_site_type_wp_customuser | + | db_password | BhDbPass123 | + And the global database should have a database named 'bh_site_type_wp_customdb' + And the global database should have a user named 'bh_site_type_wp_customuser' + And the global database 'bh_site_type_wp_customdb' should have a table named 'bhp_options' + And the global database should not have a database named 'bh_site_type_wp_dbopts_test' + And the file '{SITES_DIR}/bh-site-type-wp-dbopts.test/.env' should contain: + """ + WORDPRESS_DB_USER=bh_site_type_wp_customuser + WORDPRESS_DB_NAME=bh_site_type_wp_customdb + WORDPRESS_DB_PASSWORD=BhDbPass123 + """ + When I run WP-CLI 'config get table_prefix' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + bhp_ + """ + When I run WP-CLI 'config get DB_CHARSET' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + utf8 + """ + When I run WP-CLI 'config get DB_COLLATE' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + utf8_general_ci + """ + When I run WP-CLI 'config get DB_USER' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + bh_site_type_wp_customuser + """ + And a GET request to 'http://bh-site-type-wp-dbopts.test/' should return 200 + + Scenario: Deleting the site drops its custom database and user + Given a WordPress site 'bh-site-type-wp-dbopts.test' exists created with '--dbname=bh_site_type_wp_customdb --dbuser=bh_site_type_wp_customuser --dbpass=BhDbPass123' + When I run 'bin/ee site delete bh-site-type-wp-dbopts.test --yes' + Then the command should succeed + And the global database should not have a database named 'bh_site_type_wp_customdb' + And the global database should not have a user named 'bh_site_type_wp_customuser' + + Scenario: Create a site with a local database container + When I run 'bin/ee site create bh-site-type-wp-localdb.test --type=wp --local-db' + Then the command should succeed + And the site 'bh-site-type-wp-localdb.test' info should contain: + | field | value | + | db_host | db | + | db_name | bh_site_type_wp_localdb_test | + | db_root_password | <not empty> | + And the 'db' container of site 'bh-site-type-wp-localdb.test' should be running + And the 'db' container of site 'bh-site-type-wp-localdb.test' should use image 'easyengine/mariadb:' + And the 'db' container of site 'bh-site-type-wp-localdb.test' should have restart policy 'always' + And the site 'bh-site-type-wp-localdb.test' should have 4 running containers + And the site 'bh-site-type-wp-localdb.test' should have 11 docker volumes + And the 'php' container of site 'bh-site-type-wp-localdb.test' should not be connected to network 'ee-global-backend-network' + And the global database should not have a database named 'bh_site_type_wp_localdb_test' + And the file '{SITES_DIR}/bh-site-type-wp-localdb.test/.env' should contain: + """ + MYSQL_ROOT_PASSWORD= + MYSQL_DATABASE=bh_site_type_wp_localdb_test + WORDPRESS_DB_HOST=db + """ + And the file '{SITES_DIR}/bh-site-type-wp-localdb.test/docker-compose.yml' should contain: + """ + image: easyengine/mariadb: + - db + """ + When I run WP-CLI 'config get DB_HOST' on site 'bh-site-type-wp-localdb.test' + Then STDOUT should contain: + """ + db:3306 + """ + When I run 'bin/ee site info bh-site-type-wp-localdb.test' + Then STDOUT should contain: + """ + DB Root Password + """ + And a GET request to 'http://bh-site-type-wp-localdb.test/' should return 200 + + Scenario: Restart of a local-db site includes the db container + Given a WordPress site 'bh-site-type-wp-localdb.test' exists created with '--local-db' + And I note the start time of the 'db' container of site 'bh-site-type-wp-localdb.test' + When I run 'bin/ee site restart bh-site-type-wp-localdb.test --db' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should have been restarted + And a GET request to 'http://bh-site-type-wp-localdb.test/' should return 200 within 90 seconds + + Scenario: Disable and enable keep the local database + Given a WordPress site 'bh-site-type-wp-localdb.test' exists created with '--local-db' + When I run 'bin/ee site disable bh-site-type-wp-localdb.test' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should not exist + When I run 'bin/ee site enable bh-site-type-wp-localdb.test' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should be running + And a GET request to 'http://bh-site-type-wp-localdb.test/' should return 200 within 90 seconds + + Scenario: Deleting a local-db site removes the db container and volumes + Given a WordPress site 'bh-site-type-wp-localdb.test' exists created with '--local-db' + When I run 'bin/ee site delete bh-site-type-wp-localdb.test --yes' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should not exist + And the site 'bh-site-type-wp-localdb.test' should have 0 docker volumes + + Scenario: Remote database host requires credentials + When I run 'bin/ee site create bh-site-type-wp-remote.test --type=wp --dbhost=203.0.113.10' + Then the command should fail + And STDERR should contain: + """ + `--dbuser` and `--dbpass` are required for remote db host. + """ + And the site 'bh-site-type-wp-remote.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist + + Scenario: Unreachable remote database rolls the site back + When I run 'bin/ee site create bh-site-type-wp-remote.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:1 --dbuser=bh --dbpass=bh' + Then the command should fail + And STDERR should contain: + """ + Unable to connect to remote db + Initiating clean-up. + """ + And the site 'bh-site-type-wp-remote.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist + + # Suspected bug (new, data loss): when CREATE DATABASE fails because the name is taken, catch_clean() runs cleanup_db() and DROPs the other site's database. + # `a-b.test` and `a.b.test` both map to the default name `a_b_test`, so no flag is needed to hit it. + @known-bug + Scenario: A database name collision does not drop the other site's database + Given a WordPress site 'bh-site-type-wp-db-c.test' exists created with '--skip-install' + And the global database should have a database named 'bh_site_type_wp_db_c_test' + When I run 'bin/ee site create bh-site-type-wp-db.c.test --type=wp --skip-install' + Then the command should fail + And the site 'bh-site-type-wp-db.c.test' should not exist + And the global database should have a database named 'bh_site_type_wp_db_c_test' + + # Suspected bug (new, data loss): same rollback with --dbuser of another site DROPs that site's DB user. + @known-bug + Scenario: A database user collision does not drop the other site's user + Given a WordPress site 'bh-site-type-wp-dbuser.test' exists created with '--dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' + When I run 'bin/ee site create bh-site-type-wp-dbuser2.test --type=wp --dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' + Then the command should fail + And the site 'bh-site-type-wp-dbuser2.test' should not exist + And the global database should have a user named 'bh_site_type_wp_shareduser' + And the global database should have a database named 'bh_site_type_wp_dbuser_test' + + # Suspected bug (new): create_site_root() exits via EE::error() after create_user_in_db() already ran, so the global DB and user leak. + @known-bug + Scenario: An existing webroot aborts the create without leaking database objects + Given I run 'mkdir -p {SITES_DIR}/bh-site-type-wp-stale.test' + And I run 'touch {SITES_DIR}/bh-site-type-wp-stale.test/keep-me' + When I run 'bin/ee site create bh-site-type-wp-stale.test --type=wp' + Then the command should fail + And STDERR should contain: + """ + Webroot directory for site bh-site-type-wp-stale.test already exists. + """ + And the file '{SITES_DIR}/bh-site-type-wp-stale.test/keep-me' should exist + And the site 'bh-site-type-wp-stale.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_stale_test' diff --git a/features/external-db.feature b/features/external-db.feature new file mode 100644 index 0000000..ffddf96 --- /dev/null +++ b/features/external-db.feature @@ -0,0 +1,89 @@ +# Uses a throwaway MariaDB on the host network, bound only to the docker bridge gateway ({DOCKER_BRIDGE_IP}), so that +# both EE's verification container (default bridge) and the site's php container can reach it without publishing a port. +# Requires the host to accept connections from docker networks to that address. +@external-db +Feature: WordPress site on a remote database (--dbhost, --force) + + Scenario: Create a site on a remote database that does not exist yet + Given a MariaDB server 'bh-site-type-wp-extdb' is listening on '{DOCKER_BRIDGE_IP}' port 33306 with root password 'BhRootPass123' + When I run 'bin/ee site create bh-site-type-wp-ext.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=BhRootPass123 --dbname=bh_site_type_wp_ext' + Then the command should succeed + And STDOUT should contain: + """ + Verifying connection to remote database + Success: Connection to remote db verified + Database `bh_site_type_wp_ext` does not exist. Attempting to create it. + """ + And the site 'bh-site-type-wp-ext.test' info should contain: + | field | value | + | db_host | {DOCKER_BRIDGE_IP} | + | db_port | 33306 | + | db_name | bh_site_type_wp_ext | + | db_user | root | + | db_root_password | <empty> | + And the 'db' container of site 'bh-site-type-wp-ext.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_ext' + And the file '{SITES_DIR}/bh-site-type-wp-ext.test/.env' should contain: + """ + WORDPRESS_DB_HOST={DOCKER_BRIDGE_IP}:33306 + """ + When I run WP-CLI 'config get DB_HOST' on site 'bh-site-type-wp-ext.test' + Then STDOUT should contain: + """ + {DOCKER_BRIDGE_IP}:33306 + """ + And a GET request to 'http://bh-site-type-wp-ext.test/' should return 200 + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext"' + Then STDOUT should contain: + """ + wp_options + """ + # Remote databases are never dropped by EE. + When I run 'bin/ee site delete bh-site-type-wp-ext.test --yes' + Then the command should succeed + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext"' + Then STDOUT should contain: + """ + wp_options + """ + + Scenario: A remote database that already has tables needs --force + Given a MariaDB server 'bh-site-type-wp-extdb' is listening on '{DOCKER_BRIDGE_IP}' port 33306 with root password 'BhRootPass123' + And In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -e "CREATE DATABASE bh_site_type_wp_ext2; CREATE TABLE bh_site_type_wp_ext2.leftover (id INT)"' + When I run 'bin/ee site create bh-site-type-wp-ext2.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=BhRootPass123 --dbname=bh_site_type_wp_ext2' + Then the command should fail + And STDERR should contain: + """ + Some database tables seem to exist in database bh_site_type_wp_ext2. Please backup and reset the database or use `--force` in the site create command to reset it. + Initiating clean-up. + """ + And the site 'bh-site-type-wp-ext2.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-ext2.test' should not exist + # The rollback must not touch the remote database. + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext2"' + Then STDOUT should contain: + """ + leftover + """ + When I run 'bin/ee site create bh-site-type-wp-ext2.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=BhRootPass123 --dbname=bh_site_type_wp_ext2 --force' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-ext2.test/' should return 200 + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext2"' + Then STDOUT should contain: + """ + wp_options + """ + And STDOUT should not contain: + """ + leftover + """ + + Scenario: Wrong remote credentials roll the site back + Given a MariaDB server 'bh-site-type-wp-extdb' is listening on '{DOCKER_BRIDGE_IP}' port 33306 with root password 'BhRootPass123' + When I run 'bin/ee site create bh-site-type-wp-ext3.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=WrongPass123' + Then the command should fail + And STDERR should contain: + """ + Unable to connect to remote db + """ + And the site 'bh-site-type-wp-ext3.test' should not exist diff --git a/features/php-version.feature b/features/php-version.feature new file mode 100644 index 0000000..7c48683 --- /dev/null +++ b/features/php-version.feature @@ -0,0 +1,140 @@ +Feature: PHP versions of WordPress sites (--php on create and update) + + Scenario: Default PHP version and update to another version + Given a WordPress site 'bh-site-type-wp-php.test' exists + Then the site 'bh-site-type-wp-php.test' info should contain: + | field | value | + | php_version | 8.4 | + And the 'php' container of site 'bh-site-type-wp-php.test' should use image 'easyengine/php8.4:' + When I run 'bin/ee site update bh-site-type-wp-php.test --php=8.2' + Then the command should succeed + And STDOUT should contain: + """ + Starting php version update for: bh-site-type-wp-php.test + Taking backup of old php config. + Re-applying previous custom.ini and easyengine.conf changes. + Success: Updated site bh-site-type-wp-php.test to PHP version: 8.2 + """ + And the site 'bh-site-type-wp-php.test' info should contain: + | field | value | + | php_version | 8.2 | + And the 'php' container of site 'bh-site-type-wp-php.test' should use image 'easyengine/php8.2:' + And the directory '{SITES_DIR}/bh-site-type-wp-php.test/.backup/config/php-8.4' should exist + And the file '{SITES_DIR}/bh-site-type-wp-php.test/config/php/php/conf.d/custom.ini' should contain: + """ + sendmail_path = /usr/bin/msmtp -t + """ + When In the 'php' container of site 'bh-site-type-wp-php.test' I run 'php -v' + Then STDOUT should contain: + """ + PHP 8.2. + """ + And a GET request to 'http://bh-site-type-wp-php.test/' should return 200 within 60 seconds + + Scenario: Updating to the current PHP version fails + Given a WordPress site 'bh-site-type-wp-php.test' exists created with '--php=8.2' + When I run 'bin/ee site update bh-site-type-wp-php.test --php=8.2' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-php.test is already at PHP version: 8.2 + """ + + Scenario: Updating to an unsupported PHP version fails + Given a WordPress site 'bh-site-type-wp-php.test' exists + When I run 'bin/ee site update bh-site-type-wp-php.test --php=9.9' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'php' + """ + + Scenario Outline: Create a site on PHP <php> + When I run 'bin/ee site create bh-site-type-wp-php<name>.test --type=wp --php=<php>' + Then the command should succeed + And the site 'bh-site-type-wp-php<name>.test' info should contain: + | field | value | + | php_version | <php> | + And the 'php' container of site 'bh-site-type-wp-php<name>.test' should use image 'easyengine/<image>:' + When In the 'php' container of site 'bh-site-type-wp-php<name>.test' I run 'php -v' + Then STDOUT should contain: + """ + PHP <runtime> + """ + And a GET request to 'http://bh-site-type-wp-php<name>.test/' should return 200 + When I run 'bin/ee site delete bh-site-type-wp-php<name>.test --yes' + Then the command should succeed + + Examples: + | php | name | image | runtime | + | 8.3 | 83 | php8.3 | 8.3. | + | 8.5 | 85 | php8.5 | 8.5. | + | latest | latest | php8.4 | 8.4. | + + # These pull the older php images (not present on a fresh host) and need an older WordPress for PHP < 7.4. + @slow + Scenario Outline: Create a site on legacy PHP <php> + When I run 'bin/ee site create bh-site-type-wp-php<name>.test --type=wp --php=<php> --version=<wp>' + Then the command should succeed + And the 'php' container of site 'bh-site-type-wp-php<name>.test' should use image 'easyengine/php<php>:' + When In the 'php' container of site 'bh-site-type-wp-php<name>.test' I run 'php -v' + Then STDOUT should contain: + """ + PHP <php>. + """ + And a GET request to 'http://bh-site-type-wp-php<name>.test/' should return 200 + When I run 'bin/ee site delete bh-site-type-wp-php<name>.test --yes' + Then the command should succeed + + Examples: + | php | name | wp | + | 7.0 | 70 | 6.2.6 | + | 7.2 | 72 | 6.8.3 | + | 7.3 | 73 | 6.8.3 | + | 7.4 | 74 | latest | + | 8.0 | 80 | latest | + | 8.1 | 81 | latest | + + # PHP 5.6 uses its own custom.ini template (sendmail instead of msmtp). + @slow + Scenario: Create a site on PHP 5.6 + When I run 'bin/ee site create bh-site-type-wp-php56.test --type=wp --php=5.6 --version=6.2.6' + Then the command should succeed + And the 'php' container of site 'bh-site-type-wp-php56.test' should use image 'easyengine/php5.6:' + And the file '{SITES_DIR}/bh-site-type-wp-php56.test/config/php/php/conf.d/custom.ini' should contain: + """ + sendmail_path = /usr/sbin/sendmail -t -i -f no-reply@example.com + """ + And a GET request to 'http://bh-site-type-wp-php56.test/' should return 200 + + Scenario Outline: Unsupported PHP version <php> is rejected before anything is created + When I run 'bin/ee site create bh-site-type-wp-phpbad.test --type=wp --php=<php>' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'php' + """ + And the site 'bh-site-type-wp-phpbad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-phpbad.test' should not exist + + Examples: + | php | + | 7.1 | + | 9.9 | + | foo | + + # Suspected bug (new): options are compared loosely, so `--php=8` passes validation ("8" == "8.0") and create builds the image name `easyengine/php8:` (unknown version). + @known-bug + Scenario Outline: Loosely matching PHP version <php> is rejected + When I run 'bin/ee site create bh-site-type-wp-phploose.test --type=wp --php=<php> --skip-install' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'php' + """ + And the site 'bh-site-type-wp-phploose.test' should not exist + + Examples: + | php | + | 8 | + | 8.10 | From bf978f9011a09f1e4fe71d23304ef4f52f61821a Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:21:05 +0530 Subject: [PATCH 05/36] test(site-type-wp): cover ssl, alias domains, install options and vip --- features/alias-domains.feature | 154 +++++++++++++++++++ features/install-options.feature | 222 +++++++++++++++++++++++++++ features/ssl.feature | 247 +++++++++++++++++++++++++++++++ features/vip.feature | 47 ++++++ 4 files changed, 670 insertions(+) create mode 100644 features/alias-domains.feature create mode 100644 features/install-options.feature create mode 100644 features/ssl.feature create mode 100644 features/vip.feature diff --git a/features/alias-domains.feature b/features/alias-domains.feature new file mode 100644 index 0000000..00dc73b --- /dev/null +++ b/features/alias-domains.feature @@ -0,0 +1,154 @@ +Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias-domains) + + Scenario: Create a site with alias domains + When I run "bin/ee site create bh-site-type-wp-alias.test --type=wp --alias-domains='bh-site-type-wp-alias-a.test, bh-site-type-wp-alias-b.test'" + Then the command should succeed + And the site 'bh-site-type-wp-alias.test' info should contain: + | field | value | + | alias_domains | bh-site-type-wp-alias.test,bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test | + And the file '{SITES_DIR}/bh-site-type-wp-alias.test/docker-compose.yml' should contain: + """ + VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test + CERT_NAME=${VIRTUAL_HOST} + """ + And the file '{SITES_DIR}/bh-site-type-wp-alias.test/config/nginx/conf.d/main.conf' should contain: + """ + server_name bh-site-type-wp-alias.test bh-site-type-wp-alias-a.test bh-site-type-wp-alias-b.test; + """ + And the hosts file should contain 'bh-site-type-wp-alias.test' + And the hosts file should not contain 'bh-site-type-wp-alias-a.test' + And a GET request to 'http://bh-site-type-wp-alias.test/' should return 200 + And a GET request to 'http://bh-site-type-wp-alias-a.test/' should not return 503 + And a GET request to 'http://bh-site-type-wp-alias-b.test/' should not return 503 + When I run 'bin/ee site info bh-site-type-wp-alias.test' + Then STDOUT should contain: + """ + bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test + """ + + Scenario: An alias of another site cannot be created as a site + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run 'bin/ee site create bh-site-type-wp-alias-a.test --type=wp' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-alias-a.test already exists as an alias domain for site: bh-site-type-wp-alias.test. + """ + And the site 'bh-site-type-wp-alias-a.test' should not exist + + Scenario: An existing alias or site cannot be reused as an alias on create + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run "bin/ee site create bh-site-type-wp-alias2.test --type=wp --alias-domains='bh-site-type-wp-alias-b.test'" + Then the command should fail + And STDERR should contain: + """ + already exists as an alias domain for site: bh-site-type-wp-alias.test + """ + When I run "bin/ee site create bh-site-type-wp-alias2.test --type=wp --alias-domains='bh-site-type-wp-alias.test'" + Then the command should fail + And the site 'bh-site-type-wp-alias2.test' should not exist + + Scenario: Add and delete alias domains on a WordPress site + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' + Then the command should succeed + And STDOUT should contain: + """ + Success: Alias domains updated on site bh-site-type-wp-alias.test. + """ + And the site 'bh-site-type-wp-alias.test' info should contain: + | field | value | + | alias_domains | /,bh-site-type-wp-alias-c\.test$/ | + And the file '{SITES_DIR}/bh-site-type-wp-alias.test/docker-compose.yml' should contain: + """ + bh-site-type-wp-alias-c.test + """ + And a GET request to 'http://bh-site-type-wp-alias-c.test/' should not return 503 + When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' + Then the command should fail + And STDERR should contain: + """ + Alias domains: bh-site-type-wp-alias-c.test is/are already present on the site. + """ + When I run 'bin/ee site update bh-site-type-wp-alias.test --delete-alias-domains=bh-site-type-wp-alias-zzz.test' + Then the command should fail + And STDERR should contain: + """ + Domains to delete is/are not a subset of already existing alias domains. + """ + When I run 'bin/ee site update bh-site-type-wp-alias.test --delete-alias-domains=bh-site-type-wp-alias.test' + Then the command should fail + And STDERR should contain: + """ + Primary site domain: `bh-site-type-wp-alias.test` can not be deleted. + """ + When I run 'bin/ee site update bh-site-type-wp-alias.test --delete-alias-domains=bh-site-type-wp-alias-c.test' + Then the command should succeed + And the site 'bh-site-type-wp-alias.test' info should contain: + | field | value | + | alias_domains | /^((?!alias-c).)*$/ | + And a GET request to 'http://bh-site-type-wp-alias-c.test/' should return 503 within 30 seconds + + Scenario: Another site cannot be added as an alias + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + And a WordPress site 'bh-site-type-wp-alias3.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias3.test' + Then the command should fail + And STDERR should contain: + """ + bh-site-type-wp-alias3.test already exists as a site. + """ + When I run 'bin/ee site update bh-site-type-wp-alias3.test --add-alias-domains=bh-site-type-wp-alias-a.test' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-alias-a.test already exists as an alias domain for site: bh-site-type-wp-alias.test. + """ + + Scenario: Deleting the site frees its alias domains + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run 'bin/ee site delete bh-site-type-wp-alias.test --yes' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-alias-a.test/' should return 503 within 30 seconds + When I run 'bin/ee site create bh-site-type-wp-alias-a.test --type=wp --skip-install' + Then the command should succeed + + Scenario: Subdomain multisite with a wildcard alias domain + When I run "bin/ee site create bh-site-type-wp-aliasmu.test --type=wp --mu=subdom --skip-install --alias-domains='bh-site-type-wp-aliasmu-x.test,*.bh-site-type-wp-aliasmu-x.test'" + Then the command should succeed + And the site 'bh-site-type-wp-aliasmu.test' info should contain: + | field | value | + | alias_domains | bh-site-type-wp-aliasmu.test,*.bh-site-type-wp-aliasmu.test,bh-site-type-wp-aliasmu-x.test,*.bh-site-type-wp-aliasmu-x.test | + And the file '{SITES_DIR}/bh-site-type-wp-aliasmu.test/docker-compose.yml' should contain: + """ + VIRTUAL_HOST=${VIRTUAL_HOST},*.${VIRTUAL_HOST}, + *.bh-site-type-wp-aliasmu-x.test + """ + And a GET request to 'http://foo.bh-site-type-wp-aliasmu-x.test/' should not return 503 + + # AUTH-1: an alias like `_wildcard.<site>` passes validation and can clobber the site's `_wildcard` proxy files (fixed in site-command #502 / site-type-wp #239, not on develop). + @known-bug + Scenario: Reserved alias names are rejected on create + When I run "bin/ee site create bh-site-type-wp-auth1.test --type=wp --skip-install --alias-domains='_wildcard.bh-site-type-wp-auth1.test'" + Then the command should fail + And the site 'bh-site-type-wp-auth1.test' should not exist + + # Fixed in site-type-wp #239 (not on develop): a trailing comma stores an empty alias and a dangling `,` in VIRTUAL_HOST. + @known-bug + Scenario: Blank alias domains are dropped on create + When I run "bin/ee site create bh-site-type-wp-blank.test --type=wp --skip-install --alias-domains='bh-site-type-wp-blank-a.test,'" + Then the command should succeed + And the site 'bh-site-type-wp-blank.test' info should contain: + | field | value | + | alias_domains | bh-site-type-wp-blank.test,bh-site-type-wp-blank-a.test | + + # Suspected bug (new, since #488): update_alias_domains() dumps docker-compose.yml with nohttps only for LE sites, so a site without SSL loses HTTPS_METHOD=nohttps until the next refresh. + @known-bug + Scenario: Alias changes keep HTTPS disabled on a site without SSL + Given a WordPress site 'bh-site-type-wp-aliasnossl.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-aliasnossl.test --add-alias-domains=bh-site-type-wp-aliasnossl-a.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-aliasnossl.test/docker-compose.yml' should contain: + """ + HTTPS_METHOD=nohttps + """ diff --git a/features/install-options.feature b/features/install-options.feature new file mode 100644 index 0000000..8f38679 --- /dev/null +++ b/features/install-options.feature @@ -0,0 +1,222 @@ +Feature: WordPress install options (title/admin flags, locale, version, skip-content, skip-install, public-dir, env) + + Scenario: Custom title and administrator, production environment, URL-style site name + When I run "bin/ee site create http://BH-SITE-TYPE-WP-Install.test/ --type=wp --title='BH Test Site' --admin-user=bhadmin --admin-email=BH.Admin@Example.test --admin-pass=BhPass1234 --env=production" + Then the command should succeed + And the site 'bh-site-type-wp-install.test' should exist + And the site 'bh-site-type-wp-install.test' info should contain: + | field | value | + | app_admin_url | BH Test Site | + | app_admin_username | bhadmin | + | app_admin_password | BhPass1234 | + | app_admin_email | BH.Admin@Example.test | + | mailhog_enabled | 0 | + When I run WP-CLI 'option get blogname' on site 'bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + BH Test Site + """ + When I run WP-CLI 'user get bhadmin --field=user_email' on site 'bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + BH.Admin@Example.test + """ + When I run WP-CLI 'user check-password bhadmin BhPass1234' on site 'bh-site-type-wp-install.test' + Then the command should succeed + When I run WP-CLI 'config get WP_ENVIRONMENT_TYPE' on site 'bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + production + """ + And the 'mailhog' container of site 'bh-site-type-wp-install.test' should not exist + When I run 'bin/ee site info bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + BH Test Site + bhadmin + BhPass1234 + """ + + Scenario Outline: Weak administrator password <pass> is rejected before anything is created + When I run 'bin/ee site create bh-site-type-wp-weakpass.test --type=wp --admin-pass=<pass>' + Then the command should fail + And STDERR should contain: + """ + Issues found in input password: `<pass>` + <message> + """ + And the site 'bh-site-type-wp-weakpass.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_weakpass_test' + + Examples: + | pass | message | + | abc1 | Password too short! Must be at least 8 characters long. | + | 12345678 | Password must include at least one letter! | + | abcdefgh | Password must include at least one number! | + + Scenario: Insecure password allowed, status check skipped, default admin e-mail from --wp-mail + When I run 'bin/ee site create bh-site-type-wp-insecure.test --type=wp --admin-user=bhweak --admin-pass=abc --allow-insecure-pass --skip-status-check --wp-mail=Owner@Example.Test' + Then the command should succeed + And STDOUT should not contain: + """ + Checking and verifying site-up status. + """ + And the site 'bh-site-type-wp-insecure.test' info should contain: + | field | value | + | app_admin_password | abc | + | app_admin_email | owner@example.test | + When I run WP-CLI 'user check-password bhweak abc' on site 'bh-site-type-wp-insecure.test' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-insecure.test/' should return 200 within 60 seconds + + Scenario: EasyEngine v3 flags create a cached WordPress site + When I run 'bin/ee site create bh-site-type-wp-ee3.test --wpredis --user=bhee3 --pass=BhEe3pass1 --email=ee3@example.test' + Then the command should succeed + And the site 'bh-site-type-wp-ee3.test' info should contain: + | field | value | + | site_type | wp | + | cache_nginx_fullpage | 1 | + | cache_host | global-redis | + | app_admin_username | bhee3 | + | app_admin_password | BhEe3pass1 | + | app_admin_email | ee3@example.test | + + Scenario: Removed EasyEngine v3 cache flags are rejected + When I run 'bin/ee site create bh-site-type-wp-w3tc.test --type=wp --w3tc' + Then the command should fail + And the site 'bh-site-type-wp-w3tc.test' should not exist + + Scenario: Localized install on a staging server enables mailhog + When I run 'bin/ee site create bh-site-type-wp-locale.test --type=wp --locale=de_DE --env=staging' + Then the command should succeed + And STDOUT should contain: + """ + Enabling mailhog as this is a staging server. + """ + And the file '{SITES_DIR}/bh-site-type-wp-locale.test/app/htdocs/wp-includes/version.php' should contain: + """ + $wp_local_package = 'de_DE'; + """ + And a GET request to 'http://bh-site-type-wp-locale.test/' should return 200 + And the response body should contain 'lang="de-DE"' + And the 'mailhog' container of site 'bh-site-type-wp-locale.test' should be running + And the site 'bh-site-type-wp-locale.test' info should contain: + | field | value | + | mailhog_enabled | 1 | + When I run WP-CLI 'config get WP_ENVIRONMENT_TYPE' on site 'bh-site-type-wp-locale.test' + Then STDOUT should contain: + """ + staging + """ + + Scenario: Install a specific WordPress version + When I run 'bin/ee site create bh-site-type-wp-version.test --type=wp --version=6.8.3' + Then the command should succeed + When I run WP-CLI 'core version' on site 'bh-site-type-wp-version.test' + Then STDOUT should contain: + """ + 6.8.3 + """ + When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-version.test' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-version.test/' should return 200 + + Scenario: Install WordPress without the default themes and plugins + When I run 'bin/ee site create bh-site-type-wp-nocontent.test --type=wp --skip-content' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-nocontent.test/app/htdocs/wp-load.php' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-nocontent.test/app/htdocs/wp-content/plugins/akismet' should not exist + When I run WP-CLI 'theme list --format=count' on site 'bh-site-type-wp-nocontent.test' + Then STDOUT should contain: + """ + 0 + """ + When I run WP-CLI 'plugin list --format=count' on site 'bh-site-type-wp-nocontent.test' + Then STDOUT should contain: + """ + 0 + """ + + Scenario: Skip the WordPress install (site name without TLD gets .test) + When I run 'bin/ee site create bh-site-type-wp-skipinstall --type=wp --skip-install' + Then the command should succeed + And STDOUT should not contain: + """ + Installing WordPress site. + Checking and verifying site-up status. + """ + And the site 'bh-site-type-wp-skipinstall.test' should exist + And the site 'bh-site-type-wp-skipinstall.test' info should contain: + | field | value | + | app_admin_username | <empty> | + | app_admin_password | <empty> | + And The site 'bh-site-type-wp-skipinstall.test' should have WordPress + And the file '{SITES_DIR}/bh-site-type-wp-skipinstall.test/app/htdocs/wp-load.php' should exist + And the hosts file should not contain 'bh-site-type-wp-skipinstall.test' + And the EE database table 'cron' should have 1 rows where 'site_url' is 'bh-site-type-wp-skipinstall.test' + When I run WP-CLI 'core is-installed' on site 'bh-site-type-wp-skipinstall.test' + Then the command should fail + When I send a GET request to 'http://bh-site-type-wp-skipinstall.test/' + Then the response should have header 'wp-admin/install.php' + When I run 'bin/ee site info bh-site-type-wp-skipinstall.test' + Then STDOUT should not contain: + """ + WordPress Username + """ + + Scenario: Serve WordPress from a public directory inside htdocs + When I run 'bin/ee site create bh-site-type-wp-pubdir.test --type=wp --public-dir=current' + Then the command should succeed + And the site 'bh-site-type-wp-pubdir.test' info should contain: + | field | value | + | site_container_fs_path | /var/www/htdocs/current | + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/app/htdocs/current/wp-load.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/app/htdocs/wp-config.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/app/htdocs/wp-cli.yml' should contain: + """ + path: current/ + """ + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/config/nginx/conf.d/main.conf' should contain: + """ + root /var/www/htdocs/current; + """ + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-pubdir.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-pubdir.test + """ + And a GET request to 'http://bh-site-type-wp-pubdir.test/' should return 200 + + # Suspected bug (new): --public-dir is declared as a flag, so passing it without a value makes the public dir `htdocs/1`. + @known-bug + Scenario: --public-dir without a value keeps the default webroot + When I run 'bin/ee site create bh-site-type-wp-pubdir1.test --type=wp --public-dir --skip-install' + Then the site 'bh-site-type-wp-pubdir1.test' info should contain: + | field | value | + | site_container_fs_path | /var/www/htdocs | + + # WP-1: WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. + @known-bug + Scenario: The latest WordPress download passes its checksums + Given a WordPress site 'bh-site-type-wp-wp1.test' exists created with '--skip-install' + When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-wp1.test' + Then the command should succeed + + # Suspected bug (new): title, admin user/e-mail/password are interpolated inside single quotes of the `wp core install` shell command, so an apostrophe breaks the install and the site is rolled back. + @known-bug + Scenario: A site title with an apostrophe + When I run 'bin/ee site create bh-site-type-wp-apos.test --type=wp --title="BH's Site"' + Then the command should succeed + When I run WP-CLI 'option get blogname' on site 'bh-site-type-wp-apos.test' + Then STDOUT should contain: + """ + BH's Site + """ + + # SEC-1: shell metacharacters in the site name reach unescaped shell sinks during create (fixed locally, not on develop). + # The payload survives lowercasing and the docker-prefix stripping of `.`/`-`: it only creates an empty marker in $HOME. + @known-bug + Scenario: Shell metacharacters in the site name are rejected + When I run "bin/ee site create 'bh-site-type-wp-sec1$(cd;>bhsitetypewpsec1pwned).test' --type=wp --skip-install" + Then the command should fail + And the file '/root/bhsitetypewpsec1pwned' should not exist diff --git a/features/ssl.feature b/features/ssl.feature new file mode 100644 index 0000000..efa1428 --- /dev/null +++ b/features/ssl.feature @@ -0,0 +1,247 @@ +Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) + + Scenario: Create a site with a self-signed certificate + When I run 'bin/ee site create bh-site-type-wp-self.test --type=wp --ssl=self' + Then the command should succeed + And STDOUT should contain: + """ + Success: https://bh-site-type-wp-self.test has been created successfully! + """ + # Self-signed certs cover *.site, so the site is recorded as wildcard. + And the site 'bh-site-type-wp-self.test' info should contain: + | field | value | + | site_ssl | self | + | site_ssl_wildcard | 1 | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-self.test.crt' should exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-self.test.key' should exist + And the hosts file should contain 'bh-site-type-wp-self.test' + And the file '{SITES_DIR}/bh-site-type-wp-self.test/docker-compose.yml' should not contain: + """ + HTTPS_METHOD=nohttps + """ + And a GET request to 'https://bh-site-type-wp-self.test/' should return 200 within 30 seconds + And a GET request to 'http://bh-site-type-wp-self.test/' should return 301 + And the response should redirect to 'https://bh-site-type-wp-self.test/' + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-self.test' + Then STDOUT should contain: + """ + https://bh-site-type-wp-self.test + """ + When I run 'bin/ee site info bh-site-type-wp-self.test' + Then STDOUT should contain: + """ + https://bh-site-type-wp-self.test + SSL Wildcard + Yes + """ + When I run 'bin/ee site share bh-site-type-wp-self.test' + Then the command should fail + And STDERR should contain: + """ + site share is not yet supported for ssl sites. + """ + + Scenario: A subdomain site inherits the wildcard certificate of its parent + Given a WordPress site 'bh-site-type-wp-self.test' exists created with '--ssl=self' + When I run 'bin/ee site create child.bh-site-type-wp-self.test --type=wp --ssl=inherit' + Then the command should succeed + And STDOUT should contain: + """ + Inherited certs from parent + """ + And the site 'child.bh-site-type-wp-self.test' info should contain: + | field | value | + | site_ssl | inherit | + | site_ssl_wildcard | 0 | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/child.bh-site-type-wp-self.test.crt' should not exist + And a GET request to 'https://child.bh-site-type-wp-self.test/' should return 200 within 30 seconds + When I run 'bin/ee site delete child.bh-site-type-wp-self.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-self.test.crt' should exist + + Scenario: Inherit together with --wildcard is rejected + When I run 'bin/ee site create child2.bh-site-type-wp-self.test --type=wp --ssl=inherit --wildcard' + Then the command should fail + And STDERR should contain: + """ + --wildcard or --mu=subdom flag can not be passed together with --ssl=inherit flag. + """ + And the site 'child2.bh-site-type-wp-self.test' should not exist + + Scenario: Inherit without a parent site rolls back + When I run 'bin/ee site create orphan.bh-site-type-wp-noparent.test --type=wp --ssl=inherit' + Then the command should fail + And STDERR should contain: + """ + Unable to find existing site: bh-site-type-wp-noparent.test + Initiating clean-up. + """ + And the site 'orphan.bh-site-type-wp-noparent.test' should not exist + And the directory '{SITES_DIR}/orphan.bh-site-type-wp-noparent.test' should not exist + And the global database should not have a database named 'orphan_bh_site_type_wp_noparent_test' + + # Suspected bug (new): check_parent_site_certs() builds its message with var_dump(), which dumps the parent model to STDOUT. + @known-bug + Scenario: Inherit from a parent without SSL fails with a clean message + Given a WordPress site 'bh-site-type-wp-nossl.test' exists created with '--skip-install' + When I run 'bin/ee site create child.bh-site-type-wp-nossl.test --type=wp --ssl=inherit' + Then the command should fail + And STDERR should contain: + """ + Cannot inherit from bh-site-type-wp-nossl.test as site does not have SSL cert + """ + And STDOUT should not contain: + """ + object(EE\Model\Site) + """ + And the site 'child.bh-site-type-wp-nossl.test' should not exist + + Scenario: Create a site with a custom certificate + Given a self-signed certificate for 'bh-site-type-wp-custom.test' exists in '{TMP_DIR}/certs' + When I run 'bin/ee site create bh-site-type-wp-custom.test --type=wp --ssl=custom --ssl-key={TMP_DIR}/certs/bh-site-type-wp-custom.test.key --ssl-crt={TMP_DIR}/certs/bh-site-type-wp-custom.test.crt' + Then the command should succeed + And the site 'bh-site-type-wp-custom.test' info should contain: + | field | value | + | site_ssl | custom | + And the files '{TMP_DIR}/certs/bh-site-type-wp-custom.test.crt' and '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-custom.test.crt' should be identical + And the files '{TMP_DIR}/certs/bh-site-type-wp-custom.test.key' and '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-custom.test.key' should be identical + And a GET request to 'https://bh-site-type-wp-custom.test/' should return 200 within 30 seconds + When I run 'bin/ee site delete bh-site-type-wp-custom.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-custom.test.crt' should not exist + + Scenario: Custom SSL without key and certificate is rejected + When I run 'bin/ee site create bh-site-type-wp-custom2.test --type=wp --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-type-wp-custom2.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-custom2.test' should not exist + + Scenario: Custom SSL with missing files is rejected + When I run 'bin/ee site create bh-site-type-wp-custom2.test --type=wp --ssl=custom --ssl-key=/nonexistent/bh.key --ssl-crt=/nonexistent/bh.crt' + Then the command should fail + And STDERR should contain: + """ + ssl-key OR ssl-crt path does not exist + """ + And the site 'bh-site-type-wp-custom2.test' should not exist + + Scenario: An unknown --ssl value rolls the whole create back + When I run 'bin/ee site create bh-site-type-wp-sslbad.test --type=wp --ssl=bogus' + Then the command should fail + And STDERR should contain: + """ + Unrecognized value in --ssl flag: bogus + Initiating clean-up. + """ + And the site 'bh-site-type-wp-sslbad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-sslbad.test' should not exist + And the site 'bh-site-type-wp-sslbad.test' should have 0 docker volumes + And the global database should not have a database named 'bh_site_type_wp_sslbad_test' + And the hosts file should not contain 'bh-site-type-wp-sslbad.test' + + Scenario: Enabling and disabling SSL on a WordPress site runs search-replace with a DB backup + Given a WordPress site 'bh-site-type-wp-sslupd.test' exists + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=self' + Then the command should succeed + And STDOUT should contain: + """ + Starting SSL update for: bh-site-type-wp-sslupd.test + Running search-replace. + Taking database backup before search-replace. + Success: Successfully completed search-replace. + Success: Enabled SSL for bh-site-type-wp-sslupd.test + """ + And the file '{EE_ROOT_DIR}/.backup/bh-site-type-wp-sslupd.test/bh-site-type-wp-sslupd.test.db' should exist + And the file '{SITES_DIR}/bh-site-type-wp-sslupd.test/app/htdocs/bh-site-type-wp-sslupd.test.db' should not exist + And the site 'bh-site-type-wp-sslupd.test' info should contain: + | field | value | + | site_ssl | self | + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-sslupd.test' + Then STDOUT should contain: + """ + https://bh-site-type-wp-sslupd.test + """ + And a GET request to 'https://bh-site-type-wp-sslupd.test/' should return 200 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=self' + Then the command should fail + And STDERR should contain: + """ + SSL is already enabled on bh-site-type-wp-sslupd.test + """ + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=off' + Then the command should succeed + And STDOUT should contain: + """ + Success: Disabled SSL for bh-site-type-wp-sslupd.test + """ + And the site 'bh-site-type-wp-sslupd.test' info should contain: + | field | value | + | site_ssl | <empty> | + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-sslupd.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-sslupd.test + """ + And a GET request to 'http://bh-site-type-wp-sslupd.test/' should return 200 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=off' + Then the command should fail + And STDERR should contain: + """ + SSL is already disabled on bh-site-type-wp-sslupd.test + """ + + Scenario: Invalid SSL updates are rejected + Given a WordPress site 'bh-site-type-wp-sslupd.test' exists + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=bogus' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'ssl' + """ + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=le --wildcard' + Then the command should fail + And STDERR should contain: + """ + Update from normal SSL to wildcard SSL is not supported yet. + """ + + # Uses one Let's Encrypt issuance (the name resolves to this host through *.ee4-test.rt.gw). + @le + Scenario: Create a WordPress site with a Let's Encrypt certificate + When I run 'bin/ee site create bh-site-type-wp-le1.ee4-test.rt.gw --type=wp --ssl=le' + Then the command should succeed + And the site 'bh-site-type-wp-le1.ee4-test.rt.gw' info should contain: + | field | value | + | site_ssl | le | + | site_ssl_wildcard | 0 | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should exist + And the hosts file should not contain 'bh-site-type-wp-le1.ee4-test.rt.gw' + When I run 'curl -sS -o /dev/null -w "%{http_code} %{ssl_verify_result}" https://bh-site-type-wp-le1.ee4-test.rt.gw/' + Then STDOUT should contain: + """ + 200 0 + """ + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-le1.ee4-test.rt.gw' + Then STDOUT should contain: + """ + https://bh-site-type-wp-le1.ee4-test.rt.gw + """ + When I run 'bin/ee site delete bh-site-type-wp-le1.ee4-test.rt.gw --yes' + Then the command should succeed + And STDOUT should contain: + """ + Revoking certificate. + Removing ssl certs and other config files. + """ + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should not exist + And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/certs/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist + + # SSL-5 / SSL-9: deleting an LE site leaves acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). + @le @known-bug + Scenario: Deleting a Let's Encrypt site leaves no ACME leftovers + Then the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist + And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/www.bh-site-type-wp-le1.ee4-test.rt.gw' should not exist diff --git a/features/vip.feature b/features/vip.feature new file mode 100644 index 0000000..51df637 --- /dev/null +++ b/features/vip.feature @@ -0,0 +1,47 @@ +# Needs git on the host and access to github.com (clones vip-go-skeleton and the large vip-go-mu-plugins-built repo). +@network +Feature: WordPress VIP Go sites (--vip) + + Scenario: Create a VIP site from the default skeleton with cache + When I run 'bin/ee site create bh-site-type-wp-vip.test --type=wp --vip --cache' + Then the command should succeed + And STDOUT should contain: + """ + VIP Skeleton repo will be used for wp-content directory: https://github.com/Automattic/vip-go-skeleton.git + Repo access check completed. + Setting up VIP Go environment. + VIP Go environment setup completed. + """ + And STDERR should contain: + """ + Nginx-helper and wp-redis plugin is installed to enable cache. + """ + And the site 'bh-site-type-wp-vip.test' should have the site meta 'is_vip' set to '1' + And the site 'bh-site-type-wp-vip.test' should have the site meta 'vip_repo_url' set to 'https://github.com/Automattic/vip-go-skeleton.git' + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content/.git' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content/mu-plugins' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content/uploads' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/mu-plugins' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content-bkp' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-vip.test/app/wp-config.php' should contain: + """ + /wp-content/mu-plugins/000-pre-vip-config/requires.php + /wp-content/vip-config/vip-config.php + """ + When I run 'stat -c %U {SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content' + Then STDOUT should contain: + """ + www-data + """ + And a GET request to 'http://bh-site-type-wp-vip.test/' should return 200 + + Scenario: An inaccessible VIP repository is rejected before anything is created + When I run 'env GIT_TERMINAL_PROMPT=0 bin/ee site create bh-site-type-wp-vipbad.test --type=wp --vip=https://github.com/Automattic/bh-site-type-wp-does-not-exist.git' + Then the command should fail + And STDERR should contain: + """ + Your repo is being cloned at wp-content directory make sure the repo has wp-content data. + Could not read from remote repository. Please make sure you have the correct access rights and the repository exists. + """ + And the site 'bh-site-type-wp-vipbad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-vipbad.test' should not exist From df38e28713138c4ee535a54a790eb422d256e026 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:25:34 +0530 Subject: [PATCH 06/36] test(site-type-wp): add behat coverage inventory --- features/COVERAGE.md | 138 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 features/COVERAGE.md diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 0000000..1e88f94 --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,138 @@ +# site-type-wp Behat coverage + +Scope: `ee site create --type=wp` and everything `EE\Site\Type\WordPress` defines or overrides (`create`, `info`, `restart`, `reload`, `share`, `update_ssl`, `dump_docker_compose_yml`, rollback), plus the inherited site-command subcommands where their behaviour depends on the WordPress site type (enable/disable/refresh, delete, list, clean, update of php/proxy-cache/alias domains). Generic site-command features (backup/restore, clone/sync, ssl-verify/renew/info) belong to the site-command suite. + +Site names all start with `bh-site-type-wp-` (`*.test`, and `bh-site-type-wp-le1.ee4-test.rt.gw` for LE). `@BeforeFeature`/`@AfterFeature` delete only sites, webroots, containers, volumes, networks, global DB databases/users, redis keys, `/etc/hosts` lines and `.backup` dirs that contain that prefix; `/etc/hosts` lines added by a scenario are removed after it. + +## Inventory + +Legend: file names are under `features/`; "KB" = `@known-bug` scenario that documents the bug (expected to fail until fixed). + +### `ee site create --type=wp` arguments and flags + +| Item | Covered by | +|---|---| +| `<site-name>` | site: Create a default WordPress site; missing name: site: Create without a site name prints the usage; `http://` prefix, trailing slash, upper case: install-options: Custom title and administrator…; name without TLD gets `.test`: install-options: Skip the WordPress install; shell metacharacters: install-options KB (SEC-1) | +| existing site / existing webroot / name that is an alias | site: Creating an existing site fails…; database KB (webroot leak); alias-domains: An alias of another site cannot be created as a site | +| `--cache` | cache: Create a site with page and object cache…, The page cache is served from redis; subdom + cache: cache: Subdomain multisite with cache…; EE3 `--wpredis`: install-options | +| `--vip`, `--vip=<repo>` | vip: Create a VIP site from the default skeleton with cache; inaccessible repo: vip: An inaccessible VIP repository is rejected… | +| `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: multisite KB; subdom + inherit: Subdomain multisite cannot inherit SSL (+ KB for the DB leak); AUTH-5: multisite KB | +| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; AUTH-1 and blank alias: alias-domains KB | +| `--title` | install-options: Custom title…; apostrophe: install-options KB; default (= site name): site: Create a default WordPress site | +| `--admin-user`, `--admin-email`, `--admin-pass` | install-options: Custom title…; defaults (random `wp-user-*`, 18-char password, `admin@<site>`): site: Create a default WordPress site | +| password policy / `--allow-insecure-pass` | install-options: Weak administrator password… (3 rules); Insecure password allowed… | +| `--local-db` | database: Create a site with a local database container, restart `--db`, disable/enable, delete | +| `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: cache KB; restart policy: cache KB (MIG-16) | +| `--public-dir` | install-options: Serve WordPress from a public directory…; without value: install-options KB | +| `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP <php>; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): php-version KB | +| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database KB ×2 | +| `--dbhost` (+ `:port`) | database: Remote database host requires credentials, Unreachable remote database rolls the site back; external-db: all scenarios | +| `--dbprefix`, `--dbcharset`, `--dbcollate`, `--skip-check` | database: Custom database name, user, password, prefix, charset and collation… | +| `--version` | install-options: Install a specific WordPress version; php-version legacy outlines (6.2.6, 6.8.3) | +| `--skip-content` | install-options: Install WordPress without the default themes and plugins | +| `--skip-install` | install-options: Skip the WordPress install; used as a fast fixture in many scenarios | +| `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | +| `--ssl=self` | ssl: Create a site with a self-signed certificate | +| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files | +| `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, parent without SSL (KB), with `--wildcard` | +| `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl KB (SSL-5/SSL-9) | +| unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back | +| `--ssl` without value (= le) | not covered: same code path as `--ssl=le`, LE budget | +| `--wildcard` | ssl: Inherit together with --wildcard is rejected; update `--ssl=le --wildcard` rejection; `--ssl=le --wildcard` itself not covered (needs Cloudflare DNS-01 credentials) | +| `--proxy-cache=on\|off` | proxy-cache: Create a site with proxy cache…; invalid value: proxy-cache KB; default stored as `''`: proxy-cache KB | +| `--proxy-cache-max-size`, `--proxy-cache-max-time` | proxy-cache: Create a site with proxy cache and custom limits (valid time, invalid size fallback) | +| `--force` | external-db: A remote database that already has tables needs --force | +| `--yes` | not covered: on create it only answers the VIP "move mu-plugins" prompt (needs a repo with `mu-plugins`) and the PHP fallback prompt, which the synopsis validation makes unreachable | +| global `--locale` | install-options: Localized install on a staging server… | +| global `--wp-mail` | install-options: Insecure password allowed… (lower-cased default e-mail) | +| global `--env` | install-options: production (WP_ENVIRONMENT_TYPE, no mailhog), staging (mailhog enabled) | +| EE3 flags `--wpredis --user --pass --email`, removed `--w3tc` | install-options: EasyEngine v3 flags…, Removed EasyEngine v3 cache flags… | + +### Other subcommands defined or overridden by the wp type + +| Item | Covered by | +|---|---| +| `info <site>` table | site: Site info as a table; cache/proxy/SSL/local-db/alias/skip-install variants in their features | +| `info --format=json` | site: Site info as JSON (and every `the site … info should contain` step) | +| `info` invalid format / unknown site / from site dir / disabled site | site: Invalid info format…, Site info of an unknown site fails, Site info detects the site from the current directory, Disable and enable… | +| `restart [--all] [--nginx] [--php] [--db]` | site: Restart and reload…; `--db`: database: Restart of a local-db site…; disabled site refused: site: Disable and enable… | +| `reload [--all] [--nginx] [--php]` | site: Restart and reload…; disabled site refused | +| `share --disable` (no share) / SSL site | site: Share without an active share…; ssl: self-signed scenario | +| `share` with ngrok, `--refresh`, `--token` | not covered: needs the external ngrok service and a token | +| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected | +| `update --php` | php-version: Default PHP version and update…, same version, unsupported version | +| `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: KB | +| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps regression: KB | +| `update` of unknown / disabled site | site: Update of an unknown site fails, Disable and enable… | +| `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | +| `delete` (+ confirmation prompt) | site: Delete removes every trace…, Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | +| `list [--format] [--enabled] [--disabled]` | site: List shows the site in every format; disabled: site: Disable and enable… | +| `clean [--page] [--object] [--proxy]` | cache KB (CACHE-1), proxy-cache KB (CACHE-1); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | + +### Behaviours and side effects + +| Behaviour | Covered by | +|---|---| +| Webroot layout, `wp-config.php` one level above the docroot, no leftover `check.html` | site: Create a default WordPress site; public-dir variant | +| Generated files: `.env`, `main.conf` (php / redis / subdir blocks, server_name, root), `custom.ini` (msmtp vs 5.6 sendmail), `user.conf`, `admin-tools.conf`, `docker-compose.yml`, `wp-cli.yml` | site, cache, multisite, php-version, install-options | +| Containers (3 running, mailhog/db/redis absent), images, restart policy, network membership (frontend/backend), site network, 8/11 labelled volumes | site, labels, database, cache | +| Global DB database + user created and dropped; nothing created on early validation errors | site, database, php-version, install-options | +| `/etc/hosts` entry for non-SSL/self sites only, removed on delete | site, ssl, install-options | +| Cron entry created and removed | site, install-options | +| WordPress install: home URL (http/https), permalinks, admin user, title, locale, version, content, `WP_ENVIRONMENT_TYPE`, `COOKIE_DOMAIN` for multisite, X-Forwarded-Proto/debug extras | site, ssl, install-options, multisite | +| nginx WordPress hardening (readme/license/wp-config.txt/dotfiles/.sql/php in uploads) | site: WordPress hardening rules… | +| www / non-www redirect config (only when the other name resolves) | redirect | +| Object/page cache: plugins, wp-config constants, nginx-helper options (option vs network meta), srcache HIT/BYPASS, redis keys | cache | +| Proxy cache files in nginx-proxy `conf.d`/`vhost.d`, `X-Proxy-Cache` header | proxy-cache | +| Rollback (`catch_clean`) at levels 0–4: webroot, containers, volumes, DB, hosts entry removed; remote DB never touched | database, external-db, ssl | +| VIP: skeleton clone, mu-plugins move, wp-config requires, site meta, ownership | vip | +| Container migrations (`set_mu_cookie_domain`, `update_env_file_with_newrelic_key`) | not covered: they only run on upgrade from an older EE; new sites get the same result directly (checked) | +| Rollback on SIGINT/SIGTERM | not covered: MIG-9 (handlers never dispatched); needs killing ee mid-create | +| macOS (`IS_DARWIN`) branches | not covered: Linux-only host | + +Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wildcard` only partly); the gaps are the external-service/DNS-01 paths (`--ssl` bare, `--ssl=le --wildcard`, ngrok share, `--yes`), upgrade-only migrations, signal rollback and macOS branches. + +## Known bugs exposed (`@known-bug`) + +| Scenario | Bug | +|---|---| +| install-options: Shell metacharacters in the site name are rejected | SEC-1 | +| multisite: Subdomain multisite whose wildcard is already an alias… | AUTH-5 | +| alias-domains: Reserved alias names are rejected on create | AUTH-1 | +| alias-domains: Blank alias domains are dropped on create | fixed in site-type-wp #239 (not on develop) | +| install-options: The latest WordPress download passes its checksums | WP-1 | +| cache: The local redis container restarts with the site | MIG-16 | +| cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 | +| ssl: Deleting a Let's Encrypt site leaves no ACME leftovers | SSL-5, SSL-9 | +| database: A database name collision does not drop the other site's database | new: rollback DROPs another site's DB | +| database: A database user collision does not drop the other site's user | new: rollback DROPs another site's DB user | +| database: An existing webroot aborts the create without leaking database objects | new: DB/user leak | +| multisite: A rejected inherit + subdom create leaves nothing in the global database | new: DB/user leak | +| multisite: --mu without a value is rejected | new | +| cache: --with-local-redis without --cache enables the local redis cache | new | +| proxy-cache: Invalid proxy-cache value on update / on create is rejected | new: YAML `on`/`off` parsed as booleans | +| proxy-cache: A new site stores proxy_cache off… | new: default stored as `''` | +| php-version: Loosely matching PHP version <php> is rejected | new: loose `in_array` | +| ssl: Inherit from a parent without SSL fails with a clean message | new: `var_dump()` in the error | +| install-options: --public-dir without a value keeps the default webroot | new | +| install-options: A site title with an apostrophe | new: unescaped shell quoting | +| alias-domains: Alias changes keep HTTPS disabled on a site without SSL | new: #488 regression | + +## Tags and runtime + +| Tag | Scenarios | Meaning | +|---|---|---| +| (all) | 123 | including outline examples | +| `@known-bug` | 24 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | +| `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | +| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects leftovers | +| `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | +| `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway; needs the host to accept docker → gateway connections | +| `@network` | 2 | VIP: clones from github.com | +| `@remote` | 0 | no rclone use in this package | + +Rough timings on this host class: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@known-bug&&~@slow&&~@le&&~@external-db&&~@network'`, 90 scenarios, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3, `@known-bug` ~12. + +## Context changes (`bootstrap/FeatureContext.php`) + +Based on site-command's context. Fixed upstream steps that could never fail: the unanchored `I run` pattern (matched any step with a double-quoted string and passed an empty command), `There should be N containers with labels` (inverted, int vs string), `Following containers … should be removed` (compose v1 container names), `I create subsite` (swapped arguments, `docker exec -it`), the `of command :index` regex steps. `EE_ROOT_DIR` is only defined when core has not defined it. The cleanup hooks no longer delete a fixed list of generic site names. New generic steps are in the "Shared generic steps" section; they assert on the last command and print the full command output on failure. From d5c2fd2596bfe6372aa4e7b34455b5df8fb55b5b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:28:50 +0530 Subject: [PATCH 07/36] test(site-type-wp): guard leftover checks and clean proxy certs in the context Add a step that matches a deleted site's random DB user by prefix, and a guard so a scenario that inspects an earlier scenario's leftovers fails when run alone instead of passing vacuously. The feature cleanup now also removes this suite's nginx-proxy certs and ACME state, which `--ssl=off` and LE deletes leave in the global proxy. --- features/bootstrap/FeatureContext.php | 45 ++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 8954c7f..7a21e73 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -98,6 +98,9 @@ class FeatureContext implements Context /** @var array Container start times noted by the current scenario, keyed by "site/service". */ private $noted_start_times = []; + /** @var array Sites removed by a successful `bin/ee site delete` in the current feature. */ + private static $deleted_sites = []; + /** * Initializes context. */ @@ -149,7 +152,11 @@ public function isInstalled($command) */ public function iRun($command) { - $this->commands[] = EE::launch( $this->replace_placeholders( $command ), false, true ); + $result = EE::launch( $this->replace_placeholders( $command ), false, true ); + $this->commands[] = $result; + if ( 0 === $result->return_code && preg_match( '#^bin/ee site delete (\S+)#', $command, $matches ) ) { + self::$deleted_sites[] = $matches[1]; + } } /** @@ -396,6 +403,7 @@ public function thereShouldBeContainersWithLabel($expected_running_containers, P */ public static function prepareFeature(BeforeFeatureScope $scope) { + self::$deleted_sites = []; self::cleanup_test_artifacts(); } @@ -989,6 +997,33 @@ public function theGlobalDatabaseShouldHaveATableNamed( $database, $table ) } } + /** + * Site DB users are `<site>-<random>`, so a deleted site's user can only be matched by prefix. + * + * @Then the global database should not have users starting with :prefix + */ + public function theGlobalDatabaseShouldNotHaveUsersStartingWith( $prefix ) + { + $users = array_filter( $this->global_db_lines( 'SELECT User FROM mysql.user' ), function ( $user ) use ( $prefix ) { + return 0 === strpos( $user, $prefix ); + } ); + if ( $users ) { + throw new Exception( "The global database still has users starting with $prefix: " . implode( ', ', $users ) ); + } + } + + /** + * Guards scenarios that inspect what an earlier scenario left behind, so they can't pass when run alone. + * + * @Given the site :site was deleted earlier in this feature + */ + public function theSiteWasDeletedEarlierInThisFeature( $site ) + { + if ( ! in_array( $site, self::$deleted_sites, true ) ) { + throw new Exception( "$site was not deleted by an earlier scenario of this feature; run the whole feature." ); + } + } + /** * The DB user of a site is random unless --dbuser was passed, so it is read from the EE database. * @@ -1548,6 +1583,14 @@ private static function cleanup_test_artifacts() { } } + // Proxy certs and ACME state: `--ssl=off` and LE deletes leave them behind (WPT-12, SSL-5, SSL-9). + $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; + foreach ( [ 'certs', 'acme-conf/certs', 'acme-conf/var' ] as $sub ) { + foreach ( glob( "$proxy_dir/$sub/*" . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $path ) { + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } + } + // Backups written by `ee site update --ssl` search-replace, fixtures and injection markers. foreach ( glob( EE_ROOT_DIR . '/.backup/*' . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $dir ) { EE::launch( 'rm -rf ' . escapeshellarg( $dir ), false, true ); From 63abf44158dfe6582c1a3f09329c8b07f53066b3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:32:56 +0530 Subject: [PATCH 08/36] test(site-type-wp): expose the rollback and alias nohttps bugs, tighten assertions Add known-bug scenarios for the level-0 rollback dropping another site's database (custom SSL typo), the user-visible HTTPS redirect after an alias change on a site whose SSL was turned off, and self-signed sites that can't turn SSL off. The nohttps scenario was mislabelled as a #488 regression: the flag was already false for sites without SSL. Reference the WPT issue IDs, match DB_CHARSET and MULTISITE exactly, check that delete and the leak scenarios remove the random DB user, assert the w3tc error, and stop the missing-site delete from depending on the previous scenario. Update COVERAGE.md (126 scenarios, 27 known-bug, 86 in the default run). --- features/COVERAGE.md | 57 +++++++++++++++++--------------- features/alias-domains.feature | 21 +++++++++++- features/cache.feature | 2 +- features/database.feature | 26 +++++++++++---- features/install-options.feature | 9 +++-- features/multisite.feature | 10 +++--- features/php-version.feature | 2 +- features/proxy-cache.feature | 6 ++-- features/site.feature | 5 +-- features/ssl.feature | 14 +++++++- 10 files changed, 101 insertions(+), 51 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 1e88f94..575e115 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -2,7 +2,7 @@ Scope: `ee site create --type=wp` and everything `EE\Site\Type\WordPress` defines or overrides (`create`, `info`, `restart`, `reload`, `share`, `update_ssl`, `dump_docker_compose_yml`, rollback), plus the inherited site-command subcommands where their behaviour depends on the WordPress site type (enable/disable/refresh, delete, list, clean, update of php/proxy-cache/alias domains). Generic site-command features (backup/restore, clone/sync, ssl-verify/renew/info) belong to the site-command suite. -Site names all start with `bh-site-type-wp-` (`*.test`, and `bh-site-type-wp-le1.ee4-test.rt.gw` for LE). `@BeforeFeature`/`@AfterFeature` delete only sites, webroots, containers, volumes, networks, global DB databases/users, redis keys, `/etc/hosts` lines and `.backup` dirs that contain that prefix; `/etc/hosts` lines added by a scenario are removed after it. +Site names all start with `bh-site-type-wp-` (`*.test`, and `bh-site-type-wp-le1.ee4-test.rt.gw` for LE). `@BeforeFeature`/`@AfterFeature` delete only sites, webroots, containers, volumes, networks, global DB databases/users, redis keys, nginx-proxy certs and ACME state, `/etc/hosts` lines and `.backup` dirs that contain that prefix; `/etc/hosts` lines added by a scenario are removed after it. ## Inventory @@ -25,18 +25,18 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: cache KB; restart policy: cache KB (MIG-16) | | `--public-dir` | install-options: Serve WordPress from a public directory…; without value: install-options KB | | `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP <php>; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): php-version KB | -| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database KB ×2 | +| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database KB ×3 (WPT-1) | | `--dbhost` (+ `:port`) | database: Remote database host requires credentials, Unreachable remote database rolls the site back; external-db: all scenarios | | `--dbprefix`, `--dbcharset`, `--dbcollate`, `--skip-check` | database: Custom database name, user, password, prefix, charset and collation… | | `--version` | install-options: Install a specific WordPress version; php-version legacy outlines (6.2.6, 6.8.3) | | `--skip-content` | install-options: Install WordPress without the default themes and plugins | | `--skip-install` | install-options: Skip the WordPress install; used as a fast fixture in many scenarios | | `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | -| `--ssl=self` | ssl: Create a site with a self-signed certificate | -| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files | +| `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl KB (PHPT-10) | +| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database KB (WPT-1) | | `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, parent without SSL (KB), with `--wildcard` | | `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl KB (SSL-5/SSL-9) | -| unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back | +| unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection, WPT-13) | | `--ssl` without value (= le) | not covered: same code path as `--ssl=le`, LE budget | | `--wildcard` | ssl: Inherit together with --wildcard is rejected; update `--ssl=le --wildcard` rejection; `--ssl=le --wildcard` itself not covered (needs Cloudflare DNS-01 credentials) | | `--proxy-cache=on\|off` | proxy-cache: Create a site with proxy cache…; invalid value: proxy-cache KB; default stored as `''`: proxy-cache KB | @@ -59,13 +59,13 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `reload [--all] [--nginx] [--php]` | site: Restart and reload…; disabled site refused | | `share --disable` (no share) / SSL site | site: Share without an active share…; ssl: self-signed scenario | | `share` with ngrok, `--refresh`, `--token` | not covered: needs the external ngrok service and a token | -| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected | +| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl KB (PHPT-10) | | `update --php` | php-version: Default PHP version and update…, same version, unsupported version | | `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: KB | -| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps regression: KB | +| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps dropped on sites without SSL: KB ×2 (WPT-2) | | `update` of unknown / disabled site | site: Update of an unknown site fails, Disable and enable… | | `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | -| `delete` (+ confirmation prompt) | site: Delete removes every trace…, Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | +| `delete` (+ confirmation prompt) | site: Delete removes every trace… (incl. the random DB user), Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | | `list [--format] [--enabled] [--disabled]` | site: List shows the site in every format; disabled: site: Disable and enable… | | `clean [--page] [--object] [--proxy]` | cache KB (CACHE-1), proxy-cache KB (CACHE-1); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | @@ -104,35 +104,38 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil | cache: The local redis container restarts with the site | MIG-16 | | cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 | | ssl: Deleting a Let's Encrypt site leaves no ACME leftovers | SSL-5, SSL-9 | -| database: A database name collision does not drop the other site's database | new: rollback DROPs another site's DB | -| database: A database user collision does not drop the other site's user | new: rollback DROPs another site's DB user | -| database: An existing webroot aborts the create without leaking database objects | new: DB/user leak | -| multisite: A rejected inherit + subdom create leaves nothing in the global database | new: DB/user leak | -| multisite: --mu without a value is rejected | new | -| cache: --with-local-redis without --cache enables the local redis cache | new | -| proxy-cache: Invalid proxy-cache value on update / on create is rejected | new: YAML `on`/`off` parsed as booleans | -| proxy-cache: A new site stores proxy_cache off… | new: default stored as `''` | -| php-version: Loosely matching PHP version <php> is rejected | new: loose `in_array` | -| ssl: Inherit from a parent without SSL fails with a clean message | new: `var_dump()` in the error | -| install-options: --public-dir without a value keeps the default webroot | new | -| install-options: A site title with an apostrophe | new: unescaped shell quoting | -| alias-domains: Alias changes keep HTTPS disabled on a site without SSL | new: #488 regression | +| database: A database name collision does not drop the other site's database | WPT-1: rollback DROPs another site's DB | +| database: A database user collision does not drop the other site's user | WPT-1: rollback DROPs another site's DB user | +| database: A failed custom-SSL create does not drop another site's database | WPT-1: level-0 rollback DROPs the colliding default DB name | +| database: An existing webroot aborts the create without leaking database objects | WPT-8: DB/user leak | +| multisite: A rejected inherit + subdom create leaves nothing in the global database | WPT-8: DB/user leak | +| multisite: --mu without a value is rejected | WPT-6 | +| cache: --with-local-redis without --cache enables the local redis cache | WPT-6 | +| proxy-cache: Invalid proxy-cache value on update / on create is rejected | WPT-3: YAML `on`/`off` parsed as booleans | +| proxy-cache: A new site stores proxy_cache off… | WPT-3: default stored as `''` | +| php-version: Loosely matching PHP version <php> is rejected | WPT-4: loose `in_array` | +| ssl: Inherit from a parent without SSL fails with a clean message | WPT-7: `var_dump()` in the error | +| ssl: SSL of a site created with --ssl=self can be turned off | PHPT-10 (site-command) | +| install-options: --public-dir without a value keeps the default webroot | WPT-6 | +| install-options: A site title with an apostrophe | WPT-5: unescaped shell quoting | +| alias-domains: Alias changes keep HTTPS disabled on a site without SSL | WPT-2 (pre-existing, not a #488 regression) | +| alias-domains: A site whose SSL was turned off keeps serving HTTP after an alias change | WPT-2 + WPT-12: user-visible redirect to HTTPS | ## Tags and runtime | Tag | Scenarios | Meaning | |---|---|---| -| (all) | 123 | including outline examples | -| `@known-bug` | 24 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | +| (all) | 126 | including outline examples | +| `@known-bug` | 27 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | | `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | -| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects leftovers | +| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first | | `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | -| `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway; needs the host to accept docker → gateway connections | +| `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway (read from `docker network inspect bridge`, 10.0.0.1 on this host); reachability from the default bridge and from a user-defined network was checked with a throwaway listener | | `@network` | 2 | VIP: clones from github.com | | `@remote` | 0 | no rclone use in this package | -Rough timings on this host class: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@known-bug&&~@slow&&~@le&&~@external-db&&~@network'`, 90 scenarios, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3, `@known-bug` ~12. +Rough timings on this host class: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@known-bug&&~@slow&&~@le&&~@external-db&&~@network'`, 86 scenarios, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3, `@known-bug` ~15. ## Context changes (`bootstrap/FeatureContext.php`) -Based on site-command's context. Fixed upstream steps that could never fail: the unanchored `I run` pattern (matched any step with a double-quoted string and passed an empty command), `There should be N containers with labels` (inverted, int vs string), `Following containers … should be removed` (compose v1 container names), `I create subsite` (swapped arguments, `docker exec -it`), the `of command :index` regex steps. `EE_ROOT_DIR` is only defined when core has not defined it. The cleanup hooks no longer delete a fixed list of generic site names. New generic steps are in the "Shared generic steps" section; they assert on the last command and print the full command output on failure. +Based on site-command's context. Fixed upstream steps that could never fail: the unanchored `I run` pattern (matched any step with a double-quoted string and passed an empty command), `There should be N containers with labels` (inverted, int vs string), `Following containers … should be removed` (compose v1 container names), `I create subsite` (swapped arguments, `docker exec -it`), the `of command :index` regex steps. `EE_ROOT_DIR` is only defined when core has not defined it. The cleanup hooks no longer delete a fixed list of generic site names. New generic steps are in the "Shared generic steps" section; they assert on the last command and print the full command output on failure. `the site :site was deleted earlier in this feature` guards scenarios that inspect another scenario's leftovers, so they fail instead of passing vacuously when run alone. diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 00dc73b..0f6d1e6 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -142,7 +142,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias | field | value | | alias_domains | bh-site-type-wp-blank.test,bh-site-type-wp-blank-a.test | - # Suspected bug (new, since #488): update_alias_domains() dumps docker-compose.yml with nohttps only for LE sites, so a site without SSL loses HTTPS_METHOD=nohttps until the next refresh. + # WPT-2 (pre-existing, not from #488: the flag was already false for sites without SSL): update_alias_domains() dumps docker-compose.yml without HTTPS_METHOD=nohttps for a site without SSL, until the next refresh. @known-bug Scenario: Alias changes keep HTTPS disabled on a site without SSL Given a WordPress site 'bh-site-type-wp-aliasnossl.test' exists created with '--skip-install' @@ -152,3 +152,22 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ HTTPS_METHOD=nohttps """ + When I run 'bin/ee site update bh-site-type-wp-aliasnossl.test --delete-alias-domains=bh-site-type-wp-aliasnossl-a.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-aliasnossl.test/docker-compose.yml' should contain: + """ + HTTPS_METHOD=nohttps + """ + + # WPT-2 + WPT-12: `--ssl=off` keeps <site>.crt, and with aliases CERT_NAME=${VIRTUAL_HOST} picks it up, so without nohttps nginx-proxy redirects HTTP to HTTPS again. + @known-bug + Scenario: A site whose SSL was turned off keeps serving HTTP after an alias change + Given a WordPress site 'bh-site-type-wp-aliasoff.test' exists + When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --ssl=self' + Then the command should succeed + When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --ssl=off' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --add-alias-domains=bh-site-type-wp-aliasoff-a.test' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 within 30 seconds diff --git a/features/cache.feature b/features/cache.feature index 579bbe9..3d7faf2 100644 --- a/features/cache.feature +++ b/features/cache.feature @@ -140,7 +140,7 @@ Feature: WordPress redis cache (--cache, --with-local-redis) Given a WordPress site 'bh-site-type-wp-mig16.test' exists created with '--cache --with-local-redis --skip-install' Then the 'redis' container of site 'bh-site-type-wp-mig16.test' should have restart policy 'always' - # Suspected bug (new): --with-local-redis alone is silently ignored (no redis container, no cache) although the help says "Enable cache with local redis container". + # WPT-6: --with-local-redis alone is silently ignored (no redis container, no cache) although the help says "Enable cache with local redis container". @known-bug Scenario: --with-local-redis without --cache enables the local redis cache When I run 'bin/ee site create bh-site-type-wp-lredisonly.test --type=wp --with-local-redis --skip-install' diff --git a/features/database.feature b/features/database.feature index e72909f..ddcaec8 100644 --- a/features/database.feature +++ b/features/database.feature @@ -25,10 +25,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) bhp_ """ When I run WP-CLI 'config get DB_CHARSET' on site 'bh-site-type-wp-dbopts.test' - Then STDOUT should contain: - """ - utf8 - """ + Then STDOUT should match '/^utf8$/m' When I run WP-CLI 'config get DB_COLLATE' on site 'bh-site-type-wp-dbopts.test' Then STDOUT should contain: """ @@ -132,7 +129,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the site 'bh-site-type-wp-remote.test' should not exist And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist - # Suspected bug (new, data loss): when CREATE DATABASE fails because the name is taken, catch_clean() runs cleanup_db() and DROPs the other site's database. + # WPT-1 (data loss): when CREATE DATABASE fails because the name is taken, catch_clean() runs cleanup_db() and DROPs the other site's database. # `a-b.test` and `a.b.test` both map to the default name `a_b_test`, so no flag is needed to hit it. @known-bug Scenario: A database name collision does not drop the other site's database @@ -143,7 +140,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the site 'bh-site-type-wp-db.c.test' should not exist And the global database should have a database named 'bh_site_type_wp_db_c_test' - # Suspected bug (new, data loss): same rollback with --dbuser of another site DROPs that site's DB user. + # WPT-1 (data loss): same rollback with --dbuser of another site DROPs that site's DB user. @known-bug Scenario: A database user collision does not drop the other site's user Given a WordPress site 'bh-site-type-wp-dbuser.test' exists created with '--dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' @@ -153,7 +150,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the global database should have a user named 'bh_site_type_wp_shareduser' And the global database should have a database named 'bh_site_type_wp_dbuser_test' - # Suspected bug (new): create_site_root() exits via EE::error() after create_user_in_db() already ran, so the global DB and user leak. + # WPT-8: create_site_root() exits via EE::error() after create_user_in_db() already ran, so the global DB and user leak. @known-bug Scenario: An existing webroot aborts the create without leaking database objects Given I run 'mkdir -p {SITES_DIR}/bh-site-type-wp-stale.test' @@ -167,3 +164,18 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the file '{SITES_DIR}/bh-site-type-wp-stale.test/keep-me' should exist And the site 'bh-site-type-wp-stale.test' should not exist And the global database should not have a database named 'bh_site_type_wp_stale_test' + And the global database should not have users starting with 'bh-site-type-wp-stale.test-' + + # WPT-1 (data loss): a failed --ssl=custom validation rolls back before any DB work, yet catch_clean() still DROPs the default-named database of the colliding site. + @known-bug + Scenario: A failed custom-SSL create does not drop another site's database + Given a WordPress site 'bh-site-type-wp-db-s.test' exists created with '--skip-install' + And the global database should have a database named 'bh_site_type_wp_db_s_test' + When I run 'bin/ee site create bh-site-type-wp-db.s.test --type=wp --ssl=custom --ssl-key=/nonexistent/bh.key --ssl-crt=/nonexistent/bh.crt' + Then the command should fail + And STDERR should contain: + """ + ssl-key OR ssl-crt path does not exist + """ + And the site 'bh-site-type-wp-db.s.test' should not exist + And the global database should have a database named 'bh_site_type_wp_db_s_test' diff --git a/features/install-options.feature b/features/install-options.feature index 8f38679..b5defee 100644 --- a/features/install-options.feature +++ b/features/install-options.feature @@ -84,6 +84,11 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con Scenario: Removed EasyEngine v3 cache flags are rejected When I run 'bin/ee site create bh-site-type-wp-w3tc.test --type=wp --w3tc' Then the command should fail + # The dedicated "no longer supported in EE v4" message is unreachable (WPT-14). + And STDERR should contain: + """ + unknown --w3tc parameter + """ And the site 'bh-site-type-wp-w3tc.test' should not exist Scenario: Localized install on a staging server enables mailhog @@ -187,7 +192,7 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con """ And a GET request to 'http://bh-site-type-wp-pubdir.test/' should return 200 - # Suspected bug (new): --public-dir is declared as a flag, so passing it without a value makes the public dir `htdocs/1`. + # WPT-6: --public-dir is declared as a flag, so passing it without a value makes the public dir `htdocs/1`. @known-bug Scenario: --public-dir without a value keeps the default webroot When I run 'bin/ee site create bh-site-type-wp-pubdir1.test --type=wp --public-dir --skip-install' @@ -202,7 +207,7 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-wp1.test' Then the command should succeed - # Suspected bug (new): title, admin user/e-mail/password are interpolated inside single quotes of the `wp core install` shell command, so an apostrophe breaks the install and the site is rolled back. + # WPT-5: title, admin user/e-mail/password are interpolated inside single quotes of the `wp core install` shell command, so an apostrophe breaks the install and the site is rolled back. @known-bug Scenario: A site title with an apostrophe When I run 'bin/ee site create bh-site-type-wp-apos.test --type=wp --title="BH's Site"' diff --git a/features/multisite.feature b/features/multisite.feature index 8c8b89b..d0d60b0 100644 --- a/features/multisite.feature +++ b/features/multisite.feature @@ -20,10 +20,7 @@ Feature: WordPress multisite (--mu=subdir|subdom) define( 'COOKIE_DOMAIN', false ); """ When I run WP-CLI 'config get MULTISITE' on site 'bh-site-type-wp-subdir.test' - Then STDOUT should contain: - """ - 1 - """ + Then STDOUT should match '/^1$/m' When I create subsite 'bhsub1' in 'bh-site-type-wp-subdir.test' Then a GET request to 'http://bh-site-type-wp-subdir.test/' should return 200 And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/' should return 200 @@ -92,14 +89,15 @@ Feature: WordPress multisite (--mu=subdir|subdom) And the site 'bh-site-type-wp-muinh.test' should not exist And the directory '{SITES_DIR}/bh-site-type-wp-muinh.test' should not exist - # Suspected bug (new): the inherit/wildcard check runs after create_user_in_db() and exits via EE::error() without cleanup, so the global DB and user leak. + # WPT-8: the inherit/wildcard check runs after create_user_in_db() and exits via EE::error() without cleanup, so the global DB and user leak. @known-bug Scenario: A rejected inherit + subdom create leaves nothing in the global database When I run 'bin/ee site create bh-site-type-wp-muinh2.test --type=wp --mu=subdom --ssl=inherit' Then the command should fail And the global database should not have a database named 'bh_site_type_wp_muinh2_test' + And the global database should not have users starting with 'bh-site-type-wp-muinh2.test-' - # Suspected bug (new): `--mu` without a value only warns "--mu parameter needs a value" and silently creates a single site. + # WPT-6: `--mu` without a value only warns "--mu parameter needs a value" and creates a single site. @known-bug Scenario: --mu without a value is rejected When I run 'bin/ee site create bh-site-type-wp-muempty.test --type=wp --mu --skip-install' diff --git a/features/php-version.feature b/features/php-version.feature index 7c48683..55cc5b2 100644 --- a/features/php-version.feature +++ b/features/php-version.feature @@ -123,7 +123,7 @@ Feature: PHP versions of WordPress sites (--php on create and update) | 9.9 | | foo | - # Suspected bug (new): options are compared loosely, so `--php=8` passes validation ("8" == "8.0") and create builds the image name `easyengine/php8:` (unknown version). + # WPT-4: options are compared loosely, so `--php=8` passes validation ("8" == "8.0") and create builds the image name `easyengine/php8:` (unknown version). @known-bug Scenario Outline: Loosely matching PHP version <php> is rejected When I run 'bin/ee site create bh-site-type-wp-phploose.test --type=wp --php=<php> --skip-install' diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index 6baeadd..26d78b6 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -82,7 +82,7 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should not exist And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should not exist - # Suspected bug (new): the synopsis options `on`/`off` are parsed by YAML as booleans, so any non-empty value passes validation; update then runs the disable path. + # WPT-3: the synopsis options `on`/`off` are parsed by YAML as booleans, so any non-empty value passes validation; update then runs the disable path. @known-bug @destructive Scenario: Invalid proxy-cache value on update is rejected Given a WordPress site 'bh-site-type-wp-proxyval.test' exists created with '--skip-install' @@ -93,7 +93,7 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) Invalid value specified for 'proxy-cache' """ - # Suspected bug (new): same YAML boolean parsing on create; the invalid value is silently ignored. + # WPT-3: same YAML boolean parsing on create; the invalid value is silently ignored. @known-bug Scenario: Invalid proxy-cache value on create is rejected When I run 'bin/ee site create bh-site-type-wp-proxybad.test --type=wp --proxy-cache=maybe --skip-install' @@ -104,7 +104,7 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) """ And the site 'bh-site-type-wp-proxybad.test' should not exist - # Suspected bug (new): without --proxy-cache the YAML default `off` becomes boolean false and is stored as '' instead of 'off', so `--proxy-cache=off` is not detected as a no-op (and restarts the global proxy). + # WPT-3: without --proxy-cache the YAML default `off` becomes boolean false and is stored as '' instead of 'off', so `--proxy-cache=off` is not detected as a no-op (and restarts the global proxy). @known-bug @destructive Scenario: A new site stores proxy_cache off and refuses to turn it off again Given a WordPress site 'bh-site-type-wp-proxyoff.test' exists created with '--skip-install' diff --git a/features/site.feature b/features/site.feature index acf3480..aaca92f 100644 --- a/features/site.feature +++ b/features/site.feature @@ -473,14 +473,15 @@ Feature: WordPress site lifecycle (create, info, list, enable/disable, restart/r And the site 'bh-site-type-wp-basic.test' should have 0 docker volumes And the docker network 'bh-site-type-wp-basic.test' should not exist And the global database should not have a database named 'bh_site_type_wp_basic_test' + And the global database should not have users starting with 'bh-site-type-wp-basic.test-' And the hosts file should not contain 'bh-site-type-wp-basic.test' And the EE database table 'cron' should have 0 rows where 'site_url' is 'bh-site-type-wp-basic.test' And a GET request to 'http://bh-site-type-wp-basic.test/' should return 503 Scenario: Deleting a missing site fails - When I run 'bin/ee site delete bh-site-type-wp-basic.test --yes' + When I run 'bin/ee site delete bh-site-type-wp-missing.test --yes' Then the command should fail And STDERR should contain: """ - Site bh-site-type-wp-basic.test does not exist. + Site bh-site-type-wp-missing.test does not exist. """ diff --git a/features/ssl.feature b/features/ssl.feature index efa1428..8189c59 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -80,7 +80,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) And the directory '{SITES_DIR}/orphan.bh-site-type-wp-noparent.test' should not exist And the global database should not have a database named 'orphan_bh_site_type_wp_noparent_test' - # Suspected bug (new): check_parent_site_certs() builds its message with var_dump(), which dumps the parent model to STDOUT. + # WPT-7: check_parent_site_certs() builds its message with var_dump(), which dumps the parent model to STDOUT. @known-bug Scenario: Inherit from a parent without SSL fails with a clean message Given a WordPress site 'bh-site-type-wp-nossl.test' exists created with '--skip-install' @@ -129,6 +129,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) """ And the site 'bh-site-type-wp-custom2.test' should not exist + # WPT-13: create's `[--ssl]` is a flag, so the value is only rejected in init_ssl(), after the site was provisioned. Scenario: An unknown --ssl value rolls the whole create back When I run 'bin/ee site create bh-site-type-wp-sslbad.test --type=wp --ssl=bogus' Then the command should fail @@ -194,6 +195,16 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) SSL is already disabled on bh-site-type-wp-sslupd.test """ + # PHPT-10 (site-command): init_ssl() stores site_ssl_wildcard=1 for self-signed creates, so update refuses `--ssl=off` ("wildcard to normal SSL") and `--ssl=off --wildcard`. + @known-bug + Scenario: SSL of a site created with --ssl=self can be turned off + Given a WordPress site 'bh-site-type-wp-selfoff.test' exists created with '--ssl=self --skip-install' + When I run 'bin/ee site update bh-site-type-wp-selfoff.test --ssl=off' + Then the command should succeed + And the site 'bh-site-type-wp-selfoff.test' info should contain: + | field | value | + | site_ssl | <empty> | + Scenario: Invalid SSL updates are rejected Given a WordPress site 'bh-site-type-wp-sslupd.test' exists When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=bogus' @@ -243,5 +254,6 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) # SSL-5 / SSL-9: deleting an LE site leaves acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). @le @known-bug Scenario: Deleting a Let's Encrypt site leaves no ACME leftovers + Given the site 'bh-site-type-wp-le1.ee4-test.rt.gw' was deleted earlier in this feature Then the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/www.bh-site-type-wp-le1.ee4-test.rt.gw' should not exist From 5eb4b5d5569b33b0ad8ad4f2c2b824e46ae2bd76 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:35:19 +0530 Subject: [PATCH 09/36] test(site-type-wp): 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 575e115..727254f 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -128,7 +128,8 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil | (all) | 126 | including outline examples | | `@known-bug` | 27 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | | `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | -| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first | +| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); the create passes, the leftovers one is `@known-bug` (SSL-5/SSL-9) | +| `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | | `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | | `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway (read from `docker network inspect bridge`, 10.0.0.1 on this host); reachability from the default bridge and from a user-defined network was checked with a throwaway listener | | `@network` | 2 | VIP: clones from github.com | From 506ce89e078e5b5b046c046865dacdb154c16069 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:37:36 +0530 Subject: [PATCH 10/36] test(site-type-wp): keep the cleanup delete in the foreground process group Under a TTY, `timeout` puts ee in a background process group; the `docker exec -it` of the global redis purge then stops on SIGTTOU together with timeout, so the AfterFeature delete of a --cache site never returns. -k 30 because ee catches SIGTERM without dispatching it (MIG-9). --- features/bootstrap/FeatureContext.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 7a21e73..3645338 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1526,7 +1526,8 @@ private static function cleanup_test_artifacts() { $sites = $pdo->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ); foreach ( $sites as $site ) { if ( false !== strpos( $site, self::TEST_PREFIX ) ) { - EE::launch( "timeout 600 $ee site delete " . escapeshellarg( $site ) . ' --yes', false, true ); + // --foreground: in a background process group the `docker exec -it` of a cache purge stops on SIGTTOU (with timeout itself) and hangs forever. + EE::launch( "timeout --foreground -k 30 600 $ee site delete " . escapeshellarg( $site ) . ' --yes', false, true ); } } From d78a718522544e37f882e7f90f3c9997214b907f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:40:44 +0530 Subject: [PATCH 11/36] test(site-type-wp): mark the existing-site-as-alias create check as PHPT-7 --- features/COVERAGE.md | 7 ++++--- features/alias-domains.feature | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 727254f..12f46e3 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -17,7 +17,7 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--cache` | cache: Create a site with page and object cache…, The page cache is served from redis; subdom + cache: cache: Subdomain multisite with cache…; EE3 `--wpredis`: install-options | | `--vip`, `--vip=<repo>` | vip: Create a VIP site from the default skeleton with cache; inaccessible repo: vip: An inaccessible VIP repository is rejected… | | `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: multisite KB; subdom + inherit: Subdomain multisite cannot inherit SSL (+ KB for the DB leak); AUTH-5: multisite KB | -| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; AUTH-1 and blank alias: alias-domains KB | +| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; AUTH-1, PHPT-7 and blank alias: alias-domains KB | | `--title` | install-options: Custom title…; apostrophe: install-options KB; default (= site name): site: Create a default WordPress site | | `--admin-user`, `--admin-email`, `--admin-pass` | install-options: Custom title…; defaults (random `wp-user-*`, 18-char password, `admin@<site>`): site: Create a default WordPress site | | password policy / `--allow-insecure-pass` | install-options: Weak administrator password… (3 rules); Insecure password allowed… | @@ -99,6 +99,7 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil | install-options: Shell metacharacters in the site name are rejected | SEC-1 | | multisite: Subdomain multisite whose wildcard is already an alias… | AUTH-5 | | alias-domains: Reserved alias names are rejected on create | AUTH-1 | +| alias-domains: An existing site cannot be reused as an alias on create | PHPT-7 | | alias-domains: Blank alias domains are dropped on create | fixed in site-type-wp #239 (not on develop) | | install-options: The latest WordPress download passes its checksums | WP-1 | | cache: The local redis container restarts with the site | MIG-16 | @@ -125,8 +126,8 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil | Tag | Scenarios | Meaning | |---|---|---| -| (all) | 126 | including outline examples | -| `@known-bug` | 27 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | +| (all) | 127 | including outline examples | +| `@known-bug` | 28 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | | `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | | `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); the create passes, the leftovers one is `@known-bug` (SSL-5/SSL-9) | | `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 0f6d1e6..068d07f 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -36,7 +36,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ And the site 'bh-site-type-wp-alias-a.test' should not exist - Scenario: An existing alias or site cannot be reused as an alias on create + Scenario: An existing alias cannot be reused as an alias on create Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" When I run "bin/ee site create bh-site-type-wp-alias2.test --type=wp --alias-domains='bh-site-type-wp-alias-b.test'" Then the command should fail @@ -44,10 +44,16 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ already exists as an alias domain for site: bh-site-type-wp-alias.test """ - When I run "bin/ee site create bh-site-type-wp-alias2.test --type=wp --alias-domains='bh-site-type-wp-alias.test'" - Then the command should fail And the site 'bh-site-type-wp-alias2.test' should not exist + # PHPT-7: create only checks other sites' aliases (get_all_alias_domains() drops site URLs), so an existing site's name is accepted as an alias. + @known-bug + Scenario: An existing site cannot be reused as an alias on create + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run "bin/ee site create bh-site-type-wp-alias3.test --type=wp --skip-install --alias-domains='bh-site-type-wp-alias.test'" + Then the command should fail + And the site 'bh-site-type-wp-alias3.test' should not exist + Scenario: Add and delete alias domains on a WordPress site Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' From bd8edea28f2a18a19c6c9d8014f3ec46b0faf6e3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:44:08 +0530 Subject: [PATCH 12/36] test(site-type-wp): make the suite independent of the runner environment Match the site list JSON pretty-printed or compact, accept lang="de" (the translation picks the html lang attribute), use full installs for the redirect checks (WPT-17 race with --skip-install), and look for the SEC-1 marker in $HOME, which sudo -E keeps as the caller home on CI. --- features/bootstrap/FeatureContext.php | 12 +++++++++++- features/install-options.feature | 5 +++-- features/redirect.feature | 5 +++-- features/site.feature | 6 ++---- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 3645338..b94ff29 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1227,6 +1227,7 @@ private function replace_placeholders( $text ) { '{EE_ROOT_DIR}' => EE_ROOT_DIR, '{SITES_DIR}' => EE_SITE_ROOT, '{TMP_DIR}' => self::TEST_TMP_DIR, + '{HOME}' => self::home_dir(), ]; if ( false !== strpos( $text, '{DOCKER_BRIDGE_IP}' ) ) { $map['{DOCKER_BRIDGE_IP}'] = self::docker_bridge_ip(); @@ -1235,6 +1236,15 @@ private function replace_placeholders( $text ) { return strtr( $text, $map ); } + /** + * HOME as ee's shells see it (sudo -E keeps the caller's, e.g. /home/runner on CI). + */ + private static function home_dir() { + $home = getenv( 'HOME' ); + + return $home ? rtrim( $home, '/' ) : '/root'; + } + private static function docker_bridge_ip() { $result = EE::launch( "docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'", false, true ); $ip = trim( $result->stdout ); @@ -1596,6 +1606,6 @@ private static function cleanup_test_artifacts() { foreach ( glob( EE_ROOT_DIR . '/.backup/*' . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $dir ) { EE::launch( 'rm -rf ' . escapeshellarg( $dir ), false, true ); } - EE::launch( 'rm -rf ' . escapeshellarg( self::TEST_TMP_DIR ) . ' /root/bhsitetypewpsec1pwned', false, true ); + EE::launch( 'rm -rf ' . escapeshellarg( self::TEST_TMP_DIR ) . ' ' . escapeshellarg( self::home_dir() . '/bhsitetypewpsec1pwned' ) . ' /root/bhsitetypewpsec1pwned', false, true ); } } diff --git a/features/install-options.feature b/features/install-options.feature index b5defee..edaf526 100644 --- a/features/install-options.feature +++ b/features/install-options.feature @@ -103,7 +103,8 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con $wp_local_package = 'de_DE'; """ And a GET request to 'http://bh-site-type-wp-locale.test/' should return 200 - And the response body should contain 'lang="de-DE"' + # The translation's html_lang_attribute decides between de and de-DE. + And the response body should contain 'lang="de' And the 'mailhog' container of site 'bh-site-type-wp-locale.test' should be running And the site 'bh-site-type-wp-locale.test' info should contain: | field | value | @@ -224,4 +225,4 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con Scenario: Shell metacharacters in the site name are rejected When I run "bin/ee site create 'bh-site-type-wp-sec1$(cd;>bhsitetypewpsec1pwned).test' --type=wp --skip-install" Then the command should fail - And the file '/root/bhsitetypewpsec1pwned' should not exist + And the file '{HOME}/bhsitetypewpsec1pwned' should not exist diff --git a/features/redirect.feature b/features/redirect.feature index df4a125..537092c 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,9 +1,10 @@ Feature: www / non-www redirection of WordPress sites # The www check curls the other host name from the host, so it needs a hosts entry before the site is created. + # Full installs: with --skip-install nothing waits for the proxy to load the vhost before the www check (WPT-17). Scenario: no_www-no_ssl redirection works properly Given I add a hosts entry for 'www.bh-site-type-wp-redir.test' - When I run 'bin/ee site create bh-site-type-wp-redir.test --type=wp --skip-install' + When I run 'bin/ee site create bh-site-type-wp-redir.test --type=wp' Then the command should succeed And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should exist And a GET request to 'http://www.bh-site-type-wp-redir.test/' should return 301 within 30 seconds @@ -15,7 +16,7 @@ Feature: www / non-www redirection of WordPress sites Scenario: www-no_ssl redirection works properly Given I add a hosts entry for 'bh-site-type-wp-redir2.test' - When I run 'bin/ee site create www.bh-site-type-wp-redir2.test --type=wp --skip-install' + When I run 'bin/ee site create www.bh-site-type-wp-redir2.test --type=wp' Then the command should succeed And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/www.bh-site-type-wp-redir2.test-redirect.conf' should exist And a GET request to 'http://bh-site-type-wp-redir2.test/' should return 301 within 30 seconds diff --git a/features/site.feature b/features/site.feature index aaca92f..a1e285d 100644 --- a/features/site.feature +++ b/features/site.feature @@ -319,10 +319,8 @@ Feature: WordPress site lifecycle (create, info, list, enable/disable, restart/r """ When I run 'bin/ee site list --format=json' Then STDOUT should be valid json - And STDOUT should contain: - """ - {"site":"bh-site-type-wp-basic.test","status":"enabled"} - """ + # Pretty-printed or compact depending on the terminal. + And STDOUT should match '/\{\s*"site":\s*"bh-site-type-wp-basic\.test",\s*"status":\s*"enabled"\s*\}/' When I run 'bin/ee site list --enabled --format=text' Then STDOUT should contain: """ From 7c339e487a0b155cf75f5f14fec7bb08e3ee627e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:47:50 +0530 Subject: [PATCH 13/36] test(site-type-wp): expose the stale www redirect after delete (SC-19) --- features/COVERAGE.md | 5 +++-- features/redirect.feature | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 12f46e3..c4699a0 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -100,6 +100,7 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil | multisite: Subdomain multisite whose wildcard is already an alias… | AUTH-5 | | alias-domains: Reserved alias names are rejected on create | AUTH-1 | | alias-domains: An existing site cannot be reused as an alias on create | PHPT-7 | +| redirect: Deleting the site stops the www redirect | SC-19 (site-command) | | alias-domains: Blank alias domains are dropped on create | fixed in site-type-wp #239 (not on develop) | | install-options: The latest WordPress download passes its checksums | WP-1 | | cache: The local redis container restarts with the site | MIG-16 | @@ -126,8 +127,8 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil | Tag | Scenarios | Meaning | |---|---|---| -| (all) | 127 | including outline examples | -| `@known-bug` | 28 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | +| (all) | 128 | including outline examples | +| `@known-bug` | 29 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | | `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | | `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); the create passes, the leftovers one is `@known-bug` (SSL-5/SSL-9) | | `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | diff --git a/features/redirect.feature b/features/redirect.feature index 537092c..3f67a8f 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -30,8 +30,18 @@ Feature: www / non-www redirection of WordPress sites Scenario: Deleting the site removes its redirect config Given I add a hosts entry for 'www.bh-site-type-wp-redir.test' - And a WordPress site 'bh-site-type-wp-redir.test' exists created with '--skip-install' + And a WordPress site 'bh-site-type-wp-redir.test' exists + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should exist When I run 'bin/ee site delete bh-site-type-wp-redir.test --yes' Then the command should succeed And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should not exist - And a GET request to 'http://www.bh-site-type-wp-redir.test/' should return 503 within 30 seconds + + # SC-19: delete removes the redirect file without reloading nginx-proxy, so www keeps redirecting to the deleted site. + @known-bug + Scenario: Deleting the site stops the www redirect + Given I add a hosts entry for 'www.bh-site-type-wp-redir4.test' + And a WordPress site 'bh-site-type-wp-redir4.test' exists + And a GET request to 'http://www.bh-site-type-wp-redir4.test/' should return 301 within 30 seconds + When I run 'bin/ee site delete bh-site-type-wp-redir4.test --yes' + Then the command should succeed + And a GET request to 'http://www.bh-site-type-wp-redir4.test/' should return 503 within 30 seconds From ce886c7ef4693872a488ad31cb3b0bd7b84f48fc Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:51:01 +0530 Subject: [PATCH 14/36] test(site-type-wp): follow the merged alias domain validation (#239) AUTH-1, AUTH-5 and the blank-alias scenario pass on develop now, so they are regular regression scenarios. The WPT-2 + WPT-12 alias-change scenario passed there only because the update returns before docker-gen has the recreated container; it waits for the new alias to be served before it checks the main domain. --- features/COVERAGE.md | 7 +++---- features/alias-domains.feature | 10 +++++----- features/multisite.feature | 3 +-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index c4699a0..f930f3d 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -17,7 +17,7 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--cache` | cache: Create a site with page and object cache…, The page cache is served from redis; subdom + cache: cache: Subdomain multisite with cache…; EE3 `--wpredis`: install-options | | `--vip`, `--vip=<repo>` | vip: Create a VIP site from the default skeleton with cache; inaccessible repo: vip: An inaccessible VIP repository is rejected… | | `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: multisite KB; subdom + inherit: Subdomain multisite cannot inherit SSL (+ KB for the DB leak); AUTH-5: multisite KB | -| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; AUTH-1, PHPT-7 and blank alias: alias-domains KB | +| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; PHPT-7: alias-domains KB; AUTH-1 and blank alias: regression scenarios (fixed by #502/#239) | | `--title` | install-options: Custom title…; apostrophe: install-options KB; default (= site name): site: Create a default WordPress site | | `--admin-user`, `--admin-email`, `--admin-pass` | install-options: Custom title…; defaults (random `wp-user-*`, 18-char password, `admin@<site>`): site: Create a default WordPress site | | password policy / `--allow-insecure-pass` | install-options: Weak administrator password… (3 rules); Insecure password allowed… | @@ -94,14 +94,13 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil ## Known bugs exposed (`@known-bug`) +AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. + | Scenario | Bug | |---|---| | install-options: Shell metacharacters in the site name are rejected | SEC-1 | -| multisite: Subdomain multisite whose wildcard is already an alias… | AUTH-5 | -| alias-domains: Reserved alias names are rejected on create | AUTH-1 | | alias-domains: An existing site cannot be reused as an alias on create | PHPT-7 | | redirect: Deleting the site stops the www redirect | SC-19 (site-command) | -| alias-domains: Blank alias domains are dropped on create | fixed in site-type-wp #239 (not on develop) | | install-options: The latest WordPress download passes its checksums | WP-1 | | cache: The local redis container restarts with the site | MIG-16 | | cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 | diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 068d07f..f81e20f 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -132,15 +132,13 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ And a GET request to 'http://foo.bh-site-type-wp-aliasmu-x.test/' should not return 503 - # AUTH-1: an alias like `_wildcard.<site>` passes validation and can clobber the site's `_wildcard` proxy files (fixed in site-command #502 / site-type-wp #239, not on develop). - @known-bug + # AUTH-1 regression: an alias like `_wildcard.<site>` could clobber the site's `_wildcard` proxy files (fixed by site-command #502 / site-type-wp #239). Scenario: Reserved alias names are rejected on create When I run "bin/ee site create bh-site-type-wp-auth1.test --type=wp --skip-install --alias-domains='_wildcard.bh-site-type-wp-auth1.test'" Then the command should fail And the site 'bh-site-type-wp-auth1.test' should not exist - # Fixed in site-type-wp #239 (not on develop): a trailing comma stores an empty alias and a dangling `,` in VIRTUAL_HOST. - @known-bug + # Regression (fixed by site-type-wp #239): a trailing comma stored an empty alias and a dangling `,` in VIRTUAL_HOST. Scenario: Blank alias domains are dropped on create When I run "bin/ee site create bh-site-type-wp-blank.test --type=wp --skip-install --alias-domains='bh-site-type-wp-blank-a.test,'" Then the command should succeed @@ -176,4 +174,6 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias And a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 within 30 seconds When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --add-alias-domains=bh-site-type-wp-aliasoff-a.test' Then the command should succeed - And a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 within 30 seconds + # The update returns before docker-gen has the recreated container, so the old (HTTP) config could still answer: wait until the new alias is served. + When I wait until "curl -s -o /dev/null -D - --resolve bh-site-type-wp-aliasoff-a.test:80:127.0.0.1 http://bh-site-type-wp-aliasoff-a.test/ | head -n 1 | grep -qv ' 503'" succeeds within 60 seconds + Then a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 diff --git a/features/multisite.feature b/features/multisite.feature index d0d60b0..e879bfd 100644 --- a/features/multisite.feature +++ b/features/multisite.feature @@ -104,8 +104,7 @@ Feature: WordPress multisite (--mu=subdir|subdom) Then the command should fail And the site 'bh-site-type-wp-muempty.test' should not exist - # AUTH-5: subdom create doesn't check its implicit *.site against existing aliases (fixed in site-type-wp #239, not on develop). - @known-bug + # AUTH-5 regression: subdom create didn't check its implicit *.site against existing aliases (fixed by site-type-wp #239). Scenario: Subdomain multisite whose wildcard is already an alias of another site is rejected Given a WordPress site 'bh-site-type-wp-auth5owner.test' exists created with "--skip-install --alias-domains='*.bh-site-type-wp-auth5.test'" When I run 'bin/ee site create bh-site-type-wp-auth5.test --type=wp --mu=subdom --skip-install' From 91333ecc5abf54f874609b12f132fb146a3ca280 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:53:21 +0530 Subject: [PATCH 15/36] test(site-type-wp): wait for the LE site's HTTPS before checking it Right after create the proxy reload is still in flight (PRX-5): on the PHP 7.4 runner the first HTTPS request failed with curl error 55. Wait until a verified HTTPS request succeeds, then check the status and verify result. --- features/ssl.feature | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/features/ssl.feature b/features/ssl.feature index 8189c59..bb10fef 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -231,7 +231,9 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) | site_ssl_wildcard | 0 | And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should exist And the hosts file should not contain 'bh-site-type-wp-le1.ee4-test.rt.gw' - When I run 'curl -sS -o /dev/null -w "%{http_code} %{ssl_verify_result}" https://bh-site-type-wp-le1.ee4-test.rt.gw/' + # The proxy reload is asynchronous (PRX-5): right after create the old workers can still answer, or drop the TLS connection. + When I wait until "curl -sS -o /dev/null https://bh-site-type-wp-le1.ee4-test.rt.gw/" succeeds within 60 seconds + And I run 'curl -sS -o /dev/null -w "%{http_code} %{ssl_verify_result}" https://bh-site-type-wp-le1.ee4-test.rt.gw/' Then STDOUT should contain: """ 200 0 From ce0acf4830da18c739497c2e151f3e9bfc3c39c0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 16:57:16 +0530 Subject: [PATCH 16/36] test(site-type-wp): make the WPT-1 and WPT-8 scenarios regular The database name/user collision, failed custom-SSL create, existing webroot and inherit + subdom scenarios pass with the fix/site-name-collisions branches of site-command and site-type-wp: the name collision is now refused by the docker volume check and --dbuser of another site up front, so both assert the new error. --- features/COVERAGE.md | 13 ++++--------- features/database.feature | 22 +++++++++++++--------- features/multisite.feature | 3 +-- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index f930f3d..8d5ffe9 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -25,7 +25,7 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: cache KB; restart policy: cache KB (MIG-16) | | `--public-dir` | install-options: Serve WordPress from a public directory…; without value: install-options KB | | `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP <php>; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): php-version KB | -| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database KB ×3 (WPT-1) | +| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database ×3 (WPT-1) | | `--dbhost` (+ `:port`) | database: Remote database host requires credentials, Unreachable remote database rolls the site back; external-db: all scenarios | | `--dbprefix`, `--dbcharset`, `--dbcollate`, `--skip-check` | database: Custom database name, user, password, prefix, charset and collation… | | `--version` | install-options: Install a specific WordPress version; php-version legacy outlines (6.2.6, 6.8.3) | @@ -33,7 +33,7 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--skip-install` | install-options: Skip the WordPress install; used as a fast fixture in many scenarios | | `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | | `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl KB (PHPT-10) | -| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database KB (WPT-1) | +| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database (WPT-1) | | `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, parent without SSL (KB), with `--wildcard` | | `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl KB (SSL-5/SSL-9) | | unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection, WPT-13) | @@ -94,7 +94,7 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil ## Known bugs exposed (`@known-bug`) -AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. +AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-1 and WPT-8 scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) are regular since the fixes on the `fix/site-name-collisions` branches of site-command, site-type-wp and site-type-php (2026-09-26, local): they fail on develop until those are merged. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. | Scenario | Bug | |---|---| @@ -105,11 +105,6 @@ AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09- | cache: The local redis container restarts with the site | MIG-16 | | cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 | | ssl: Deleting a Let's Encrypt site leaves no ACME leftovers | SSL-5, SSL-9 | -| database: A database name collision does not drop the other site's database | WPT-1: rollback DROPs another site's DB | -| database: A database user collision does not drop the other site's user | WPT-1: rollback DROPs another site's DB user | -| database: A failed custom-SSL create does not drop another site's database | WPT-1: level-0 rollback DROPs the colliding default DB name | -| database: An existing webroot aborts the create without leaking database objects | WPT-8: DB/user leak | -| multisite: A rejected inherit + subdom create leaves nothing in the global database | WPT-8: DB/user leak | | multisite: --mu without a value is rejected | WPT-6 | | cache: --with-local-redis without --cache enables the local redis cache | WPT-6 | | proxy-cache: Invalid proxy-cache value on update / on create is rejected | WPT-3: YAML `on`/`off` parsed as booleans | @@ -127,7 +122,7 @@ AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09- | Tag | Scenarios | Meaning | |---|---|---| | (all) | 128 | including outline examples | -| `@known-bug` | 29 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | +| `@known-bug` | 24 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | | `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | | `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); the create passes, the leftovers one is `@known-bug` (SSL-5/SSL-9) | | `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | diff --git a/features/database.feature b/features/database.feature index ddcaec8..6ffe076 100644 --- a/features/database.feature +++ b/features/database.feature @@ -129,29 +129,34 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the site 'bh-site-type-wp-remote.test' should not exist And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist - # WPT-1 (data loss): when CREATE DATABASE fails because the name is taken, catch_clean() runs cleanup_db() and DROPs the other site's database. - # `a-b.test` and `a.b.test` both map to the default name `a_b_test`, so no flag is needed to hit it. - @known-bug + # WPT-1: `a-b.test` and `a.b.test` both map to the default name `a_b_test` (and to the same volumes, PHPT-4). The create is + # refused before the database step, and a failed create only drops the database and user it created itself. Scenario: A database name collision does not drop the other site's database Given a WordPress site 'bh-site-type-wp-db-c.test' exists created with '--skip-install' And the global database should have a database named 'bh_site_type_wp_db_c_test' When I run 'bin/ee site create bh-site-type-wp-db.c.test --type=wp --skip-install' Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-db.c.test would share docker volumes (bhsitetypewpdbctest_*) with the existing site bh-site-type-wp-db-c.test. + """ And the site 'bh-site-type-wp-db.c.test' should not exist And the global database should have a database named 'bh_site_type_wp_db_c_test' - # WPT-1 (data loss): same rollback with --dbuser of another site DROPs that site's DB user. - @known-bug + # WPT-1: --dbuser of another site is refused up front instead of being dropped by the rollback. Scenario: A database user collision does not drop the other site's user Given a WordPress site 'bh-site-type-wp-dbuser.test' exists created with '--dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' When I run 'bin/ee site create bh-site-type-wp-dbuser2.test --type=wp --dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' Then the command should fail + And STDERR should contain: + """ + Database user `bh_site_type_wp_shareduser` is already used by site bh-site-type-wp-dbuser.test on the global database. + """ And the site 'bh-site-type-wp-dbuser2.test' should not exist And the global database should have a user named 'bh_site_type_wp_shareduser' And the global database should have a database named 'bh_site_type_wp_dbuser_test' - # WPT-8: create_site_root() exits via EE::error() after create_user_in_db() already ran, so the global DB and user leak. - @known-bug + # WPT-8: the webroot check runs before the database step, so nothing is created. Scenario: An existing webroot aborts the create without leaking database objects Given I run 'mkdir -p {SITES_DIR}/bh-site-type-wp-stale.test' And I run 'touch {SITES_DIR}/bh-site-type-wp-stale.test/keep-me' @@ -166,8 +171,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the global database should not have a database named 'bh_site_type_wp_stale_test' And the global database should not have users starting with 'bh-site-type-wp-stale.test-' - # WPT-1 (data loss): a failed --ssl=custom validation rolls back before any DB work, yet catch_clean() still DROPs the default-named database of the colliding site. - @known-bug + # WPT-1: a failed --ssl=custom validation rolls back before any DB work and must not drop the colliding site's default-named database. Scenario: A failed custom-SSL create does not drop another site's database Given a WordPress site 'bh-site-type-wp-db-s.test' exists created with '--skip-install' And the global database should have a database named 'bh_site_type_wp_db_s_test' diff --git a/features/multisite.feature b/features/multisite.feature index e879bfd..a5233c2 100644 --- a/features/multisite.feature +++ b/features/multisite.feature @@ -89,8 +89,7 @@ Feature: WordPress multisite (--mu=subdir|subdom) And the site 'bh-site-type-wp-muinh.test' should not exist And the directory '{SITES_DIR}/bh-site-type-wp-muinh.test' should not exist - # WPT-8: the inherit/wildcard check runs after create_user_in_db() and exits via EE::error() without cleanup, so the global DB and user leak. - @known-bug + # WPT-8: the inherit/wildcard check runs before the database step, so nothing is created. Scenario: A rejected inherit + subdom create leaves nothing in the global database When I run 'bin/ee site create bh-site-type-wp-muinh2.test --type=wp --mu=subdom --ssl=inherit' Then the command should fail From dc91ae05d46eb0f9a7c67be1241a6d3a0d0936be Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:00:37 +0530 Subject: [PATCH 17/36] test(site-type-wp): make the WPT-2, SC-19, PHPT-10 and SSL-5/SSL-9 scenarios regular They pass with site-command fix/proxy-cache-rollback-and-le-alias and fail on develop until it merges. --- features/COVERAGE.md | 19 +++++++------------ features/alias-domains.feature | 6 ++---- features/redirect.feature | 3 +-- features/ssl.feature | 7 +++---- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 8d5ffe9..ad538d7 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -32,10 +32,10 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--skip-content` | install-options: Install WordPress without the default themes and plugins | | `--skip-install` | install-options: Skip the WordPress install; used as a fast fixture in many scenarios | | `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | -| `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl KB (PHPT-10) | +| `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl (PHPT-10, fixed) | | `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database (WPT-1) | | `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, parent without SSL (KB), with `--wildcard` | -| `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl KB (SSL-5/SSL-9) | +| `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl (SSL-5/SSL-9, fixed) | | unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection, WPT-13) | | `--ssl` without value (= le) | not covered: same code path as `--ssl=le`, LE budget | | `--wildcard` | ssl: Inherit together with --wildcard is rejected; update `--ssl=le --wildcard` rejection; `--ssl=le --wildcard` itself not covered (needs Cloudflare DNS-01 credentials) | @@ -59,10 +59,10 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `reload [--all] [--nginx] [--php]` | site: Restart and reload…; disabled site refused | | `share --disable` (no share) / SSL site | site: Share without an active share…; ssl: self-signed scenario | | `share` with ngrok, `--refresh`, `--token` | not covered: needs the external ngrok service and a token | -| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl KB (PHPT-10) | +| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl (PHPT-10, fixed) | | `update --php` | php-version: Default PHP version and update…, same version, unsupported version | | `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: KB | -| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps dropped on sites without SSL: KB ×2 (WPT-2) | +| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps kept on sites without SSL: ×2 (WPT-2, fixed) | | `update` of unknown / disabled site | site: Update of an unknown site fails, Disable and enable… | | `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | | `delete` (+ confirmation prompt) | site: Delete removes every trace… (incl. the random DB user), Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | @@ -94,37 +94,32 @@ Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wil ## Known bugs exposed (`@known-bug`) -AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-1 and WPT-8 scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) are regular since the fixes on the `fix/site-name-collisions` branches of site-command, site-type-wp and site-type-php (2026-09-26, local): they fail on develop until those are merged. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. +AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-1 and WPT-8 scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) are regular since the fixes on the `fix/site-name-collisions` branches of site-command, site-type-wp and site-type-php (2026-09-26, local): they fail on develop until those are merged. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. The WPT-2 ×2, SC-19, PHPT-10 and SSL-5/SSL-9 scenarios are regular since the fixes on site-command `fix/proxy-cache-rollback-and-le-alias` (2026-09-27, local); they fail on develop until that is merged. | Scenario | Bug | |---|---| | install-options: Shell metacharacters in the site name are rejected | SEC-1 | | alias-domains: An existing site cannot be reused as an alias on create | PHPT-7 | -| redirect: Deleting the site stops the www redirect | SC-19 (site-command) | | install-options: The latest WordPress download passes its checksums | WP-1 | | cache: The local redis container restarts with the site | MIG-16 | | cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 | -| ssl: Deleting a Let's Encrypt site leaves no ACME leftovers | SSL-5, SSL-9 | | multisite: --mu without a value is rejected | WPT-6 | | cache: --with-local-redis without --cache enables the local redis cache | WPT-6 | | proxy-cache: Invalid proxy-cache value on update / on create is rejected | WPT-3: YAML `on`/`off` parsed as booleans | | proxy-cache: A new site stores proxy_cache off… | WPT-3: default stored as `''` | | php-version: Loosely matching PHP version <php> is rejected | WPT-4: loose `in_array` | | ssl: Inherit from a parent without SSL fails with a clean message | WPT-7: `var_dump()` in the error | -| ssl: SSL of a site created with --ssl=self can be turned off | PHPT-10 (site-command) | | install-options: --public-dir without a value keeps the default webroot | WPT-6 | | install-options: A site title with an apostrophe | WPT-5: unescaped shell quoting | -| alias-domains: Alias changes keep HTTPS disabled on a site without SSL | WPT-2 (pre-existing, not a #488 regression) | -| alias-domains: A site whose SSL was turned off keeps serving HTTP after an alias change | WPT-2 + WPT-12: user-visible redirect to HTTPS | ## Tags and runtime | Tag | Scenarios | Meaning | |---|---|---| | (all) | 128 | including outline examples | -| `@known-bug` | 24 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | +| `@known-bug` | 19 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | | `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | -| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); the create passes, the leftovers one is `@known-bug` (SSL-5/SSL-9) | +| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); both regular (the leftovers one fails on develop until site-command `fix/proxy-cache-rollback-and-le-alias`, SSL-5/SSL-9, is merged) | | `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | | `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | | `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway (read from `docker network inspect bridge`, 10.0.0.1 on this host); reachability from the default bridge and from a user-defined network was checked with a throwaway listener | diff --git a/features/alias-domains.feature b/features/alias-domains.feature index f81e20f..18c2a89 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -146,8 +146,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias | field | value | | alias_domains | bh-site-type-wp-blank.test,bh-site-type-wp-blank-a.test | - # WPT-2 (pre-existing, not from #488: the flag was already false for sites without SSL): update_alias_domains() dumps docker-compose.yml without HTTPS_METHOD=nohttps for a site without SSL, until the next refresh. - @known-bug + # WPT-2 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): update_alias_domains() dumped docker-compose.yml without HTTPS_METHOD=nohttps for a site without SSL, until the next refresh. Scenario: Alias changes keep HTTPS disabled on a site without SSL Given a WordPress site 'bh-site-type-wp-aliasnossl.test' exists created with '--skip-install' When I run 'bin/ee site update bh-site-type-wp-aliasnossl.test --add-alias-domains=bh-site-type-wp-aliasnossl-a.test' @@ -163,8 +162,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias HTTPS_METHOD=nohttps """ - # WPT-2 + WPT-12: `--ssl=off` keeps <site>.crt, and with aliases CERT_NAME=${VIRTUAL_HOST} picks it up, so without nohttps nginx-proxy redirects HTTP to HTTPS again. - @known-bug + # WPT-2 + WPT-12 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): `--ssl=off` kept <site>.crt, and with aliases CERT_NAME=${VIRTUAL_HOST} picked it up, so without nohttps nginx-proxy redirected HTTP to HTTPS again. Scenario: A site whose SSL was turned off keeps serving HTTP after an alias change Given a WordPress site 'bh-site-type-wp-aliasoff.test' exists When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --ssl=self' diff --git a/features/redirect.feature b/features/redirect.feature index 3f67a8f..8cea300 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -36,8 +36,7 @@ Feature: www / non-www redirection of WordPress sites Then the command should succeed And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should not exist - # SC-19: delete removes the redirect file without reloading nginx-proxy, so www keeps redirecting to the deleted site. - @known-bug + # SC-19 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): delete removed the redirect file without reloading nginx-proxy, so www kept redirecting to the deleted site. Scenario: Deleting the site stops the www redirect Given I add a hosts entry for 'www.bh-site-type-wp-redir4.test' And a WordPress site 'bh-site-type-wp-redir4.test' exists diff --git a/features/ssl.feature b/features/ssl.feature index bb10fef..8c822d6 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -195,8 +195,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) SSL is already disabled on bh-site-type-wp-sslupd.test """ - # PHPT-10 (site-command): init_ssl() stores site_ssl_wildcard=1 for self-signed creates, so update refuses `--ssl=off` ("wildcard to normal SSL") and `--ssl=off --wildcard`. - @known-bug + # PHPT-10 / SC-3 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): update refused `--ssl=off` for self-signed creates, which store site_ssl_wildcard=1. Scenario: SSL of a site created with --ssl=self can be turned off Given a WordPress site 'bh-site-type-wp-selfoff.test' exists created with '--ssl=self --skip-install' When I run 'bin/ee site update bh-site-type-wp-selfoff.test --ssl=off' @@ -253,8 +252,8 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should not exist And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/certs/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist - # SSL-5 / SSL-9: deleting an LE site leaves acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). - @le @known-bug + # SSL-5 / SSL-9 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): deleting an LE site left acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). + @le Scenario: Deleting a Let's Encrypt site leaves no ACME leftovers Given the site 'bh-site-type-wp-le1.ee4-test.rt.gw' was deleted earlier in this feature Then the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist From 3e0b78b4b7103b818315935bf5943303d6dd1cf9 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:04:00 +0530 Subject: [PATCH 18/36] test(site-type-wp): follow the v4.13.x fixes and wait for the added alias CACHE-1, WP-1 and SEC-1 scenarios are regular; the weak password message no longer echoes the password (#244); the wildcard refusal runs on a site that never had SSL, since one that had --ssl=self keeps site_ssl_wildcard=1 and goes on to request a certificate; the new alias is polled until the proxy serves it (PRX-7). --- features/COVERAGE.md | 8 ++++---- features/alias-domains.feature | 2 +- features/bootstrap/FeatureContext.php | 20 ++++++++++++++++++++ features/cache.feature | 3 +-- features/install-options.feature | 16 +++++++++++----- features/proxy-cache.feature | 4 ++-- features/ssl.feature | 4 +++- 7 files changed, 42 insertions(+), 15 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index ad538d7..91e5327 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -67,7 +67,7 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | | `delete` (+ confirmation prompt) | site: Delete removes every trace… (incl. the random DB user), Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | | `list [--format] [--enabled] [--disabled]` | site: List shows the site in every format; disabled: site: Disable and enable… | -| `clean [--page] [--object] [--proxy]` | cache KB (CACHE-1), proxy-cache KB (CACHE-1); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | +| `clean [--page] [--object] [--proxy]` | cache and proxy-cache (CACHE-1, regular since the v4.13.2 fix); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | ### Behaviours and side effects @@ -98,11 +98,11 @@ AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09- | Scenario | Bug | |---|---| -| install-options: Shell metacharacters in the site name are rejected | SEC-1 | +| install-options: Shell metacharacters in the site name are rejected | SEC-1 (fixed in v4.13.2, regular since Behat r2) | | alias-domains: An existing site cannot be reused as an alias on create | PHPT-7 | -| install-options: The latest WordPress download passes its checksums | WP-1 | +| install-options: The latest WordPress download passes its checksums | WP-1 (fixed in v4.13.1, regular since Behat r2) | | cache: The local redis container restarts with the site | MIG-16 | -| cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 | +| cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 (fixed in v4.13.2, regular since Behat r2) | | multisite: --mu without a value is rejected | WPT-6 | | cache: --with-local-redis without --cache enables the local redis cache | WPT-6 | | proxy-cache: Invalid proxy-cache value on update / on create is rejected | WPT-3: YAML `on`/`off` parsed as booleans | diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 18c2a89..cb031df 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -69,7 +69,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ bh-site-type-wp-alias-c.test """ - And a GET request to 'http://bh-site-type-wp-alias-c.test/' should not return 503 + And a GET request to 'http://bh-site-type-wp-alias-c.test/' should not return 503 within 30 seconds When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' Then the command should fail And STDERR should contain: diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index b94ff29..82ead78 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1121,6 +1121,26 @@ public function aGetRequestToShouldNotReturn( $url, $code ) } } + /** + * Alias updates return before the proxy serves the new name (PRX-7), so poll. + * + * @Then a GET request to :url should not return :code within :seconds seconds + */ + public function aGetRequestToShouldNotReturnWithin( $url, $code, $seconds ) + { + $url = $this->replace_placeholders( $url ); + $deadline = time() + (int) $seconds; + do { + $response = $this->http_request( $url, null, true ); + if ( (int) $code !== $response['code'] ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "GET $url still returned $code after $seconds seconds.\n" . $response['headers'] ); + } + /** * Polls until a response carries the header line, e.g. `X-SRCache-Fetch-Status: HIT`. * diff --git a/features/cache.feature b/features/cache.feature index 3d7faf2..b3da534 100644 --- a/features/cache.feature +++ b/features/cache.feature @@ -60,8 +60,7 @@ Feature: WordPress redis cache (--cache, --with-local-redis) When I send a GET request to 'http://bh-site-type-wp-cache.test/?bh=1' Then the response should have header 'X-SRCache-Fetch-Status: BYPASS' - # CACHE-1: clean_site_cache() runs `docker exec -it`, which fails without a TTY (and can hang under nested ptys), yet `ee site clean` still prints Success. - @known-bug + # CACHE-1 (fixed in v4.13.2, site-command 5c5abb9), regression check: clean_site_cache() runs `docker exec -it`, which fails without a TTY (and can hang under nested ptys), yet `ee site clean` still prints Success. Scenario: ee site clean purges the page and object cache keys Given a WordPress site 'bh-site-type-wp-cacheclean.test' exists created with '--cache --skip-install' And I store the key 'bh-site-type-wp-cacheclean.test_page:httpGET/bh' in the global redis diff --git a/features/install-options.feature b/features/install-options.feature index edaf526..2805ff4 100644 --- a/features/install-options.feature +++ b/features/install-options.feature @@ -37,14 +37,22 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con BhPass1234 """ + # The message no longer echoes the password (site-type-wp#244, v4.13.2). Scenario Outline: Weak administrator password <pass> is rejected before anything is created When I run 'bin/ee site create bh-site-type-wp-weakpass.test --type=wp --admin-pass=<pass>' Then the command should fail And STDERR should contain: """ - Issues found in input password: `<pass>` + Issues found in the admin password: + """ + And STDERR should contain: + """ <message> """ + And STDERR should not contain: + """ + <pass> + """ And the site 'bh-site-type-wp-weakpass.test' should not exist And the global database should not have a database named 'bh_site_type_wp_weakpass_test' @@ -201,8 +209,7 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con | field | value | | site_container_fs_path | /var/www/htdocs | - # WP-1: WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. - @known-bug + # WP-1 (fixed in v4.13.1, site-type-wp#241), regression check: WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. Scenario: The latest WordPress download passes its checksums Given a WordPress site 'bh-site-type-wp-wp1.test' exists created with '--skip-install' When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-wp1.test' @@ -219,9 +226,8 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con BH's Site """ - # SEC-1: shell metacharacters in the site name reach unescaped shell sinks during create (fixed locally, not on develop). + # SEC-1 (fixed in v4.13.2, site-type-wp#243 and site-command#509), regression check: shell metacharacters in the site name reach unescaped shell sinks during create. # The payload survives lowercasing and the docker-prefix stripping of `.`/`-`: it only creates an empty marker in $HOME. - @known-bug Scenario: Shell metacharacters in the site name are rejected When I run "bin/ee site create 'bh-site-type-wp-sec1$(cd;>bhsitetypewpsec1pwned).test' --type=wp --skip-install" Then the command should fail diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index 26d78b6..c952141 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -118,8 +118,8 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) Site bh-site-type-wp-proxyoff.test already has proxy cache: off """ - # CACHE-1: the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. - @known-bug @destructive + # CACHE-1 (fixed in v4.13.2, site-command 276d118), regression check: the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. + @destructive Scenario: ee site clean --proxy empties the proxy cache directory Given a WordPress site 'bh-site-type-wp-proxyclean.test' exists created with '--proxy-cache=on --skip-install' And I run 'docker exec services_global-nginx-proxy_1 sh -c "mkdir -p /var/cache/nginx/bh-site-type-wp-proxyclean.test/x && touch /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry"' diff --git a/features/ssl.feature b/features/ssl.feature index 8c822d6..936ae61 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -212,7 +212,9 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) """ Invalid value specified for 'ssl' """ - When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=le --wildcard' + # A site that never had SSL: sslupd had --ssl=self above, which keeps site_ssl_wildcard=1 after --ssl=off (site-command#505), so it would request a wildcard certificate. + Given a WordPress site 'bh-site-type-wp-nossl.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-nossl.test --ssl=le --wildcard' Then the command should fail And STDERR should contain: """ From 825cd02560d4d517b93bae95a5605b3ea8772fb6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:07:10 +0530 Subject: [PATCH 19/36] test(site-type-wp): 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 82ead78..07b9f86 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -232,8 +232,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); } } @@ -245,8 +244,7 @@ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, { $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index ed9a5b5..a5f1a1e 100644 --- a/features/bootstrap/support.php +++ b/features/bootstrap/support.php @@ -81,13 +81,27 @@ function compareContents( $expected, $actual ) { if ( is_object( $expected ) ) { foreach ( get_object_vars( $expected ) as $name => $value ) { - if ( ! compareContents( $value, $actual->$name ) ) { + if ( ! property_exists( $actual, $name ) || ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) && array_values( $expected ) === $expected ) { + // Lists match by membership, so [1,3,5] is contained in [1,2,3,4,5]. + foreach ( $expected as $value ) { + $found = false; + foreach ( $actual as $candidate ) { + if ( compareContents( $value, $candidate ) ) { + $found = true; + break; + } + } + if ( ! $found ) { return false; } } } else if ( is_array( $expected ) ) { foreach ( $expected as $key => $value ) { - if ( ! compareContents( $value, $actual[$key] ) ) { + if ( ! array_key_exists( $key, $actual ) || ! compareContents( $value, $actual[ $key ] ) ) { return false; } } From c25c8838b8622bded7570ea473242c44e57662d2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:12:49 +0530 Subject: [PATCH 20/36] 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 0000000..3a60473 --- /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://<target>/ (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 + <?php foreach (['curl.cainfo', 'openssl.cafile'] as $k) { $v = ini_get($k); if ($v) { echo $v, "\n"; } } + PHP +} + +trust_cas() { + local f pem + cp "$PEBBLE_DIR/ca.pem" "$CA_LINK" + cp "$PEBBLE_DIR/pebble-root.pem" "$ROOT_LINK" + remember "rm -f $CA_LINK $ROOT_LINK; update-ca-certificates --fresh >/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' + <?php + $u = 'https://' . getenv('ACME_HOST') . '/directory'; + $m = getenv('TOS_MARKER'); + $c = curl_init($u); + curl_setopt_array($c, [CURLOPT_RETURNTRANSFER => 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 0000000..30ee54a --- /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 febe3972041efbbe4a4392bd99a3d3ce243c9eb7 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:17:24 +0530 Subject: [PATCH 21/36] ci: run the behat suite on GitHub Actions Runs the package features/ inside a fresh core develop checkout, with this checkout installed as the package through a Composer path repository, so the PR code is under test. @le runs in behat-le.yml against Pebble. @slow (legacy PHP images) is its own job; @known-bug scenarios run in a separate non-gating job. --- .github/workflows/behat.yml | 171 ++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 .github/workflows/behat.yml diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 0000000..26798dc --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,171 @@ +# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +name: Behat + +on: + pull_request: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + # Not RCLONE_*: rclone reads those variables as its own flags. + RCLONE_RELEASE: v1.75.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + # Local rclone alias written to EE's rclone-path; empty = no rclone. + EE_RCLONE_PATH: '' + COMPOSE_INTERACTIVE_NO_CLI: 1 + RCLONE_CONFIG: /etc/rclone/rclone.conf + EE_BEHAT_DESTRUCTIVE: 1 + +jobs: + behat: + name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 150 + # Known bugs are expected to fail; they're reported, not gating. + continue-on-error: ${{ matrix.lane == 'known-bug' }} + strategy: + fail-fast: false + matrix: + php: ['8.4'] + lane: [main, slow, known-bug] + include: + - lane: main + tags: '~@known-bug&&~@slow' + - lane: slow + tags: '@slow&&~@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 + # A hung step (e.g. a command stopped on the TTY) should still leave logs. + timeout-minutes: 75 + 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 c1b44ee21820801fdf564fc26703379fad3e40a5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:22:05 +0530 Subject: [PATCH 22/36] 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, 47 insertions(+), 139 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 30ee54a..0000000 --- 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 26798dc..7dbcfe2 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: '' 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, slow, known-bug] + php: ['7.4', '8.5'] + lane: [main, slow, known-bug, le, le-known-bug] include: - lane: main tags: '~@known-bug&&~@slow' @@ -47,6 +49,12 @@ jobs: tags: '@slow&&~@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,24 +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 - 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" + 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() @@ -147,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') @@ -162,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 5432e638ecd66e33cacf797709653c019e692c57 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:24:46 +0530 Subject: [PATCH 23/36] 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 7dbcfe2..1469bbb 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 a39c66d93c942e6a17dc01bc245010278a6fbb23 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:27:03 +0530 Subject: [PATCH 24/36] ci(site-type-wp): 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 1469bbb..abd5506 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, slow, known-bug, le, le-known-bug] include: - lane: main From 7c3511de99c9769876243eebf877adcfa66fc095 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:30:29 +0530 Subject: [PATCH 25/36] test(site-type-wp): 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 07b9f86..e75c1ff 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -405,6 +405,44 @@ public static function prepareFeature(BeforeFeatureScope $scope) self::cleanup_test_artifacts(); } + /** + * 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 this suite's sites (upstream deleted a fixed list of generic names) and restores shared state. * From f96f63162566ed669f74a7fcc543560fdfb71593 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:35:05 +0530 Subject: [PATCH 26/36] 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 3a60473..696ce1c 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://<target>/ (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 abd5506..91b22fe 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 <version>" 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 f8fce96..0000000 --- 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 c70e206cc51c8b4982c8d8a4b1ddad38da3972dc Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:39:55 +0530 Subject: [PATCH 27/36] test(behat): leave the known-bug scenarios out until their fixes land --- .github/workflows/behat.yml | 9 +---- features/COVERAGE.md | 62 ++++++++++++++++---------------- features/alias-domains.feature | 8 ----- features/cache.feature | 16 --------- features/install-options.feature | 19 ---------- features/multisite.feature | 7 ---- features/php-version.feature | 16 --------- features/proxy-cache.feature | 36 ------------------- features/ssl.feature | 16 --------- 9 files changed, 31 insertions(+), 158 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 91b22fe..82d89da 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, slow, known-bug, le, le-known-bug] + lane: [main, slow, le] include: - lane: main tags: '~@known-bug&&~@slow' - lane: slow tags: '@slow&&~@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 91e5327..19df1f5 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -6,25 +6,25 @@ Site names all start with `bh-site-type-wp-` (`*.test`, and `bh-site-type-wp-le1 ## Inventory -Legend: file names are under `features/`; "KB" = `@known-bug` scenario that documents the bug (expected to fail until fixed). +Legend: file names are under `features/`. Scenarios that assert the correct behaviour of an open bug (they fail until it is fixed) are left out of the suite for now; their rows say "not covered yet" and the scenario comes back with the fix. ### `ee site create --type=wp` arguments and flags | Item | Covered by | |---|---| -| `<site-name>` | site: Create a default WordPress site; missing name: site: Create without a site name prints the usage; `http://` prefix, trailing slash, upper case: install-options: Custom title and administrator…; name without TLD gets `.test`: install-options: Skip the WordPress install; shell metacharacters: install-options KB (SEC-1) | -| existing site / existing webroot / name that is an alias | site: Creating an existing site fails…; database KB (webroot leak); alias-domains: An alias of another site cannot be created as a site | +| `<site-name>` | site: Create a default WordPress site; missing name: site: Create without a site name prints the usage; `http://` prefix, trailing slash, upper case: install-options: Custom title and administrator…; name without TLD gets `.test`: install-options: Skip the WordPress install; shell metacharacters: install-options: Shell metacharacters in the site name are rejected (SEC-1) | +| existing site / existing webroot / name that is an alias | site: Creating an existing site fails…; database: An existing webroot aborts the create…; alias-domains: An alias of another site cannot be created as a site; an existing site's name as an alias on create: not covered yet: it is accepted because create only checks other sites' aliases (its scenario comes back with the fix) | | `--cache` | cache: Create a site with page and object cache…, The page cache is served from redis; subdom + cache: cache: Subdomain multisite with cache…; EE3 `--wpredis`: install-options | | `--vip`, `--vip=<repo>` | vip: Create a VIP site from the default skeleton with cache; inaccessible repo: vip: An inaccessible VIP repository is rejected… | -| `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: multisite KB; subdom + inherit: Subdomain multisite cannot inherit SSL (+ KB for the DB leak); AUTH-5: multisite KB | -| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; PHPT-7: alias-domains KB; AUTH-1 and blank alias: regression scenarios (fixed by #502/#239) | -| `--title` | install-options: Custom title…; apostrophe: install-options KB; default (= site name): site: Create a default WordPress site | +| `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: not covered yet: `--mu` without a value only warns and creates a single site (its scenario comes back with the fix); subdom + inherit: Subdomain multisite cannot inherit SSL, A rejected inherit + subdom create leaves nothing in the global database; AUTH-5: multisite regression scenario | +| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; AUTH-1 and blank alias: regression scenarios (fixed by #502/#239) | +| `--title` | install-options: Custom title…; apostrophe: not covered yet: an apostrophe in the title breaks the unescaped `wp core install` command (its scenario comes back with the fix); default (= site name): site: Create a default WordPress site | | `--admin-user`, `--admin-email`, `--admin-pass` | install-options: Custom title…; defaults (random `wp-user-*`, 18-char password, `admin@<site>`): site: Create a default WordPress site | | password policy / `--allow-insecure-pass` | install-options: Weak administrator password… (3 rules); Insecure password allowed… | | `--local-db` | database: Create a site with a local database container, restart `--db`, disable/enable, delete | -| `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: cache KB; restart policy: cache KB (MIG-16) | -| `--public-dir` | install-options: Serve WordPress from a public directory…; without value: install-options KB | -| `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP <php>; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): php-version KB | +| `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: not covered yet: `--with-local-redis` without `--cache` is silently ignored (its scenario comes back with the fix); restart policy: not covered yet: the local redis service has no restart policy (its scenario comes back with the fix) | +| `--public-dir` | install-options: Serve WordPress from a public directory…; without value: not covered yet: a bare `--public-dir` makes the public dir `htdocs/1` (its scenario comes back with the fix) | +| `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP <php>; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): not covered yet: versions are compared loosely, so `--php=8` passes validation (its outline comes back with the fix) | | `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database ×3 (WPT-1) | | `--dbhost` (+ `:port`) | database: Remote database host requires credentials, Unreachable remote database rolls the site back; external-db: all scenarios | | `--dbprefix`, `--dbcharset`, `--dbcollate`, `--skip-check` | database: Custom database name, user, password, prefix, charset and collation… | @@ -34,12 +34,12 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | | `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl (PHPT-10, fixed) | | `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database (WPT-1) | -| `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, parent without SSL (KB), with `--wildcard` | +| `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, with `--wildcard`; parent without SSL: not covered yet: the error dumps the parent site object with `var_dump()` (its scenario comes back with the fix) | | `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl (SSL-5/SSL-9, fixed) | | unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection, WPT-13) | | `--ssl` without value (= le) | not covered: same code path as `--ssl=le`, LE budget | | `--wildcard` | ssl: Inherit together with --wildcard is rejected; update `--ssl=le --wildcard` rejection; `--ssl=le --wildcard` itself not covered (needs Cloudflare DNS-01 credentials) | -| `--proxy-cache=on\|off` | proxy-cache: Create a site with proxy cache…; invalid value: proxy-cache KB; default stored as `''`: proxy-cache KB | +| `--proxy-cache=on\|off` | proxy-cache: Create a site with proxy cache…; invalid value: not covered yet: `on`/`off` are parsed as booleans, so any value passes validation (its scenario comes back with the fix); default stored as `''`: not covered yet: a new site stores `''` instead of `off` (its scenario comes back with the fix) | | `--proxy-cache-max-size`, `--proxy-cache-max-time` | proxy-cache: Create a site with proxy cache and custom limits (valid time, invalid size fallback) | | `--force` | external-db: A remote database that already has tables needs --force | | `--yes` | not covered: on create it only answers the VIP "move mu-plugins" prompt (needs a repo with `mu-plugins`) and the PHP fallback prompt, which the synopsis validation makes unreachable | @@ -61,7 +61,7 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu | `share` with ngrok, `--refresh`, `--token` | not covered: needs the external ngrok service and a token | | `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl (PHPT-10, fixed) | | `update --php` | php-version: Default PHP version and update…, same version, unsupported version | -| `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: KB | +| `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: not covered yet (same boolean parsing as on create; its scenario comes back with the fix) | | `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps kept on sites without SSL: ×2 (WPT-2, fixed) | | `update` of unknown / disabled site | site: Update of an unknown site fails, Disable and enable… | | `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | @@ -92,33 +92,31 @@ Legend: file names are under `features/`; "KB" = `@known-bug` scenario that docu Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wildcard` only partly); the gaps are the external-service/DNS-01 paths (`--ssl` bare, `--ssl=le --wildcard`, ngrok share, `--yes`), upgrade-only migrations, signal rollback and macOS branches. -## Known bugs exposed (`@known-bug`) +## Known bugs and regression scenarios AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-1 and WPT-8 scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) are regular since the fixes on the `fix/site-name-collisions` branches of site-command, site-type-wp and site-type-php (2026-09-26, local): they fail on develop until those are merged. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. The WPT-2 ×2, SC-19, PHPT-10 and SSL-5/SSL-9 scenarios are regular since the fixes on site-command `fix/proxy-cache-rollback-and-le-alias` (2026-09-27, local); they fail on develop until that is merged. -| Scenario | Bug | -|---|---| -| install-options: Shell metacharacters in the site name are rejected | SEC-1 (fixed in v4.13.2, regular since Behat r2) | -| alias-domains: An existing site cannot be reused as an alias on create | PHPT-7 | -| install-options: The latest WordPress download passes its checksums | WP-1 (fixed in v4.13.1, regular since Behat r2) | -| cache: The local redis container restarts with the site | MIG-16 | -| cache: ee site clean purges…; proxy-cache: ee site clean --proxy empties… | CACHE-1 (fixed in v4.13.2, regular since Behat r2) | -| multisite: --mu without a value is rejected | WPT-6 | -| cache: --with-local-redis without --cache enables the local redis cache | WPT-6 | -| proxy-cache: Invalid proxy-cache value on update / on create is rejected | WPT-3: YAML `on`/`off` parsed as booleans | -| proxy-cache: A new site stores proxy_cache off… | WPT-3: default stored as `''` | -| php-version: Loosely matching PHP version <php> is rejected | WPT-4: loose `in_array` | -| ssl: Inherit from a parent without SSL fails with a clean message | WPT-7: `var_dump()` in the error | -| install-options: --public-dir without a value keeps the default webroot | WPT-6 | -| install-options: A site title with an apostrophe | WPT-5: unescaped shell quoting | +Scenarios for open bugs are left out of this suite until their fixes land; each comes back as a regular scenario with its fix: + +- alias-domains: An existing site cannot be reused as an alias on create +- cache: The local redis container restarts with the site +- cache: --with-local-redis without --cache enables the local redis cache +- multisite: --mu without a value is rejected +- proxy-cache: Invalid proxy-cache value on update / on create is rejected +- proxy-cache: A new site stores proxy_cache off and refuses to turn it off again +- php-version: Loosely matching PHP version <php> is rejected (2 examples) +- ssl: Inherit from a parent without SSL fails with a clean message +- install-options: --public-dir without a value keeps the default webroot +- install-options: A site title with an apostrophe + +Regression scenarios for fixed bugs: install-options: Shell metacharacters in the site name are rejected (SEC-1, fixed in v4.13.2); install-options: The latest WordPress download passes its checksums (WP-1, fixed in v4.13.1); cache: ee site clean purges…, proxy-cache: ee site clean --proxy empties… (CACHE-1, fixed in v4.13.2). ## Tags and runtime | Tag | Scenarios | Meaning | |---|---|---| -| (all) | 128 | including outline examples | -| `@known-bug` | 19 | expected to fail until the bug is fixed; exclude with `--tags='~@known-bug'` | -| `@destructive` | 6 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | +| (all) | 116 | including outline examples (main lane 107, `@slow` 7, `@le` 2) | +| `@destructive` | 4 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | | `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); both regular (the leftovers one fails on develop until site-command `fix/proxy-cache-rollback-and-le-alias`, SSL-5/SSL-9, is merged) | | `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | | `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | @@ -126,7 +124,7 @@ AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09- | `@network` | 2 | VIP: clones from github.com | | `@remote` | 0 | no rclone use in this package | -Rough timings on this host class: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@known-bug&&~@slow&&~@le&&~@external-db&&~@network'`, 86 scenarios, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3, `@known-bug` ~15. +Rough timings on this host class: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@slow&&~@le&&~@external-db&&~@network'`, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3. ## Context changes (`bootstrap/FeatureContext.php`) diff --git a/features/alias-domains.feature b/features/alias-domains.feature index cb031df..6ddc157 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -46,14 +46,6 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ And the site 'bh-site-type-wp-alias2.test' should not exist - # PHPT-7: create only checks other sites' aliases (get_all_alias_domains() drops site URLs), so an existing site's name is accepted as an alias. - @known-bug - Scenario: An existing site cannot be reused as an alias on create - Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" - When I run "bin/ee site create bh-site-type-wp-alias3.test --type=wp --skip-install --alias-domains='bh-site-type-wp-alias.test'" - Then the command should fail - And the site 'bh-site-type-wp-alias3.test' should not exist - Scenario: Add and delete alias domains on a WordPress site Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' diff --git a/features/cache.feature b/features/cache.feature index b3da534..3f5c4d0 100644 --- a/features/cache.feature +++ b/features/cache.feature @@ -132,19 +132,3 @@ Feature: WordPress redis cache (--cache, --with-local-redis) "redis_hostname":"global-redis" """ And a GET request to 'http://bh-site-type-wp-mucache.test/' should return 200 - - # MIG-16: the local redis service has no restart policy, unlike every other service. - @known-bug - Scenario: The local redis container restarts with the site - Given a WordPress site 'bh-site-type-wp-mig16.test' exists created with '--cache --with-local-redis --skip-install' - Then the 'redis' container of site 'bh-site-type-wp-mig16.test' should have restart policy 'always' - - # WPT-6: --with-local-redis alone is silently ignored (no redis container, no cache) although the help says "Enable cache with local redis container". - @known-bug - Scenario: --with-local-redis without --cache enables the local redis cache - When I run 'bin/ee site create bh-site-type-wp-lredisonly.test --type=wp --with-local-redis --skip-install' - Then the command should succeed - And the 'redis' container of site 'bh-site-type-wp-lredisonly.test' should be running - And the site 'bh-site-type-wp-lredisonly.test' info should contain: - | field | value | - | cache_host | redis | diff --git a/features/install-options.feature b/features/install-options.feature index 2805ff4..4e6a6b1 100644 --- a/features/install-options.feature +++ b/features/install-options.feature @@ -201,31 +201,12 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con """ And a GET request to 'http://bh-site-type-wp-pubdir.test/' should return 200 - # WPT-6: --public-dir is declared as a flag, so passing it without a value makes the public dir `htdocs/1`. - @known-bug - Scenario: --public-dir without a value keeps the default webroot - When I run 'bin/ee site create bh-site-type-wp-pubdir1.test --type=wp --public-dir --skip-install' - Then the site 'bh-site-type-wp-pubdir1.test' info should contain: - | field | value | - | site_container_fs_path | /var/www/htdocs | - # WP-1 (fixed in v4.13.1, site-type-wp#241), regression check: WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. Scenario: The latest WordPress download passes its checksums Given a WordPress site 'bh-site-type-wp-wp1.test' exists created with '--skip-install' When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-wp1.test' Then the command should succeed - # WPT-5: title, admin user/e-mail/password are interpolated inside single quotes of the `wp core install` shell command, so an apostrophe breaks the install and the site is rolled back. - @known-bug - Scenario: A site title with an apostrophe - When I run 'bin/ee site create bh-site-type-wp-apos.test --type=wp --title="BH's Site"' - Then the command should succeed - When I run WP-CLI 'option get blogname' on site 'bh-site-type-wp-apos.test' - Then STDOUT should contain: - """ - BH's Site - """ - # SEC-1 (fixed in v4.13.2, site-type-wp#243 and site-command#509), regression check: shell metacharacters in the site name reach unescaped shell sinks during create. # The payload survives lowercasing and the docker-prefix stripping of `.`/`-`: it only creates an empty marker in $HOME. Scenario: Shell metacharacters in the site name are rejected diff --git a/features/multisite.feature b/features/multisite.feature index a5233c2..c6c4345 100644 --- a/features/multisite.feature +++ b/features/multisite.feature @@ -96,13 +96,6 @@ Feature: WordPress multisite (--mu=subdir|subdom) And the global database should not have a database named 'bh_site_type_wp_muinh2_test' And the global database should not have users starting with 'bh-site-type-wp-muinh2.test-' - # WPT-6: `--mu` without a value only warns "--mu parameter needs a value" and creates a single site. - @known-bug - Scenario: --mu without a value is rejected - When I run 'bin/ee site create bh-site-type-wp-muempty.test --type=wp --mu --skip-install' - Then the command should fail - And the site 'bh-site-type-wp-muempty.test' should not exist - # AUTH-5 regression: subdom create didn't check its implicit *.site against existing aliases (fixed by site-type-wp #239). Scenario: Subdomain multisite whose wildcard is already an alias of another site is rejected Given a WordPress site 'bh-site-type-wp-auth5owner.test' exists created with "--skip-install --alias-domains='*.bh-site-type-wp-auth5.test'" diff --git a/features/php-version.feature b/features/php-version.feature index 55cc5b2..0e04b05 100644 --- a/features/php-version.feature +++ b/features/php-version.feature @@ -122,19 +122,3 @@ Feature: PHP versions of WordPress sites (--php on create and update) | 7.1 | | 9.9 | | foo | - - # WPT-4: options are compared loosely, so `--php=8` passes validation ("8" == "8.0") and create builds the image name `easyengine/php8:` (unknown version). - @known-bug - Scenario Outline: Loosely matching PHP version <php> is rejected - When I run 'bin/ee site create bh-site-type-wp-phploose.test --type=wp --php=<php> --skip-install' - Then the command should fail - And STDERR should contain: - """ - Invalid value specified for 'php' - """ - And the site 'bh-site-type-wp-phploose.test' should not exist - - Examples: - | php | - | 8 | - | 8.10 | diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index c952141..1a42dd7 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -82,42 +82,6 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should not exist And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should not exist - # WPT-3: the synopsis options `on`/`off` are parsed by YAML as booleans, so any non-empty value passes validation; update then runs the disable path. - @known-bug @destructive - Scenario: Invalid proxy-cache value on update is rejected - Given a WordPress site 'bh-site-type-wp-proxyval.test' exists created with '--skip-install' - When I run 'bin/ee site update bh-site-type-wp-proxyval.test --proxy-cache=maybe' - Then the command should fail - And STDERR should contain: - """ - Invalid value specified for 'proxy-cache' - """ - - # WPT-3: same YAML boolean parsing on create; the invalid value is silently ignored. - @known-bug - Scenario: Invalid proxy-cache value on create is rejected - When I run 'bin/ee site create bh-site-type-wp-proxybad.test --type=wp --proxy-cache=maybe --skip-install' - Then the command should fail - And STDERR should contain: - """ - Invalid value specified for 'proxy-cache' - """ - And the site 'bh-site-type-wp-proxybad.test' should not exist - - # WPT-3: without --proxy-cache the YAML default `off` becomes boolean false and is stored as '' instead of 'off', so `--proxy-cache=off` is not detected as a no-op (and restarts the global proxy). - @known-bug @destructive - Scenario: A new site stores proxy_cache off and refuses to turn it off again - Given a WordPress site 'bh-site-type-wp-proxyoff.test' exists created with '--skip-install' - Then the site 'bh-site-type-wp-proxyoff.test' info should contain: - | field | value | - | proxy_cache | off | - When I run 'bin/ee site update bh-site-type-wp-proxyoff.test --proxy-cache=off' - Then the command should fail - And STDERR should contain: - """ - Site bh-site-type-wp-proxyoff.test already has proxy cache: off - """ - # CACHE-1 (fixed in v4.13.2, site-command 276d118), regression check: the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. @destructive Scenario: ee site clean --proxy empties the proxy cache directory diff --git a/features/ssl.feature b/features/ssl.feature index 936ae61..17c872b 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -80,22 +80,6 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) And the directory '{SITES_DIR}/orphan.bh-site-type-wp-noparent.test' should not exist And the global database should not have a database named 'orphan_bh_site_type_wp_noparent_test' - # WPT-7: check_parent_site_certs() builds its message with var_dump(), which dumps the parent model to STDOUT. - @known-bug - Scenario: Inherit from a parent without SSL fails with a clean message - Given a WordPress site 'bh-site-type-wp-nossl.test' exists created with '--skip-install' - When I run 'bin/ee site create child.bh-site-type-wp-nossl.test --type=wp --ssl=inherit' - Then the command should fail - And STDERR should contain: - """ - Cannot inherit from bh-site-type-wp-nossl.test as site does not have SSL cert - """ - And STDOUT should not contain: - """ - object(EE\Model\Site) - """ - And the site 'child.bh-site-type-wp-nossl.test' should not exist - Scenario: Create a site with a custom certificate Given a self-signed certificate for 'bh-site-type-wp-custom.test' exists in '{TMP_DIR}/certs' When I run 'bin/ee site create bh-site-type-wp-custom.test --type=wp --ssl=custom --ssl-key={TMP_DIR}/certs/bh-site-type-wp-custom.test.key --ssl-crt={TMP_DIR}/certs/bh-site-type-wp-custom.test.crt' From a7429604066277bb290a297f435699f8db70362e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 17:43:37 +0530 Subject: [PATCH 28/36] docs(site-type-wp): describe coverage gaps without workspace references --- .github/behat/pebble-setup.sh | 2 +- .github/workflows/behat.yml | 4 ++-- features/COVERAGE.md | 34 +++++++++++++-------------- features/alias-domains.feature | 8 +++---- features/bootstrap/FeatureContext.php | 4 ++-- features/cache.feature | 2 +- features/database.feature | 9 ++++--- features/install-options.feature | 8 +++---- features/multisite.feature | 4 ++-- features/proxy-cache.feature | 2 +- features/redirect.feature | 4 ++-- features/ssl.feature | 10 ++++---- 12 files changed, 45 insertions(+), 46 deletions(-) diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh index 696ce1c..3366469 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 82d89da..c7e0154 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 19df1f5..600d56a 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -12,12 +12,12 @@ Legend: file names are under `features/`. Scenarios that assert the correct beha | Item | Covered by | |---|---| -| `<site-name>` | site: Create a default WordPress site; missing name: site: Create without a site name prints the usage; `http://` prefix, trailing slash, upper case: install-options: Custom title and administrator…; name without TLD gets `.test`: install-options: Skip the WordPress install; shell metacharacters: install-options: Shell metacharacters in the site name are rejected (SEC-1) | +| `<site-name>` | site: Create a default WordPress site; missing name: site: Create without a site name prints the usage; `http://` prefix, trailing slash, upper case: install-options: Custom title and administrator…; name without TLD gets `.test`: install-options: Skip the WordPress install; shell metacharacters: install-options: Shell metacharacters in the site name are rejected | | existing site / existing webroot / name that is an alias | site: Creating an existing site fails…; database: An existing webroot aborts the create…; alias-domains: An alias of another site cannot be created as a site; an existing site's name as an alias on create: not covered yet: it is accepted because create only checks other sites' aliases (its scenario comes back with the fix) | | `--cache` | cache: Create a site with page and object cache…, The page cache is served from redis; subdom + cache: cache: Subdomain multisite with cache…; EE3 `--wpredis`: install-options | | `--vip`, `--vip=<repo>` | vip: Create a VIP site from the default skeleton with cache; inaccessible repo: vip: An inaccessible VIP repository is rejected… | -| `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: not covered yet: `--mu` without a value only warns and creates a single site (its scenario comes back with the fix); subdom + inherit: Subdomain multisite cannot inherit SSL, A rejected inherit + subdom create leaves nothing in the global database; AUTH-5: multisite regression scenario | -| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; AUTH-1 and blank alias: regression scenarios (fixed by #502/#239) | +| `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: not covered yet: `--mu` without a value only warns and creates a single site (its scenario comes back with the fix); subdom + inherit: Subdomain multisite cannot inherit SSL, A rejected inherit + subdom create leaves nothing in the global database; subdom wildcard that is another site's alias: multisite regression scenario | +| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; `_wildcard.<site>` alias and blank alias: regression scenarios (fixed by EasyEngine/site-command#502 and EasyEngine/site-type-wp#239) | | `--title` | install-options: Custom title…; apostrophe: not covered yet: an apostrophe in the title breaks the unescaped `wp core install` command (its scenario comes back with the fix); default (= site name): site: Create a default WordPress site | | `--admin-user`, `--admin-email`, `--admin-pass` | install-options: Custom title…; defaults (random `wp-user-*`, 18-char password, `admin@<site>`): site: Create a default WordPress site | | password policy / `--allow-insecure-pass` | install-options: Weak administrator password… (3 rules); Insecure password allowed… | @@ -25,18 +25,18 @@ Legend: file names are under `features/`. Scenarios that assert the correct beha | `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: not covered yet: `--with-local-redis` without `--cache` is silently ignored (its scenario comes back with the fix); restart policy: not covered yet: the local redis service has no restart policy (its scenario comes back with the fix) | | `--public-dir` | install-options: Serve WordPress from a public directory…; without value: not covered yet: a bare `--public-dir` makes the public dir `htdocs/1` (its scenario comes back with the fix) | | `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP <php>; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): not covered yet: versions are compared loosely, so `--php=8` passes validation (its outline comes back with the fix) | -| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database ×3 (WPT-1) | +| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database ×3 | | `--dbhost` (+ `:port`) | database: Remote database host requires credentials, Unreachable remote database rolls the site back; external-db: all scenarios | | `--dbprefix`, `--dbcharset`, `--dbcollate`, `--skip-check` | database: Custom database name, user, password, prefix, charset and collation… | | `--version` | install-options: Install a specific WordPress version; php-version legacy outlines (6.2.6, 6.8.3) | | `--skip-content` | install-options: Install WordPress without the default themes and plugins | | `--skip-install` | install-options: Skip the WordPress install; used as a fast fixture in many scenarios | | `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | -| `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl (PHPT-10, fixed) | -| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database (WPT-1) | +| `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl (regression check) | +| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database | | `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, with `--wildcard`; parent without SSL: not covered yet: the error dumps the parent site object with `var_dump()` (its scenario comes back with the fix) | -| `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl (SSL-5/SSL-9, fixed) | -| unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection, WPT-13) | +| `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl (regression check) | +| unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection: the value is only checked after the site was provisioned) | | `--ssl` without value (= le) | not covered: same code path as `--ssl=le`, LE budget | | `--wildcard` | ssl: Inherit together with --wildcard is rejected; update `--ssl=le --wildcard` rejection; `--ssl=le --wildcard` itself not covered (needs Cloudflare DNS-01 credentials) | | `--proxy-cache=on\|off` | proxy-cache: Create a site with proxy cache…; invalid value: not covered yet: `on`/`off` are parsed as booleans, so any value passes validation (its scenario comes back with the fix); default stored as `''`: not covered yet: a new site stores `''` instead of `off` (its scenario comes back with the fix) | @@ -59,15 +59,15 @@ Legend: file names are under `features/`. Scenarios that assert the correct beha | `reload [--all] [--nginx] [--php]` | site: Restart and reload…; disabled site refused | | `share --disable` (no share) / SSL site | site: Share without an active share…; ssl: self-signed scenario | | `share` with ngrok, `--refresh`, `--token` | not covered: needs the external ngrok service and a token | -| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl (PHPT-10, fixed) | +| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl (regression check) | | `update --php` | php-version: Default PHP version and update…, same version, unsupported version | | `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: not covered yet (same boolean parsing as on create; its scenario comes back with the fix) | -| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps kept on sites without SSL: ×2 (WPT-2, fixed) | +| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps kept on sites without SSL: ×2 (regression checks) | | `update` of unknown / disabled site | site: Update of an unknown site fails, Disable and enable… | | `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | | `delete` (+ confirmation prompt) | site: Delete removes every trace… (incl. the random DB user), Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | | `list [--format] [--enabled] [--disabled]` | site: List shows the site in every format; disabled: site: Disable and enable… | -| `clean [--page] [--object] [--proxy]` | cache and proxy-cache (CACHE-1, regular since the v4.13.2 fix); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | +| `clean [--page] [--object] [--proxy]` | cache and proxy-cache (regression checks: `ee site clean` failed without a TTY yet printed Success, fixed in v4.13.2 by EasyEngine/site-command#510); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | ### Behaviours and side effects @@ -87,14 +87,14 @@ Legend: file names are under `features/`. Scenarios that assert the correct beha | Rollback (`catch_clean`) at levels 0–4: webroot, containers, volumes, DB, hosts entry removed; remote DB never touched | database, external-db, ssl | | VIP: skeleton clone, mu-plugins move, wp-config requires, site meta, ownership | vip | | Container migrations (`set_mu_cookie_domain`, `update_env_file_with_newrelic_key`) | not covered: they only run on upgrade from an older EE; new sites get the same result directly (checked) | -| Rollback on SIGINT/SIGTERM | not covered: MIG-9 (handlers never dispatched); needs killing ee mid-create | +| Rollback on SIGINT/SIGTERM | not covered: the signal handlers are never dispatched; needs killing ee mid-create | | macOS (`IS_DARWIN`) branches | not covered: Linux-only host | Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wildcard` only partly); the gaps are the external-service/DNS-01 paths (`--ssl` bare, `--ssl=le --wildcard`, ngrok share, `--yes`), upgrade-only migrations, signal rollback and macOS branches. ## Known bugs and regression scenarios -AUTH-1, AUTH-5 and the blank-alias scenario passed on develop after the 2026-09-25 merges of site-command #502 and site-type-wp #239 (https://github.com/mrrobot47/site-type-wp/actions/runs/36099148649) and are regular regression scenarios since. The WPT-1 and WPT-8 scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) are regular since the fixes on the `fix/site-name-collisions` branches of site-command, site-type-wp and site-type-php (2026-09-26, local): they fail on develop until those are merged. The WPT-2 + WPT-12 alias-change scenario passed there too, but only because the alias update now returns before docker-gen has the recreated container; it now waits for the new alias to be served before checking. The WPT-2 ×2, SC-19, PHPT-10 and SSL-5/SSL-9 scenarios are regular since the fixes on site-command `fix/proxy-cache-rollback-and-le-alias` (2026-09-27, local); they fail on develop until that is merged. +The alias-name scenarios (an alias that could clobber the site's `_wildcard` proxy files, a subdomain multisite whose wildcard is another site's alias, a trailing comma in `--alias-domains`) are regression checks for EasyEngine/site-command#502 and EasyEngine/site-type-wp#239. The name-collision scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) check EasyEngine/site-command#503, EasyEngine/site-type-wp#240 and EasyEngine/site-type-php#115. The alias-change, www-redirect-after-delete, `--ssl=off` after a self-signed create and LE-leftover scenarios check EasyEngine/site-command#505; the alias-change scenario waits for the new alias to be served before checking, because the alias update returns before docker-gen has the recreated container. Scenarios for open bugs are left out of this suite until their fixes land; each comes back as a regular scenario with its fix: @@ -109,7 +109,7 @@ Scenarios for open bugs are left out of this suite until their fixes land; each - install-options: --public-dir without a value keeps the default webroot - install-options: A site title with an apostrophe -Regression scenarios for fixed bugs: install-options: Shell metacharacters in the site name are rejected (SEC-1, fixed in v4.13.2); install-options: The latest WordPress download passes its checksums (WP-1, fixed in v4.13.1); cache: ee site clean purges…, proxy-cache: ee site clean --proxy empties… (CACHE-1, fixed in v4.13.2). +Regression scenarios for fixed bugs: install-options: Shell metacharacters in the site name are rejected (fixed in v4.13.2 by EasyEngine/site-type-wp#243 and EasyEngine/site-command#509); install-options: The latest WordPress download passes its checksums (fixed in v4.13.1 by EasyEngine/site-type-wp#241); cache: ee site clean purges…, proxy-cache: ee site clean --proxy empties… (fixed in v4.13.2 by EasyEngine/site-command#510). ## Tags and runtime @@ -117,14 +117,14 @@ Regression scenarios for fixed bugs: install-options: Shell metacharacters in th |---|---|---| | (all) | 116 | including outline examples (main lane 107, `@slow` 7, `@le` 2) | | `@destructive` | 4 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | -| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`behat/ci/pebble-setup.sh`); both regular (the leftovers one fails on develop until site-command `fix/proxy-cache-rollback-and-le-alias`, SSL-5/SSL-9, is merged) | +| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`.github/behat/pebble-setup.sh`); the leftovers one is a regression check for EasyEngine/site-command#505 | | `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | | `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | -| `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway (read from `docker network inspect bridge`, 10.0.0.1 on this host); reachability from the default bridge and from a user-defined network was checked with a throwaway listener | +| `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway (read from `docker network inspect bridge`); reachability from the default bridge and from a user-defined network was checked with a throwaway listener | | `@network` | 2 | VIP: clones from github.com | | `@remote` | 0 | no rclone use in this package | -Rough timings on this host class: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@slow&&~@le&&~@external-db&&~@network'`, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3. +Rough timings: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@slow&&~@le&&~@external-db&&~@network'`, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3. ## Context changes (`bootstrap/FeatureContext.php`) diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 6ddc157..8f6458f 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -124,13 +124,13 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias """ And a GET request to 'http://foo.bh-site-type-wp-aliasmu-x.test/' should not return 503 - # AUTH-1 regression: an alias like `_wildcard.<site>` could clobber the site's `_wildcard` proxy files (fixed by site-command #502 / site-type-wp #239). + # Regression check: an alias like `_wildcard.<site>` could clobber the site's `_wildcard` proxy files (fixed by EasyEngine/site-command#502 and EasyEngine/site-type-wp#239). Scenario: Reserved alias names are rejected on create When I run "bin/ee site create bh-site-type-wp-auth1.test --type=wp --skip-install --alias-domains='_wildcard.bh-site-type-wp-auth1.test'" Then the command should fail And the site 'bh-site-type-wp-auth1.test' should not exist - # Regression (fixed by site-type-wp #239): a trailing comma stored an empty alias and a dangling `,` in VIRTUAL_HOST. + # Regression check (fixed by EasyEngine/site-type-wp#239): a trailing comma stored an empty alias and a dangling `,` in VIRTUAL_HOST. Scenario: Blank alias domains are dropped on create When I run "bin/ee site create bh-site-type-wp-blank.test --type=wp --skip-install --alias-domains='bh-site-type-wp-blank-a.test,'" Then the command should succeed @@ -138,7 +138,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias | field | value | | alias_domains | bh-site-type-wp-blank.test,bh-site-type-wp-blank-a.test | - # WPT-2 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): update_alias_domains() dumped docker-compose.yml without HTTPS_METHOD=nohttps for a site without SSL, until the next refresh. + # Regression check (fixed by EasyEngine/site-command#505): update_alias_domains() dumped docker-compose.yml without HTTPS_METHOD=nohttps for a site without SSL, until the next refresh. Scenario: Alias changes keep HTTPS disabled on a site without SSL Given a WordPress site 'bh-site-type-wp-aliasnossl.test' exists created with '--skip-install' When I run 'bin/ee site update bh-site-type-wp-aliasnossl.test --add-alias-domains=bh-site-type-wp-aliasnossl-a.test' @@ -154,7 +154,7 @@ Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias HTTPS_METHOD=nohttps """ - # WPT-2 + WPT-12 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): `--ssl=off` kept <site>.crt, and with aliases CERT_NAME=${VIRTUAL_HOST} picked it up, so without nohttps nginx-proxy redirected HTTP to HTTPS again. + # Regression check (fixed by EasyEngine/site-command#505): `--ssl=off` kept <site>.crt, and with aliases CERT_NAME=${VIRTUAL_HOST} picked it up, so without nohttps nginx-proxy redirected HTTP to HTTPS again. Scenario: A site whose SSL was turned off keeps serving HTTP after an alias change Given a WordPress site 'bh-site-type-wp-aliasoff.test' exists When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --ssl=self' diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index e75c1ff..b586494 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1158,7 +1158,7 @@ public function aGetRequestToShouldNotReturn( $url, $code ) } /** - * Alias updates return before the proxy serves the new name (PRX-7), so poll. + * Alias updates return before the proxy serves the new name, so poll. * * @Then a GET request to :url should not return :code within :seconds seconds */ @@ -1650,7 +1650,7 @@ private static function cleanup_test_artifacts() { } } - // Proxy certs and ACME state: `--ssl=off` and LE deletes leave them behind (WPT-12, SSL-5, SSL-9). + // Proxy certs and ACME state: `--ssl=off` and LE deletes leave them behind. $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; foreach ( [ 'certs', 'acme-conf/certs', 'acme-conf/var' ] as $sub ) { foreach ( glob( "$proxy_dir/$sub/*" . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $path ) { diff --git a/features/cache.feature b/features/cache.feature index 3f5c4d0..6b85905 100644 --- a/features/cache.feature +++ b/features/cache.feature @@ -60,7 +60,7 @@ Feature: WordPress redis cache (--cache, --with-local-redis) When I send a GET request to 'http://bh-site-type-wp-cache.test/?bh=1' Then the response should have header 'X-SRCache-Fetch-Status: BYPASS' - # CACHE-1 (fixed in v4.13.2, site-command 5c5abb9), regression check: clean_site_cache() runs `docker exec -it`, which fails without a TTY (and can hang under nested ptys), yet `ee site clean` still prints Success. + # Regression check (fixed in v4.13.2 by EasyEngine/site-command#510): clean_site_cache() runs `docker exec -it`, which fails without a TTY (and can hang under nested ptys), yet `ee site clean` still prints Success. Scenario: ee site clean purges the page and object cache keys Given a WordPress site 'bh-site-type-wp-cacheclean.test' exists created with '--cache --skip-install' And I store the key 'bh-site-type-wp-cacheclean.test_page:httpGET/bh' in the global redis diff --git a/features/database.feature b/features/database.feature index 6ffe076..bbb973a 100644 --- a/features/database.feature +++ b/features/database.feature @@ -129,8 +129,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the site 'bh-site-type-wp-remote.test' should not exist And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist - # WPT-1: `a-b.test` and `a.b.test` both map to the default name `a_b_test` (and to the same volumes, PHPT-4). The create is - # refused before the database step, and a failed create only drops the database and user it created itself. + # `a-b.test` and `a.b.test` both map to the default name `a_b_test` (and to the same volumes). The create is refused before the database step, and a failed create only drops the database and user it created itself. Scenario: A database name collision does not drop the other site's database Given a WordPress site 'bh-site-type-wp-db-c.test' exists created with '--skip-install' And the global database should have a database named 'bh_site_type_wp_db_c_test' @@ -143,7 +142,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the site 'bh-site-type-wp-db.c.test' should not exist And the global database should have a database named 'bh_site_type_wp_db_c_test' - # WPT-1: --dbuser of another site is refused up front instead of being dropped by the rollback. + # --dbuser of another site is refused up front instead of being dropped by the rollback. Scenario: A database user collision does not drop the other site's user Given a WordPress site 'bh-site-type-wp-dbuser.test' exists created with '--dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' When I run 'bin/ee site create bh-site-type-wp-dbuser2.test --type=wp --dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' @@ -156,7 +155,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the global database should have a user named 'bh_site_type_wp_shareduser' And the global database should have a database named 'bh_site_type_wp_dbuser_test' - # WPT-8: the webroot check runs before the database step, so nothing is created. + # The webroot check runs before the database step, so nothing is created. Scenario: An existing webroot aborts the create without leaking database objects Given I run 'mkdir -p {SITES_DIR}/bh-site-type-wp-stale.test' And I run 'touch {SITES_DIR}/bh-site-type-wp-stale.test/keep-me' @@ -171,7 +170,7 @@ Feature: Database options of WordPress sites (global DB flags, --local-db) And the global database should not have a database named 'bh_site_type_wp_stale_test' And the global database should not have users starting with 'bh-site-type-wp-stale.test-' - # WPT-1: a failed --ssl=custom validation rolls back before any DB work and must not drop the colliding site's default-named database. + # A failed --ssl=custom validation rolls back before any DB work and must not drop the colliding site's default-named database. Scenario: A failed custom-SSL create does not drop another site's database Given a WordPress site 'bh-site-type-wp-db-s.test' exists created with '--skip-install' And the global database should have a database named 'bh_site_type_wp_db_s_test' diff --git a/features/install-options.feature b/features/install-options.feature index 4e6a6b1..07d558d 100644 --- a/features/install-options.feature +++ b/features/install-options.feature @@ -37,7 +37,7 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con BhPass1234 """ - # The message no longer echoes the password (site-type-wp#244, v4.13.2). + # The message no longer echoes the password (EasyEngine/site-type-wp#244, v4.13.2). Scenario Outline: Weak administrator password <pass> is rejected before anything is created When I run 'bin/ee site create bh-site-type-wp-weakpass.test --type=wp --admin-pass=<pass>' Then the command should fail @@ -92,7 +92,7 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con Scenario: Removed EasyEngine v3 cache flags are rejected When I run 'bin/ee site create bh-site-type-wp-w3tc.test --type=wp --w3tc' Then the command should fail - # The dedicated "no longer supported in EE v4" message is unreachable (WPT-14). + # The dedicated "no longer supported in EE v4" message is unreachable. And STDERR should contain: """ unknown --w3tc parameter @@ -201,13 +201,13 @@ Feature: WordPress install options (title/admin flags, locale, version, skip-con """ And a GET request to 'http://bh-site-type-wp-pubdir.test/' should return 200 - # WP-1 (fixed in v4.13.1, site-type-wp#241), regression check: WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. + # Regression check (fixed in v4.13.1 by EasyEngine/site-type-wp#241): WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. Scenario: The latest WordPress download passes its checksums Given a WordPress site 'bh-site-type-wp-wp1.test' exists created with '--skip-install' When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-wp1.test' Then the command should succeed - # SEC-1 (fixed in v4.13.2, site-type-wp#243 and site-command#509), regression check: shell metacharacters in the site name reach unescaped shell sinks during create. + # Regression check (fixed in v4.13.2 by EasyEngine/site-type-wp#243 and EasyEngine/site-command#509): shell metacharacters in the site name reach unescaped shell sinks during create. # The payload survives lowercasing and the docker-prefix stripping of `.`/`-`: it only creates an empty marker in $HOME. Scenario: Shell metacharacters in the site name are rejected When I run "bin/ee site create 'bh-site-type-wp-sec1$(cd;>bhsitetypewpsec1pwned).test' --type=wp --skip-install" diff --git a/features/multisite.feature b/features/multisite.feature index c6c4345..23d1b03 100644 --- a/features/multisite.feature +++ b/features/multisite.feature @@ -89,14 +89,14 @@ Feature: WordPress multisite (--mu=subdir|subdom) And the site 'bh-site-type-wp-muinh.test' should not exist And the directory '{SITES_DIR}/bh-site-type-wp-muinh.test' should not exist - # WPT-8: the inherit/wildcard check runs before the database step, so nothing is created. + # The inherit/wildcard check runs before the database step, so nothing is created. Scenario: A rejected inherit + subdom create leaves nothing in the global database When I run 'bin/ee site create bh-site-type-wp-muinh2.test --type=wp --mu=subdom --ssl=inherit' Then the command should fail And the global database should not have a database named 'bh_site_type_wp_muinh2_test' And the global database should not have users starting with 'bh-site-type-wp-muinh2.test-' - # AUTH-5 regression: subdom create didn't check its implicit *.site against existing aliases (fixed by site-type-wp #239). + # Regression check: subdom create didn't check its implicit *.site against existing aliases (fixed by EasyEngine/site-type-wp#239). Scenario: Subdomain multisite whose wildcard is already an alias of another site is rejected Given a WordPress site 'bh-site-type-wp-auth5owner.test' exists created with "--skip-install --alias-domains='*.bh-site-type-wp-auth5.test'" When I run 'bin/ee site create bh-site-type-wp-auth5.test --type=wp --mu=subdom --skip-install' diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index 1a42dd7..b0277d1 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -82,7 +82,7 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should not exist And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should not exist - # CACHE-1 (fixed in v4.13.2, site-command 276d118), regression check: the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. + # Regression check (fixed in v4.13.2 by EasyEngine/site-command#510): the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. @destructive Scenario: ee site clean --proxy empties the proxy cache directory Given a WordPress site 'bh-site-type-wp-proxyclean.test' exists created with '--proxy-cache=on --skip-install' diff --git a/features/redirect.feature b/features/redirect.feature index 8cea300..97cb464 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,7 +1,7 @@ Feature: www / non-www redirection of WordPress sites # The www check curls the other host name from the host, so it needs a hosts entry before the site is created. - # Full installs: with --skip-install nothing waits for the proxy to load the vhost before the www check (WPT-17). + # Full installs: with --skip-install nothing waits for the proxy to load the vhost before the www check. Scenario: no_www-no_ssl redirection works properly Given I add a hosts entry for 'www.bh-site-type-wp-redir.test' When I run 'bin/ee site create bh-site-type-wp-redir.test --type=wp' @@ -36,7 +36,7 @@ Feature: www / non-www redirection of WordPress sites Then the command should succeed And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should not exist - # SC-19 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): delete removed the redirect file without reloading nginx-proxy, so www kept redirecting to the deleted site. + # Regression check (fixed by EasyEngine/site-command#505): delete removed the redirect file without reloading nginx-proxy, so www kept redirecting to the deleted site. Scenario: Deleting the site stops the www redirect Given I add a hosts entry for 'www.bh-site-type-wp-redir4.test' And a WordPress site 'bh-site-type-wp-redir4.test' exists diff --git a/features/ssl.feature b/features/ssl.feature index 17c872b..9dd5a7c 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -113,7 +113,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) """ And the site 'bh-site-type-wp-custom2.test' should not exist - # WPT-13: create's `[--ssl]` is a flag, so the value is only rejected in init_ssl(), after the site was provisioned. + # Create's `[--ssl]` is a flag, so the value is only rejected in init_ssl(), after the site was provisioned. Scenario: An unknown --ssl value rolls the whole create back When I run 'bin/ee site create bh-site-type-wp-sslbad.test --type=wp --ssl=bogus' Then the command should fail @@ -179,7 +179,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) SSL is already disabled on bh-site-type-wp-sslupd.test """ - # PHPT-10 / SC-3 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): update refused `--ssl=off` for self-signed creates, which store site_ssl_wildcard=1. + # Regression check (fixed by EasyEngine/site-command#505): update refused `--ssl=off` for self-signed creates, which store site_ssl_wildcard=1. Scenario: SSL of a site created with --ssl=self can be turned off Given a WordPress site 'bh-site-type-wp-selfoff.test' exists created with '--ssl=self --skip-install' When I run 'bin/ee site update bh-site-type-wp-selfoff.test --ssl=off' @@ -196,7 +196,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) """ Invalid value specified for 'ssl' """ - # A site that never had SSL: sslupd had --ssl=self above, which keeps site_ssl_wildcard=1 after --ssl=off (site-command#505), so it would request a wildcard certificate. + # A site that never had SSL: sslupd had --ssl=self above, which keeps site_ssl_wildcard=1 after --ssl=off (EasyEngine/site-command#505), so it would request a wildcard certificate. Given a WordPress site 'bh-site-type-wp-nossl.test' exists created with '--skip-install' When I run 'bin/ee site update bh-site-type-wp-nossl.test --ssl=le --wildcard' Then the command should fail @@ -216,7 +216,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) | site_ssl_wildcard | 0 | And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should exist And the hosts file should not contain 'bh-site-type-wp-le1.ee4-test.rt.gw' - # The proxy reload is asynchronous (PRX-5): right after create the old workers can still answer, or drop the TLS connection. + # The proxy reload is asynchronous: right after create the old workers can still answer, or drop the TLS connection. When I wait until "curl -sS -o /dev/null https://bh-site-type-wp-le1.ee4-test.rt.gw/" succeeds within 60 seconds And I run 'curl -sS -o /dev/null -w "%{http_code} %{ssl_verify_result}" https://bh-site-type-wp-le1.ee4-test.rt.gw/' Then STDOUT should contain: @@ -238,7 +238,7 @@ Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should not exist And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/certs/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist - # SSL-5 / SSL-9 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): deleting an LE site left acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). + # Regression check (fixed by EasyEngine/site-command#505): deleting an LE site left acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). @le Scenario: Deleting a Let's Encrypt site leaves no ACME leftovers Given the site 'bh-site-type-wp-le1.ee4-test.rt.gw' was deleted earlier in this feature From d20240a3fed9d15a2689d0a5ac325a2b95755cf3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Tue, 20 Oct 2026 18:02:18 +0530 Subject: [PATCH 29/36] test(site-type-wp): fail negative HTTP checks on transport errors and check the proxy purge fixture --- features/bootstrap/FeatureContext.php | 10 ++++++---- features/proxy-cache.feature | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index b586494..4dcb780 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1152,8 +1152,9 @@ public function aGetRequestToShouldReturnWithin( $url, $code, $seconds ) public function aGetRequestToShouldNotReturn( $url, $code ) { $response = $this->http_request( $this->replace_placeholders( $url ), null, true ); - if ( (int) $code === $response['code'] ) { - throw new Exception( "GET $url returned $code.\n" . $response['headers'] ); + // Code 0 is a transport error (no HTTP response), never a pass. + if ( 0 === $response['code'] || (int) $code === $response['code'] ) { + throw new Exception( "GET $url returned {$response['code']}.\n" . $response['headers'] ); } } @@ -1168,13 +1169,14 @@ public function aGetRequestToShouldNotReturnWithin( $url, $code, $seconds ) $deadline = time() + (int) $seconds; do { $response = $this->http_request( $url, null, true ); - if ( (int) $code !== $response['code'] ) { + // Keep polling on a transport error (code 0), e.g. during a proxy reload. + if ( 0 !== $response['code'] && (int) $code !== $response['code'] ) { return; } sleep( 2 ); } while ( time() < $deadline ); - throw new Exception( "GET $url still returned $code after $seconds seconds.\n" . $response['headers'] ); + throw new Exception( "GET $url still returned {$response['code']} after $seconds seconds.\n" . $response['headers'] ); } /** diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature index b0277d1..9f7824b 100644 --- a/features/proxy-cache.feature +++ b/features/proxy-cache.feature @@ -87,11 +87,12 @@ Feature: Proxy cache of WordPress sites (--proxy-cache) Scenario: ee site clean --proxy empties the proxy cache directory Given a WordPress site 'bh-site-type-wp-proxyclean.test' exists created with '--proxy-cache=on --skip-install' And I run 'docker exec services_global-nginx-proxy_1 sh -c "mkdir -p /var/cache/nginx/bh-site-type-wp-proxyclean.test/x && touch /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry"' + And the command should succeed When I run 'bin/ee site clean bh-site-type-wp-proxyclean.test --proxy' Then the command should succeed And STDOUT should contain: """ Proxy cache cleared for bh-site-type-wp-proxyclean.test """ - When I run 'docker exec services_global-nginx-proxy_1 test -e /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry' - Then the command should fail + When I run 'docker exec services_global-nginx-proxy_1 test ! -e /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry' + Then the command should succeed From ca7f6784879a595ea899df536eb7cac0cb09cec0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Fri, 23 Oct 2026 19:48:27 +0530 Subject: [PATCH 30/36] test(site-type-wp): fail docker checks when the docker query fails Docker helpers check the exit status before reading output, and network absence comes from a successful ls instead of an inspect failure, so a failed query no longer reads as nothing left. --- features/bootstrap/FeatureContext.php | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 4dcb780..f23a6b1 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -848,6 +848,9 @@ public function theContainerOfSiteShouldNotExist( $service, $site ) public function theSiteShouldHaveRunningContainers( $site, $count ) { $result = EE::launch( 'docker ps --format "{{.Names}}" --filter ' . escapeshellarg( "label=io.easyengine.site=$site" ), false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list site containers:\n" . $this->describe_command( $result ) ); + } $names = array_filter( explode( "\n", trim( $result->stdout ) ) ); if ( (int) $count !== count( $names ) ) { throw new Exception( "Expected $count running containers for $site, found " . count( $names ) . ': ' . implode( ', ', $names ) ); @@ -935,7 +938,12 @@ public function theDockerNetworkShouldExist( $network ) */ public function theDockerNetworkShouldNotExist( $network ) { - if ( 0 === EE::launch( 'docker network inspect ' . escapeshellarg( $network ), false, true )->return_code ) { + $result = EE::launch( 'docker network ls --format "{{.Name}}"', false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list Docker networks:\n" . $this->describe_command( $result ) ); + } + $networks = array_filter( explode( "\n", trim( $result->stdout ) ) ); + if ( in_array( $network, $networks, true ) ) { throw new Exception( "Docker network $network exists." ); } } @@ -1445,7 +1453,11 @@ private function site_container_id( $site, $service, $include_stopped = true ) { escapeshellarg( "label=io.easyengine.site=$site" ), escapeshellarg( "label=com.docker.compose.service=$service" ) ); - $ids = array_filter( explode( "\n", trim( EE::launch( $command, false, true )->stdout ) ) ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list site containers:\n" . $this->describe_command( $result ) ); + } + $ids = array_filter( explode( "\n", trim( $result->stdout ) ) ); return $ids ? reset( $ids ) : ''; } @@ -1456,7 +1468,12 @@ private function inspect_site_container( $site, $service, $format ) { throw new Exception( "No $service container exists for $site." ); } - return trim( EE::launch( 'docker inspect -f ' . escapeshellarg( $format ) . " $id", false, true )->stdout ); + $result = EE::launch( 'docker inspect -f ' . escapeshellarg( $format ) . " $id", false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not inspect the $service container of $site:\n" . $this->describe_command( $result ) ); + } + + return trim( $result->stdout ); } private function container_networks( $site, $service ) { @@ -1467,6 +1484,9 @@ private function container_networks( $site, $service ) { private static function site_volumes( $site ) { $result = EE::launch( 'docker volume ls -q --filter ' . escapeshellarg( "label=io.easyengine.site=$site" ), false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list site volumes:\n" . $result->stdout . $result->stderr ); + } return array_values( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); } From d8988c5c85bb5b350efa5d1e39063366a109dafa Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Fri, 23 Oct 2026 19:55:37 +0530 Subject: [PATCH 31/36] test(site-type-wp): fail the negative header check on a transport error --- features/bootstrap/FeatureContext.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index f23a6b1..4d43034 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1224,6 +1224,10 @@ public function theResponseShouldHaveHeader( $header ) public function theResponseShouldNotHaveHeader( $name ) { $response = $this->last_response(); + // Code 0 is a transport error (no HTTP response), never a pass. + if ( 0 === $response['code'] ) { + throw new Exception( "No HTTP response to check:\n" . $response['headers'] ); + } if ( preg_match( '/^' . preg_quote( $name, '/' ) . '\s*:/mi', $response['headers'] ) ) { throw new Exception( "Header `$name` found in:\n" . $response['headers'] ); } From c82845176fac829382954f90bff737d6f3b16f30 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Mon, 26 Oct 2026 06:23:41 +0530 Subject: [PATCH 32/36] test(site-type-wp): treat a truncated HTTP transfer as no response curl_exec() returns false when the body ends short of Content-Length, but the status code and headers already arrived. The helper kept code 200 while replacing the headers with a failure note, so the 200 poll and "the response should not have header 'X-Proxy-Cache'" (proxy-cache.feature:57-58) could both pass on a response that carried the header. Map a failed transfer to code 0 so the existing guards reject or retry it. --- features/bootstrap/FeatureContext.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 4d43034..fa1c0d6 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1553,7 +1553,8 @@ private function http_request( $url, $host = null, $resolve = true, $headers = [ $this->response = [ 'url' => $url, - 'code' => $code, + // Incomplete transfers fail even when a status and headers were received. + 'code' => false === $raw ? 0 : $code, 'headers' => false === $raw ? "(request failed: $error)" : substr( $raw, 0, $header_size ), 'body' => false === $raw ? '' : (string) substr( $raw, $header_size ), ]; From 2a72865ee6b5b48eb2b5b6c7dd98329f9d93a6ce Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Mon, 26 Oct 2026 08:23:16 +0530 Subject: [PATCH 33/36] test(site-type-wp): skip the cleanup's site query before core creates its database The feature cleanup opened ee.sqlite unconditionally, so on a host where core had not run a non-cli command yet the suite failed before any scenario (PDO cannot open a missing directory, and an empty file has no sites table). With no database there are no sites to delete. --- features/bootstrap/FeatureContext.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index fa1c0d6..f498d94 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1615,8 +1615,9 @@ private function remove_added_hosts_lines() { private static function cleanup_test_artifacts() { $ee = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ); - $pdo = new PDO( 'sqlite:' . EE_ROOT_DIR . '/db/ee.sqlite' ); - $sites = $pdo->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ); + // Core creates the DB on its first non-cli command; opening a missing SQLite file would create an empty one without the sites table. + $db = EE_ROOT_DIR . '/db/ee.sqlite'; + $sites = is_file( $db ) ? ( new PDO( 'sqlite:' . $db ) )->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ) : []; foreach ( $sites as $site ) { if ( false !== strpos( $site, self::TEST_PREFIX ) ) { // --foreground: in a background process group the `docker exec -it` of a cache purge stops on SIGTTOU (with timeout itself) and hangs forever. From 19f0f8eff001160e10d06ca110a5f46bd3bc1d6d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Mon, 26 Oct 2026 08:42:40 +0530 Subject: [PATCH 34/36] test(site-type-wp): treat a missing EE database as no site row A feature that starts with "a WordPress site ... exists" looked the site up before core had created ee.sqlite, so on a fresh host it failed instead of creating the site (and opening the missing file left an empty database behind for the cleanup). No database now means no row, and the create command initialises core as usual. --- features/bootstrap/FeatureContext.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index f498d94..844aa46 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1416,6 +1416,10 @@ private function assert_identifier( $name ) { } private function site_row( $site ) { + // Before core's first non-cli command there is no DB (and so no site); opening it would create an empty one. + if ( ! is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ) { + return false; + } $statement = $this->ee_db()->prepare( 'SELECT * FROM sites WHERE site_url = ?' ); $statement->execute( [ $site ] ); From 6da147a6ee666dd01216e5081f0052c95af2621c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Mon, 26 Oct 2026 08:45:16 +0530 Subject: [PATCH 35/36] test(site-type-wp): bound each "wait until ... succeeds" attempt to the time left An attempt ran without a time limit, so a stalled command (the LE scenario's curl has no maximum time) could hold the step far past its deadline. Each attempt now runs under timeout with the remaining budget. --- features/bootstrap/FeatureContext.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 844aa46..524c7bc 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -543,7 +543,8 @@ public function iWaitUntilSucceedsWithin( $command, $seconds ) $command = $this->replace_placeholders( $command ); $deadline = time() + (int) $seconds; do { - $result = EE::launch( $command, false, true ); + // Each attempt gets only the time left, so a stalled command can't outlast the deadline. + $result = EE::launch( sprintf( 'timeout -k 5 %d sh -c %s', max( 1, $deadline - time() ), escapeshellarg( $command ) ), false, true ); if ( 0 === $result->return_code ) { return; } From 91a68832d358766b7ad5dffe53ab24bc6d5c74d0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi <riddhesh237@gmail.com> Date: Mon, 26 Oct 2026 08:47:35 +0530 Subject: [PATCH 36/36] ci(site-type-wp): give the Pebble readiness wait a wall-clock deadline wait_for counted 60 attempts but ran each one without a limit, so a stalled curl to the Pebble directory could hold the job until its timeout instead of failing after 60 seconds. It now stops after 60 seconds and runs each attempt under timeout with the time left. --- .github/behat/pebble-setup.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh index 3366469..5bc2011 100755 --- a/.github/behat/pebble-setup.sh +++ b/.github/behat/pebble-setup.sh @@ -141,11 +141,13 @@ write_front_config() { } wait_for() { - # $1 = description, rest = command; up to 60 s. - local what=$1 i + # $1 = description, rest = command; up to 60 s of wall-clock time, each attempt bounded by what is left. + local what=$1 end=$((SECONDS + 60)) left shift - for ((i = 0; i < 60; i++)); do - "$@" >/dev/null 2>&1 && return 0 + while ((SECONDS < end)); do + # timeout 0 would mean no limit. + left=$((end - SECONDS)) + timeout -k 5 $((left > 0 ? left : 1)) "$@" >/dev/null 2>&1 && return 0 sleep 1 done die "timed out waiting for $what"