From 3c535910e8da0576265a9d676bc7317bc28b0dc7 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 13:15:37 +0530 Subject: [PATCH 01/35] test(site-type-php): rebase behat context on site-command and add shared steps Start from site-command's context and fix its traps: the unanchored I-run regex that matched any step with a double-quoted string, assertions that always read the first command, the inverted container-count check, compose-v1 container names, the vacuous mysql checks (DB-1) and `docker exec -it` (CACHE-1). Cleanup now deletes only this suite's bh-site-type-php-* sites and their leftovers instead of every site. Add a marked section of generic steps (command result, JSON, sites table, files, docker services/volumes/networks, HTTP with --resolve or Host, TLS certificates, global db/redis, hosts file, idempotent site givens). Commands run with stdin on /dev/null so docker never allocates a TTY. --- features/bootstrap/FeatureContext.php | 1754 +++++++++++++++++++++++-- 1 file changed, 1612 insertions(+), 142 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index fb9448d..1730468 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -12,9 +12,20 @@ include_once( EE_ROOT . '/php/EE/Runner.php' ); include_once( EE_ROOT . '/php/utils.php' ); -define( 'EE', true ); -define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); -define( 'EE_ROOT_DIR', '/opt/easyengine' ); +defined( 'EE' ) || define( 'EE', true ); +defined( 'EE_VERSION' ) || define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); + +// php/init-ee.php may already have defined it (through the includes above). +if ( ! defined( 'EE_ROOT_DIR' ) ) { + $root_alias = '/opt'; + if ( 'Darwin' === php_uname( 's' ) ) { + $sys_name = posix_getpwuid(posix_geteuid())['name']; + if ( ! empty( $sys_name ) ) { + $root_alias = '/Users/' . $sys_name; + } + } + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -47,6 +58,8 @@ use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\AfterFeatureScope; use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Testwork\Hook\Scope\BeforeSuiteScope; +use Behat\Testwork\Hook\Scope\AfterSuiteScope; use Behat\Gherkin\Node\PyStringNode, Behat\Gherkin\Node\TableNode; @@ -55,10 +68,35 @@ class FeatureContext implements Context { + /** + * Sites owned by this suite: `bh-site-type-php-.` and their subdomains. Cleanup only ever touches these. + */ + const SITE_PATTERN = '/(^|\.)bh-site-type-php-/'; + + /** + * Global-db databases and users created by this suite (explicit `bh_stp_*` names, or derived from the site name). + */ + const DB_NAME_LIKE = [ 'bh\\_stp\\_%', 'bh\\_site\\_type\\_php\\_%' ]; + const DB_USER_LIKE = [ 'bh\\_stp\\_%', 'bh-site-type-php-%' ]; + + const TMP_DIR = '/var/tmp/bh-site-type-php'; + const HOSTS_MARKER = '# behat:site-type-php'; + + const GLOBAL_DB_CONTAINER = 'services_global-db_1'; + const GLOBAL_REDIS_CONTAINER = 'services_global-redis_1'; + const PROXY_CONTAINER = 'services_global-nginx-proxy_1'; + public $command; + public $commands; public $webroot_path; public $ee_path; + /** @var array|null Last HTTP response: code, headers, body, error. */ + private $response; + + /** @var array Container start times noted by `I note the start time of ...`. */ + private $start_times = []; + /** * Initializes context. */ @@ -74,39 +112,42 @@ public function __construct() public function eePharIsPresent() { // Checks if phar already exists, replaces it - if( file_exists( 'ee-old.phar' ) ) { + if(file_exists('ee-old.phar')) { // Below exec call is required as currenly `ee cli update` is ran with root // which updates ee.phar with root privileges. - exec( "sudo rm ee.phar" ); - copy( 'ee-old.phar','ee.phar' ); + exec("sudo rm ee.phar"); + copy('ee-old.phar','ee.phar'); return 0; } - exec( "php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status ); - if ( 0 !== $return_status ) { - throw new Exception( "Unable to generate phar" . $return_status ); + exec("php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status); + if (0 !== $return_status) { + throw new Exception("Unable to generate phar" . $return_status); } // Cache generaed phar as it is expensive to generate one - copy( 'ee.phar','ee-old.phar' ); + copy('ee.phar','ee-old.phar'); } /** * @Given :command is installed */ - public function isInstalled( $command ) + public function isInstalled($command) { - exec( "type " . $command, $output, $return_status ); - if ( 0 !== $return_status ) { - throw new Exception( $command . " is not installed! Exit code is:" . $return_status ); + exec("type " . $command, $output, $return_status); + if (0 !== $return_status) { + throw new Exception($command . " is not installed! Exit code is:" . $return_status); } } /** - * @When /I run '(.*)'|"(.*)"/ + * Two anchored patterns: the old unanchored `/I run '(.*)'|"(.*)"/` matched every step containing a double-quoted string. + * + * @When /^I run '(.*)'$/ + * @When /^I run "(.*)"$/ */ - public function iRun( $command ) + public function iRun($command) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = self::run( $command ); } /** @@ -114,7 +155,26 @@ public function iRun( $command ) */ public function iTry( $command ) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = self::run( $command ); + } + + /** + * @Then return value should be 0 + */ + public function returnValueShouldBe0() + { + $this->assertReturnCode( 0 ); + } + + /** + * @Then return value of command :index should be 0 + */ + public function returnValueOfCommandShouldBe0($index) + { + $result = $this->commandAt( $index ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Return code of command $index is {$result->return_code}, not 0.\n" . $this->describe( $result ) ); + } } /** @@ -126,11 +186,11 @@ public function afterDelayOfSeconds( $time ) } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * @Then /^(STDOUT|STDERR) should return exactly$/ */ - public function stdoutShouldReturnExactly( $output_stream, PyStringNode $expected_output ) + public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) { - $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + $command_output = $this->streamOf( $this->lastCommand(), $output_stream ); $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); @@ -140,56 +200,76 @@ public function stdoutShouldReturnExactly( $output_stream, PyStringNode $expecte } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return exactly$/ */ - public function stdoutShouldReturnSomethingLike( $output_stream, PyStringNode $expected_output ) + public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) { - $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + $command_output = $this->streamOf( $this->commandAt( $index ), $output_stream ); + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); - $expected_out = isset( $expected_output->getStrings()[0] ) ? $expected_output->getStrings()[0] : ''; - if ( strpos( $command_output, $expected_out ) === false ) { - throw new Exception( "Actual output is:\n" . $command_output ); + if ( $expected_output->getRaw() !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then The :site db entry should be removed + * @Then /^(STDOUT|STDERR) should return something like$/ + */ + public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) + { + $command_output = $this->streamOf( $this->lastCommand(), $output_stream ); + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + if (strpos($command_output, $expected_out) === false) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /^(STDOUT|STDERR) of command (\d+) should return something like$/ */ - public function theDbEntryShouldBeRemoved( $site ) + public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) { - $out = shell_exec( "sudo bin/ee site list" ); - if ( false !== strpos( $out, $site ) ) { - throw new Exception( "$site db entry not been removed!" ); + $command_output = $this->streamOf( $this->commandAt( $index ), $output_stream ); + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + if (strpos($command_output, $expected_out) === false) { + throw new Exception("Actual output is:\n" . $command_output); } + } + /** + * @Then The :site db entry should be removed + */ + public function theDbEntryShouldBeRemoved($site) + { + if ( null !== $this->siteRow( $site ) ) { + throw new Exception("$site db entry not been removed!"); + } } /** * @Then The :site webroot should be removed */ - public function theWebrootShouldBeRemoved( $site ) + public function theWebrootShouldBeRemoved($site) { - if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { - throw new Exception( "Webroot has not been removed!" ); + if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been removed!"); } } /** + * Looks containers up by label: compose v2 names them `--1`, not `__1`. + * * @Then Following containers of site :site should be removed: */ - public function followingContainersOfSiteShouldBeRemoved( $site, TableNode $table ) + public function followingContainersOfSiteShouldBeRemoved($site, TableNode $table) { - $containers = $table->getHash(); - $site_name = implode( explode( '.', $site ) ); - - foreach ( $containers as $container ) { - - $sevice = $container['container']; - $container_name = $site_name . '_' . $sevice . '_1'; - - exec( "docker inspect -f '{{.State.Running}}' $container_name > /dev/null 2>&1", $exec_out, $return ); - if ( ! $return ) { - throw new Exception( "$container_name has not been removed!" ); + foreach ( $table->getHash() as $container ) { + $id = $this->serviceContainerId( $site, $container['container'], false ); + if ( '' !== $id ) { + throw new Exception( "The {$container['container']} container of $site ($id) has not been removed!" ); } } } @@ -197,10 +277,10 @@ public function followingContainersOfSiteShouldBeRemoved( $site, TableNode $tabl /** * @Then The site :site should have webroot */ - public function theSiteShouldHaveWebroot( $site ) + public function theSiteShouldHaveWebroot($site) { if ( ! file_exists( EE_SITE_ROOT . '/' . $site ) ) { - throw new Exception( "Webroot has not been created!" ); + throw new Exception("Webroot has not been created!"); } } @@ -217,103 +297,71 @@ public function theSiteShouldHaveIndexFile( $site ) /** * @Then Request on :site should contain following headers: */ - public function requestOnShouldContainFollowingHeaders( $site, TableNode $table ) + 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 ); + $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_TIMEOUT, 30); + $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 ); + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); } } } /** - * @AfterScenario - */ - public function cleanupScenario( AfterScenarioScope $scope ) - { - $this->commands = []; - chdir( $this->ee_path ); - } - - /** - * @AfterFeature + * @Then Request on :host with header :header should contain following headers: */ - public static function cleanup( AfterFeatureScope $scope ) + public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) { - $test_sites = [ - 'php.test', - 'example.test', - 'www.example1.test', - 'labels.test', - 'php-local-db.test', - 'php-local-redis.test', - 'php-local-db-redis.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" ); - } - - if( file_exists( 'ee.phar' ) ) { - unlink( 'ee.phar' ); - } - if( file_exists( 'ee-old.phar' ) ) { - unlink( 'ee-old.phar' ); - } - } + $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_TIMEOUT, 30); + $headers = curl_exec($ch); - /** - * @Then There should be :expected_running_containers containers with labels - */ - public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) - { - $labels = $pyStringNode->getStrings(); - $label_string = implode(' -f label=',$labels); + curl_close($ch); - $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); - $running_containers = (int) trim($result->stdout); + $rows = $table->getHash(); - if($expected_running_containers === $running_containers) { - throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } } } /** - * @Then Request on :host with header :header should contain following headers: + * @Then Request on :host with resolve option :resolve should contain following headers: */ - public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) + public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $host); curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_RESOLVE, [ $resolve ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); $headers = curl_exec($ch); - curl_close($ch); $rows = $table->getHash(); @@ -326,16 +374,39 @@ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, } /** + * The old version threw when the count matched, so it passed for any other count. + * + * @Then There should be :expected_running_containers containers with labels + */ + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + { + $filters = ''; + foreach ( $pyStringNode->getStrings() as $label ) { + $filters .= ' -f ' . escapeshellarg( 'label=' . trim( $label ) ); + } + + $result = self::run( "docker ps -q $filters" ); + $running_containers = count( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + } + } + + /** + * No `-it`: without a TTY `docker exec -it` fails, and under the harness's nested ptys it can hang (CACHE-1). + * * @Then Check global redis cache for :site */ public function checkGlobalRedisCacheOfSite($site) { - exec( sprintf( "docker exec -it %s redis-cli set 'easyengine' 'rocks'", GLOBAL_REDIS_CONTAINER ) ); - $output = exec( sprintf( "docker exec -it %s redis-cli get 'easyengine'", GLOBAL_REDIS_CONTAINER ) ); - if ( '"rocks"' !== $output ) { - throw new Exception("Global redis not working for $site site. Getting '$output' instead of 'rock'"); + $key = 'behat-' . $site; + $this->globalRedis( sprintf( 'set %s rocks', escapeshellarg( $key ) ) ); + $output = trim( $this->globalRedis( sprintf( 'get %s', escapeshellarg( $key ) ) )->stdout ); + $this->globalRedis( sprintf( 'del %s', escapeshellarg( $key ) ) ); + if ( 'rocks' !== $output ) { + throw new Exception("Global redis not working for $site site. Getting '$output' instead of 'rocks'"); } - exec( sprintf( "docker exec -it %s redis-cli del 'easyengine'", GLOBAL_REDIS_CONTAINER ) ); } /** @@ -343,16 +414,17 @@ public function checkGlobalRedisCacheOfSite($site) */ public function checkLocalRedisCacheOfSite($site) { - $site_root_folder = EE_SITE_ROOT . '/' . $site; - exec("cd $site_root_folder && docker-compose exec redis redis-cli set 'easyengine' 'rocks'"); - $output = exec("cd $site_root_folder && docker-compose exec redis redis-cli get 'easyengine'"); + $this->execInService( $site, 'redis', "redis-cli set easyengine rocks" ); + $output = trim( $this->execInService( $site, 'redis', "redis-cli get easyengine" )->stdout ); + $this->execInService( $site, 'redis', "redis-cli del easyengine" ); if ( 'rocks' !== $output ) { throw new Exception("Local redis not working for $site site. Getting '$output' instead of 'rocks'."); } - exec("cd $site_root_folder && docker-compose exec redis redis-cli del 'easyengine'"); } /** + * Uses the mysql CLI of the php image; see DB-1 for the TLS failure against global-db on php 8.2+. + * * @Then Check local mysql database connection for :site */ public function checkLocalDbConnection(string $site) @@ -369,7 +441,9 @@ public function checkGlobalDbConnection(string $site) } /** - * Helper function to check global and local database connection. + * Checks the site's DB credentials with the mysql CLI inside its php container. + * + * The old version passed whenever `ee shell` printed nothing to stdout, even on failure (DB-1). * * @param string $site * @param string $db_type @@ -378,33 +452,1429 @@ public function checkGlobalDbConnection(string $site) */ public function checkMysqlConnection(string $site, string $db_type) { - $db = new SQLite3( EE_ROOT_DIR . '/db/ee.sqlite' ); - $query = "SELECT `db_user`, `db_password` FROM sites WHERE site_url='".$site."' LIMIT 1;"; - $exec_query = $db->query( $query ); - $site_info = $exec_query->fetchArray( SQLITE3_ASSOC ); + $site_info = $this->siteRow( $site ); if ( empty( $site_info['db_user'] ) || empty( $site_info['db_password'] ) ) { - throw new exception( 'Database credentials not found.' ); + throw new Exception( 'Database credentials not found.' ); } - $mysql_query = sprintf( - 'mysql -h"%s" -u"%s" -p"%s" -e"exit"', - $db_type, - $site_info['db_user'], - $site_info['db_password'] - ); - - $cmd_command = sprintf( - 'ee shell %s --command="%s"', + $result = $this->execInService( $site, - $mysql_query + 'php', + sprintf( 'mysql -h%s -u%s -p%s -e exit', escapeshellarg( $db_type ), escapeshellarg( $site_info['db_user'] ), escapeshellarg( $site_info['db_password'] ) ) ); - $db_connection = exec( $cmd_command ); + if ( 0 !== $result->return_code ) { + throw new Exception( "mysql CLI in the php container of $site could not connect to $db_type.\n" . $this->describe( $result ) ); + } + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) + { + $this->commands = []; + $this->response = null; + $this->start_times = []; + chdir($this->ee_path); + + // Marker file a SEC-1 payload may create in the working directory. + if ( file_exists( EE_ROOT . '/bhstpsec1' ) ) { + unlink( EE_ROOT . '/bhstpsec1' ); + } + } + + /** + * @BeforeSuite + */ + public static function prepareSuite( BeforeSuiteScope $scope ) + { + self::cleanupTestState(); + if ( ! is_dir( self::TMP_DIR ) ) { + mkdir( self::TMP_DIR, 0700, true ); + } + } + + /** + * Deletes only this suite's sites; never "all sites" like the old context did. + * + * @AfterFeature + */ + public static function cleanup(AfterFeatureScope $scope) + { + self::cleanupTestState(); + + if(file_exists('ee.phar')) { + unlink('ee.phar'); + } + if(file_exists('ee-old.phar')) { + unlink('ee-old.phar'); + } + } + + /** + * @AfterSuite + */ + public static function finishSuite( AfterSuiteScope $scope ) + { + self::cleanupTestState(); + if ( is_dir( self::TMP_DIR ) ) { + self::run( 'rm -rf ' . escapeshellarg( self::TMP_DIR ) ); + } + } + + /* + * ===================================================================================== + * Shared steps (added for site-type-php coverage). Generic on purpose so other packages + * can reuse them. All assertions act on the LAST command / response of the scenario. + * ===================================================================================== + */ + + /** + * Runs a command with `bin/ee` resolved to the absolute path, from another working directory. + * + * @When I change to the directory :dir and run :command + */ + public function iChangeToTheDirectoryAndRun( $dir, $command ) + { + $ee = realpath( EE_ROOT ) . '/bin/ee'; + $command = preg_replace( '#^bin/ee\b#', escapeshellarg( $ee ), $command ); + $this->commands[] = self::run( $command, $dir ); + } + + /** + * @When I execute :command in the :service service of site :site + */ + public function iExecuteInTheServiceOfSite( $command, $service, $site ) + { + $this->commands[] = $this->execInService( $site, $service, $command ); + } + + /** + * @When I wait until the command :command succeeds within :seconds seconds + */ + public function iWaitUntilTheCommandSucceeds( $command, $seconds ) + { + $deadline = time() + (int) $seconds; + do { + $result = self::run( $command ); + if ( 0 === $result->return_code ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "`$command` did not succeed within $seconds seconds.\n" . $this->describe( $result ) ); + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $this->assertReturnCode( 0 ); + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $result = $this->lastCommand(); + if ( 0 === $result->return_code ) { + throw new Exception( "Expected a non-zero return code.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then the return code should be :code + */ + public function theReturnCodeShouldBe( $code ) + { + $this->assertReturnCode( (int) $code ); + } + + /** + * @Then /^(STDOUT|STDERR) should( not)? contain:$/ + */ + public function streamShouldContainBlock( $stream, $not, PyStringNode $text ) + { + $this->assertStreamContains( $stream, $text->getRaw(), '' !== $not ); + } + + /** + * @Then /^(STDOUT|STDERR) should( not)? contain "([^"]*)"$/ + */ + public function streamShouldContainText( $stream, $not, $text ) + { + $this->assertStreamContains( $stream, $text, '' !== $not ); + } + + /** + * @Then /^(STDOUT|STDERR) should match:$/ + */ + public function streamShouldMatch( $stream, PyStringNode $regex ) + { + $output = $this->streamOf( $this->lastCommand(), $stream ); + if ( 1 !== preg_match( trim( $regex->getRaw() ), $output ) ) { + throw new Exception( "$stream does not match {$regex->getRaw()}.\n" . $this->describe( $this->lastCommand() ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be empty$/ + */ + public function streamShouldBeEmpty( $stream ) + { + if ( '' !== trim( $this->streamOf( $this->lastCommand(), $stream ) ) ) { + throw new Exception( "$stream is not empty.\n" . $this->describe( $this->lastCommand() ) ); + } + } + + /** + * @Then STDOUT should be valid json + */ + public function stdoutShouldBeValidJson() + { + $this->decodedStdout(); + } + + /** + * Dot paths are supported (`a.b`). Scalars are compared as strings; null and false compare as "". + * + * @Then the JSON in STDOUT should have :key set to :value + */ + public function theJsonInStdoutShouldHaveKeySetTo( $key, $value ) + { + $data = $this->decodedStdout(); + $actual = $this->jsonPath( $data, $key ); + if ( $this->scalarToString( $actual ) !== $value ) { + throw new Exception( sprintf( "JSON key %s is %s, expected %s.\nSTDOUT: %s", $key, var_export( $actual, true ), $value, $this->lastCommand()->stdout ) ); + } + } + + /** + * @Then the JSON list in STDOUT should contain an item with :key set to :value + */ + public function theJsonListShouldContainItem( $key, $value ) + { + foreach ( (array) $this->decodedStdout() as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && $this->scalarToString( $item[ $key ] ) === $value ) { + return; + } + } + throw new Exception( "No item with $key=$value in:\n" . $this->lastCommand()->stdout ); + } + + /** + * @Then the JSON list in STDOUT should not contain an item with :key set to :value + */ + public function theJsonListShouldNotContainItem( $key, $value ) + { + foreach ( (array) $this->decodedStdout() as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && $this->scalarToString( $item[ $key ] ) === $value ) { + throw new Exception( "Unexpected item with $key=$value in:\n" . $this->lastCommand()->stdout ); + } + } + } + + /** + * @Then the site :site should exist + */ + public function theSiteShouldExist( $site ) + { + if ( null === $this->siteRow( $site ) ) { + throw new Exception( "Site $site has no row in the sites table." ); + } + } + + /** + * @Then the site :site should not exist + */ + public function theSiteShouldNotExist( $site ) + { + if ( null !== $this->siteRow( $site ) ) { + throw new Exception( "Site $site still has a row in the sites table." ); + } + } - if ( ! empty( $db_connection ) ) { - throw new Exception( $db_connection ); + /** + * @Then /^the site "([^"]*)" should be (enabled|disabled)$/ + */ + public function theSiteShouldBeEnabledOrDisabled( $site, $state ) + { + $row = $this->requireSiteRow( $site ); + if ( (int) $row['site_enabled'] !== ( 'enabled' === $state ? 1 : 0 ) ) { + throw new Exception( "Site $site has site_enabled={$row['site_enabled']}, expected it to be $state." ); } } + /** + * @Then /^the site "([^"]*)" should( not)? have the field "([^"]*)" set to "([^"]*)"$/ + */ + public function theSiteShouldHaveFieldSetTo( $site, $not, $field, $value ) + { + $row = $this->requireSiteRow( $site, $field ); + $equal = $this->scalarToString( $row[ $field ] ) === $value; + if ( $equal === ( '' !== $not ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, expected %s%s.', $site, $field, var_export( $row[ $field ], true ), $not ? 'anything but ' : '', $value ) ); + } + } + + /** + * @Then /^the site "([^"]*)" should have the field "([^"]*)" (containing|not containing) "([^"]*)"$/ + */ + public function theSiteShouldHaveFieldContaining( $site, $field, $mode, $text ) + { + $row = $this->requireSiteRow( $site, $field ); + $found = false !== strpos( $this->scalarToString( $row[ $field ] ), $text ); + if ( $found !== ( 'containing' === $mode ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, expected it %s %s.', $site, $field, var_export( $row[ $field ], true ), $mode, $text ) ); + } + } + + /** + * @Then the site :site should have the field :field matching :regex + */ + public function theSiteShouldHaveFieldMatching( $site, $field, $regex ) + { + $row = $this->requireSiteRow( $site, $field ); + if ( 1 !== preg_match( $regex, $this->scalarToString( $row[ $field ] ) ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, which does not match %s.', $site, $field, var_export( $row[ $field ], true ), $regex ) ); + } + } + + /** + * "Empty" is PHP-falsy, the way ee reads these columns: a boolean false is saved as "0" (e.g. site_ssl after --ssl=off). + * + * @Then /^the site "([^"]*)" should have an? (empty|non-empty) "([^"]*)"$/ + */ + public function theSiteShouldHaveEmptyField( $site, $state, $field ) + { + $row = $this->requireSiteRow( $site, $field ); + $empty = in_array( $this->scalarToString( $row[ $field ] ), [ '', '0' ], true ); + if ( $empty !== ( 'empty' === $state ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, expected it to be %s.', $site, $field, var_export( $row[ $field ], true ), $state ) ); + } + } + + /** + * @Then /^the (file|directory) "([^"]*)" should( not)? exist$/ + */ + public function thePathShouldExist( $type, $path, $not ) + { + clearstatcache(); + $exists = 'file' === $type ? is_file( $path ) : is_dir( $path ); + if ( $exists === ( '' !== $not ) ) { + throw new Exception( sprintf( 'The %s %s %s.', $type, $path, $exists ? 'exists' : 'does not exist' ) ); + } + } + + /** + * @Then the path :path should be a symlink + */ + public function thePathShouldBeASymlink( $path ) + { + clearstatcache(); + if ( ! is_link( $path ) ) { + throw new Exception( "$path is not a symlink." ); + } + } + + /** + * @Then /^the file "([^"]*)" should( not)? contain:$/ + */ + public function theFileShouldContain( $path, $not, PyStringNode $text ) + { + $this->assertFileContains( $path, $text->getRaw(), '' !== $not ); + } + + /** + * @Then /^the file "([^"]*)" should( not)? contain "([^"]*)"$/ + */ + public function theFileShouldContainText( $path, $not, $text ) + { + $this->assertFileContains( $path, $text, '' !== $not ); + } + + /** + * @Then the file :path should match :regex + */ + public function theFileShouldMatch( $path, $regex ) + { + $content = $this->readFile( $path ); + if ( 1 !== preg_match( $regex, $content ) ) { + throw new Exception( "$path does not match $regex. Content:\n$content" ); + } + } + + /** + * @Then the files :first and :second should be identical + */ + public function theFilesShouldBeIdentical( $first, $second ) + { + if ( $this->readFile( $first ) !== $this->readFile( $second ) ) { + throw new Exception( "$first and $second differ." ); + } + } + + /** + * @Given I append :text to the file :path + */ + public function iAppendToTheFile( $text, $path ) + { + if ( false === file_put_contents( $path, PHP_EOL . $text . PHP_EOL, FILE_APPEND ) ) { + throw new Exception( "Could not append to $path." ); + } + } + + /** + * @Given I write :text to the file :path + */ + public function iWriteToTheFile( $text, $path ) + { + if ( ! is_dir( dirname( $path ) ) ) { + mkdir( dirname( $path ), 0755, true ); + } + if ( false === file_put_contents( $path, $text ) ) { + throw new Exception( "Could not write $path." ); + } + } + + /** + * @Then the docker container :name should be running + */ + public function theDockerContainerShouldBeRunning( $name ) + { + $result = self::run( 'docker inspect -f {{.State.Running}} ' . escapeshellarg( $name ) ); + if ( 'true' !== trim( $result->stdout ) ) { + throw new Exception( "Container $name is not running.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not)? be running$/ + */ + public function theServiceOfSiteShouldBeRunning( $service, $site, $not ) + { + $running = '' !== $this->serviceContainerId( $site, $service ); + if ( $running === ( '' !== $not ) ) { + throw new Exception( sprintf( "The %s service of %s is %s.\n%s", $service, $site, $running ? 'running' : 'not running', $this->siteContainersSummary( $site ) ) ); + } + } + + /** + * @Then the :service service of site :site should not exist + */ + public function theServiceOfSiteShouldNotExist( $service, $site ) + { + $id = $this->serviceContainerId( $site, $service, false ); + if ( '' !== $id ) { + throw new Exception( "The $service container of $site still exists ($id)." ); + } + } + + /** + * @Then the site :site should have :count running containers + */ + public function theSiteShouldHaveRunningContainers( $site, $count ) + { + $ids = $this->lines( self::run( 'docker ps -q -f ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) )->stdout ); + if ( count( $ids ) !== (int) $count ) { + throw new Exception( sprintf( "Expected %d running containers for %s, found %d.\n%s", $count, $site, count( $ids ), $this->siteContainersSummary( $site ) ) ); + } + } + + /** + * @Then the site :site should have :count docker volumes + */ + public function theSiteShouldHaveDockerVolumes( $site, $count ) + { + $volumes = $this->siteVolumes( $site ); + if ( count( $volumes ) !== (int) $count ) { + throw new Exception( sprintf( "Expected %d volumes labelled for %s, found %d:\n%s", $count, $site, count( $volumes ), implode( "\n", $volumes ) ) ); + } + } + + /** + * @Then /^the docker network "([^"]*)" should( not)? exist$/ + */ + public function theDockerNetworkShouldExist( $network, $not ) + { + $exists = 0 === self::run( 'docker network inspect ' . escapeshellarg( $network ) )->return_code; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Docker network $network " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Then the docker network :network should have the label :label + */ + public function theDockerNetworkShouldHaveTheLabel( $network, $label ) + { + list( $key, $value ) = array_pad( explode( '=', $label, 2 ), 2, '' ); + $result = self::run( 'docker network inspect -f ' . escapeshellarg( '{{index .Labels "' . $key . '"}}' ) . ' ' . escapeshellarg( $network ) ); + if ( 0 !== $result->return_code || trim( $result->stdout ) !== $value ) { + throw new Exception( "Docker network $network does not have the label $label.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not)? be attached to the docker network "([^"]*)"$/ + */ + public function theServiceShouldBeAttachedToNetwork( $service, $site, $not, $network ) + { + $id = $this->requireServiceContainer( $site, $service ); + $networks = $this->lines( self::run( 'docker inspect -f ' . escapeshellarg( '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' ) . ' ' . $id )->stdout ); + $attached = in_array( $network, $networks, true ); + if ( $attached === ( '' !== $not ) ) { + throw new Exception( sprintf( "The %s service of %s is %s to %s. Networks: %s", $service, $site, $attached ? 'attached' : 'not attached', $network, implode( ', ', $networks ) ) ); + } + } + + /** + * @Then the :service service of site :site should use the image :image + */ + public function theServiceShouldUseTheImage( $service, $site, $image ) + { + $id = $this->requireServiceContainer( $site, $service ); + $actual = trim( self::run( 'docker inspect -f {{.Config.Image}} ' . $id )->stdout ); + if ( $actual !== $image ) { + throw new Exception( "The $service service of $site runs $actual, expected $image." ); + } + } + + /** + * Expects `:` with the tag pinned in core's img-versions.json. + * + * @Then the :service service of site :site should use the pinned :image image + */ + public function theServiceShouldUseThePinnedImage( $service, $site, $image ) + { + $this->theServiceShouldUseTheImage( $service, $site, $image . ':' . $this->pinnedTag( $image ) ); + } + + /** + * @Then the docker-compose file of site :site should use the pinned :image image + */ + public function theComposeFileShouldUseThePinnedImage( $site, $image ) + { + $this->assertFileContains( EE_SITE_ROOT . "/$site/docker-compose.yml", 'image: ' . $image . ':' . $this->pinnedTag( $image ), false ); + } + + /** + * @Then the :service service of site :site should have the restart policy :policy + */ + public function theServiceShouldHaveRestartPolicy( $service, $site, $policy ) + { + $id = $this->requireServiceContainer( $site, $service ); + $actual = trim( self::run( 'docker inspect -f {{.HostConfig.RestartPolicy.Name}} ' . $id )->stdout ); + if ( $actual !== $policy ) { + throw new Exception( "The $service service of $site has restart policy '$actual', expected '$policy'." ); + } + } + + /** + * @Given I note the start time of the :service service of site :site + */ + public function iNoteTheStartTimeOfTheService( $service, $site ) + { + $this->start_times[ "$site/$service" ] = $this->containerStartedAt( $this->requireServiceContainer( $site, $service ) ); + } + + /** + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not)? have been restarted$/ + */ + public function theServiceShouldHaveBeenRestarted( $service, $site, $not ) + { + if ( ! isset( $this->start_times[ "$site/$service" ] ) ) { + throw new Exception( "Note the start time of the $service service of $site first." ); + } + $before = $this->start_times[ "$site/$service" ]; + $after = $this->containerStartedAt( $this->requireServiceContainer( $site, $service ) ); + $restarted = $before !== $after; + if ( $restarted === ( '' !== $not ) ) { + throw new Exception( "The $service service of $site started at $before and now $after." ); + } + } + + /** + * @Then the php service of site :site should run PHP :version + */ + public function thePhpServiceShouldRunPhp( $site, $version ) + { + $result = $this->execInService( $site, 'php', 'php -r \'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;\'' ); + if ( trim( $result->stdout ) !== $version ) { + throw new Exception( "The php service of $site runs PHP " . trim( $result->stdout ) . ", expected $version.\n" . $this->describe( $result ) ); + } + } + + /** + * Connects the way site code would (mysqli), with the credentials ee stored for the site. + * + * @Then /^the php service of site "([^"]*)" should( not)? be able to connect to the site database$/ + */ + public function thePhpServiceShouldConnectToTheSiteDatabase( $site, $not ) + { + $row = $this->requireSiteRow( $site ); + $code = sprintf( + 'connect_errno ? "ERR " . $m->connect_error : "OK";', + var_export( (string) $row['db_host'], true ), + var_export( (string) $row['db_user'], true ), + var_export( (string) $row['db_password'], true ), + var_export( (string) $row['db_name'], true ), + empty( $row['db_port'] ) ? 3306 : (int) $row['db_port'] + ); + $id = $this->requireServiceContainer( $site, 'php' ); + $result = self::run( sprintf( 'printf %%s %s | docker exec -i %s php', escapeshellarg( $code ), $id ) ); + $ok = 'OK' === trim( $result->stdout ); + if ( $ok === ( '' !== $not ) ) { + throw new Exception( sprintf( "The php service of %s %s connect to %s@%s/%s.\n%s", $site, $ok ? 'could' : 'could not', $row['db_user'], $row['db_host'], $row['db_name'], $this->describe( $result ) ) ); + } + } + + /** + * @Then /^the global database "([^"]*)" should( not)? exist$/ + */ + public function theGlobalDatabaseShouldExist( $db, $not ) + { + $found = trim( self::globalDbQuery( sprintf( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '%s'", addslashes( $db ) ), true )->stdout ); + $exists = $found === $db; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Global database $db " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Then /^the global database user "([^"]*)" should( not)? exist$/ + */ + public function theGlobalDatabaseUserShouldExist( $user, $not ) + { + $found = trim( self::globalDbQuery( sprintf( "SELECT user FROM mysql.user WHERE user = '%s'", addslashes( $user ) ), true )->stdout ); + $exists = $found === $user; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Global database user $user " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Given the global database :db with user :user identified by :password exists + */ + public function theGlobalDatabaseWithUserExists( $db, $user, $password ) + { + $sql = sprintf( + "CREATE DATABASE IF NOT EXISTS `%1\$s`; CREATE USER IF NOT EXISTS '%2\$s'@'%%' IDENTIFIED BY '%3\$s'; GRANT ALL PRIVILEGES ON `%1\$s`.* TO '%2\$s'@'%%'; FLUSH PRIVILEGES;", + str_replace( '`', '', $db ), + addslashes( $user ), + addslashes( $password ) + ); + self::globalDbQuery( $sql, true ); + } + + /** + * @Given the global database user :user identified by :password exists + */ + public function theGlobalDatabaseUserExists( $user, $password ) + { + self::globalDbQuery( sprintf( "CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'; FLUSH PRIVILEGES;", addslashes( $user ), addslashes( $password ) ), true ); + } + + /** + * @Given the global database :db contains the table :table + */ + public function theGlobalDatabaseContainsTheTable( $db, $table ) + { + self::globalDbQuery( sprintf( 'CREATE TABLE IF NOT EXISTS `%s`.`%s` (id INT PRIMARY KEY)', str_replace( '`', '', $db ), str_replace( '`', '', $table ) ), true ); + } + + /** + * @Then /^the global database "([^"]*)" should( not)? contain the table "([^"]*)"$/ + */ + public function theGlobalDatabaseShouldContainTheTable( $db, $not, $table ) + { + $found = trim( self::globalDbQuery( sprintf( "SELECT table_name FROM information_schema.tables WHERE table_schema = '%s' AND table_name = '%s'", addslashes( $db ), addslashes( $table ) ), true )->stdout ); + $exists = $found === $table; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Table $db.$table " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Then /^the database "([^"]*)" should( not)? exist in the db service of site "([^"]*)"$/ + */ + public function theDatabaseShouldExistInTheDbService( $db, $not, $site ) + { + $result = $this->execInService( $site, 'db', sprintf( 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e %s', escapeshellarg( sprintf( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '%s'", addslashes( $db ) ) ) ) ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not query the db service of $site.\n" . $this->describe( $result ) ); + } + $exists = trim( $result->stdout ) === $db; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Database $db " . ( $exists ? 'exists' : 'does not exist' ) . " in the db service of $site." ); + } + } + + /** + * @Then /^the global redis should( not)? have keys matching "([^"]*)"$/ + */ + public function theGlobalRedisShouldHaveKeysMatching( $not, $pattern ) + { + $result = $this->globalRedis( '--scan --pattern ' . escapeshellarg( $pattern ) ); + $this->assertKeys( 'global redis', $result, $pattern, '' !== $not ); + } + + /** + * @Then /^the redis service of site "([^"]*)" should( not)? have keys matching "([^"]*)"$/ + */ + public function theSiteRedisShouldHaveKeysMatching( $site, $not, $pattern ) + { + $result = $this->execInService( $site, 'redis', 'redis-cli --scan --pattern ' . escapeshellarg( $pattern ) ); + $this->assertKeys( "redis service of $site", $result, $pattern, '' !== $not ); + } + + /** + * @When /^I send a GET request to "([^"]*)"$/ + */ + public function iSendAGetRequestTo( $url ) + { + $this->response = $this->httpRequest( $url ); + } + + /** + * @When /^I send a GET request to "([^"]*)" via "([^"]*)"$/ + */ + public function iSendAGetRequestVia( $url, $ip ) + { + $this->response = $this->httpRequest( $url, [ 'resolve' => $ip ] ); + } + + /** + * @When /^I send a GET request to "([^"]*)" with Host "([^"]*)"$/ + */ + public function iSendAGetRequestWithHost( $url, $host ) + { + $this->response = $this->httpRequest( $url, [ 'host' => $host ] ); + } + + /** + * @Then /^a GET request to "([^"]*)" should return (\d{3})$/ + */ + public function aGetRequestShouldReturn( $url, $code ) + { + $this->response = $this->httpRequest( $url ); + $this->theResponseCodeShouldBe( $code ); + } + + /** + * @Then /^a GET request to "([^"]*)" via "([^"]*)" should( not)? return (\d{3})$/ + */ + public function aGetRequestViaShouldReturn( $url, $ip, $not, $code ) + { + $this->response = $this->httpRequest( $url, [ 'resolve' => $ip ] ); + if ( '' === $not ) { + $this->theResponseCodeShouldBe( $code ); + } elseif ( (int) $code === $this->response['code'] ) { + throw new Exception( "Expected anything but HTTP $code.\n" . $this->describeResponse() ); + } + } + + /** + * @Then /^a GET request to "([^"]*)" with Host "([^"]*)" should return (\d{3})$/ + */ + public function aGetRequestWithHostShouldReturn( $url, $host, $code ) + { + $this->response = $this->httpRequest( $url, [ 'host' => $host ] ); + $this->theResponseCodeShouldBe( $code ); + } + + /** + * @Then /^a GET request to "([^"]*)" with certificate verification should return (\d{3})$/ + */ + public function aVerifiedGetRequestShouldReturn( $url, $code ) + { + $this->response = $this->httpRequest( $url, [ 'verify' => true ] ); + $this->theResponseCodeShouldBe( $code ); + } + + /** + * @When /^I wait until a GET request to "([^"]*)"(?: via "([^"]*)")? (returns|no longer returns) (\d{3}) within (\d+) seconds$/ + */ + public function iWaitUntilAGetRequestReturns( $url, $ip, $mode, $code, $seconds ) + { + $options = '' === $ip ? [] : [ 'resolve' => $ip ]; + $want = 'returns' === $mode; + $deadline = time() + (int) $seconds; + do { + $this->response = $this->httpRequest( $url, $options ); + if ( ( (int) $code === $this->response['code'] ) === $want ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "GET $url still did not satisfy '$mode $code' after $seconds seconds.\n" . $this->describeResponse() ); + } + + /** + * @Then the response code should be :code + */ + public function theResponseCodeShouldBe( $code ) + { + if ( null === $this->response ) { + throw new Exception( 'No request was sent.' ); + } + if ( (int) $code !== $this->response['code'] ) { + throw new Exception( "Expected HTTP $code.\n" . $this->describeResponse() ); + } + } + + /** + * @Then /^the response should( not)? contain "([^"]*)"$/ + */ + public function theResponseShouldContain( $not, $text ) + { + $found = false !== strpos( $this->requireResponse()['body'], $text ); + if ( $found === ( '' !== $not ) ) { + throw new Exception( sprintf( "The response body %s %s.\n%s", $found ? 'contains' : 'does not contain', $text, $this->describeResponse() ) ); + } + } + + /** + * @Then /^the response header "([^"]*)" should( not)? contain "([^"]*)"$/ + */ + public function theResponseHeaderShouldContain( $header, $not, $text ) + { + $value = $this->responseHeader( $header ); + $found = null !== $value && false !== strpos( $value, $text ); + if ( $found === ( '' !== $not ) ) { + throw new Exception( sprintf( "Header %s is %s.\n%s", $header, var_export( $value, true ), $this->describeResponse() ) ); + } + } + + /** + * @Then /^the response should( not)? have the header "([^"]*)"$/ + */ + public function theResponseShouldHaveTheHeader( $not, $header ) + { + $present = null !== $this->responseHeader( $header ); + if ( $present === ( '' !== $not ) ) { + throw new Exception( sprintf( "Header %s is %s.\n%s", $header, $present ? 'present' : 'missing', $this->describeResponse() ) ); + } + } + + /** + * @Given a self-signed certificate for :domain exists in :dir + */ + public function aSelfSignedCertificateExists( $domain, $dir ) + { + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0700, true ); + } + $cmd = sprintf( + 'openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj %s -addext %s -keyout %s -out %s', + escapeshellarg( '/CN=' . $domain ), + escapeshellarg( 'subjectAltName=DNS:' . $domain ), + escapeshellarg( "$dir/$domain.key" ), + escapeshellarg( "$dir/$domain.crt" ) + ); + $result = self::run( $cmd ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not create a certificate for $domain.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then the certificate served for :host should match the file :path + */ + public function theCertificateServedShouldMatchTheFile( $host, $path ) + { + $served = openssl_x509_fingerprint( $this->servedCertificate( $host ), 'sha256' ); + $expected = openssl_x509_fingerprint( $this->readFile( $path ), 'sha256' ); + if ( $served !== $expected ) { + throw new Exception( "The certificate served for $host ($served) is not the one in $path ($expected)." ); + } + } + + /** + * @Then the certificate served for :host should cover :name + */ + public function theCertificateServedShouldCover( $host, $name ) + { + $info = openssl_x509_parse( $this->servedCertificate( $host ) ); + $sans = isset( $info['extensions']['subjectAltName'] ) ? $info['extensions']['subjectAltName'] : ''; + $names = array_map( function ( $entry ) { + return trim( str_replace( 'DNS:', '', $entry ) ); + }, explode( ',', $sans ) ); + if ( ! in_array( $name, $names, true ) ) { + throw new Exception( "The certificate served for $host covers " . implode( ', ', $names ) . ", not $name." ); + } + } + + /** + * @Then the certificate served for :host should be issued by :issuer + */ + public function theCertificateServedShouldBeIssuedBy( $host, $issuer ) + { + $info = openssl_x509_parse( $this->servedCertificate( $host ) ); + $actual = isset( $info['issuer'] ) ? implode( ', ', (array) $info['issuer'] ) : ''; + if ( false === stripos( $actual, $issuer ) ) { + throw new Exception( "The certificate served for $host is issued by $actual, not $issuer." ); + } + } + + /** + * @Then /^the hosts file should( not)? have an entry for "([^"]*)"$/ + */ + public function theHostsFileShouldHaveAnEntryFor( $not, $host ) + { + $found = 1 === preg_match( '/^\s*[0-9a-f:.]+\s+(.*\s)?' . preg_quote( $host, '/' ) . '(\s|$)/mi', $this->readFile( '/etc/hosts' ) ); + if ( $found === ( '' !== $not ) ) { + throw new Exception( "/etc/hosts " . ( $found ? 'has' : 'has no' ) . " entry for $host." ); + } + } + + /** + * Adds a marked line that the suite cleanup removes again. + * + * @Given the hosts file maps :host to :ip + */ + public function theHostsFileMaps( $host, $ip ) + { + $hosts = $this->readFile( '/etc/hosts' ); + if ( 1 !== preg_match( '/^\s*' . preg_quote( $ip, '/' ) . '\s+(.*\s)?' . preg_quote( $host, '/' ) . '(\s|$)/m', $hosts ) ) { + file_put_contents( '/etc/hosts', rtrim( $hosts, "\n" ) . "\n$ip\t$host " . self::HOSTS_MARKER . "\n" ); + } + } + + /** + * @Given the domain :domain resolves publicly + */ + public function theDomainResolvesPublicly( $domain ) + { + $result = self::run( 'dig +short ' . escapeshellarg( $domain ) . ' @1.1.1.1' ); + if ( '' === trim( $result->stdout ) ) { + throw new Exception( "$domain does not resolve publicly; Let's Encrypt scenarios need the ee4-test.rt.gw DNS records." ); + } + } + + /** + * Creates the site only if it does not exist yet, so scenarios can run alone or reuse a site made earlier in the feature. + * + * @Given a php site :site exists + */ + public function aPhpSiteExists( $site ) + { + $this->aPhpSiteCreatedWithFlagsExists( $site, '' ); + } + + /** + * @Given a php site :site created with :flags exists + */ + public function aPhpSiteCreatedWithFlagsExists( $site, $flags ) + { + if ( null !== $this->siteRow( $site ) ) { + return; + } + $result = self::run( sprintf( 'bin/ee site create %s --type=php %s', escapeshellarg( $site ), $flags ) ); + if ( 0 !== $result->return_code || null === $this->siteRow( $site ) ) { + throw new Exception( "Could not create php site $site ($flags).\n" . $this->describe( $result ) ); + } + } + + /** + * @Given the site :site has the alias domain :alias + */ + public function theSiteHasTheAliasDomain( $site, $alias ) + { + $row = $this->requireSiteRow( $site ); + if ( in_array( $alias, explode( ',', (string) $row['alias_domains'] ), true ) ) { + return; + } + $result = self::run( sprintf( 'bin/ee site update %s --add-alias-domains=%s', escapeshellarg( $site ), escapeshellarg( $alias ) ) ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not add alias $alias to $site.\n" . $this->describe( $result ) ); + } + } + + /** + * @Given the site :site is disabled + */ + public function theSiteIsDisabled( $site ) + { + $row = $this->requireSiteRow( $site ); + if ( 0 === (int) $row['site_enabled'] ) { + return; + } + $result = self::run( 'bin/ee site disable ' . escapeshellarg( $site ) ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not disable $site.\n" . $this->describe( $result ) ); + } + } + + /** + * @Given the site :site does not exist + */ + public function theSiteDoesNotExist( $site ) + { + if ( null === $this->siteRow( $site ) ) { + return; + } + if ( 1 !== preg_match( self::SITE_PATTERN, $site ) ) { + throw new Exception( "Refusing to delete $site: it is not a site of this suite." ); + } + $result = self::run( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes' ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not delete $site.\n" . $this->describe( $result ) ); + } + } + + /* + * ----- helpers ----- + */ + + /** + * Runs a shell command with stdin on /dev/null, so docker/compose never allocate a TTY: under the harness's + * nested ptys a TTY exec can hang (CACHE-1), and without one `docker exec -it` fails fast instead. + * + * @return object command, stdout, stderr, return_code (same fields as EE's ProcessRun). + */ + private static function run( $command, $cwd = null ) + { + $out = tempnam( sys_get_temp_dir(), 'bhstp' ); + $err = tempnam( sys_get_temp_dir(), 'bhstp' ); + $proc = proc_open( $command, [ 0 => [ 'file', '/dev/null', 'r' ], 1 => [ 'file', $out, 'w' ], 2 => [ 'file', $err, 'w' ] ], $pipes, $cwd ); + $code = is_resource( $proc ) ? proc_close( $proc ) : -1; + $result = (object) [ + 'command' => $command, + 'stdout' => (string) file_get_contents( $out ), + 'stderr' => (string) file_get_contents( $err ), + 'return_code' => $code, + ]; + unlink( $out ); + unlink( $err ); + + return $result; + } + + private function lastCommand() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command was run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function commandAt( $index ) + { + if ( ! isset( $this->commands[ $index - 1 ] ) ) { + throw new Exception( "Command $index was not run in this scenario." ); + } + + return $this->commands[ $index - 1 ]; + } + + private function streamOf( $result, $stream ) + { + return 'STDOUT' === $stream ? $result->stdout : $result->stderr; + } + + private function describe( $result ) + { + return sprintf( "Command: %s\nReturn code: %s\nSTDOUT:\n%s\nSTDERR:\n%s", $result->command, $result->return_code, $result->stdout, $result->stderr ); + } + + private function assertReturnCode( $code ) + { + $result = $this->lastCommand(); + if ( $code !== $result->return_code ) { + throw new Exception( "Expected return code $code.\n" . $this->describe( $result ) ); + } + } + + private function assertStreamContains( $stream, $text, $negate ) + { + $result = $this->lastCommand(); + $output = str_replace( [ "\033[1;31m", "\033[0m" ], '', $this->streamOf( $result, $stream ) ); + $found = false !== strpos( $output, $text ); + if ( $found === $negate ) { + throw new Exception( sprintf( "%s %s:\n%s\n\n%s", $stream, $negate ? 'unexpectedly contains' : 'does not contain', $text, $this->describe( $result ) ) ); + } + } + + private function decodedStdout() + { + $stdout = trim( $this->lastCommand()->stdout ); + $data = json_decode( $stdout, true ); + if ( null === $data && 'null' !== $stdout ) { + throw new Exception( 'STDOUT is not valid JSON (' . json_last_error_msg() . "):\n" . $stdout ); + } + + return $data; + } + + private function jsonPath( $data, $path ) + { + foreach ( explode( '.', $path ) as $part ) { + if ( ! is_array( $data ) || ! array_key_exists( $part, $data ) ) { + throw new Exception( "JSON key $path not found in:\n" . $this->lastCommand()->stdout ); + } + $data = $data[ $part ]; + } + + return $data; + } + + private function scalarToString( $value ) + { + if ( null === $value || false === $value ) { + return ''; + } + if ( true === $value ) { + return '1'; + } + + return (string) $value; + } + + private function lines( $text ) + { + return array_values( array_filter( array_map( 'trim', explode( "\n", trim( $text ) ) ), 'strlen' ) ); + } + + private function readFile( $path ) + { + clearstatcache(); + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + + return file_get_contents( $path ); + } + + private function assertFileContains( $path, $text, $negate ) + { + $content = $this->readFile( $path ); + $found = false !== strpos( $content, $text ); + if ( $found === $negate ) { + throw new Exception( sprintf( "%s %s:\n%s\n\nContent:\n%s", $path, $negate ? 'unexpectedly contains' : 'does not contain', $text, $content ) ); + } + } + + /** + * @return array|null The site's row from ee.sqlite, read-only. + */ + private function siteRow( $site ) + { + $rows = self::sqliteRows( 'SELECT * FROM sites WHERE site_url = :site', [ ':site' => $site ] ); + + return empty( $rows ) ? null : $rows[0]; + } + + private function requireSiteRow( $site, $field = null ) + { + $row = $this->siteRow( $site ); + if ( null === $row ) { + throw new Exception( "Site $site has no row in the sites table." ); + } + if ( null !== $field && ! array_key_exists( $field, $row ) ) { + throw new Exception( "The sites table has no column $field." ); + } + + return $row; + } + + private static function sqliteRows( $sql, array $params = [] ) + { + $path = EE_ROOT_DIR . '/db/ee.sqlite'; + if ( ! file_exists( $path ) ) { + return []; + } + $db = new SQLite3( $path, SQLITE3_OPEN_READONLY ); + $db->busyTimeout( 5000 ); + $stmt = $db->prepare( $sql ); + foreach ( $params as $key => $value ) { + $stmt->bindValue( $key, $value, SQLITE3_TEXT ); + } + $result = $stmt->execute(); + $rows = []; + while ( $row = $result->fetchArray( SQLITE3_ASSOC ) ) { + $rows[] = $row; + } + $db->close(); + + return $rows; + } + + private function serviceContainerId( $site, $service, $running_only = true ) + { + $cmd = sprintf( + 'docker ps %s -q -f %s -f %s', + $running_only ? '' : '-a', + escapeshellarg( 'label=io.easyengine.site=' . $site ), + escapeshellarg( 'label=com.docker.compose.service=' . $service ) + ); + + return implode( ' ', $this->lines( self::run( $cmd )->stdout ) ); + } + + private function requireServiceContainer( $site, $service ) + { + $id = $this->serviceContainerId( $site, $service ); + if ( '' === $id || false !== strpos( $id, ' ' ) ) { + throw new Exception( "Expected exactly one running $service container for $site, found '$id'.\n" . $this->siteContainersSummary( $site ) ); + } + + return $id; + } + + private function execInService( $site, $service, $command ) + { + $id = $this->requireServiceContainer( $site, $service ); + + return self::run( sprintf( 'docker exec %s sh -c %s', $id, escapeshellarg( $command ) ) ); + } + + private function siteContainersSummary( $site ) + { + return self::run( 'docker ps -a -f ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) . ' --format ' . escapeshellarg( '{{.Names}} {{.Status}} {{.Image}}' ) )->stdout; + } + + private function siteVolumes( $site ) + { + return $this->lines( self::run( 'docker volume ls -q -f label=org.label-schema.vendor=EasyEngine -f ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) )->stdout ); + } + + private function containerStartedAt( $id ) + { + return trim( self::run( 'docker inspect -f {{.State.StartedAt}} ' . $id )->stdout ); + } + + private function pinnedTag( $image ) + { + $versions = json_decode( file_get_contents( EE_ROOT . '/img-versions.json' ), true ); + if ( empty( $versions[ $image ] ) ) { + throw new Exception( "img-versions.json has no tag for $image." ); + } + + return $versions[ $image ]; + } + + private function globalRedis( $args ) + { + return self::run( sprintf( 'docker exec %s redis-cli %s', self::GLOBAL_REDIS_CONTAINER, $args ) ); + } + + private function assertKeys( $where, $result, $pattern, $negate ) + { + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not scan the $where.\n" . $this->describe( $result ) ); + } + $keys = $this->lines( $result->stdout ); + if ( empty( $keys ) !== $negate ) { + throw new Exception( sprintf( "The %s has %d keys matching %s:\n%s", $where, count( $keys ), $pattern, implode( "\n", $keys ) ) ); + } + } + + private static function globalDbQuery( $sql, $throw = false ) + { + $script = 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e ' . escapeshellarg( $sql ); + $result = self::run( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_DB_CONTAINER, escapeshellarg( $script ) ) ); + if ( $throw && 0 !== $result->return_code ) { + throw new Exception( "Global db query failed: $sql\n{$result->stderr}" ); + } + + return $result; + } + + /** + * @param array $options resolve: IP to pin the URL's host to (keeps SNI), host: Host header, verify: check TLS. + */ + private function httpRequest( $url, array $options = [] ) + { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $url ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + $verify = ! empty( $options['verify'] ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, $verify ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, $verify ? 2 : 0 ); + if ( ! empty( $options['resolve'] ) ) { + $parts = parse_url( $url ); + $port = isset( $parts['port'] ) ? $parts['port'] : ( 'https' === $parts['scheme'] ? 443 : 80 ); + curl_setopt( $ch, CURLOPT_RESOLVE, [ "{$parts['host']}:$port:{$options['resolve']}" ] ); + } + if ( ! empty( $options['host'] ) ) { + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Host: ' . $options['host'] ] ); + } + $raw = curl_exec( $ch ); + $code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $header_size = (int) curl_getinfo( $ch, CURLINFO_HEADER_SIZE ); + $error = curl_error( $ch ); + curl_close( $ch ); + $raw = false === $raw ? '' : $raw; + + return [ + 'url' => $url, + 'code' => $code, + 'headers' => substr( $raw, 0, $header_size ), + 'body' => (string) substr( $raw, $header_size ), + 'error' => $error, + ]; + } + + private function requireResponse() + { + if ( null === $this->response ) { + throw new Exception( 'No request was sent.' ); + } + + return $this->response; + } + + private function responseHeader( $name ) + { + foreach ( explode( "\n", $this->requireResponse()['headers'] ) as $line ) { + $pos = strpos( $line, ':' ); + if ( false !== $pos && 0 === strcasecmp( trim( substr( $line, 0, $pos ) ), $name ) ) { + return trim( substr( $line, $pos + 1 ) ); + } + } + + return null; + } + + private function describeResponse() + { + $r = $this->requireResponse(); + + return sprintf( "GET %s -> %d %s\nHeaders:\n%s\nBody (first 500 bytes):\n%s", $r['url'], $r['code'], $r['error'], trim( $r['headers'] ), substr( $r['body'], 0, 500 ) ); + } + + /** + * @return string PEM of the certificate nginx-proxy serves for $host on 127.0.0.1:443 (SNI = $host). + */ + private function servedCertificate( $host ) + { + $context = stream_context_create( [ + 'ssl' => [ + 'peer_name' => $host, + 'SNI_enabled' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'capture_peer_cert' => true, + ], + ] ); + $stream = @stream_socket_client( 'ssl://127.0.0.1:443', $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context ); + if ( ! $stream ) { + throw new Exception( "TLS connection for $host failed: $errstr" ); + } + $params = stream_context_get_params( $stream ); + fclose( $stream ); + openssl_x509_export( $params['options']['ssl']['peer_certificate'], $pem ); + + return $pem; + } + + /** + * Removes everything this suite may leave behind, touching only names that match SITE_PATTERN / the bh_stp_ prefixes. + */ + private static function cleanupTestState() + { + $ee = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ); + $sites = []; + foreach ( self::sqliteRows( 'SELECT site_url FROM sites' ) as $row ) { + if ( 1 === preg_match( self::SITE_PATTERN, $row['site_url'] ) ) { + $sites[] = $row['site_url']; + } + } + // Children (e.g. --ssl=inherit subdomains) first. + usort( $sites, function ( $a, $b ) { + return substr_count( $b, '.' ) - substr_count( $a, '.' ); + } ); + foreach ( $sites as $site ) { + self::run( sprintf( 'timeout 600 %s site delete %s --yes', $ee, escapeshellarg( $site ) ), realpath( EE_ROOT ) ); + } + + $remaining = array_column( self::sqliteRows( 'SELECT site_url FROM sites' ), 'site_url' ); + $orphan = function ( $name ) use ( $remaining ) { + return 1 === preg_match( self::SITE_PATTERN, $name ) && ! in_array( $name, $remaining, true ); + }; + + // Orphaned containers, networks and volumes of sites that no longer exist. + $containers = self::run( 'docker ps -a -f label=io.easyengine.site --format ' . escapeshellarg( '{{.ID}} {{.Label "io.easyengine.site"}}' ) )->stdout; + foreach ( array_filter( explode( "\n", $containers ) ) as $line ) { + list( $id, $label ) = array_pad( explode( ' ', trim( $line ), 2 ), 2, '' ); + if ( $orphan( $label ) ) { + self::run( 'docker rm -f ' . escapeshellarg( $id ) ); + } + } + $networks = self::run( 'docker network ls --format {{.Name}}' )->stdout; + foreach ( array_filter( explode( "\n", $networks ) ) as $network ) { + if ( $orphan( trim( $network ) ) ) { + self::run( 'docker network rm ' . escapeshellarg( trim( $network ) ) ); + } + } + $volumes = self::run( 'docker volume ls -f label=io.easyengine.site --format ' . escapeshellarg( '{{.Name}} {{.Label "io.easyengine.site"}}' ) )->stdout; + foreach ( array_filter( explode( "\n", $volumes ) ) as $line ) { + list( $name, $label ) = array_pad( explode( ' ', trim( $line ), 2 ), 2, '' ); + if ( $orphan( $label ) ) { + self::run( 'docker volume rm ' . escapeshellarg( $name ) ); + } + } + + // Site roots and proxy files left by failed creates. + foreach ( (array) glob( EE_SITE_ROOT . '/*' ) as $dir ) { + $name = basename( $dir ); + if ( 1 === preg_match( '/^[a-z0-9.-]+$/', $name ) && $orphan( $name ) ) { + self::run( 'rm -rf -- ' . escapeshellarg( $dir ) ); + } + } + $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; + $reload = false; + foreach ( [ 'conf.d', 'vhost.d', 'certs', 'htpasswd' ] as $sub ) { + foreach ( (array) glob( "$proxy_dir/$sub/*bh-site-type-php-*" ) as $file ) { + $site = preg_replace( '/(-redirect\.conf|\.conf|_location|\.crt|\.key|\.chain\.pem|\.dhparam\.pem)$/', '', basename( $file ) ); + if ( is_file( $file ) && $orphan( $site ) ) { + unlink( $file ); + $reload = true; + } + } + } + if ( $reload ) { + self::run( 'docker exec ' . self::PROXY_CONTAINER . ' sh -c ' . escapeshellarg( 'nginx -t && nginx -s reload' ) ); + } + + // Global-db databases and users created by the suite. + if ( 0 === self::run( 'docker inspect ' . self::GLOBAL_DB_CONTAINER )->return_code ) { + $where_db = implode( ' OR ', array_map( function ( $like ) { + return "schema_name LIKE '$like'"; + }, self::DB_NAME_LIKE ) ); + $where_user = implode( ' OR ', array_map( function ( $like ) { + return "user LIKE '$like'"; + }, self::DB_USER_LIKE ) ); + foreach ( array_filter( explode( "\n", self::globalDbQuery( "SELECT schema_name FROM information_schema.schemata WHERE $where_db" )->stdout ) ) as $db ) { + self::globalDbQuery( sprintf( 'DROP DATABASE IF EXISTS `%s`', trim( $db ) ) ); + } + foreach ( array_filter( explode( "\n", self::globalDbQuery( "SELECT CONCAT(user, '@', host) FROM mysql.user WHERE $where_user" )->stdout ) ) as $user_host ) { + list( $user, $host ) = explode( '@', trim( $user_host ), 2 ); + self::globalDbQuery( sprintf( "DROP USER IF EXISTS '%s'@'%s'", addslashes( $user ), addslashes( $host ) ) ); + } + } + + // Hosts entries for this suite's names (ee's own and the ones added by `the hosts file maps ...`). + if ( is_writable( '/etc/hosts' ) ) { + $hosts = file_get_contents( '/etc/hosts' ); + $kept = array_filter( explode( "\n", $hosts ), function ( $line ) { + return false === strpos( $line, self::HOSTS_MARKER ) && false === strpos( $line, 'bh-site-type-php-' ); + } ); + $new = implode( "\n", $kept ); + if ( rtrim( $new, "\n" ) !== rtrim( $hosts, "\n" ) ) { + file_put_contents( '/etc/hosts', rtrim( $new, "\n" ) . "\n" ); + } + } + } } From 05a0e13718822c5d8b1c1ecaace744c611611cb2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 13:19:30 +0530 Subject: [PATCH 02/35] test(site-type-php): cover php site create Usage and help, defaults in the sites table, generated files, compose file, containers, volumes, network, HTTP, name normalisation, duplicate and invalid input, EE3 compat flags, labels and www redirects. Known bugs: loose --php matching, --php without --type, SEC-1 and the volume prefix collision. --- features/create.feature | 213 ++++++++++++++++++++++++++++++++++++++ features/labels.feature | 13 ++- features/redirect.feature | 44 +++++--- features/site.feature | 134 +++++++++--------------- 4 files changed, 298 insertions(+), 106 deletions(-) create mode 100644 features/create.feature diff --git a/features/create.feature b/features/create.feature new file mode 100644 index 0000000..2074afa --- /dev/null +++ b/features/create.feature @@ -0,0 +1,213 @@ +Feature: Create a plain php site + + Scenario: Create a php site with default options + When I run "bin/ee site create bh-site-type-php-1.test --type=php" + Then the command should succeed + And STDOUT should contain "Creating PHP site bh-site-type-php-1.test" + And STDOUT should contain "Success: Configuration files copied." + And STDOUT should contain "Checking and verifying site-up status" + And STDOUT should contain "Site entry created." + And STDOUT should contain "| Site | http://bh-site-type-php-1.test" + And STDOUT should contain "| Site Root | /opt/easyengine/sites/bh-site-type-php-1.test" + And STDOUT should not contain "DB Host" + And the site "bh-site-type-php-1.test" should exist + + Scenario: The site row holds the php defaults + Given a php site "bh-site-type-php-1.test" exists + Then the site "bh-site-type-php-1.test" should be enabled + And the site "bh-site-type-php-1.test" should have the field "site_type" set to "php" + And the site "bh-site-type-php-1.test" should have the field "app_sub_type" set to "php" + And the site "bh-site-type-php-1.test" should have the field "php_version" set to "8.4" + And the site "bh-site-type-php-1.test" should have the field "site_fs_path" set to "/opt/easyengine/sites/bh-site-type-php-1.test" + And the site "bh-site-type-php-1.test" should have the field "site_container_fs_path" set to "/var/www/htdocs" + And the site "bh-site-type-php-1.test" should have the field "alias_domains" set to "bh-site-type-php-1.test" + And the site "bh-site-type-php-1.test" should have the field "app_admin_email" set to "admin@bh-site-type-php-1.test" + And the site "bh-site-type-php-1.test" should have the field "cache_nginx_fullpage" set to "0" + And the site "bh-site-type-php-1.test" should have the field "cache_nginx_browser" set to "0" + And the site "bh-site-type-php-1.test" should have the field "cache_mysql_query" set to "0" + And the site "bh-site-type-php-1.test" should have the field "site_ssl_wildcard" set to "0" + And the site "bh-site-type-php-1.test" should have the field "proxy_cache" set to "off" + And the site "bh-site-type-php-1.test" should have an empty "site_ssl" + And the site "bh-site-type-php-1.test" should have an empty "cache_host" + And the site "bh-site-type-php-1.test" should have an empty "db_host" + And the site "bh-site-type-php-1.test" should have an empty "db_name" + And the site "bh-site-type-php-1.test" should have an empty "db_user" + And the site "bh-site-type-php-1.test" should have an empty "db_password" + + Scenario: The site files are generated from the php templates + Given a php site "bh-site-type-php-1.test" exists + Then the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/.env" should contain "VIRTUAL_HOST=bh-site-type-php-1.test" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/.env" should not contain "MYSQL_ROOT_PASSWORD" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-type-php-1.test;" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "server bh-site-type-php-1.test_php:9000;" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs;" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "# PHP NGINX CONFIGURATION" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should not contain "upstream redis" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/custom/user.conf" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/custom/admin-tools.conf" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/php/php/conf.d/custom.ini" should contain "sendmail_path = /usr/bin/msmtp -t" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs/index.php" should contain "Congratulations! Your PHP site with EasyEngine is working perfectly." + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs/index.php" should contain "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs" + And the directory "/opt/easyengine/sites/bh-site-type-php-1.test/app/html" should not exist + And the path "/opt/easyengine/sites/bh-site-type-php-1.test/app" should be a symlink + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs/check.html" should not exist + + Scenario: The compose file has the php services and no db or redis + Given a php site "bh-site-type-php-1.test" exists + Then the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/php8.4" image + And the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/nginx" image + And the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/postfix" image + And the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/mailhog" image + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST}" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should contain "- ${VIRTUAL_HOST}_php" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should not contain "easyengine/mariadb" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should not contain "easyengine/redis" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should not contain "CERT_NAME" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should contain "HTTPS_METHOD=nohttps" + + Scenario: The site containers, volumes and network are up + Given a php site "bh-site-type-php-1.test" exists + Then the "php" service of site "bh-site-type-php-1.test" should be running + And the "nginx" service of site "bh-site-type-php-1.test" should be running + And the "postfix" service of site "bh-site-type-php-1.test" should be running + And the "mailhog" service of site "bh-site-type-php-1.test" should not be running + And the site "bh-site-type-php-1.test" should have 3 running containers + And the site "bh-site-type-php-1.test" should have 8 docker volumes + And the docker network "bh-site-type-php-1.test" should exist + And the "php" service of site "bh-site-type-php-1.test" should use the pinned "easyengine/php8.4" image + And the "nginx" service of site "bh-site-type-php-1.test" should be attached to the docker network "ee-global-frontend-network" + And the "php" service of site "bh-site-type-php-1.test" should not be attached to the docker network "ee-global-frontend-network" + And the "php" service of site "bh-site-type-php-1.test" should have the restart policy "always" + And the php service of site "bh-site-type-php-1.test" should run PHP "8.4" + + Scenario: The site is reachable over HTTP and runs PHP + Given a php site "bh-site-type-php-1.test" exists + Then the hosts file should have an entry for "bh-site-type-php-1.test" + And a GET request to "http://bh-site-type-php-1.test/" should return 200 + And the response should contain "Congratulations! Your PHP site with EasyEngine is working perfectly." + And the response should not have the header "X-SRCache-Fetch-Status" + When I write "" + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + And the site "bh-site-type-php-9.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-9.test" should not exist + + Examples: + | version | + | 5.5 | + | 7.1 | + | 9.0 | + | abc | + | 8.4.1 | + + # New (suspected): the --php option check uses a loose in_array(), so "8" == "8.0" and "8.40" == 8.4 pass. + # The compose file then references a non-existent easyengine/php8 (or php8.40) image and the create fails late. + @known-bug + Scenario Outline: A --php value that only loosely matches a supported version is rejected up front + When I run "bin/ee site create bh-site-type-php-9.test --type=php --php=" + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + And the site "bh-site-type-php-9.test" should not exist + + Examples: + | version | + | 8 | + | 7 | + | 8.40 | + + # EE3 compat: --mysql maps to --type=php --with-db (Site_Command::convert_old_args_to_new_args). + Scenario: The legacy --mysql flag creates a php site with a database + When I run "bin/ee site create bh-site-type-php-7.test --mysql" + Then the command should succeed + And the site "bh-site-type-php-7.test" should have the field "site_type" set to "php" + And the site "bh-site-type-php-7.test" should have the field "app_sub_type" set to "mysql" + And the site "bh-site-type-php-7.test" should have the field "db_host" set to "global-db" + + # New (suspected): without --type, `--php=8.3` is consumed as the EE3 `--php` type flag and the version is dropped. + @known-bug + Scenario: --php= without --type keeps the requested version + When I run "bin/ee site create bh-site-type-php-6.test --php=8.3" + Then the command should succeed + And the site "bh-site-type-php-6.test" should have the field "site_type" set to "php" + And the site "bh-site-type-php-6.test" should have the field "php_version" set to "8.3" + + # SEC-1: the site name reaches shell sinks unescaped. Benign payload: touches ./bhstpsec1 in the working directory. + @known-bug @security + Scenario: A site name with shell syntax is rejected and never executed + When I run "bin/ee site create 'bh-site-type-php-5$(touch${IFS}bhstpsec1).test' --type=php" + Then the command should fail + And STDERR should contain "Invalid site name" + And the file "bhstpsec1" should not exist + And the file "/opt/easyengine/sites/bh-site-type-php-5$(touch${IFS}bhstpsec1).test/bhstpsec1" should not exist + + # New (suspected), core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to + # the prefix bhsitetypephp81test. create_volumes() skips volumes that already exist, and the second site mounts the + # first site's htdocs/config volumes. + @known-bug + Scenario: Two site names that differ only in dots and dashes get separate volumes + Given a php site "bh-site-type-php-8-1.test" exists + When I run "bin/ee site create bh-site-type-php-81.test --type=php" + Then the site "bh-site-type-php-81.test" should have 8 docker volumes + And the site "bh-site-type-php-8-1.test" should have 8 docker volumes + And a GET request to "http://bh-site-type-php-8-1.test/" should return 200 + And the response should contain "/opt/easyengine/sites/bh-site-type-php-8-1.test/app/htdocs" diff --git a/features/labels.feature b/features/labels.feature index 3737994..2f8a280 100644 --- a/features/labels.feature +++ b/features/labels.feature @@ -1,9 +1,12 @@ Feature: Container Labels - - Scenario: All easyengine containers are tagged - Given I run "bin/ee site create labels.test --type=php" - Then There should be 5 containers with labels + # The old assertion was inverted and passed for any count but the expected one. + Scenario: All easyengine containers, volumes and the network of a php site are tagged + Given a php site "bh-site-type-php-90.test" exists + Then There should be 3 containers with labels """ - io.easyengine.site=labels.test + io.easyengine.site=bh-site-type-php-90.test """ + And the site "bh-site-type-php-90.test" should have 8 docker volumes + And the docker network "bh-site-type-php-90.test" should have the label "io.easyengine.site=bh-site-type-php-90.test" + And the docker network "bh-site-type-php-90.test" should have the label "org.label-schema.vendor=EasyEngine" diff --git a/features/redirect.feature b/features/redirect.feature index 0171eb6..24fa2d9 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,19 +1,35 @@ Feature: Site Redirection + # The www check curls www. from the host, so the www name must resolve 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=php' - 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 the hosts file maps "www.bh-site-type-php-91.test" to "127.0.0.1" + When I run "bin/ee site create bh-site-type-php-91.test --type=php" + Then the command should succeed + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should exist + And I wait until a GET request to "http://www.bh-site-type-php-91.test/" returns 301 within 30 seconds + And Request on 'localhost' with header 'Host: www.bh-site-type-php-91.test' should contain following headers: + | header | + | HTTP/1.1 301 Moved Permanently | + | Location: http://bh-site-type-php-91.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=php' - 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 the hosts file maps "bh-site-type-php-92.test" to "127.0.0.1" + When I run "bin/ee site create www.bh-site-type-php-92.test --type=php" + Then the command should succeed + And I wait until a GET request to "http://bh-site-type-php-92.test/" returns 301 within 30 seconds + And Request on 'localhost' with header 'Host: bh-site-type-php-92.test' should contain following headers: + | header | + | HTTP/1.1 301 Moved Permanently | + | Location: http://www.bh-site-type-php-92.test/ | + + Scenario: Without a resolvable www name no redirect is configured + When I run "bin/ee site create bh-site-type-php-93.test --type=php" + Then the command should succeed + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-93.test-redirect.conf" should not exist + + Scenario: Deleting a site removes its redirect config + Given the hosts file maps "www.bh-site-type-php-91.test" to "127.0.0.1" + And a php site "bh-site-type-php-91.test" exists + When I run "bin/ee site delete bh-site-type-php-91.test --yes" + Then the command should succeed + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should not exist diff --git a/features/site.feature b/features/site.feature index f442d41..bd5ce3c 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1,4 +1,4 @@ -Feature: Site Command +Feature: Site command presence and usage errors Scenario: ee executable is command working correctly Given 'bin/ee' is installed @@ -9,6 +9,7 @@ Feature: Site Command ee """ + Scenario: Check site command is present When I run 'bin/ee site' Then STDOUT should return something like @@ -23,21 +24,47 @@ Feature: Site Command usage: ee site create [--ssl] [--ssl-key=] [--ssl-crt=] [--wildcard] [--type=] [--alias-domains=] [--skip-status-check] [--public-dir] """ - Scenario: Create php site successfully - When I run 'bin/ee site create php.test --type=php' - Then After delay of 5 seconds - And The site 'php.test' should have webroot - And The site 'php.test' should have index file - And Request on 'php.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | + Scenario: php site create without a site name prints the php usage + When I run "bin/ee site create --type=php" + Then the return code should be 1 + And STDOUT should contain: + """ + usage: ee site create [--cache] [--admin-email=] [--with-db] [--local-db] [--php=] + """ - Scenario: List the sites - When I run 'bin/ee site list --format=text' - Then STDOUT should return exactly - """ - php.test - """ + Scenario: Help for php site create lists every php option + When I run "bin/ee help site create --type=php" + Then the command should succeed + And STDOUT should contain "Runs the standard PHP Site installation." + And STDOUT should contain "[--cache]" + And STDOUT should contain "[--admin-email=]" + And STDOUT should contain "[--with-db]" + And STDOUT should contain "[--local-db]" + And STDOUT should contain "[--php=]" + And STDOUT should contain "[--alias-domains=]" + And STDOUT should contain "[--dbname=]" + And STDOUT should contain "[--dbuser=]" + And STDOUT should contain "[--dbpass=]" + And STDOUT should contain "[--dbhost=]" + And STDOUT should contain "[--with-local-redis]" + And STDOUT should contain "[--skip-check]" + And STDOUT should contain "[--skip-status-check]" + And STDOUT should contain "[--ssl]" + And STDOUT should contain "[--ssl-key=]" + And STDOUT should contain "[--ssl-crt=]" + And STDOUT should contain "[--wildcard]" + And STDOUT should contain "[--force]" + And STDOUT should contain "[--public-dir]" + And STDOUT should contain "default: 8.4" + + Scenario: Help for php site info, restart and reload shows the php variants + When I run "bin/ee help site restart --type=php" + Then STDOUT should contain "[--php]" + And STDOUT should contain "[--db]" + When I run "bin/ee help site reload --type=php" + Then STDOUT should contain "[--php]" + When I run "bin/ee help site info --type=php" + Then STDOUT should contain "[--format=]" Scenario: Check site disable sub command is present When I run 'bin/ee site disable' @@ -75,27 +102,6 @@ Feature: Site Command or run `ee site info` from inside the site folder. """ - Scenario: Details of the site uing site info command - When I run 'bin/ee site info php.test' - Then STDOUT should return something like - """ - | Site | http://php.test - """ - - Scenario: Reload site services - When I run 'bin/ee site reload php.test' - Then STDOUT should return something like - """ - Reloading nginx - """ - - Scenario: Reload site nginx services - When I run 'bin/ee site reload php.test --nginx' - Then STDOUT should return something like - """ - Reloading nginx - """ - Scenario: Check site delete sub command is present When I run 'bin/ee site delete' Then STDOUT should return exactly @@ -103,54 +109,8 @@ Feature: Site Command usage: ee site delete [--yes] """ - Scenario: Create PHP site with local DB and global redis - When I run 'bin/ee site create php-local-db.test --cache --type=php --local-db --with-db' - Then After delay of 5 seconds - And The site 'php-local-db.test' should have webroot - And The site 'php-local-db.test' should have index file - And Request on 'php-local-db.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - And Check global redis cache for 'php-local-db.test' - And Check local mysql database connection for 'php-local-db.test' - - Scenario: Create PHP site with local redis and global db - When I run 'bin/ee site create php-local-redis.test --cache --type=php --with-db --with-local-redis' - Then After delay of 5 seconds - And The site 'php-local-redis.test' should have webroot - And The site 'php-local-redis.test' should have index file - And Request on 'php-local-redis.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - And Check local redis cache for 'php-local-redis.test' - And Check global mysql database connection for 'php-local-redis.test' - - Scenario: Create PHP site with local DB and local redis - When I run 'bin/ee site create php-local-db-redis.test --cache --type=php --local-db --with-db --with-local-redis' - Then After delay of 5 seconds - And The site 'php-local-db-redis.test' should have webroot - And The site 'php-local-db-redis.test' should have index file - And Request on 'php-local-db-redis.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - And Check local redis cache for 'php-local-db-redis.test' - And Check local mysql database connection for 'php-local-db-redis.test' - - Scenario: Delete the sites - When I run 'bin/ee site delete php.test --yes' - Then STDOUT should return something like - """ - Site php.test deleted. - """ - And STDERR should return exactly - """ - """ - And The 'php.test' db entry should be removed - And The 'php.test' webroot should be removed - And Following containers of site 'php.test' should be removed: - | container | - | nginx | - | php | - | db | - | redis | - | phpmyadmin | + Scenario: An unknown site type is rejected + When I run "bin/ee site create bh-site-type-php-199.test --type=bh-nope" + Then the command should fail + And STDERR should contain "'bh-nope' is not a registered site type" + And the site "bh-site-type-php-199.test" should not exist From 9584f108f15d9e08f6ed43b93b06b8d6129ee8f5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 13:23:17 +0530 Subject: [PATCH 03/35] test(site-type-php): cover database options Global db, custom db credentials, --local-db, remote --dbhost, --skip-check and site delete cleanup. Known bugs: DB-1, remote db never verified, and rollbacks that drop another site's database or user. --- features/database.feature | 190 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 features/database.feature diff --git a/features/database.feature b/features/database.feature new file mode 100644 index 0000000..cded8db --- /dev/null +++ b/features/database.feature @@ -0,0 +1,190 @@ +Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbname, --dbuser, --dbpass) + + Scenario: --with-db creates a database and user on the global db + When I run "bin/ee site create bh-site-type-php-10.test --type=php --with-db" + Then the command should succeed + And STDOUT should match: + """ + /\|\s*DB Host\s*\|\s*global-db\s*\|/ + """ + And STDOUT should match: + """ + /\|\s*DB Name\s*\|\s*bh_site_type_php_10_test\s*\|/ + """ + And STDOUT should not contain "DB Root Password" + And the site "bh-site-type-php-10.test" should have the field "app_sub_type" set to "mysql" + And the site "bh-site-type-php-10.test" should have the field "db_host" set to "global-db" + And the site "bh-site-type-php-10.test" should have the field "db_port" set to "3306" + And the site "bh-site-type-php-10.test" should have the field "db_name" set to "bh_site_type_php_10_test" + And the site "bh-site-type-php-10.test" should have the field "db_user" matching "/^bh-site-type-php-10\.test-[A-Za-z0-9]{6}$/" + And the site "bh-site-type-php-10.test" should have a non-empty "db_password" + And the site "bh-site-type-php-10.test" should have an empty "db_root_password" + And the global database "bh_site_type_php_10_test" should exist + + Scenario: A global-db php site connects to its database and has no db container + Given a php site "bh-site-type-php-10.test" created with "--with-db" exists + Then the php service of site "bh-site-type-php-10.test" should be able to connect to the site database + And the "db" service of site "bh-site-type-php-10.test" should not exist + And the site "bh-site-type-php-10.test" should have 3 running containers + And the site "bh-site-type-php-10.test" should have 8 docker volumes + And the "php" service of site "bh-site-type-php-10.test" should be attached to the docker network "ee-global-backend-network" + And the file "/opt/easyengine/sites/bh-site-type-php-10.test/docker-compose.yml" should not contain "easyengine/mariadb" + And the file "/opt/easyengine/sites/bh-site-type-php-10.test/.env" should not contain "MYSQL_ROOT_PASSWORD" + And a GET request to "http://bh-site-type-php-10.test/" should return 200 + + # DB-1: the MariaDB 11.8 client in php8.2+ images demands TLS, which global-db does not offer. + @known-bug + Scenario: The mysql CLI inside the php container reaches the global db + Given a php site "bh-site-type-php-10.test" created with "--with-db" exists + Then Check global mysql database connection for 'bh-site-type-php-10.test' + + Scenario: --dbname, --dbuser and --dbpass are used for the global db + When I run "bin/ee site create bh-site-type-php-11.test --type=php --with-db --dbname=bh_stp_db11 --dbuser=bh_stp_user11 --dbpass=Bh-Stp-Pass-11" + Then the command should succeed + And the site "bh-site-type-php-11.test" should have the field "db_name" set to "bh_stp_db11" + And the site "bh-site-type-php-11.test" should have the field "db_user" set to "bh_stp_user11" + And the site "bh-site-type-php-11.test" should have the field "db_password" set to "Bh-Stp-Pass-11" + And the global database "bh_stp_db11" should exist + And the global database user "bh_stp_user11" should exist + And the php service of site "bh-site-type-php-11.test" should be able to connect to the site database + + Scenario: --dbhost=global-db is the same as the default global db + When I run "bin/ee site create bh-site-type-php-15.test --type=php --with-db --dbhost=global-db" + Then the command should succeed + And the site "bh-site-type-php-15.test" should have the field "db_host" set to "global-db" + And the site "bh-site-type-php-15.test" should have the field "db_name" set to "bh_site_type_php_15_test" + And the global database "bh_site_type_php_15_test" should exist + And the php service of site "bh-site-type-php-15.test" should be able to connect to the site database + + Scenario: --local-db runs a MariaDB container for the site + When I run "bin/ee site create bh-site-type-php-12.test --type=php --with-db --local-db" + Then the command should succeed + And STDOUT should match: + """ + /\|\s*DB Host\s*\|\s*db\s*\|/ + """ + And STDOUT should contain "DB Root Password" + And the site "bh-site-type-php-12.test" should have the field "db_host" set to "db" + And the site "bh-site-type-php-12.test" should have the field "db_name" set to "bh_site_type_php_12_test" + And the site "bh-site-type-php-12.test" should have a non-empty "db_root_password" + And the site "bh-site-type-php-12.test" should have a non-empty "db_password" + And the global database "bh_site_type_php_12_test" should not exist + + Scenario: A local-db php site has its db service, env and volumes + Given a php site "bh-site-type-php-12.test" created with "--with-db --local-db" exists + Then the "db" service of site "bh-site-type-php-12.test" should be running + And the "db" service of site "bh-site-type-php-12.test" should use the pinned "easyengine/mariadb" image + And the "db" service of site "bh-site-type-php-12.test" should have the restart policy "always" + And the site "bh-site-type-php-12.test" should have 4 running containers + And the site "bh-site-type-php-12.test" should have 11 docker volumes + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_ROOT_PASSWORD=" + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_DATABASE=bh_site_type_php_12_test" + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_USER=bh-site-type-php-12.test-" + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_PASSWORD=" + And the database "bh_site_type_php_12_test" should exist in the db service of site "bh-site-type-php-12.test" + And the php service of site "bh-site-type-php-12.test" should be able to connect to the site database + And the "php" service of site "bh-site-type-php-12.test" should not be attached to the docker network "ee-global-backend-network" + + # DB-1 (inferred): the local db runs the same easyengine/mariadb image as global-db, so the same TLS demand applies. + @known-bug + Scenario: The mysql CLI inside the php container reaches the local db + Given a php site "bh-site-type-php-12.test" created with "--with-db --local-db" exists + Then Check local mysql database connection for 'bh-site-type-php-12.test' + + # Current behaviour: --local-db only acts inside --with-db, so alone it is silently ignored. + Scenario: --local-db without --with-db creates no database at all + When I run "bin/ee site create bh-site-type-php-13.test --type=php --local-db" + Then the command should succeed + And the site "bh-site-type-php-13.test" should have the field "app_sub_type" set to "php" + And the site "bh-site-type-php-13.test" should have an empty "db_host" + And the "db" service of site "bh-site-type-php-13.test" should not exist + And the global database "bh_site_type_php_13_test" should not exist + + Scenario: A remote --dbhost with host:port and existing credentials is used as given + Given the global database "bh_stp_remote14" with user "bh_stp_remote14" identified by "Bh-Stp-Remote-14" exists + When I run "bin/ee site create bh-site-type-php-14.test --type=php --with-db --dbhost=global-db:3306 --dbname=bh_stp_remote14 --dbuser=bh_stp_remote14 --dbpass=Bh-Stp-Remote-14" + Then the command should succeed + And the site "bh-site-type-php-14.test" should have the field "db_host" set to "global-db" + And the site "bh-site-type-php-14.test" should have the field "db_port" set to "3306" + And the site "bh-site-type-php-14.test" should have the field "db_name" set to "bh_stp_remote14" + And the site "bh-site-type-php-14.test" should have the field "db_user" set to "bh_stp_remote14" + And the php service of site "bh-site-type-php-14.test" should be able to connect to the site database + + Scenario: A remote --dbhost needs --dbuser and --dbpass + When I run "bin/ee site create bh-site-type-php-16.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid" + Then the return code should be 1 + And STDERR should contain "`--dbuser` and `--dbpass` are required for remote db host." + And the site "bh-site-type-php-16.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-16.test" should not exist + When I run "bin/ee site create bh-site-type-php-16.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid --dbuser=bh_stp_u16" + Then the return code should be 1 + And STDERR should contain "`--dbuser` and `--dbpass` are required for remote db host." + + Scenario: --skip-check creates the site without probing the remote db, and the port is parsed + When I run "bin/ee site create bh-site-type-php-18.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid:3307 --dbuser=bh_stp_u18 --dbpass=bh-stp-p18 --skip-check" + Then the command should succeed + And STDOUT should not contain "Verifying connection to remote database" + And the site "bh-site-type-php-18.test" should have the field "db_host" set to "bh-stp-unreachable.invalid" + And the site "bh-site-type-php-18.test" should have the field "db_port" set to "3307" + And the site "bh-site-type-php-18.test" should have the field "db_user" set to "bh_stp_u18" + And the site "bh-site-type-php-18.test" should have an empty "db_root_password" + And the "db" service of site "bh-site-type-php-18.test" should not exist + + # New (suspected): PHP::create_site() calls maybe_verify_remote_db_connection() only for --local-db, where it returns at once, + # so remote credentials are never checked and --skip-check/--force are dead options (site-type-wp calls it for every create). + @known-bug + Scenario: An unreachable remote db host fails the create and rolls back + When I run "bin/ee site create bh-site-type-php-17.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid --dbuser=bh_stp_u17 --dbpass=bh-stp-p17" + Then the command should fail + And STDOUT should contain "Verifying connection to remote database" + And the site "bh-site-type-php-17.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-17.test" should not exist + + # New (suspected), data loss: create_user_in_db() fails on the existing database, and catch_clean() then runs + # cleanup_db() with the requested --dbname, dropping the OTHER site's database. + @known-bug + Scenario: Reusing another site's --dbname fails without dropping that database + Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists + And the global database "bh_stp_shared20" contains the table "bh_marker" + When I run "bin/ee site create bh-site-type-php-21.test --type=php --with-db --dbname=bh_stp_shared20" + Then the command should fail + And the site "bh-site-type-php-21.test" should not exist + And the global database "bh_stp_shared20" should contain the table "bh_marker" + And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database + + # New (suspected), data loss: same rollback path with --dbuser; cleanup_db_user() drops the other site's user. + @known-bug + Scenario: Reusing another site's --dbuser fails without dropping that user + Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists + When I run "bin/ee site create bh-site-type-php-24.test --type=php --with-db --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Other-24" + Then the command should fail + And the site "bh-site-type-php-24.test" should not exist + And the global database user "bh_stp_user20" should exist + And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database + + # New (suspected), data loss: the default db name replaces "." and "-" with "_", so these two names collide, + # and the failed second create drops the first site's database. + @known-bug + Scenario: Two site names that map to the same default db name do not destroy each other's database + Given a php site "bh-site-type-php-22.x.test" created with "--with-db" exists + And the global database "bh_site_type_php_22_x_test" contains the table "bh_marker" + When I run "bin/ee site create bh-site-type-php-22-x.test --type=php --with-db" + Then the global database "bh_site_type_php_22_x_test" should contain the table "bh_marker" + And the php service of site "bh-site-type-php-22.x.test" should be able to connect to the site database + + Scenario: Deleting a global-db site drops its database and user + Given a php site "bh-site-type-php-25.test" created with "--with-db --dbuser=bh_stp_user25 --dbpass=Bh-Stp-Pass-25" exists + And the global database "bh_site_type_php_25_test" should exist + When I run "bin/ee site delete bh-site-type-php-25.test --yes" + Then the command should succeed + And the site "bh-site-type-php-25.test" should not exist + And the global database "bh_site_type_php_25_test" should not exist + And the global database user "bh_stp_user25" should not exist + + Scenario: Deleting a local-db site removes the db container and its volumes + Given a php site "bh-site-type-php-26.test" created with "--with-db --local-db" exists + When I run "bin/ee site delete bh-site-type-php-26.test --yes" + Then the command should succeed + And the "db" service of site "bh-site-type-php-26.test" should not exist + And the site "bh-site-type-php-26.test" should have 0 docker volumes + And the directory "/opt/easyengine/sites/bh-site-type-php-26.test" should not exist From e4d47c0715ee0d7947b0953bd40359d3989ee733 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 13:26:37 +0530 Subject: [PATCH 04/35] test(site-type-php): cover redis cache and site clean Global and local redis page cache (HIT/BYPASS, keys, networks), cache flag combinations with local and global db, and site clean. Known bugs: MIG-16, CACHE-1 and clean missing the php page cache key format and local redis. --- features/cache.feature | 138 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 features/cache.feature diff --git a/features/cache.feature b/features/cache.feature new file mode 100644 index 0000000..443d38b --- /dev/null +++ b/features/cache.feature @@ -0,0 +1,138 @@ +Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site clean + + Scenario: --cache uses the global redis for the nginx page cache + When I run "bin/ee site create bh-site-type-php-30.test --type=php --cache" + Then the command should succeed + And STDOUT should contain "| Cache | Enabled" + And the site "bh-site-type-php-30.test" should have the field "cache_host" set to "global-redis" + And the site "bh-site-type-php-30.test" should have the field "cache_nginx_fullpage" set to "1" + And the site "bh-site-type-php-30.test" should have the field "cache_nginx_browser" set to "1" + And the site "bh-site-type-php-30.test" should have the field "cache_mysql_query" set to "1" + And the docker container "services_global-redis_1" should be running + + Scenario: The global-redis cache config is in nginx and compose + Given a php site "bh-site-type-php-30.test" created with "--cache" exists + Then the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain "upstream redis" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain "server global-redis:6379;" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain "# Redis NGINX CONFIGURATION" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain: + """ + set $key "bh-site-type-php-30.test:http$request_method$host$request_uri"; + """ + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should not contain "# PHP NGINX CONFIGURATION" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/docker-compose.yml" should not contain "easyengine/redis" + And the "nginx" service of site "bh-site-type-php-30.test" should be attached to the docker network "ee-global-backend-network" + And the "php" service of site "bh-site-type-php-30.test" should be attached to the docker network "ee-global-backend-network" + And the "redis" service of site "bh-site-type-php-30.test" should not exist + And the site "bh-site-type-php-30.test" should have 3 running containers + + Scenario: Pages of a --cache site are stored in and served from the global redis + Given a php site "bh-site-type-php-30.test" created with "--cache" exists + When I send a GET request to "http://bh-site-type-php-30.test/" + And I send a GET request to "http://bh-site-type-php-30.test/" + Then the response code should be 200 + And the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the global redis should have keys matching "bh-site-type-php-30.test:*" + And Check global redis cache for 'bh-site-type-php-30.test' + + Scenario: Requests with a query string bypass the page cache + Given a php site "bh-site-type-php-30.test" created with "--cache" exists + When I send a GET request to "http://bh-site-type-php-30.test/?bh=1" + And I send a GET request to "http://bh-site-type-php-30.test/?bh=1" + Then the response code should be 200 + And the response header "X-SRCache-Fetch-Status" should contain "BYPASS" + + Scenario: --cache --with-local-redis runs a redis container for the site + When I run "bin/ee site create bh-site-type-php-31.test --type=php --cache --with-local-redis" + Then the command should succeed + And the site "bh-site-type-php-31.test" should have the field "cache_host" set to "redis" + And the site "bh-site-type-php-31.test" should have the field "cache_nginx_fullpage" set to "1" + And the "redis" service of site "bh-site-type-php-31.test" should be running + And the "redis" service of site "bh-site-type-php-31.test" should use the pinned "easyengine/redis" image + And the site "bh-site-type-php-31.test" should have 4 running containers + And the file "/opt/easyengine/sites/bh-site-type-php-31.test/config/nginx/conf.d/main.conf" should contain "server redis:6379;" + And the "nginx" service of site "bh-site-type-php-31.test" should not be attached to the docker network "ee-global-backend-network" + + Scenario: Pages of a local-redis site are cached in the site's own redis only + Given a php site "bh-site-type-php-31.test" created with "--cache --with-local-redis" exists + When I send a GET request to "http://bh-site-type-php-31.test/" + And I send a GET request to "http://bh-site-type-php-31.test/" + Then the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the redis service of site "bh-site-type-php-31.test" should have keys matching "bh-site-type-php-31.test:*" + And the global redis should not have keys matching "bh-site-type-php-31.test:*" + And Check local redis cache for 'bh-site-type-php-31.test' + + # MIG-16: the local redis service has no restart policy, unlike every other service. + @known-bug + Scenario: The local redis container restarts with Docker + Given a php site "bh-site-type-php-31.test" created with "--cache --with-local-redis" exists + Then the "redis" service of site "bh-site-type-php-31.test" should have the restart policy "always" + + # Current behaviour: --with-local-redis only acts together with --cache. + Scenario: --with-local-redis without --cache enables no cache + When I run "bin/ee site create bh-site-type-php-32.test --type=php --with-local-redis" + Then the command should succeed + And the site "bh-site-type-php-32.test" should have an empty "cache_host" + And the site "bh-site-type-php-32.test" should have the field "cache_nginx_fullpage" set to "0" + And the "redis" service of site "bh-site-type-php-32.test" should not exist + And the file "/opt/easyengine/sites/bh-site-type-php-32.test/config/nginx/conf.d/main.conf" should not contain "upstream redis" + + Scenario: Local db and global redis + When I run "bin/ee site create bh-site-type-php-33.test --cache --type=php --local-db --with-db" + Then the command should succeed + And a GET request to "http://bh-site-type-php-33.test/" should return 200 + And the "db" service of site "bh-site-type-php-33.test" should be running + And the "redis" service of site "bh-site-type-php-33.test" should not exist + And the php service of site "bh-site-type-php-33.test" should be able to connect to the site database + And Check global redis cache for 'bh-site-type-php-33.test' + + Scenario: Local redis and global db + When I run "bin/ee site create bh-site-type-php-37.test --cache --type=php --with-db --with-local-redis" + Then the command should succeed + And a GET request to "http://bh-site-type-php-37.test/" should return 200 + And the "redis" service of site "bh-site-type-php-37.test" should be running + And the "db" service of site "bh-site-type-php-37.test" should not exist + And the php service of site "bh-site-type-php-37.test" should be able to connect to the site database + And Check local redis cache for 'bh-site-type-php-37.test' + + Scenario: Local db and local redis + When I run "bin/ee site create bh-site-type-php-38.test --cache --type=php --local-db --with-db --with-local-redis" + Then the command should succeed + And a GET request to "http://bh-site-type-php-38.test/" should return 200 + And the site "bh-site-type-php-38.test" should have 5 running containers + And the site "bh-site-type-php-38.test" should have 11 docker volumes + And the php service of site "bh-site-type-php-38.test" should be able to connect to the site database + And Check local redis cache for 'bh-site-type-php-38.test' + + Scenario: ee site clean on a site without cache reports that no cache is enabled + Given a php site "bh-site-type-php-34.test" exists + When I run "timeout 120 bin/ee site clean bh-site-type-php-34.test" + Then the return code should be 1 + And STDERR should contain "Site object cache is not enabled. Site page cache is not enabled." + When I run "timeout 120 bin/ee site clean bh-site-type-php-34.test --proxy" + Then the return code should be 1 + And STDERR should contain "Proxy cache is not enabled on site." + + # New (suspected): clean purges "_page*" but php sites store pages under ":http...", so nothing is purged. + # CACHE-1: the purge also runs `docker exec -it`, which fails without a TTY (or hangs under nested ptys), hence the timeout. + @known-bug + Scenario: ee site clean --page purges the php page cache in the global redis + Given a php site "bh-site-type-php-35.test" created with "--cache" exists + When I send a GET request to "http://bh-site-type-php-35.test/" + And I send a GET request to "http://bh-site-type-php-35.test/" + Then the global redis should have keys matching "bh-site-type-php-35.test:*" + When I run "timeout 120 bin/ee site clean bh-site-type-php-35.test --page" + Then the command should succeed + And STDOUT should contain "Success: Page cache cleared for bh-site-type-php-35.test" + And the global redis should not have keys matching "bh-site-type-php-35.test:*" + + # New (suspected): clean_site_cache() always targets the global redis container, never the site's local redis. + @known-bug + Scenario: ee site clean purges the page cache of a local-redis site + Given a php site "bh-site-type-php-36.test" created with "--cache --with-local-redis" exists + When I send a GET request to "http://bh-site-type-php-36.test/" + And I send a GET request to "http://bh-site-type-php-36.test/" + Then the redis service of site "bh-site-type-php-36.test" should have keys matching "bh-site-type-php-36.test:*" + When I run "timeout 120 bin/ee site clean bh-site-type-php-36.test" + Then the command should succeed + And the redis service of site "bh-site-type-php-36.test" should not have keys matching "bh-site-type-php-36.test:*" From 3ab1f1299d92b0b72537b3bab0ec069797b080d3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 13:29:48 +0530 Subject: [PATCH 05/35] test(site-type-php): cover php versions Create on every supported version and latest, and site update --php including backup, custom.ini, 5.6 sendmail handling and error paths. --- features/php-version.feature | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 features/php-version.feature diff --git a/features/php-version.feature b/features/php-version.feature new file mode 100644 index 0000000..8dde1c2 --- /dev/null +++ b/features/php-version.feature @@ -0,0 +1,91 @@ +Feature: PHP versions of php sites (create --php, update --php) + + @slow + Scenario Outline: Create a php site on every supported PHP version + When I run "bin/ee site create --type=php --php=" + Then the command should succeed + And the site "" should have the field "php_version" set to "" + And the "php" service of site "" should use the pinned "" image + And the php service of site "" should run PHP "" + And a GET request to "http:///" should return 200 + And the response should contain "Congratulations!" + And the file "/opt/easyengine/sites//config/php/php/conf.d/custom.ini" should contain "" + + # "latest" maps to the php8.4 image (Site_PHP_Docker), not to the newest supported version. + Examples: + | site | version | image | runs | sendmail | + | bh-site-type-php-40.test | 5.6 | easyengine/php5.6 | 5.6 | sendmail_path = /usr/sbin/sendmail -t -i -f no-reply@example.com | + | bh-site-type-php-41.test | 7.0 | easyengine/php7.0 | 7.0 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-42.test | 7.2 | easyengine/php7.2 | 7.2 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-43.test | 7.3 | easyengine/php7.3 | 7.3 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-44.test | 7.4 | easyengine/php7.4 | 7.4 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-45.test | 8.0 | easyengine/php8.0 | 8.0 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-46.test | 8.1 | easyengine/php8.1 | 8.1 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-47.test | 8.2 | easyengine/php8.2 | 8.2 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-48.test | 8.3 | easyengine/php8.3 | 8.3 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-49.test | 8.4 | easyengine/php8.4 | 8.4 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-50.test | 8.5 | easyengine/php8.5 | 8.5 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-51.test | latest | easyengine/php8.4 | 8.4 | sendmail_path = /usr/bin/msmtp -t | + + Scenario: Update the PHP version of a php site and keep custom php settings + Given a php site "bh-site-type-php-52.test" exists + And I append "; bh-custom-ini-marker" to the file "/opt/easyengine/sites/bh-site-type-php-52.test/config/php/php/conf.d/custom.ini" + When I run "bin/ee site update bh-site-type-php-52.test --php=8.3" + Then the command should succeed + And STDOUT should contain "Starting php version update for: bh-site-type-php-52.test" + And STDOUT should contain "Success: Updated site bh-site-type-php-52.test to PHP version: 8.3" + And the site "bh-site-type-php-52.test" should have the field "php_version" set to "8.3" + And the docker-compose file of site "bh-site-type-php-52.test" should use the pinned "easyengine/php8.3" image + And the "php" service of site "bh-site-type-php-52.test" should use the pinned "easyengine/php8.3" image + And the php service of site "bh-site-type-php-52.test" should run PHP "8.3" + And the directory "/opt/easyengine/sites/bh-site-type-php-52.test/.backup/config/php-8.4" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-52.test/config/php/php/conf.d/custom.ini" should contain "; bh-custom-ini-marker" + And a GET request to "http://bh-site-type-php-52.test/" should return 200 + + Scenario: Updating to the current PHP version is refused + Given a php site "bh-site-type-php-49.test" created with "--php=8.4" exists + When I run "bin/ee site update bh-site-type-php-49.test --php=8.4" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-49.test is already at PHP version: 8.4" + + Scenario Outline: An unsupported PHP version is refused by site update + Given a php site "bh-site-type-php-49.test" created with "--php=8.4" exists + When I run "bin/ee site update bh-site-type-php-49.test --php=" + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + And the site "bh-site-type-php-49.test" should have the field "php_version" set to "8.4" + + Examples: + | version | + | 7.1 | + | latest | + | 9.9 | + + Scenario: Updating from PHP 5.6 switches sendmail to msmtp + Given a php site "bh-site-type-php-53.test" created with "--php=5.6" exists + When I run "bin/ee site update bh-site-type-php-53.test --php=8.4" + Then the command should succeed + And the php service of site "bh-site-type-php-53.test" should run PHP "8.4" + And the file "/opt/easyengine/sites/bh-site-type-php-53.test/config/php/php/conf.d/custom.ini" should contain "sendmail_path = /usr/bin/msmtp -t" + And a GET request to "http://bh-site-type-php-53.test/" should return 200 + + Scenario: Updating to PHP 5.6 switches msmtp to sendmail + Given a php site "bh-site-type-php-54.test" exists + When I run "bin/ee site update bh-site-type-php-54.test --php=5.6" + Then the command should succeed + And the php service of site "bh-site-type-php-54.test" should run PHP "5.6" + And the file "/opt/easyengine/sites/bh-site-type-php-54.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-php-54.test/" should return 200 + + Scenario: The PHP version of a disabled site can not be updated + Given a php site "bh-site-type-php-55.test" exists + And the site "bh-site-type-php-55.test" is disabled + When I run "bin/ee site update bh-site-type-php-55.test --php=8.3" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-55.test is not enabled. Use `ee site enable bh-site-type-php-55.test` to enable it." + And the site "bh-site-type-php-55.test" should have the field "php_version" set to "8.4" + + Scenario: Updating a site that does not exist fails + When I run "bin/ee site update bh-site-type-php-198.test --php=8.3" + Then the return code should be 1 + And STDERR should contain "Could not find the site you wish to run site update command on." From a5578084b87faaefe1984251632b841d72e0aaf5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:17:20 +0530 Subject: [PATCH 06/35] test(site-type-php): cover public dir Relative, full, nested and sanitised document roots, with cache, and the bare --public-dir known bug. --- features/public-dir.feature | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 features/public-dir.feature diff --git a/features/public-dir.feature b/features/public-dir.feature new file mode 100644 index 0000000..0c25555 --- /dev/null +++ b/features/public-dir.feature @@ -0,0 +1,56 @@ +Feature: Custom document root of php sites (--public-dir) + + Scenario: --public-dir serves the site from a sub directory of htdocs + When I run "bin/ee site create bh-site-type-php-60.test --type=php --public-dir=public" + Then the command should succeed + And the site "bh-site-type-php-60.test" should have the field "site_container_fs_path" set to "/var/www/htdocs/public" + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs/public;" + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/public/index.php" should contain "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/public" + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/index.php" should not exist + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/public/check.html" should not exist + And a GET request to "http://bh-site-type-php-60.test/" should return 200 + And the response should contain "Congratulations!" + + Scenario: Files outside the public directory are not served + Given a php site "bh-site-type-php-60.test" created with "--public-dir=public" exists + When I write " Date: Tue, 20 Oct 2026 14:20:44 +0530 Subject: [PATCH 07/35] test(site-type-php): cover alias domains Create with plain and wildcard aliases, conflicts, trimming, and site update --add/--delete-alias-domains with every error path. Known bugs: existing site as alias, blank entries and AUTH-1. --- features/alias-domains.feature | 144 +++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 features/alias-domains.feature diff --git a/features/alias-domains.feature b/features/alias-domains.feature new file mode 100644 index 0000000..01ccfdf --- /dev/null +++ b/features/alias-domains.feature @@ -0,0 +1,144 @@ +Feature: Alias domains of php sites (create --alias-domains, update --add/--delete-alias-domains) + + Scenario: Create a php site with plain and wildcard alias domains + When I run "bin/ee site create bh-site-type-php-70.test --type=php --alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" + Then the command should succeed + And STDOUT should contain "| Alias Domains | bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test" + And the site "bh-site-type-php-70.test" should have the field "alias_domains" set to "bh-site-type-php-70.test,bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test" + + Scenario: Alias domains reach nginx-proxy and the site nginx + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + Then the file "/opt/easyengine/sites/bh-site-type-php-70.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test" + And the file "/opt/easyengine/sites/bh-site-type-php-70.test/docker-compose.yml" should contain "CERT_NAME=${VIRTUAL_HOST}" + And the file "/opt/easyengine/sites/bh-site-type-php-70.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-type-php-70.test bh-site-type-php-70-a.test bh-site-type-php-70-b.test *.bh-site-type-php-70.test;" + And the hosts file should have an entry for "bh-site-type-php-70.test" + And the hosts file should not have an entry for "bh-site-type-php-70-a.test" + + Scenario: Every alias domain serves the site + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + Then a GET request to "http://bh-site-type-php-70-a.test/" via "127.0.0.1" should return 200 + And the response should contain "Congratulations!" + And a GET request to "http://bh-site-type-php-70-b.test/" via "127.0.0.1" should return 200 + And a GET request to "http://any.bh-site-type-php-70.test/" via "127.0.0.1" should return 200 + And a GET request to "http://bh-site-type-php-70-z.test/" via "127.0.0.1" should not return 200 + + Scenario: An alias domain that already belongs to another site is refused + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + When I run "bin/ee site create bh-site-type-php-71.test --type=php --alias-domains=bh-site-type-php-70-a.test" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-70-a.test already exists as an alias domain for site: bh-site-type-php-70.test." + And the site "bh-site-type-php-71.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-71.test" should not exist + + Scenario: A site can not be created with a name that is another site's alias + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + When I run "bin/ee site create bh-site-type-php-70-b.test --type=php" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-70-b.test already exists as an alias domain for site: bh-site-type-php-70.test. Please delete it from alias domains of bh-site-type-php-70.test if you want to create an independent site for it." + And the site "bh-site-type-php-70-b.test" should not exist + + Scenario: Spaces around alias domains are trimmed + When I run "bin/ee site create bh-site-type-php-78.test --type=php --alias-domains=' bh-site-type-php-78-a.test , bh-site-type-php-78-b.test '" + Then the command should succeed + And the site "bh-site-type-php-78.test" should have the field "alias_domains" set to "bh-site-type-php-78.test,bh-site-type-php-78-a.test,bh-site-type-php-78-b.test" + And a GET request to "http://bh-site-type-php-78-b.test/" via "127.0.0.1" should return 200 + + # New (suspected): create only checks aliases against other sites' aliases (check_alias_in_db), not against existing + # site names, so two sites end up claiming the same VIRTUAL_HOST. `site update --add-alias-domains` does check this. + @known-bug + Scenario: An alias domain that is an existing site is refused on create + Given a php site "bh-site-type-php-74.test" exists + When I run "bin/ee site create bh-site-type-php-73.test --type=php --alias-domains=bh-site-type-php-74.test" + Then the command should fail + And STDERR should contain "bh-site-type-php-74.test already exists as a site" + And the site "bh-site-type-php-73.test" should not exist + + # Blank entries are stored as-is on develop; fixed in site-type-php #114 (c7d7bc4, not merged). + @known-bug + Scenario: Empty entries in --alias-domains are dropped + When I run "bin/ee site create bh-site-type-php-75.test --type=php --alias-domains='bh-site-type-php-75-a.test,,bh-site-type-php-75-b.test,'" + Then the command should succeed + And the site "bh-site-type-php-75.test" should have the field "alias_domains" set to "bh-site-type-php-75.test,bh-site-type-php-75-a.test,bh-site-type-php-75-b.test" + And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-75-a.test,bh-site-type-php-75-b.test" + And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should not contain ",," + + # AUTH-1: an alias like _wildcard. can overwrite proxy auth files; rejected by site-type-php #114 (268532a, not merged). + @known-bug + Scenario Outline: An alias domain that is not a valid hostname is refused + When I run "bin/ee site create --type=php --alias-domains=''" + Then the return code should be 1 + And STDERR should contain "Invalid alias domain(s)" + And the site "" should not exist + + Examples: + | site | alias | + | bh-site-type-php-76.test | _wildcard.bh-site-type-php-76.test | + | bh-site-type-php-76-1.test | -bh-site-type-php-76-a.test | + | bh-site-type-php-76-2.test | bh-site-type-php-76 a.test | + + Scenario: Add an alias domain to a php site + Given a php site "bh-site-type-php-77.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-77-a.test" + Then the command should succeed + And STDOUT should contain "Success: Alias domains updated on site bh-site-type-php-77.test." + And the site "bh-site-type-php-77.test" should have the field "alias_domains" set to "bh-site-type-php-77.test,bh-site-type-php-77-a.test" + And the file "/opt/easyengine/sites/bh-site-type-php-77.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-77-a.test" + And I wait until a GET request to "http://bh-site-type-php-77-a.test/" via "127.0.0.1" returns 200 within 60 seconds + And a GET request to "http://bh-site-type-php-77.test/" should return 200 + + Scenario: Adding an alias domain the site already has is refused + Given a php site "bh-site-type-php-77.test" exists + And the site "bh-site-type-php-77.test" has the alias domain "bh-site-type-php-77-a.test" + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-77-a.test" + Then the return code should be 1 + And STDERR should contain "Error: Alias domains: bh-site-type-php-77-a.test is/are already present on the site." + + Scenario: Adding a mix of new and existing alias domains skips the existing ones + Given a php site "bh-site-type-php-77.test" exists + And the site "bh-site-type-php-77.test" has the alias domain "bh-site-type-php-77-a.test" + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-77-a.test,bh-site-type-php-77-b.test" + Then the command should succeed + And STDERR should contain "Following domains: bh-site-type-php-77-a.test is/are already present on site, skipping addition of those." + And the site "bh-site-type-php-77.test" should have the field "alias_domains" set to "bh-site-type-php-77.test,bh-site-type-php-77-a.test,bh-site-type-php-77-b.test" + + Scenario: Delete an alias domain from a php site + Given a php site "bh-site-type-php-77.test" exists + And the site "bh-site-type-php-77.test" has the alias domain "bh-site-type-php-77-c.test" + When I run "bin/ee site update bh-site-type-php-77.test --delete-alias-domains=bh-site-type-php-77-c.test" + Then the command should succeed + And STDOUT should contain "Success: Alias domains updated on site bh-site-type-php-77.test." + And the site "bh-site-type-php-77.test" should have the field "alias_domains" not containing "bh-site-type-php-77-c.test" + And the file "/opt/easyengine/sites/bh-site-type-php-77.test/docker-compose.yml" should not contain "bh-site-type-php-77-c.test" + And I wait until a GET request to "http://bh-site-type-php-77-c.test/" via "127.0.0.1" no longer returns 200 within 60 seconds + + Scenario: The primary domain can not be deleted from the alias domains + Given a php site "bh-site-type-php-77.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --delete-alias-domains=bh-site-type-php-77.test" + Then the return code should be 1 + And STDERR should contain "Primary site domain: `bh-site-type-php-77.test` can not be deleted." + + Scenario: Deleting an alias domain the site does not have is refused + Given a php site "bh-site-type-php-77.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --delete-alias-domains=bh-site-type-php-77-nope.test" + Then the return code should be 1 + And STDERR should contain "Domains to delete is/are not a subset of already existing alias domains." + + Scenario: An existing site can not be added as an alias domain + Given a php site "bh-site-type-php-77.test" exists + And a php site "bh-site-type-php-74.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-74.test" + Then the return code should be 1 + And STDERR should contain "bh-site-type-php-74.test already exists as a site." + + Scenario: Another site's alias domain can not be added + Given a php site "bh-site-type-php-77.test" exists + And a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-70-a.test" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-70-a.test already exists as an alias domain for site: bh-site-type-php-70.test." + + Scenario: A wildcard alias domain can be added later + Given a php site "bh-site-type-php-79.test" exists + When I run "bin/ee site update bh-site-type-php-79.test --add-alias-domains='*.bh-site-type-php-79.test'" + Then the command should succeed + And I wait until a GET request to "http://sub.bh-site-type-php-79.test/" via "127.0.0.1" returns 200 within 60 seconds From 8dcae10be9bc418063d5136e2ef550f66d5b2bef Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:24:22 +0530 Subject: [PATCH 08/35] test(site-type-php): cover ssl Self-signed, custom, inherit and one Let's Encrypt create (@le), argument errors, and site update --ssl. Known bug: SSL of a --ssl=self site can't be turned off. --- features/ssl.feature | 133 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 features/ssl.feature diff --git a/features/ssl.feature b/features/ssl.feature new file mode 100644 index 0000000..8c6150d --- /dev/null +++ b/features/ssl.feature @@ -0,0 +1,133 @@ +Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) + + Scenario: Create a php site with a self-signed certificate + When I run "bin/ee site create bh-site-type-php-80.test --type=php --ssl=self" + Then the command should succeed + And STDOUT should contain "| Site | https://bh-site-type-php-80.test" + And STDOUT should contain "| SSL | Enabled" + And STDOUT should contain "| SSL Wildcard | Yes" + And the site "bh-site-type-php-80.test" should have the field "site_ssl" set to "self" + And the site "bh-site-type-php-80.test" should have the field "site_ssl_wildcard" set to "1" + And the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-80.test.crt" should exist + And the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-80.test.key" should exist + And the hosts file should have an entry for "bh-site-type-php-80.test" + + Scenario: A self-signed php site is served over HTTPS and redirects HTTP + Given a php site "bh-site-type-php-80.test" created with "--ssl=self" exists + Then a GET request to "https://bh-site-type-php-80.test/" should return 200 + And the response should contain "Congratulations!" + And a GET request to "http://bh-site-type-php-80.test/" should return 301 + And the response header "Location" should contain "https://bh-site-type-php-80.test/" + And the certificate served for "bh-site-type-php-80.test" should cover "bh-site-type-php-80.test" + And the certificate served for "bh-site-type-php-80.test" should cover "*.bh-site-type-php-80.test" + And the certificate served for "bh-site-type-php-80.test" should match the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-80.test.crt" + And the file "/opt/easyengine/sites/bh-site-type-php-80.test/docker-compose.yml" should not contain "HTTPS_METHOD=nohttps" + + Scenario: Create a php site with a custom certificate + Given a self-signed certificate for "bh-site-type-php-81.test" exists in "/var/tmp/bh-site-type-php/certs" + And the hosts file maps "bh-site-type-php-81.test" to "127.0.0.1" + When I run "bin/ee site create bh-site-type-php-81.test --type=php --ssl=custom --ssl-key=/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.key --ssl-crt=/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" + Then the command should succeed + And the site "bh-site-type-php-81.test" should have the field "site_ssl" set to "custom" + And the files "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" and "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-81.test.crt" should be identical + And the files "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.key" and "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-81.test.key" should be identical + And a GET request to "https://bh-site-type-php-81.test/" via "127.0.0.1" should return 200 + And the certificate served for "bh-site-type-php-81.test" should match the file "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" + + Scenario: --ssl=custom needs both --ssl-key and --ssl-crt + When I run "bin/ee site create bh-site-type-php-82.test --type=php --ssl=custom --ssl-crt=/var/tmp/bh-site-type-php/certs/none.crt" + Then the return code should be 1 + And STDERR should contain "Pass --ssl-key and --ssl-crt for custom SSL" + And the site "bh-site-type-php-82.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-82.test" should not exist + + Scenario: --ssl=custom with a missing certificate file fails + When I run "bin/ee site create bh-site-type-php-83.test --type=php --ssl=custom --ssl-key=/var/tmp/bh-site-type-php/certs/none.key --ssl-crt=/var/tmp/bh-site-type-php/certs/none.crt" + Then the return code should be 1 + And STDERR should contain "ssl-key OR ssl-crt path does not exist" + And the site "bh-site-type-php-83.test" should not exist + + Scenario: --ssl=inherit without a parent site fails and rolls back + When I run "bin/ee site create bh-site-type-php-84.test --type=php --ssl=inherit" + Then the return code should be 1 + And STDERR should contain "Unable to find existing site: test" + And the site "bh-site-type-php-84.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-84.test" should not exist + + Scenario: A subdomain site inherits the wildcard certificate of its parent + Given a php site "bh-site-type-php-85.test" created with "--ssl=self" exists + And the hosts file maps "sub.bh-site-type-php-85.test" to "127.0.0.1" + When I run "bin/ee site create sub.bh-site-type-php-85.test --type=php --ssl=inherit" + Then the command should succeed + And STDOUT should contain "Inherited certs from parent" + And the site "sub.bh-site-type-php-85.test" should have the field "site_ssl" set to "inherit" + And a GET request to "https://sub.bh-site-type-php-85.test/" via "127.0.0.1" should return 200 + And the certificate served for "sub.bh-site-type-php-85.test" should cover "*.bh-site-type-php-85.test" + + Scenario: --ssl=inherit together with --wildcard is refused and rolled back + Given a php site "bh-site-type-php-85.test" created with "--ssl=self" exists + And the hosts file maps "sub2.bh-site-type-php-85.test" to "127.0.0.1" + When I run "bin/ee site create sub2.bh-site-type-php-85.test --type=php --ssl=inherit --wildcard" + Then the return code should be 1 + And STDERR should contain "Cannot use --wildcard with --ssl=inherit" + And the site "sub2.bh-site-type-php-85.test" should not exist + + Scenario: --ssl with an unknown value fails and rolls back + When I run "bin/ee site create bh-site-type-php-87.test --type=php --ssl=bogus --skip-status-check" + Then the return code should be 1 + And STDERR should contain "Unrecognized value in --ssl flag: bogus" + And the site "bh-site-type-php-87.test" should not exist + And the site "bh-site-type-php-87.test" should have 0 docker volumes + + Scenario: Enable and disable self-signed SSL on an existing php site + Given a php site "bh-site-type-php-88.test" exists + When I run "bin/ee site update bh-site-type-php-88.test --ssl=self" + Then the command should succeed + And STDOUT should contain "Success: Enabled SSL for bh-site-type-php-88.test" + And the site "bh-site-type-php-88.test" should have the field "site_ssl" set to "self" + And I wait until a GET request to "https://bh-site-type-php-88.test/" returns 200 within 60 seconds + When I run "bin/ee site update bh-site-type-php-88.test --ssl=self" + Then the return code should be 1 + And STDERR should contain "Error: SSL is already enabled on bh-site-type-php-88.test" + When I run "bin/ee site update bh-site-type-php-88.test --ssl=off" + Then the command should succeed + And STDOUT should contain "Success: Disabled SSL for bh-site-type-php-88.test" + And the site "bh-site-type-php-88.test" should have an empty "site_ssl" + And the file "/opt/easyengine/sites/bh-site-type-php-88.test/docker-compose.yml" should contain "HTTPS_METHOD=nohttps" + And I wait until a GET request to "http://bh-site-type-php-88.test/" returns 200 within 60 seconds + When I run "bin/ee site update bh-site-type-php-88.test --ssl=off" + Then the return code should be 1 + And STDERR should contain "Error: SSL is already disabled on bh-site-type-php-88.test" + + Scenario: --ssl=off with --wildcard is refused + Given a php site "bh-site-type-php-88.test" exists + When I run "bin/ee site update bh-site-type-php-88.test --ssl=off --wildcard" + Then the return code should be 1 + + # New (suspected, site-command): create --ssl=self stores site_ssl_wildcard=1, and update_ssl() then refuses + # --ssl=off ("wildcard to normal") and --ssl=off --wildcard ("cannot use --wildcard"), so SSL can never be turned off. + @known-bug + Scenario: SSL of a site created with --ssl=self can be disabled + Given a php site "bh-site-type-php-89.test" created with "--ssl=self" exists + When I run "bin/ee site update bh-site-type-php-89.test --ssl=off" + Then the command should succeed + And the site "bh-site-type-php-89.test" should have an empty "site_ssl" + + @le + Scenario: Create and delete a php site with a Let's Encrypt certificate + Given the domain "bh-site-type-php-1.ee4-test.rt.gw" resolves publicly + And the site "bh-site-type-php-1.ee4-test.rt.gw" does not exist + When I run "bin/ee site create bh-site-type-php-1.ee4-test.rt.gw --type=php --ssl=le" + Then the command should succeed + And the site "bh-site-type-php-1.ee4-test.rt.gw" should have the field "site_ssl" set to "le" + And the site "bh-site-type-php-1.ee4-test.rt.gw" should have the field "site_ssl_wildcard" set to "0" + And the hosts file should not have an entry for "bh-site-type-php-1.ee4-test.rt.gw" + And a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" with certificate verification should return 200 + And the response should contain "Congratulations!" + And the certificate served for "bh-site-type-php-1.ee4-test.rt.gw" should be issued by "Let's Encrypt" + And a GET request to "http://bh-site-type-php-1.ee4-test.rt.gw/" should return 301 + When I run "bin/ee site delete bh-site-type-php-1.ee4-test.rt.gw --yes" + Then the command should succeed + And STDOUT should contain "Revoking certificate." + And the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-1.ee4-test.rt.gw.crt" should not exist + And the site "bh-site-type-php-1.ee4-test.rt.gw" should not exist From 9de87cd3616a17bfd8e25a9966f51a5956899214 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:27:35 +0530 Subject: [PATCH 09/35] test(site-type-php): cover info, lifecycle and delete site info (table, json, from the site directory), list filters, enable, disable, refresh, restart, reload, proxy cache (@destructive) and delete. --- features/lifecycle.feature | 253 +++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 features/lifecycle.feature diff --git a/features/lifecycle.feature b/features/lifecycle.feature new file mode 100644 index 0000000..dd61436 --- /dev/null +++ b/features/lifecycle.feature @@ -0,0 +1,253 @@ +Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and delete of php sites + + Scenario: site info shows the php site details + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site info bh-site-type-php-100.test" + Then the command should succeed + And STDOUT should contain "| Site | http://bh-site-type-php-100.test" + And STDOUT should contain "| Site Root | /opt/easyengine/sites/bh-site-type-php-100.test" + And STDOUT should contain "| Alias Domains | None" + And STDOUT should contain "| E-Mail | admin@bh-site-type-php-100.test" + And STDOUT should contain "| SSL | Not Enabled" + And STDOUT should contain "| Cache | None" + And STDOUT should not contain "SSL Wildcard" + And STDOUT should not contain "DB Name" + + Scenario: site info --format=json dumps the site row + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site info bh-site-type-php-100.test --format=json" + Then the command should succeed + And STDOUT should be valid json + And the JSON in STDOUT should have "site_url" set to "bh-site-type-php-100.test" + And the JSON in STDOUT should have "site_type" set to "php" + And the JSON in STDOUT should have "app_sub_type" set to "php" + And the JSON in STDOUT should have "php_version" set to "8.4" + And the JSON in STDOUT should have "site_enabled" set to "1" + And the JSON in STDOUT should have "site_container_fs_path" set to "/var/www/htdocs" + + Scenario: site info of a php site with a database and cache lists them + Given a php site "bh-site-type-php-101.test" created with "--with-db --cache --dbname=bh_stp_db101 --dbuser=bh_stp_user101 --dbpass=Bh-Stp-Pass-101" exists + When I run "bin/ee site info bh-site-type-php-101.test" + Then the command should succeed + And STDOUT should match: + """ + /\|\s*DB Host\s*\|\s*global-db\s*\|.*\|\s*DB Name\s*\|\s*bh_stp_db101\s*\|.*\|\s*DB User\s*\|\s*bh_stp_user101\s*\|.*\|\s*DB Password\s*\|\s*Bh-Stp-Pass-101\s*\|/s + """ + And STDOUT should contain "| Cache | Enabled" + When I run "bin/ee site info bh-site-type-php-101.test --format=json" + Then the JSON in STDOUT should have "db_name" set to "bh_stp_db101" + And the JSON in STDOUT should have "cache_host" set to "global-redis" + + Scenario: site info works from inside the site directory + Given a php site "bh-site-type-php-100.test" exists + When I change to the directory "/opt/easyengine/sites/bh-site-type-php-100.test/app/htdocs" and run "bin/ee site info" + Then the command should succeed + And STDOUT should contain "| Site | http://bh-site-type-php-100.test" + + Scenario: site info of an unknown site fails + When I run "bin/ee site info bh-site-type-php-197.test" + Then the return code should be 1 + And STDERR should contain "Could not find the site you wish to run site info command on." + + Scenario: site list shows php sites with their status + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site list --format=json" + Then the command should succeed + And the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" + And the JSON list in STDOUT should contain an item with "status" set to "enabled" + When I run "bin/ee site list --format=text" + Then STDOUT should contain "bh-site-type-php-100.test" + + Scenario: site list --enabled and --disabled filter php sites + Given a php site "bh-site-type-php-100.test" exists + And a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site list --enabled --format=json" + Then the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" + And the JSON list in STDOUT should not contain an item with "site" set to "bh-site-type-php-102.test" + When I run "bin/ee site list --disabled --format=json" + Then the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-102.test" + And the JSON list in STDOUT should not contain an item with "site" set to "bh-site-type-php-100.test" + + Scenario: Disable a php site + Given a php site "bh-site-type-php-102.test" exists + And I run "bin/ee site enable bh-site-type-php-102.test --force" + When I run "bin/ee site disable bh-site-type-php-102.test" + Then the command should succeed + And STDOUT should contain "Success: Site bh-site-type-php-102.test disabled." + And the site "bh-site-type-php-102.test" should be disabled + And the "php" service of site "bh-site-type-php-102.test" should not exist + And the "nginx" service of site "bh-site-type-php-102.test" should not exist + And the site "bh-site-type-php-102.test" should have 8 docker volumes + And the file "/opt/easyengine/sites/bh-site-type-php-102.test/app/htdocs/index.php" should exist + And I wait until a GET request to "http://bh-site-type-php-102.test/" no longer returns 200 within 60 seconds + + Scenario: Disabling a disabled php site is harmless + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site disable bh-site-type-php-102.test" + Then the command should succeed + And the site "bh-site-type-php-102.test" should be disabled + + Scenario: site info still works for a disabled php site + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site info bh-site-type-php-102.test" + Then the command should succeed + And STDOUT should contain "| Site | http://bh-site-type-php-102.test" + + Scenario: Enable a disabled php site + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site enable bh-site-type-php-102.test" + Then the command should succeed + And STDOUT should contain "Success: Site bh-site-type-php-102.test enabled." + And STDOUT should contain "Success: Post enable configurations complete." + And the site "bh-site-type-php-102.test" should be enabled + And the "php" service of site "bh-site-type-php-102.test" should be running + And the "nginx" service of site "bh-site-type-php-102.test" should be running + And the "postfix" service of site "bh-site-type-php-102.test" should be running + And the "mailhog" service of site "bh-site-type-php-102.test" should not be running + And I wait until a GET request to "http://bh-site-type-php-102.test/" returns 200 within 60 seconds + + Scenario: Enabling an enabled php site is refused unless forced + Given a php site "bh-site-type-php-102.test" exists + And I run "bin/ee site enable bh-site-type-php-102.test --force" + When I run "bin/ee site enable bh-site-type-php-102.test" + Then the return code should be 1 + And STDERR should contain "Error: bh-site-type-php-102.test is already enabled!" + When I run "bin/ee site enable bh-site-type-php-102.test --force" + Then the command should succeed + And STDOUT should contain "Success: Site bh-site-type-php-102.test enabled." + + Scenario: site refresh regenerates the docker-compose file of a php site + Given a php site "bh-site-type-php-106.test" exists + And I append "# bh-compose-marker" to the file "/opt/easyengine/sites/bh-site-type-php-106.test/docker-compose.yml" + When I run "bin/ee site refresh bh-site-type-php-106.test" + Then the command should succeed + And the file "/opt/easyengine/sites/bh-site-type-php-106.test/docker-compose.yml" should not contain "# bh-compose-marker" + And the docker-compose file of site "bh-site-type-php-106.test" should use the pinned "easyengine/php8.4" image + And I wait until a GET request to "http://bh-site-type-php-106.test/" returns 200 within 60 seconds + + Scenario: site restart restarts nginx, php and the local db, not postfix + Given a php site "bh-site-type-php-103.test" created with "--with-db --local-db" exists + And I note the start time of the "nginx" service of site "bh-site-type-php-103.test" + And I note the start time of the "php" service of site "bh-site-type-php-103.test" + And I note the start time of the "db" service of site "bh-site-type-php-103.test" + And I note the start time of the "postfix" service of site "bh-site-type-php-103.test" + When I run "bin/ee site restart bh-site-type-php-103.test" + Then the command should succeed + And STDOUT should contain "Restarting nginx" + And STDOUT should contain "Restarting php" + And STDOUT should contain "Restarting db" + And the "nginx" service of site "bh-site-type-php-103.test" should have been restarted + And the "php" service of site "bh-site-type-php-103.test" should have been restarted + And the "db" service of site "bh-site-type-php-103.test" should have been restarted + And the "postfix" service of site "bh-site-type-php-103.test" should not have been restarted + And I wait until a GET request to "http://bh-site-type-php-103.test/" returns 200 within 60 seconds + + Scenario: site restart --php restarts only php + Given a php site "bh-site-type-php-103.test" created with "--with-db --local-db" exists + And I note the start time of the "nginx" service of site "bh-site-type-php-103.test" + And I note the start time of the "php" service of site "bh-site-type-php-103.test" + When I run "bin/ee site restart bh-site-type-php-103.test --php" + Then the command should succeed + And STDOUT should contain "Restarting php" + And STDOUT should not contain "Restarting nginx" + And the "php" service of site "bh-site-type-php-103.test" should have been restarted + And the "nginx" service of site "bh-site-type-php-103.test" should not have been restarted + + Scenario: site restart --db restarts the local db + Given a php site "bh-site-type-php-103.test" created with "--with-db --local-db" exists + And I note the start time of the "db" service of site "bh-site-type-php-103.test" + When I run "bin/ee site restart bh-site-type-php-103.test --db" + Then the command should succeed + And STDOUT should contain "Restarting db" + And the "db" service of site "bh-site-type-php-103.test" should have been restarted + + # New (suspected): run_compose_command() ignores the docker-compose exit code, so restarting a service the site + # does not have prints "no such service" and still exits 0. + @known-bug + Scenario: site restart --db on a php site without a local db fails + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site restart bh-site-type-php-100.test --db" + Then the command should fail + And STDERR should contain "no such service" + + Scenario: site reload reloads nginx and php-fpm without restarting containers + Given a php site "bh-site-type-php-100.test" exists + And I note the start time of the "nginx" service of site "bh-site-type-php-100.test" + And I note the start time of the "php" service of site "bh-site-type-php-100.test" + When I run "bin/ee site reload bh-site-type-php-100.test" + Then the command should succeed + And STDOUT should contain "Reloading nginx" + And STDOUT should contain "Reloading php" + And the "nginx" service of site "bh-site-type-php-100.test" should not have been restarted + And the "php" service of site "bh-site-type-php-100.test" should not have been restarted + And a GET request to "http://bh-site-type-php-100.test/" should return 200 + + Scenario: site reload --php and --nginx reload a single service + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site reload bh-site-type-php-100.test --php" + Then the command should succeed + And STDOUT should contain "Reloading php" + And STDOUT should not contain "Reloading nginx" + When I run "bin/ee site reload bh-site-type-php-100.test --nginx" + Then the command should succeed + And STDOUT should contain "Reloading nginx" + And STDOUT should not contain "Reloading php" + + Scenario: site reload of a disabled php site is refused + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site reload bh-site-type-php-102.test" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-102.test is not enabled. Use `ee site enable bh-site-type-php-102.test` to enable it." + + # update --proxy-cache restarts the global nginx-proxy container, so it briefly affects every site on the host. + @destructive + Scenario: Turn the proxy cache of a php site with an alias domain on and off + Given a php site "bh-site-type-php-104.test" created with "--alias-domains=bh-site-type-php-104-a.test" exists + When I run "bin/ee site update bh-site-type-php-104.test --proxy-cache=on" + Then the command should succeed + And STDOUT should contain "Success: Proxy cache update on site bh-site-type-php-104.test completed." + And the site "bh-site-type-php-104.test" should have the field "proxy_cache" set to "on" + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-104.test.conf" should exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104.test_location" should exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104-a.test_location" should exist + And I wait until a GET request to "http://bh-site-type-php-104.test/" returns 200 within 90 seconds + When I run "bin/ee site update bh-site-type-php-104.test --proxy-cache=on" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-104.test already has proxy cache: on" + When I run "bin/ee site update bh-site-type-php-104.test --proxy-cache=off" + Then the command should succeed + And the site "bh-site-type-php-104.test" should have the field "proxy_cache" set to "off" + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-104.test.conf" should not exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104.test_location" should not exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104-a.test_location" should not exist + And I wait until a GET request to "http://bh-site-type-php-104.test/" returns 200 within 90 seconds + + Scenario: Delete a php site and everything it owns + Given a php site "bh-site-type-php-105.test" exists + When I run "bin/ee site delete bh-site-type-php-105.test --yes" + Then the command should succeed + And STDOUT should contain "Site bh-site-type-php-105.test deleted." + And STDERR should be empty + And The 'bh-site-type-php-105.test' db entry should be removed + And The 'bh-site-type-php-105.test' webroot should be removed + And Following containers of site 'bh-site-type-php-105.test' should be removed: + | container | + | nginx | + | php | + | postfix | + | mailhog | + And the site "bh-site-type-php-105.test" should have 0 docker volumes + And the docker network "bh-site-type-php-105.test" should not exist + And the hosts file should not have an entry for "bh-site-type-php-105.test" + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-105.test-redirect.conf" should not exist + + Scenario: Deleting a php site twice fails the second time + Given the site "bh-site-type-php-105.test" does not exist + When I run "bin/ee site delete bh-site-type-php-105.test --yes" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-105.test does not exist." From ba888fc2de44a24e0b081fc8a4fd2a0ee2f9de8e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:31:12 +0530 Subject: [PATCH 10/35] test(site-type-php): add behat coverage inventory Map every create flag, php subcommand and side effect to its scenarios, list the known and newly suspected bugs, tags, naming rules and runtime. --- features/COVERAGE.md | 196 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 features/COVERAGE.md diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 0000000..a94dda4 --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,196 @@ +# site-type-php Behat coverage + +Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, `src/Site_PHP_Docker.php`, `templates/`), plus the site-command subcommands as they behave on a php site (the php class supplies `dump_docker_compose_yml()`, so update/enable/refresh output is php-specific). Generic site-command internals (LE renewal, backup/restore, clone, share) belong to the site-command suite. + +## Inventory + +### `ee site create --type=php` (`PHP::create`) + +| Item | Covered by | +|---|---| +| `` required (usage on missing) | site.feature "php site create without a site name prints the php usage" | +| name normalisation: `http(s)://` stripped, trailing `/` stripped, lowercased | create.feature "The site name is normalised" | +| name without a dot gets `.test` | create.feature "A site name without a dot gets the .test TLD" | +| duplicate site name refused, first site untouched | create.feature "Creating the same site twice fails" | +| site name validation (SEC-1) | create.feature "A site name with shell syntax is rejected" `@known-bug @security` | +| unknown flag refused | create.feature "An unknown flag is rejected" | +| unknown `--type` refused | site.feature "An unknown site type is rejected" | +| `--cache` (global redis) | cache.feature scenarios 1-4 (DB flags, main.conf, networks, HIT from redis, BYPASS on query string) | +| `--with-local-redis` (with `--cache`) | cache.feature "--cache --with-local-redis", "Pages of a local-redis site", "Local redis and global db", "Local db and local redis" | +| `--with-local-redis` without `--cache` (ignored) | cache.feature "--with-local-redis without --cache enables no cache" | +| local redis restart policy (MIG-16) | cache.feature `@known-bug` | +| `--admin-email` (and default `admin@`) | create.feature "--admin-email is stored and shown", "The site row holds the php defaults" | +| `--with-db` global db (DB/user created, creds stored, php connects) | database.feature scenarios 1-2 | +| `--dbname` / `--dbuser` / `--dbpass` on global db | database.feature "--dbname, --dbuser and --dbpass are used for the global db" | +| `--dbhost=global-db` | database.feature "--dbhost=global-db is the same as the default global db" | +| `--local-db` (db container, env, volumes, root password, php connects) | database.feature "--local-db runs a MariaDB container", "A local-db php site has its db service" | +| `--local-db` without `--with-db` (ignored) | database.feature "--local-db without --with-db creates no database at all" | +| remote `--dbhost=host:port` with credentials | database.feature "A remote --dbhost with host:port and existing credentials" (uses `global-db:3306` as the "remote") | +| remote `--dbhost` needs `--dbuser` and `--dbpass` | database.feature "A remote --dbhost needs --dbuser and --dbpass" | +| `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (passes vacuously today, see N3) | +| remote DB verification / DB auto-create / non-empty DB check | database.feature "An unreachable remote db host fails the create" `@known-bug` (N3); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | +| `--force` (reset non-empty remote DB) | not covered: dead code today (N3), needs a reachable remote MariaDB | +| DB name / user collisions on rollback | database.feature 3 `@known-bug` scenarios (N4, N5, N6) | +| DB-1 (mysql CLI in php images vs MariaDB TLS) | database.feature 2 `@known-bug` scenarios | +| `--php=` every supported version + `latest` | php-version.feature outline (12 examples, `@slow`) | +| `--php` default 8.4 | create.feature "The site row holds the php defaults", "The compose file ..." | +| `--php` invalid values | create.feature outline "An unsupported --php value is rejected" | +| `--php` loosely matching values (`8`, `7`, `8.40`) | create.feature outline `@known-bug` (N1) | +| `--php=` without `--type` (EE3 compat mapping) | create.feature `@known-bug` (N2) | +| EE3 `--mysql` compat flag | create.feature "The legacy --mysql flag creates a php site with a database" | +| php 5.6 ini (sendmail) vs msmtp | php-version.feature outline `sendmail` column | +| `--alias-domains` plain + wildcard (DB, compose VIRTUAL_HOST/CERT_NAME, main.conf server_name, HTTP) | alias-domains.feature scenarios 1-3 | +| alias owned by another site / site name that is an alias | alias-domains.feature scenarios 4-5 | +| alias trimming | alias-domains.feature "Spaces around alias domains are trimmed" | +| alias that is an existing site | alias-domains.feature `@known-bug` (N7) | +| blank alias entries | alias-domains.feature `@known-bug` (fixed in site-type-php #114) | +| invalid alias names (AUTH-1) | alias-domains.feature outline `@known-bug` (3 examples) | +| `--public-dir` (relative, full path, nested, traversal sanitised, with cache, outside files not served) | public-dir.feature | +| bare `--public-dir` | public-dir.feature `@known-bug` (N8) | +| `--skip-status-check` | create.feature "--admin-email is stored and shown, --skip-status-check skips the HTTP probe" | +| `--ssl=self` (+ wildcard flag stored, HTTPS, HTTP->HTTPS redirect, cert SANs) | ssl.feature scenarios 1-2 | +| `--ssl=custom --ssl-key --ssl-crt` | ssl.feature "Create a php site with a custom certificate" | +| custom SSL argument errors (missing key/crt, missing file) | ssl.feature 2 scenarios | +| `--ssl=inherit` (no parent, parent with wildcard, with `--wildcard`) | ssl.feature 3 scenarios | +| `--ssl=` | ssl.feature "--ssl with an unknown value fails and rolls back" | +| `--ssl=le` | ssl.feature `@le` (create, verified HTTPS, issuer, delete + revoke) | +| `--wildcard` with `--ssl=le` | not covered: needs Cloudflare DNS-01 credentials (not configured here) | +| rollback (`catch_clean`) leaves no site row, dir or volumes | asserted in every failing-create scenario (site/dir/volumes absent) | +| www redirect (non-www and www sites), no redirect without resolvable www | redirect.feature | + +### Generated files, containers and side effects of create + +| Item | Covered by | +|---|---| +| sites row (type, sub type, php version, paths, alias list, cache flags, SSL fields, DB fields) | create.feature "The site row holds the php defaults", database/cache/ssl features | +| `docker-compose.yml` (pinned images from img-versions.json, services, env, php network alias, no db/redis unless asked, nohttps on http sites) | create.feature "The compose file has the php services and no db or redis", cache/database/php-version features | +| `.env`, `main.conf` (upstream, root, server_name, php vs redis blocks), `custom.ini`, `user.conf`, `admin-tools.conf`, `index.php`, `app/html` removed, `app` symlinked to the htdocs volume, `check.html` removed | create.feature "The site files are generated from the php templates" | +| running containers (php, nginx, postfix; mailhog not started; + db/redis), labels, restart policy, networks | create.feature "The site containers, volumes and network are up", labels.feature, database/cache features | +| docker volumes (8, or 11 with local db) and network labels | create.feature, labels.feature, database.feature | +| volume prefix collision between names differing only in `.`/`-` | create.feature `@known-bug` (N13) | +| `/etc/hosts` entry (http and self-signed only) | create.feature, ssl.feature, alias-domains.feature | +| HTTP: 200 + PHP executes, pretty URLs, 404, hidden/backup files denied, fpm ping | create.feature "The site is reachable over HTTP and runs PHP", "nginx denies hidden and backup files", "The php-fpm status and ping endpoints are wired" | + +### `ee site info [] [--format=table|json]` (`PHP::info`) + +| Item | Covered by | +|---|---| +| table rows: Site, Site Root, DB Host/Name/User/Password, DB Root Password (local db only), Alias Domains, E-Mail, SSL, SSL Wildcard, Cache | lifecycle.feature 3 info scenarios, create/database/ssl/cache/alias create outputs | +| `--format=json` | lifecycle.feature "site info --format=json dumps the site row" (+ db site) | +| site auto-detected from the current directory | lifecycle.feature "site info works from inside the site directory" | +| disabled site | lifecycle.feature "site info still works for a disabled php site" | +| missing / unknown site | site.feature "Check site info sub command is present", lifecycle.feature "site info of an unknown site fails" | +| "Access admin-tools" row | not covered: admin-tools-command's suite owns enabling admin tools | + +### `ee site restart [] [--all] [--nginx] [--php] [--db]` (`PHP::restart`) + +| Item | Covered by | +|---|---| +| no flag: nginx + php + local db restarted, postfix not | lifecycle.feature "site restart restarts nginx, php and the local db, not postfix" | +| `--php` only | lifecycle.feature "site restart --php restarts only php" | +| `--db` with local db | lifecycle.feature "site restart --db restarts the local db" | +| `--db` without local db | lifecycle.feature `@known-bug` (N12) | +| `--all`, `--nginx` alone | not covered separately (same code path as no flag / `--php`) | + +### `ee site reload [] [--all] [--nginx] [--php]` (`PHP::reload`) + +| Item | Covered by | +|---|---| +| no flag: nginx + php-fpm (USR2) reloaded, containers not restarted | lifecycle.feature "site reload reloads nginx and php-fpm without restarting containers" | +| `--php`, `--nginx` | lifecycle.feature "site reload --php and --nginx reload a single service" | +| disabled site refused | lifecycle.feature "site reload of a disabled php site is refused" | +| missing site | site.feature "Check site reload sub command is present" | +| `--all` | not covered separately (same path as no flag) | + +### site-command subcommands on a php site + +| Item | Covered by | +|---|---| +| `update --php` (compose image, container version, backup dir, custom.ini kept, 5.6 <-> msmtp/sendmail) | php-version.feature 3 update scenarios | +| `update --php` same version / invalid / `latest` / disabled / unknown site | php-version.feature 4 scenarios | +| `update --add-alias-domains` / `--delete-alias-domains` (+ all error paths, wildcard) | alias-domains.feature 9 update scenarios | +| `update --ssl=self` / `--ssl=off` / already enabled / already disabled / `--ssl=off --wildcard` | ssl.feature 2 scenarios | +| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature `@known-bug` (N11) | +| `update --ssl=le`, `--wildcard` | not covered (LE budget; wildcard needs DNS-01) | +| `update --proxy-cache=on/off` (conf.d, vhost.d incl. alias, already-on error) | lifecycle.feature `@destructive` (restarts nginx-proxy) | +| `--proxy-cache-max-size/-max-time/-key-zone-size` | not covered: site-command generic | +| `enable` (containers, post-enable, already enabled, `--force`) | lifecycle.feature 2 scenarios | +| `enable --refresh` / `refresh` | lifecycle.feature "site refresh regenerates the docker-compose file" | +| `enable --verify` | not covered: site-command generic | +| `disable` (containers removed, volumes/files kept, HTTP down, idempotent) | lifecycle.feature 2 scenarios | +| `list` (`--format=json/text`, `--enabled`, `--disabled`) | lifecycle.feature 2 scenarios | +| `delete` (row, dir, containers, volumes, network, hosts, redirect conf; global DB + user dropped; local db volumes) | lifecycle.feature "Delete a php site and everything it owns", database.feature 2 delete scenarios, redirect.feature | +| `delete` twice / unknown site | lifecycle.feature "Deleting a php site twice fails the second time" | +| `delete` of an LE site revokes and removes certs | ssl.feature `@le` | +| `clean` without cache / `--proxy` without proxy cache | cache.feature "ee site clean on a site without cache" | +| `clean --page` on a php cache site | cache.feature `@known-bug` (N9, CACHE-1) | +| `clean` on a local-redis site | cache.feature `@known-bug` (N10) | +| `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares N9's path; `--proxy` runs `docker exec -it` (CACHE-1) | +| `ssl-verify`, `ssl-info`, `ssl-renew`, `share`, `clone`, `sync`, `backup`, `restore` | not covered here: generic, owned by the site-command suite (no php-specific code) | +| `site_cleanup` hook on delete | indirectly (delete scenarios); hook consumers are tested in their packages | + +Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). + +## Known bugs exercised (`@known-bug`, 23 scenarios) + +Existing issues.md IDs: SEC-1, AUTH-1 (+ blank aliases, both fixed in the unmerged site-type-php #114), DB-1 (2), MIG-16, CACHE-1. + +New suspects found while writing this suite (for issues.md): + +| ID | Location | Issue | +|---|---|---| +| N1 | core `Dispatcher/Subcommand.php` option check + `PHP::create` | `--php` values are matched with loose `in_array()`, so `8`, `7`, `8.40` pass and the compose file references a non-existent `easyengine/php8` image; the create fails late (after containers/volumes were made) instead of up front. | +| N2 | site-command `Site_Command::convert_old_args_to_new_args()` | Without `--type`, `ee site create x --php=8.3` is taken as the EE3 `--php` type flag: the version is dropped and PHP 8.4 is used. | +| N3 | `PHP::create_site()` ~L812 | `maybe_verify_remote_db_connection()` is only called for `--local-db`, where it returns at once, so remote DB credentials are never checked, the remote DB is never created, and `--skip-check`/`--force` do nothing (site-type-wp calls it for every create). | +| N4 | `PHP::create()` + `catch_clean()` + site-command `cleanup_db()` | Data loss: `--dbname` of an existing database makes `create_user_in_db()` fail, and the rollback then DROPs that (other site's) database. | +| N5 | same, `cleanup_db_user()` | `--dbuser` of an existing user: the rollback DROPs the other site's DB user. | +| N6 | `PHP::create()` default db name | The default db name maps `.` and `-` to `_`, so `a-b.test` and `a.b.test` collide and the second create destroys the first site's DB (N4). | +| N7 | `PHP::create()` / site-command `check_alias_in_db()` | Create accepts an existing site's name as an alias (only other sites' aliases are checked), so two sites claim the same VIRTUAL_HOST. | +| N8 | `[--public-dir]` declared as a flag + site-command `get_public_dir()` | A bare `--public-dir` becomes `true`, i.e. the document root `/var/www/htdocs/1`. | +| N9 | `templates/config/nginx/main.conf.mustache` vs site-command `clean()` | php sites store pages as `:http…`, but `ee site clean` purges `_page*` (the WP key format), so the php page cache is never purged while Success is printed. | +| N10 | site-command `clean_site_cache()` | Always purges in the global redis container, never in a `--with-local-redis` site's redis. | +| N11 | site-command `update_ssl()` | A site created with `--ssl=self` stores `site_ssl_wildcard=1`, so `update --ssl=off` fails ("wildcard to normal SSL is not supported") and `--ssl=off --wildcard` fails too: SSL can't be turned off. (`update --ssl=self` stores 0, so the two paths are inconsistent.) | +| N12 | site-command `run_compose_command()` | Ignores the docker-compose exit code: `ee site restart --db` on a site without a local db prints "no such service" and exits 0. | +| N13 | core `EE_DOCKER::get_docker_style_prefix()` + `create_volumes()` | Volume names drop `.` and `-`, so `a-b.test` and `ab.test` share volume names; `create_volumes()` skips existing volumes and the second site mounts the first site's htdocs/config. | + +Suspected but not tested (no clean black-box assertion, or out of scope): + +- `PHP.php` renders `.env` and `php.ini` from `SITE_WP_TEMPLATE_ROOT`, so php sites hard-depend on site-type-wp being installed. +- site-command `check_www_or_non_www_domain()` uses `ltrim( $site_url, 'www.' )` (a character list), so `www.web.test` checks `eb.test`; `add_site_redirects()` has the same `ltrim( $site_url, '.www' )` pattern. +- site-command `check_parent_site_certs()` puts `var_dump( $parent_site )` in an exception message (dumps the object to stdout). +- `--ssl` is declared as a flag, so its `options:` list is never enforced; an unknown value is only rejected in `init_ssl()` after the whole site was built (then rolled back). +- custom and inherit SSL sites on local names get no `/etc/hosts` entry, so the create's status check fails unless the name already resolves (the suite maps the names itself). +- `--php=latest` maps to the php8.4 image, not the newest supported (8.5); may be intended. +- `update --add/--delete-alias-domains` doesn't regenerate `main.conf`, so `server_name` goes stale (harmless: one server block). +- A site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. +- The old suite's label assertion was inverted and its mysql checks passed vacuously (DB-1); both fixed in this context. + +## Tags + +- `@known-bug` (23): expected to fail until fixed; exclude with `--tags='~@known-bug'`. +- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` and deletes the site in the same scenario. +- `@destructive` (1): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring). +- `@slow` (12): the PHP version matrix (one site per version). +- `@security` (1): the SEC-1 payload (benign: `touch ./bhstpsec1`, removed after the scenario). +- `@remote`: none (no backups in this package). + +## Environment rules followed + +- Sites: `bh-site-type-php-.test` (and `sub.`/`sub2.` children, `--x` variants); aliases `bh-site-type-php--.test`; global-db names `bh_stp_*` or the derived `bh_site_type_php_*`. `FeatureContext::cleanupTestState()` runs before the suite, after each feature and after the suite, and only touches these names: sites (children first), orphaned containers/networks/volumes/site dirs/proxy files, `bh_stp_*` databases and users, and `/etc/hosts` lines with `bh-site-type-php-` or the `# behat:site-type-php` marker. +- Commands run with stdin on `/dev/null`, so docker/compose never allocate a TTY (no CACHE-1 hangs; `docker exec -it` fails fast instead). +- HTTP checks for aliases/extra names pin the host to 127.0.0.1 with curl's `--resolve` (keeps SNI for HTTPS); 127.0.0.1 arrives at nginx-proxy as 10.1.0.1 and bypasses HTTP auth. Nothing here needs the public IP. +- Temporary files (custom certs) live in `/var/tmp/bh-site-type-php`, removed after the suite. + +## Site number registry + +1-9 create (8-1/81 volume collision), 10-26 database (22.x/22-x collision), 30-38 cache, 40-55 php versions, 60-65 public dir, 70-79 alias domains, 80-89 ssl (85 + sub/sub2 children), 90-93 labels/redirect, 100-106 lifecycle, 197-199 never created. LE: `bh-site-type-php-1.ee4-test.rt.gw`. + +## Runtime estimate + +About 80 site creations (about 35 s each), plus deletes, updates and restarts: roughly 70-80 minutes for the whole suite, about 60 minutes with `--tags='~@slow'`, and about 55 minutes with `--tags='~@slow&&~@le&&~@known-bug'`. + +## Validation done + +- `vendor/bin/behat --dry-run --strict` in a private core copy on `upstream/develop`: no undefined or ambiguous steps (158 scenarios, 1061 steps). Behat 3.8 exits 1 on `--dry-run --strict` because every step is "skipped"; the upstream suite does the same. +- Copy the features with `cp -r`, not `cp -a`: Behat's Gherkin cache in `/tmp/behat_gherkin_cache` is keyed on mtime, so preserving older mtimes serves stale parses. +- `php -l` of `features/bootstrap/*.php` on php:7.4-cli and php:8.5-cli. From ca3959bcf2e546c599a42b8aa3400277480296de Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:34:32 +0530 Subject: [PATCH 11/35] test(site-type-php): harden cleanup against dropping foreign databases The cleanup no longer drops a database or user still referenced by any site row, removes a hosts line only when every name on it is the suite's, clears the suite's redis page-cache keys and removes the SEC-1 site dir. The suite refuses to start next to a foreign site whose volume prefix or db names could collide, and the db setup steps only accept suite names. The --dbuser data-loss scenario gets its own site (the --dbname one drops site 20's database), and the SEC-1 payload no longer uses ${IFS}, which the lowercased name turns into an empty variable. --- features/bootstrap/FeatureContext.php | 82 ++++++++++++++++++++++++--- features/create.feature | 6 +- features/database.feature | 9 +-- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 1730468..3ddbc33 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -490,6 +490,20 @@ public function cleanupScenario(AfterScenarioScope $scope) */ public static function prepareSuite( BeforeSuiteScope $scope ) { + // Volume prefixes drop "." and "-" and default db names map them to "_" (PHPT-1..4), and the create rollback drops + // colliding databases and users. Refuse to run next to a foreign site whose volumes, db or db user could collide. + foreach ( self::sqliteRows( 'SELECT site_url, db_name, db_user FROM sites' ) as $row ) { + if ( 1 === preg_match( self::SITE_PATTERN, $row['site_url'] ) ) { + continue; + } + $collides = 0 === strpos( str_replace( [ '.', '-' ], '', $row['site_url'] ), 'bhsitetypephp' ) + || 0 === strpos( str_replace( [ '.', '-' ], '_', $row['site_url'] ), 'bh_site_type_php_' ) + || 1 === preg_match( '/^(bh_stp_|bh_site_type_php_)/', (string) $row['db_name'] ) + || 1 === preg_match( '/^(bh_stp_|bh-site-type-php-)/', (string) $row['db_user'] ); + if ( $collides ) { + throw new Exception( "Refusing to run: the volumes or database of site {$row['site_url']} could collide with this suite's names." ); + } + } self::cleanupTestState(); if ( ! is_dir( self::TMP_DIR ) ) { mkdir( self::TMP_DIR, 0700, true ); @@ -1063,6 +1077,7 @@ public function theGlobalDatabaseUserShouldExist( $user, $not ) */ public function theGlobalDatabaseWithUserExists( $db, $user, $password ) { + self::assertSuiteDbNames( $db, $user ); $sql = sprintf( "CREATE DATABASE IF NOT EXISTS `%1\$s`; CREATE USER IF NOT EXISTS '%2\$s'@'%%' IDENTIFIED BY '%3\$s'; GRANT ALL PRIVILEGES ON `%1\$s`.* TO '%2\$s'@'%%'; FLUSH PRIVILEGES;", str_replace( '`', '', $db ), @@ -1077,6 +1092,7 @@ public function theGlobalDatabaseWithUserExists( $db, $user, $password ) */ public function theGlobalDatabaseUserExists( $user, $password ) { + self::assertSuiteDbNames( null, $user ); self::globalDbQuery( sprintf( "CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'; FLUSH PRIVILEGES;", addslashes( $user ), addslashes( $password ) ), true ); } @@ -1085,6 +1101,7 @@ public function theGlobalDatabaseUserExists( $user, $password ) */ public function theGlobalDatabaseContainsTheTable( $db, $table ) { + self::assertSuiteDbNames( $db, null ); self::globalDbQuery( sprintf( 'CREATE TABLE IF NOT EXISTS `%s`.`%s` (id INT PRIMARY KEY)', str_replace( '`', '', $db ), str_replace( '`', '', $table ) ), true ); } @@ -1677,6 +1694,19 @@ private function assertKeys( $where, $result, $pattern, $negate ) } } + /** + * Setup steps write to global-db as root: keep them on the suite's own names. + */ + private static function assertSuiteDbNames( $db, $user ) + { + if ( null !== $db && 1 !== preg_match( '/^(bh_stp_|bh_site_type_php_)[A-Za-z0-9_]+$/', $db ) ) { + throw new Exception( "Refusing to touch the database $db: not a name of this suite." ); + } + if ( null !== $user && 1 !== preg_match( '/^(bh_stp_[A-Za-z0-9_]+|bh-site-type-php-[A-Za-z0-9.-]+)$/', $user ) ) { + throw new Exception( "Refusing to touch the database user $user: not a name of this suite." ); + } + } + private static function globalDbQuery( $sql, $throw = false ) { $script = 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e ' . escapeshellarg( $sql ); @@ -1829,7 +1859,8 @@ private static function cleanupTestState() // Site roots and proxy files left by failed creates. foreach ( (array) glob( EE_SITE_ROOT . '/*' ) as $dir ) { $name = basename( $dir ); - if ( 1 === preg_match( '/^[a-z0-9.-]+$/', $name ) && $orphan( $name ) ) { + // Any name, so a dir left by the SEC-1 scenario's `$(...)` site name goes too. + if ( $orphan( $name ) ) { self::run( 'rm -rf -- ' . escapeshellarg( $dir ) ); } } @@ -1848,8 +1879,12 @@ private static function cleanupTestState() self::run( 'docker exec ' . self::PROXY_CONTAINER . ' sh -c ' . escapeshellarg( 'nginx -t && nginx -s reload' ) ); } - // Global-db databases and users created by the suite. + // Global-db databases and users created by the suite. Names still referenced by any remaining site row (another + // site, or a suite site whose delete failed) are never dropped, whatever their prefix. if ( 0 === self::run( 'docker inspect ' . self::GLOBAL_DB_CONTAINER )->return_code ) { + $in_use = self::sqliteRows( "SELECT db_name, db_user FROM sites WHERE db_name != '' OR db_user != ''" ); + $used_dbs = array_filter( array_column( $in_use, 'db_name' ), 'strlen' ); + $used_users = array_filter( array_column( $in_use, 'db_user' ), 'strlen' ); $where_db = implode( ' OR ', array_map( function ( $like ) { return "schema_name LIKE '$like'"; }, self::DB_NAME_LIKE ) ); @@ -1857,19 +1892,52 @@ private static function cleanupTestState() return "user LIKE '$like'"; }, self::DB_USER_LIKE ) ); foreach ( array_filter( explode( "\n", self::globalDbQuery( "SELECT schema_name FROM information_schema.schemata WHERE $where_db" )->stdout ) ) as $db ) { - self::globalDbQuery( sprintf( 'DROP DATABASE IF EXISTS `%s`', trim( $db ) ) ); + $db = trim( $db ); + if ( '' !== $db && ! in_array( $db, $used_dbs, true ) && false === strpos( $db, '`' ) ) { + self::globalDbQuery( sprintf( 'DROP DATABASE IF EXISTS `%s`', $db ) ); + } } foreach ( array_filter( explode( "\n", self::globalDbQuery( "SELECT CONCAT(user, '@', host) FROM mysql.user WHERE $where_user" )->stdout ) ) as $user_host ) { - list( $user, $host ) = explode( '@', trim( $user_host ), 2 ); - self::globalDbQuery( sprintf( "DROP USER IF EXISTS '%s'@'%s'", addslashes( $user ), addslashes( $host ) ) ); + list( $user, $host ) = array_pad( explode( '@', trim( $user_host ), 2 ), 2, '' ); + if ( '' !== $user && ! in_array( $user, $used_users, true ) ) { + self::globalDbQuery( sprintf( "DROP USER IF EXISTS '%s'@'%s'", addslashes( $user ), addslashes( $host ) ) ); + } + } + } + + // Page-cache keys of the suite's sites live for 4 hours and would let a later run's cache checks pass on old keys. + if ( 0 === self::run( 'docker inspect ' . self::GLOBAL_REDIS_CONTAINER )->return_code ) { + foreach ( [ 'bh-site-type-php-*', 'behat-bh-site-type-php-*' ] as $pattern ) { + $script = sprintf( 'redis-cli --scan --pattern %s | while IFS= read -r k; do redis-cli del "$k" >/dev/null; done', escapeshellarg( $pattern ) ); + self::run( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( $script ) ) ); } } - // Hosts entries for this suite's names (ee's own and the ones added by `the hosts file maps ...`). + // The SEC-1 payload can run again when that site is deleted above. + if ( file_exists( EE_ROOT . '/bhstpsec1' ) ) { + unlink( EE_ROOT . '/bhstpsec1' ); + } + + // Hosts entries for this suite's names (ee's own and the ones added by `the hosts file maps ...`). A line is only + // dropped when every host name on it belongs to the suite. if ( is_writable( '/etc/hosts' ) ) { $hosts = file_get_contents( '/etc/hosts' ); $kept = array_filter( explode( "\n", $hosts ), function ( $line ) { - return false === strpos( $line, self::HOSTS_MARKER ) && false === strpos( $line, 'bh-site-type-php-' ); + if ( false !== strpos( $line, self::HOSTS_MARKER ) ) { + return false; + } + $fields = preg_split( '/\s+/', trim( preg_replace( '/#.*/', '', $line ) ) ); + $names = array_slice( $fields, 1 ); + if ( empty( $names ) ) { + return true; + } + foreach ( $names as $name ) { + if ( 1 !== preg_match( self::SITE_PATTERN, $name ) ) { + return true; + } + } + + return false; } ); $new = implode( "\n", $kept ); if ( rtrim( $new, "\n" ) !== rtrim( $hosts, "\n" ) ) { diff --git a/features/create.feature b/features/create.feature index 2074afa..715819b 100644 --- a/features/create.feature +++ b/features/create.feature @@ -191,14 +191,14 @@ Feature: Create a plain php site And the site "bh-site-type-php-6.test" should have the field "site_type" set to "php" And the site "bh-site-type-php-6.test" should have the field "php_version" set to "8.3" - # SEC-1: the site name reaches shell sinks unescaped. Benign payload: touches ./bhstpsec1 in the working directory. + # SEC-1: the site name reaches shell sinks unescaped (volume commands run from the core root). Benign payload that + # survives the lowercasing: writes ./bhstpsec1 there, which the scenario cleanup removes. @known-bug @security Scenario: A site name with shell syntax is rejected and never executed - When I run "bin/ee site create 'bh-site-type-php-5$(touch${IFS}bhstpsec1).test' --type=php" + When I run "bin/ee site create 'bh-site-type-php-5$(id>bhstpsec1).test' --type=php" Then the command should fail And STDERR should contain "Invalid site name" And the file "bhstpsec1" should not exist - And the file "/opt/easyengine/sites/bh-site-type-php-5$(touch${IFS}bhstpsec1).test/bhstpsec1" should not exist # New (suspected), core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to # the prefix bhsitetypephp81test. create_volumes() skips volumes that already exist, and the second site mounts the diff --git a/features/database.feature b/features/database.feature index cded8db..052a06e 100644 --- a/features/database.feature +++ b/features/database.feature @@ -153,14 +153,15 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database # New (suspected), data loss: same rollback path with --dbuser; cleanup_db_user() drops the other site's user. + # Own site 23 (not 20): the --dbname scenario above may already have dropped site 20's database. @known-bug Scenario: Reusing another site's --dbuser fails without dropping that user - Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists - When I run "bin/ee site create bh-site-type-php-24.test --type=php --with-db --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Other-24" + Given a php site "bh-site-type-php-23.test" created with "--with-db --dbname=bh_stp_db23 --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Pass-23" exists + When I run "bin/ee site create bh-site-type-php-24.test --type=php --with-db --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Other-24" Then the command should fail And the site "bh-site-type-php-24.test" should not exist - And the global database user "bh_stp_user20" should exist - And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database + And the global database user "bh_stp_user23" should exist + And the php service of site "bh-site-type-php-23.test" should be able to connect to the site database # New (suspected), data loss: the default db name replaces "." and "-" with "_", so these two names collide, # and the failed second create drops the first site's database. From 8695fe0270e1b41ab1cb232b03ef20fdb19ce7e0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:37:44 +0530 Subject: [PATCH 12/35] test(site-type-php): fix wrong and vacuous assertions site info from the site dir runs from the site root (app is a symlink into the volume and ee reads the physical pwd). --ssl=off --wildcard now asserts its own error on a wildcard site, with a separate normal-to-wildcard scenario. Failing creates that really roll back assert the dir, volumes, containers and network are gone. The redirect delete check first asserts the conf existed, the global/local redis combo scenarios check the site's real page cache instead of a set/get on redis, the list status is checked for the site itself, and 8.4.1 moves to the loose --php known bug (it passes validation on PHP 7). The bare --public-dir known bug accepts either fix. HTTP checks after a restart, reload or cert change poll, and the DB connect step waits for a fresh MariaDB. The blank and space alias known bugs are tagged @destructive (they break the nginx-proxy config until rolled back), the LE scenario checks le-mail is configured, the fpm and nginx status pages are covered, and every known bug comment carries its PHPT ID. --- features/alias-domains.feature | 13 +++++++-- features/bootstrap/FeatureContext.php | 41 +++++++++++++++++++++++++-- features/cache.feature | 16 ++++++++--- features/create.feature | 15 ++++++---- features/database.feature | 13 +++++---- features/lifecycle.feature | 14 ++++----- features/php-version.feature | 6 ++-- features/public-dir.feature | 8 +++--- features/redirect.feature | 1 + features/site.feature | 10 +++++-- features/ssl.feature | 26 ++++++++++++++--- 11 files changed, 121 insertions(+), 42 deletions(-) diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 01ccfdf..4791f58 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -43,7 +43,7 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And the site "bh-site-type-php-78.test" should have the field "alias_domains" set to "bh-site-type-php-78.test,bh-site-type-php-78-a.test,bh-site-type-php-78-b.test" And a GET request to "http://bh-site-type-php-78-b.test/" via "127.0.0.1" should return 200 - # New (suspected): create only checks aliases against other sites' aliases (check_alias_in_db), not against existing + # PHPT-7: create only checks aliases against other sites' aliases (check_alias_in_db), not against existing # site names, so two sites end up claiming the same VIRTUAL_HOST. `site update --add-alias-domains` does check this. @known-bug Scenario: An alias domain that is an existing site is refused on create @@ -53,8 +53,10 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And STDERR should contain "bh-site-type-php-74.test already exists as a site" And the site "bh-site-type-php-73.test" should not exist - # Blank entries are stored as-is on develop; fixed in site-type-php #114 (c7d7bc4, not merged). - @known-bug + # Blank entries are stored as-is on develop; fixed in site-type-php #114 (c7d7bc4, not merged). Until then the empty + # VIRTUAL_HOST entry renders an invalid nginx-proxy config (`upstream {`), so proxy reloads for every site fail until + # the status check rolls the site back (~2 minutes): hence @destructive. + @known-bug @destructive Scenario: Empty entries in --alias-domains are dropped When I run "bin/ee site create bh-site-type-php-75.test --type=php --alias-domains='bh-site-type-php-75-a.test,,bh-site-type-php-75-b.test,'" Then the command should succeed @@ -74,6 +76,11 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele | site | alias | | bh-site-type-php-76.test | _wildcard.bh-site-type-php-76.test | | bh-site-type-php-76-1.test | -bh-site-type-php-76-a.test | + + # A space in VIRTUAL_HOST breaks the nginx-proxy config the same way as the blank entries above. + @destructive + Examples: + | site | alias | | bh-site-type-php-76-2.test | bh-site-type-php-76 a.test | Scenario: Add an alias domain to a php site diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 3ddbc33..c208c05 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -681,6 +681,20 @@ public function theJsonListShouldContainItem( $key, $value ) throw new Exception( "No item with $key=$value in:\n" . $this->lastCommand()->stdout ); } + /** + * @Then the JSON list in STDOUT should contain an item with :key set to :value and :key2 set to :value2 + */ + public function theJsonListShouldContainItemWithBoth( $key, $value, $key2, $value2 ) + { + foreach ( (array) $this->decodedStdout() as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && array_key_exists( $key2, $item ) + && $this->scalarToString( $item[ $key ] ) === $value && $this->scalarToString( $item[ $key2 ] ) === $value2 ) { + return; + } + } + throw new Exception( "No item with $key=$value and $key2=$value2 in:\n" . $this->lastCommand()->stdout ); + } + /** * @Then the JSON list in STDOUT should not contain an item with :key set to :value */ @@ -1040,9 +1054,17 @@ public function thePhpServiceShouldConnectToTheSiteDatabase( $site, $not ) var_export( (string) $row['db_name'], true ), empty( $row['db_port'] ) ? 3306 : (int) $row['db_port'] ); - $id = $this->requireServiceContainer( $site, 'php' ); - $result = self::run( sprintf( 'printf %%s %s | docker exec -i %s php', escapeshellarg( $code ), $id ) ); - $ok = 'OK' === trim( $result->stdout ); + $id = $this->requireServiceContainer( $site, 'php' ); + $deadline = time() + ( '' === $not ? 60 : 0 ); + // A fresh local MariaDB refuses connections until its first-start init is done. + do { + $result = self::run( sprintf( 'printf %%s %s | docker exec -i %s php', escapeshellarg( $code ), $id ) ); + $ok = 'OK' === trim( $result->stdout ); + if ( $ok || time() >= $deadline ) { + break; + } + sleep( 2 ); + } while ( true ); if ( $ok === ( '' !== $not ) ) { throw new Exception( sprintf( "The php service of %s %s connect to %s@%s/%s.\n%s", $site, $ok ? 'could' : 'could not', $row['db_user'], $row['db_host'], $row['db_name'], $this->describe( $result ) ) ); } @@ -1375,6 +1397,19 @@ public function theDomainResolvesPublicly( $domain ) } } + /** + * Commands run with stdin on /dev/null, so a value ee would prompt for (e.g. le-mail) must already be configured. + * + * @Given the ee config has a :key value + */ + public function theEeConfigHasAValue( $key ) + { + $config = $this->readFile( EE_ROOT_DIR . '/config/config.yml' ); + if ( 1 !== preg_match( '/^' . preg_quote( $key, '/' ) . ':[ \t]*\S/m', $config ) ) { + throw new Exception( "Set $key in " . EE_ROOT_DIR . '/config/config.yml first (ee would prompt for it).' ); + } + } + /** * Creates the site only if it does not exist yet, so scenarios can run alone or reuse a site made earlier in the feature. * diff --git a/features/cache.feature b/features/cache.feature index 443d38b..6ba0c0a 100644 --- a/features/cache.feature +++ b/features/cache.feature @@ -84,7 +84,11 @@ Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site And the "db" service of site "bh-site-type-php-33.test" should be running And the "redis" service of site "bh-site-type-php-33.test" should not exist And the php service of site "bh-site-type-php-33.test" should be able to connect to the site database - And Check global redis cache for 'bh-site-type-php-33.test' + And the "nginx" service of site "bh-site-type-php-33.test" should be attached to the docker network "ee-global-backend-network" + When I send a GET request to "http://bh-site-type-php-33.test/" + And I send a GET request to "http://bh-site-type-php-33.test/" + Then the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the global redis should have keys matching "bh-site-type-php-33.test:*" Scenario: Local redis and global db When I run "bin/ee site create bh-site-type-php-37.test --cache --type=php --with-db --with-local-redis" @@ -93,7 +97,11 @@ Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site And the "redis" service of site "bh-site-type-php-37.test" should be running And the "db" service of site "bh-site-type-php-37.test" should not exist And the php service of site "bh-site-type-php-37.test" should be able to connect to the site database - And Check local redis cache for 'bh-site-type-php-37.test' + When I send a GET request to "http://bh-site-type-php-37.test/" + And I send a GET request to "http://bh-site-type-php-37.test/" + Then the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the redis service of site "bh-site-type-php-37.test" should have keys matching "bh-site-type-php-37.test:*" + And the global redis should not have keys matching "bh-site-type-php-37.test:*" Scenario: Local db and local redis When I run "bin/ee site create bh-site-type-php-38.test --cache --type=php --local-db --with-db --with-local-redis" @@ -113,7 +121,7 @@ Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site Then the return code should be 1 And STDERR should contain "Proxy cache is not enabled on site." - # New (suspected): clean purges "_page*" but php sites store pages under ":http...", so nothing is purged. + # PHPT-8: clean purges "_page*" but php sites store pages under ":http...", so nothing is purged. # CACHE-1: the purge also runs `docker exec -it`, which fails without a TTY (or hangs under nested ptys), hence the timeout. @known-bug Scenario: ee site clean --page purges the php page cache in the global redis @@ -126,7 +134,7 @@ Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site And STDOUT should contain "Success: Page cache cleared for bh-site-type-php-35.test" And the global redis should not have keys matching "bh-site-type-php-35.test:*" - # New (suspected): clean_site_cache() always targets the global redis container, never the site's local redis. + # PHPT-9: clean_site_cache() always targets the global redis container, never the site's local redis. @known-bug Scenario: ee site clean purges the page cache of a local-redis site Given a php site "bh-site-type-php-36.test" created with "--cache --with-local-redis" exists diff --git a/features/create.feature b/features/create.feature index 715819b..5150de3 100644 --- a/features/create.feature +++ b/features/create.feature @@ -105,6 +105,10 @@ Feature: Create a plain php site Given a php site "bh-site-type-php-1.test" exists Then a GET request to "http://bh-site-type-php-1.test/ee-admin/ping" should return 200 And the response should contain "pong" + And a GET request to "http://bh-site-type-php-1.test/ee-admin/status" should return 200 + And the response should contain "pool:" + And a GET request to "http://bh-site-type-php-1.test/ee-admin/nginx_status" should return 200 + And the response should contain "Active connections" Scenario: Creating the same site twice fails and leaves the first one intact Given a php site "bh-site-type-php-1.test" exists @@ -128,7 +132,6 @@ Feature: Create a plain php site When I run "bin/ee site create bh-site-type-php-3 --type=php" Then the command should succeed And the site "bh-site-type-php-3.test" should exist - And the site "bh-site-type-php-3" should not exist Scenario: --admin-email is stored and shown, --skip-status-check skips the HTTP probe When I run "bin/ee site create bh-site-type-php-4.test --type=php --admin-email=ops@bh-site-type-php-4.test --skip-status-check" @@ -158,10 +161,9 @@ Feature: Create a plain php site | 7.1 | | 9.0 | | abc | - | 8.4.1 | - # New (suspected): the --php option check uses a loose in_array(), so "8" == "8.0" and "8.40" == 8.4 pass. - # The compose file then references a non-existent easyengine/php8 (or php8.40) image and the create fails late. + # PHPT-11: the --php option check uses a loose in_array(), so "8" == "8.0" and "8.40" == 8.4 pass (and "8.4.1" on + # PHP 7.x). The compose file then references a non-existent easyengine/php8 (or php8.40) image and the create fails late. @known-bug Scenario Outline: A --php value that only loosely matches a supported version is rejected up front When I run "bin/ee site create bh-site-type-php-9.test --type=php --php=" @@ -174,6 +176,7 @@ Feature: Create a plain php site | 8 | | 7 | | 8.40 | + | 8.4.1 | # EE3 compat: --mysql maps to --type=php --with-db (Site_Command::convert_old_args_to_new_args). Scenario: The legacy --mysql flag creates a php site with a database @@ -183,7 +186,7 @@ Feature: Create a plain php site And the site "bh-site-type-php-7.test" should have the field "app_sub_type" set to "mysql" And the site "bh-site-type-php-7.test" should have the field "db_host" set to "global-db" - # New (suspected): without --type, `--php=8.3` is consumed as the EE3 `--php` type flag and the version is dropped. + # PHPT-6: without --type, `--php=8.3` is consumed as the EE3 `--php` type flag and the version is dropped. @known-bug Scenario: --php= without --type keeps the requested version When I run "bin/ee site create bh-site-type-php-6.test --php=8.3" @@ -200,7 +203,7 @@ Feature: Create a plain php site And STDERR should contain "Invalid site name" And the file "bhstpsec1" should not exist - # New (suspected), core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to + # PHPT-4, core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to # the prefix bhsitetypephp81test. create_volumes() skips volumes that already exist, and the second site mounts the # first site's htdocs/config volumes. @known-bug diff --git a/features/database.feature b/features/database.feature index 052a06e..bf2ba90 100644 --- a/features/database.feature +++ b/features/database.feature @@ -120,6 +120,8 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam Then the return code should be 1 And STDERR should contain "`--dbuser` and `--dbpass` are required for remote db host." + # PHPT-5: nothing probes a remote db today, with or without --skip-check, so this only proves the create and the port + # parsing; the skip itself becomes testable once PHPT-5 is fixed. Scenario: --skip-check creates the site without probing the remote db, and the port is parsed When I run "bin/ee site create bh-site-type-php-18.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid:3307 --dbuser=bh_stp_u18 --dbpass=bh-stp-p18 --skip-check" Then the command should succeed @@ -130,7 +132,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the site "bh-site-type-php-18.test" should have an empty "db_root_password" And the "db" service of site "bh-site-type-php-18.test" should not exist - # New (suspected): PHP::create_site() calls maybe_verify_remote_db_connection() only for --local-db, where it returns at once, + # PHPT-5: PHP::create_site() calls maybe_verify_remote_db_connection() only for --local-db, where it returns at once, # so remote credentials are never checked and --skip-check/--force are dead options (site-type-wp calls it for every create). @known-bug Scenario: An unreachable remote db host fails the create and rolls back @@ -140,7 +142,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the site "bh-site-type-php-17.test" should not exist And the directory "/opt/easyengine/sites/bh-site-type-php-17.test" should not exist - # New (suspected), data loss: create_user_in_db() fails on the existing database, and catch_clean() then runs + # PHPT-1, data loss: create_user_in_db() fails on the existing database, and catch_clean() then runs # cleanup_db() with the requested --dbname, dropping the OTHER site's database. @known-bug Scenario: Reusing another site's --dbname fails without dropping that database @@ -152,7 +154,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the global database "bh_stp_shared20" should contain the table "bh_marker" And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database - # New (suspected), data loss: same rollback path with --dbuser; cleanup_db_user() drops the other site's user. + # PHPT-3, data loss: same rollback path with --dbuser; cleanup_db_user() drops the other site's user. # Own site 23 (not 20): the --dbname scenario above may already have dropped site 20's database. @known-bug Scenario: Reusing another site's --dbuser fails without dropping that user @@ -163,8 +165,9 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the global database user "bh_stp_user23" should exist And the php service of site "bh-site-type-php-23.test" should be able to connect to the site database - # New (suspected), data loss: the default db name replaces "." and "-" with "_", so these two names collide, - # and the failed second create drops the first site's database. + # PHPT-2, data loss: the default db name replaces "." and "-" with "_", so these two names collide, + # and the failed second create drops the first site's database. The same pair also shares volume names (PHPT-4), + # so the second create is expected to fail even once PHPT-2 is fixed; only site 22.x's data is asserted. @known-bug Scenario: Two site names that map to the same default db name do not destroy each other's database Given a php site "bh-site-type-php-22.x.test" created with "--with-db" exists diff --git a/features/lifecycle.feature b/features/lifecycle.feature index dd61436..739f8f9 100644 --- a/features/lifecycle.feature +++ b/features/lifecycle.feature @@ -38,9 +38,10 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and Then the JSON in STDOUT should have "db_name" set to "bh_stp_db101" And the JSON in STDOUT should have "cache_host" set to "global-redis" + # The site root, not app/htdocs: app is a symlink into the docker volume, and get_site_name() reads the physical `pwd`. Scenario: site info works from inside the site directory Given a php site "bh-site-type-php-100.test" exists - When I change to the directory "/opt/easyengine/sites/bh-site-type-php-100.test/app/htdocs" and run "bin/ee site info" + When I change to the directory "/opt/easyengine/sites/bh-site-type-php-100.test" and run "bin/ee site info" Then the command should succeed And STDOUT should contain "| Site | http://bh-site-type-php-100.test" @@ -54,7 +55,7 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and When I run "bin/ee site list --format=json" Then the command should succeed And the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" - And the JSON list in STDOUT should contain an item with "status" set to "enabled" + And the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" and "status" set to "enabled" When I run "bin/ee site list --format=text" Then STDOUT should contain "bh-site-type-php-100.test" @@ -165,14 +166,13 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and And STDOUT should contain "Restarting db" And the "db" service of site "bh-site-type-php-103.test" should have been restarted - # New (suspected): run_compose_command() ignores the docker-compose exit code, so restarting a service the site - # does not have prints "no such service" and still exits 0. + # PHPT-13: run_compose_command() ignores the docker-compose exit code, so restarting a service the site + # does not have prints compose's "no such service" and still exits 0. @known-bug Scenario: site restart --db on a php site without a local db fails Given a php site "bh-site-type-php-100.test" exists When I run "bin/ee site restart bh-site-type-php-100.test --db" Then the command should fail - And STDERR should contain "no such service" Scenario: site reload reloads nginx and php-fpm without restarting containers Given a php site "bh-site-type-php-100.test" exists @@ -184,7 +184,7 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and And STDOUT should contain "Reloading php" And the "nginx" service of site "bh-site-type-php-100.test" should not have been restarted And the "php" service of site "bh-site-type-php-100.test" should not have been restarted - And a GET request to "http://bh-site-type-php-100.test/" should return 200 + And I wait until a GET request to "http://bh-site-type-php-100.test/" returns 200 within 30 seconds Scenario: site reload --php and --nginx reload a single service Given a php site "bh-site-type-php-100.test" exists @@ -226,6 +226,7 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104.test_location" should not exist And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104-a.test_location" should not exist And I wait until a GET request to "http://bh-site-type-php-104.test/" returns 200 within 90 seconds + And the docker container "services_global-nginx-proxy_1" should be running Scenario: Delete a php site and everything it owns Given a php site "bh-site-type-php-105.test" exists @@ -244,7 +245,6 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and And the site "bh-site-type-php-105.test" should have 0 docker volumes And the docker network "bh-site-type-php-105.test" should not exist And the hosts file should not have an entry for "bh-site-type-php-105.test" - And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-105.test-redirect.conf" should not exist Scenario: Deleting a php site twice fails the second time Given the site "bh-site-type-php-105.test" does not exist diff --git a/features/php-version.feature b/features/php-version.feature index 8dde1c2..0457c33 100644 --- a/features/php-version.feature +++ b/features/php-version.feature @@ -40,7 +40,7 @@ Feature: PHP versions of php sites (create --php, update --php) And the php service of site "bh-site-type-php-52.test" should run PHP "8.3" And the directory "/opt/easyengine/sites/bh-site-type-php-52.test/.backup/config/php-8.4" should exist And the file "/opt/easyengine/sites/bh-site-type-php-52.test/config/php/php/conf.d/custom.ini" should contain "; bh-custom-ini-marker" - And a GET request to "http://bh-site-type-php-52.test/" should return 200 + And I wait until a GET request to "http://bh-site-type-php-52.test/" returns 200 within 60 seconds Scenario: Updating to the current PHP version is refused Given a php site "bh-site-type-php-49.test" created with "--php=8.4" exists @@ -67,7 +67,7 @@ Feature: PHP versions of php sites (create --php, update --php) Then the command should succeed And the php service of site "bh-site-type-php-53.test" should run PHP "8.4" And the file "/opt/easyengine/sites/bh-site-type-php-53.test/config/php/php/conf.d/custom.ini" should contain "sendmail_path = /usr/bin/msmtp -t" - And a GET request to "http://bh-site-type-php-53.test/" should return 200 + And I wait until a GET request to "http://bh-site-type-php-53.test/" returns 200 within 60 seconds Scenario: Updating to PHP 5.6 switches msmtp to sendmail Given a php site "bh-site-type-php-54.test" exists @@ -75,7 +75,7 @@ Feature: PHP versions of php sites (create --php, update --php) Then the command should succeed And the php service of site "bh-site-type-php-54.test" should run PHP "5.6" And the file "/opt/easyengine/sites/bh-site-type-php-54.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-php-54.test/" should return 200 + And I wait until a GET request to "http://bh-site-type-php-54.test/" returns 200 within 60 seconds Scenario: The PHP version of a disabled site can not be updated Given a php site "bh-site-type-php-55.test" exists diff --git a/features/public-dir.feature b/features/public-dir.feature index 0c25555..a43fd1a 100644 --- a/features/public-dir.feature +++ b/features/public-dir.feature @@ -38,12 +38,12 @@ Feature: Custom document root of php sites (--public-dir) And the site "bh-site-type-php-63.test" should have the field "site_container_fs_path" set to "/var/www/htdocs/etc" And the file "/opt/easyengine/sites/bh-site-type-php-63.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs/etc;" - # New (suspected): `[--public-dir]` is declared as a flag, so a bare --public-dir arrives as `true` and becomes the directory "1". + # PHPT-12: `[--public-dir]` is declared as a flag, so a bare --public-dir arrives as `true` and becomes the directory "1". + # Either fix (reject the bare flag, or keep the default root) passes: only the "1" document root is asserted against. @known-bug - Scenario: A bare --public-dir without a value keeps the default document root + Scenario: A bare --public-dir without a value does not create the document root "1" When I run "bin/ee site create bh-site-type-php-64.test --type=php --public-dir" - Then the site "bh-site-type-php-64.test" should not have the field "site_container_fs_path" set to "/var/www/htdocs/1" - And the directory "/opt/easyengine/sites/bh-site-type-php-64.test/app/htdocs/1" should not exist + Then the directory "/opt/easyengine/sites/bh-site-type-php-64.test/app/htdocs/1" should not exist Scenario: --public-dir works together with the redis page cache When I run "bin/ee site create bh-site-type-php-65.test --type=php --public-dir=public --cache" diff --git a/features/redirect.feature b/features/redirect.feature index 24fa2d9..f163ab6 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -30,6 +30,7 @@ Feature: Site Redirection Scenario: Deleting a site removes its redirect config Given the hosts file maps "www.bh-site-type-php-91.test" to "127.0.0.1" And a php site "bh-site-type-php-91.test" exists + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should exist When I run "bin/ee site delete bh-site-type-php-91.test --yes" Then the command should succeed And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should not exist diff --git a/features/site.feature b/features/site.feature index bd5ce3c..101c0a6 100644 --- a/features/site.feature +++ b/features/site.feature @@ -59,12 +59,16 @@ Feature: Site command presence and usage errors Scenario: Help for php site info, restart and reload shows the php variants When I run "bin/ee help site restart --type=php" - Then STDOUT should contain "[--php]" + Then the command should succeed + And STDOUT should contain "[--php]" And STDOUT should contain "[--db]" When I run "bin/ee help site reload --type=php" - Then STDOUT should contain "[--php]" + Then the command should succeed + And STDOUT should contain "[--php]" + And STDOUT should not contain "[--db]" When I run "bin/ee help site info --type=php" - Then STDOUT should contain "[--format=]" + Then the command should succeed + And STDOUT should contain "[--format=]" Scenario: Check site disable sub command is present When I run 'bin/ee site disable' diff --git a/features/ssl.feature b/features/ssl.feature index 8c6150d..c07bdfe 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -31,7 +31,7 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) And the site "bh-site-type-php-81.test" should have the field "site_ssl" set to "custom" And the files "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" and "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-81.test.crt" should be identical And the files "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.key" and "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-81.test.key" should be identical - And a GET request to "https://bh-site-type-php-81.test/" via "127.0.0.1" should return 200 + And I wait until a GET request to "https://bh-site-type-php-81.test/" via "127.0.0.1" returns 200 within 60 seconds And the certificate served for "bh-site-type-php-81.test" should match the file "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" Scenario: --ssl=custom needs both --ssl-key and --ssl-crt @@ -61,7 +61,7 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) Then the command should succeed And STDOUT should contain "Inherited certs from parent" And the site "sub.bh-site-type-php-85.test" should have the field "site_ssl" set to "inherit" - And a GET request to "https://sub.bh-site-type-php-85.test/" via "127.0.0.1" should return 200 + And I wait until a GET request to "https://sub.bh-site-type-php-85.test/" via "127.0.0.1" returns 200 within 60 seconds And the certificate served for "sub.bh-site-type-php-85.test" should cover "*.bh-site-type-php-85.test" Scenario: --ssl=inherit together with --wildcard is refused and rolled back @@ -71,13 +71,20 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) Then the return code should be 1 And STDERR should contain "Cannot use --wildcard with --ssl=inherit" And the site "sub2.bh-site-type-php-85.test" should not exist + And the directory "/opt/easyengine/sites/sub2.bh-site-type-php-85.test" should not exist + And the site "sub2.bh-site-type-php-85.test" should have 0 docker volumes + And the "nginx" service of site "sub2.bh-site-type-php-85.test" should not exist + And the file "/opt/easyengine/services/nginx-proxy/conf.d/sub2.bh-site-type-php-85.test-redirect.conf" should not exist Scenario: --ssl with an unknown value fails and rolls back When I run "bin/ee site create bh-site-type-php-87.test --type=php --ssl=bogus --skip-status-check" Then the return code should be 1 And STDERR should contain "Unrecognized value in --ssl flag: bogus" And the site "bh-site-type-php-87.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-87.test" should not exist And the site "bh-site-type-php-87.test" should have 0 docker volumes + And the "nginx" service of site "bh-site-type-php-87.test" should not exist + And the docker network "bh-site-type-php-87.test" should not exist Scenario: Enable and disable self-signed SSL on an existing php site Given a php site "bh-site-type-php-88.test" exists @@ -99,12 +106,21 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) Then the return code should be 1 And STDERR should contain "Error: SSL is already disabled on bh-site-type-php-88.test" + # Site 80 is SSL-on with site_ssl_wildcard=1 (from create --ssl=self), the only state that reaches this check. Scenario: --ssl=off with --wildcard is refused + Given a php site "bh-site-type-php-80.test" created with "--ssl=self" exists + When I run "bin/ee site update bh-site-type-php-80.test --ssl=off --wildcard" + Then the return code should be 1 + And STDERR should contain "You cannot use --wildcard flag with --ssl=off" + And the site "bh-site-type-php-80.test" should have the field "site_ssl" set to "self" + + Scenario: Switching a normal SSL site to wildcard SSL is refused Given a php site "bh-site-type-php-88.test" exists - When I run "bin/ee site update bh-site-type-php-88.test --ssl=off --wildcard" + When I run "bin/ee site update bh-site-type-php-88.test --ssl=self --wildcard" Then the return code should be 1 + And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." - # New (suspected, site-command): create --ssl=self stores site_ssl_wildcard=1, and update_ssl() then refuses + # PHPT-10 (site-command): create --ssl=self stores site_ssl_wildcard=1, and update_ssl() then refuses # --ssl=off ("wildcard to normal") and --ssl=off --wildcard ("cannot use --wildcard"), so SSL can never be turned off. @known-bug Scenario: SSL of a site created with --ssl=self can be disabled @@ -116,12 +132,14 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) @le Scenario: Create and delete a php site with a Let's Encrypt certificate Given the domain "bh-site-type-php-1.ee4-test.rt.gw" resolves publicly + And the ee config has a "le-mail" value And the site "bh-site-type-php-1.ee4-test.rt.gw" does not exist When I run "bin/ee site create bh-site-type-php-1.ee4-test.rt.gw --type=php --ssl=le" Then the command should succeed And the site "bh-site-type-php-1.ee4-test.rt.gw" should have the field "site_ssl" set to "le" And the site "bh-site-type-php-1.ee4-test.rt.gw" should have the field "site_ssl_wildcard" set to "0" And the hosts file should not have an entry for "bh-site-type-php-1.ee4-test.rt.gw" + And I wait until a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" returns 200 within 60 seconds And a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" with certificate verification should return 200 And the response should contain "Congratulations!" And the certificate served for "bh-site-type-php-1.ee4-test.rt.gw" should be issued by "Let's Encrypt" From be3ae03cabe0cf8a9253280f76dbbd21464da6aa Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:40:14 +0530 Subject: [PATCH 13/35] test(site-type-php): move the volume collision pair off the ssl site numbers bh-site-type-php-81.test is also the custom-cert site of ssl.feature; the PHPT-4 pair is now 2-7/27, which no other scenario uses. --- features/create.feature | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/features/create.feature b/features/create.feature index 5150de3..44247d5 100644 --- a/features/create.feature +++ b/features/create.feature @@ -204,13 +204,13 @@ Feature: Create a plain php site And the file "bhstpsec1" should not exist # PHPT-4, core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to - # the prefix bhsitetypephp81test. create_volumes() skips volumes that already exist, and the second site mounts the + # the prefix bhsitetypephp27test. create_volumes() skips volumes that already exist, and the second site mounts the # first site's htdocs/config volumes. @known-bug Scenario: Two site names that differ only in dots and dashes get separate volumes - Given a php site "bh-site-type-php-8-1.test" exists - When I run "bin/ee site create bh-site-type-php-81.test --type=php" - Then the site "bh-site-type-php-81.test" should have 8 docker volumes - And the site "bh-site-type-php-8-1.test" should have 8 docker volumes - And a GET request to "http://bh-site-type-php-8-1.test/" should return 200 - And the response should contain "/opt/easyengine/sites/bh-site-type-php-8-1.test/app/htdocs" + Given a php site "bh-site-type-php-2-7.test" exists + When I run "bin/ee site create bh-site-type-php-27.test --type=php" + Then the site "bh-site-type-php-27.test" should have 8 docker volumes + And the site "bh-site-type-php-2-7.test" should have 8 docker volumes + And a GET request to "http://bh-site-type-php-2-7.test/" should return 200 + And the response should contain "/opt/easyengine/sites/bh-site-type-php-2-7.test/app/htdocs" From af635a5b6e4fe3bd5c8aa990bc5e5d27747cdd0b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:43:38 +0530 Subject: [PATCH 14/35] test(site-type-php): update coverage inventory after review Maps every known bug to its issues.md PHPT ID, corrects the claims that were only vacuously covered (rollback, --ssl=off --wildcard, directory auto-detect, LE revocation, --skip-check), and documents the cleanup and data-loss guards, the new @destructive scenarios and the LE duplicate-certificate limit. --- features/COVERAGE.md | 121 ++++++++++++++++++++----------------------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index a94dda4..692b22e 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -9,7 +9,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | Item | Covered by | |---|---| | `` required (usage on missing) | site.feature "php site create without a site name prints the php usage" | -| name normalisation: `http(s)://` stripped, trailing `/` stripped, lowercased | create.feature "The site name is normalised" | +| name normalisation: `http://` stripped, trailing `/` stripped, lowercased (`https://` shares the code path, not tested) | create.feature "The site name is normalised" | | name without a dot gets `.test` | create.feature "A site name without a dot gets the .test TLD" | | duplicate site name refused, first site untouched | create.feature "Creating the same site twice fails" | | site name validation (SEC-1) | create.feature "A site name with shell syntax is rejected" `@known-bug @security` | @@ -27,35 +27,35 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `--local-db` without `--with-db` (ignored) | database.feature "--local-db without --with-db creates no database at all" | | remote `--dbhost=host:port` with credentials | database.feature "A remote --dbhost with host:port and existing credentials" (uses `global-db:3306` as the "remote") | | remote `--dbhost` needs `--dbuser` and `--dbpass` | database.feature "A remote --dbhost needs --dbuser and --dbpass" | -| `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (passes vacuously today, see N3) | -| remote DB verification / DB auto-create / non-empty DB check | database.feature "An unreachable remote db host fails the create" `@known-bug` (N3); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | -| `--force` (reset non-empty remote DB) | not covered: dead code today (N3), needs a reachable remote MariaDB | -| DB name / user collisions on rollback | database.feature 3 `@known-bug` scenarios (N4, N5, N6) | +| `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (only proves the create and port parsing: nothing probes today, PHPT-5) | +| remote DB verification / DB auto-create / non-empty DB check | database.feature "An unreachable remote db host fails the create" `@known-bug` (PHPT-5); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | +| `--force` (reset non-empty remote DB) | not covered: dead code today (PHPT-5), needs a reachable remote MariaDB | +| DB name / user collisions on rollback | database.feature 3 `@known-bug` scenarios (PHPT-1, PHPT-3, PHPT-2), each on its own sites | | DB-1 (mysql CLI in php images vs MariaDB TLS) | database.feature 2 `@known-bug` scenarios | | `--php=` every supported version + `latest` | php-version.feature outline (12 examples, `@slow`) | | `--php` default 8.4 | create.feature "The site row holds the php defaults", "The compose file ..." | -| `--php` invalid values | create.feature outline "An unsupported --php value is rejected" | -| `--php` loosely matching values (`8`, `7`, `8.40`) | create.feature outline `@known-bug` (N1) | -| `--php=` without `--type` (EE3 compat mapping) | create.feature `@known-bug` (N2) | +| `--php` invalid values | create.feature outline "An unsupported --php value is rejected" (4 examples) | +| `--php` loosely matching values (`8`, `7`, `8.40`, `8.4.1`) | create.feature outline `@known-bug` (PHPT-11; `8.4.1` only passes validation on PHP 7) | +| `--php=` without `--type` (EE3 compat mapping) | create.feature `@known-bug` (PHPT-6) | | EE3 `--mysql` compat flag | create.feature "The legacy --mysql flag creates a php site with a database" | | php 5.6 ini (sendmail) vs msmtp | php-version.feature outline `sendmail` column | | `--alias-domains` plain + wildcard (DB, compose VIRTUAL_HOST/CERT_NAME, main.conf server_name, HTTP) | alias-domains.feature scenarios 1-3 | | alias owned by another site / site name that is an alias | alias-domains.feature scenarios 4-5 | | alias trimming | alias-domains.feature "Spaces around alias domains are trimmed" | -| alias that is an existing site | alias-domains.feature `@known-bug` (N7) | -| blank alias entries | alias-domains.feature `@known-bug` (fixed in site-type-php #114) | -| invalid alias names (AUTH-1) | alias-domains.feature outline `@known-bug` (3 examples) | +| alias that is an existing site | alias-domains.feature `@known-bug` (PHPT-7) | +| blank alias entries | alias-domains.feature `@known-bug @destructive` (fixed in site-type-php #114; on develop it breaks the nginx-proxy config until the rollback) | +| invalid alias names (AUTH-1) | alias-domains.feature outline `@known-bug` (3 examples; the one with a space is also `@destructive`) | | `--public-dir` (relative, full path, nested, traversal sanitised, with cache, outside files not served) | public-dir.feature | -| bare `--public-dir` | public-dir.feature `@known-bug` (N8) | +| bare `--public-dir` | public-dir.feature `@known-bug` (PHPT-12) | | `--skip-status-check` | create.feature "--admin-email is stored and shown, --skip-status-check skips the HTTP probe" | | `--ssl=self` (+ wildcard flag stored, HTTPS, HTTP->HTTPS redirect, cert SANs) | ssl.feature scenarios 1-2 | | `--ssl=custom --ssl-key --ssl-crt` | ssl.feature "Create a php site with a custom certificate" | | custom SSL argument errors (missing key/crt, missing file) | ssl.feature 2 scenarios | | `--ssl=inherit` (no parent, parent with wildcard, with `--wildcard`) | ssl.feature 3 scenarios | -| `--ssl=` | ssl.feature "--ssl with an unknown value fails and rolls back" | +| `--ssl=` (rejected only after the site is built, PHPT-17) | ssl.feature "--ssl with an unknown value fails and rolls back" | | `--ssl=le` | ssl.feature `@le` (create, verified HTTPS, issuer, delete + revoke) | | `--wildcard` with `--ssl=le` | not covered: needs Cloudflare DNS-01 credentials (not configured here) | -| rollback (`catch_clean`) leaves no site row, dir or volumes | asserted in every failing-create scenario (site/dir/volumes absent) | +| rollback (`catch_clean`) leaves no site row, dir, volumes, containers or network | ssl.feature "--ssl with an unknown value" and "--ssl=inherit together with --wildcard" (real level-4 rollbacks); the other failing creates stop in validation and assert the row and dir only | | www redirect (non-www and www sites), no redirect without resolvable www | redirect.feature | ### Generated files, containers and side effects of create @@ -67,9 +67,9 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `.env`, `main.conf` (upstream, root, server_name, php vs redis blocks), `custom.ini`, `user.conf`, `admin-tools.conf`, `index.php`, `app/html` removed, `app` symlinked to the htdocs volume, `check.html` removed | create.feature "The site files are generated from the php templates" | | running containers (php, nginx, postfix; mailhog not started; + db/redis), labels, restart policy, networks | create.feature "The site containers, volumes and network are up", labels.feature, database/cache features | | docker volumes (8, or 11 with local db) and network labels | create.feature, labels.feature, database.feature | -| volume prefix collision between names differing only in `.`/`-` | create.feature `@known-bug` (N13) | +| volume prefix collision between names differing only in `.`/`-` | create.feature `@known-bug` (PHPT-4) | | `/etc/hosts` entry (http and self-signed only) | create.feature, ssl.feature, alias-domains.feature | -| HTTP: 200 + PHP executes, pretty URLs, 404, hidden/backup files denied, fpm ping | create.feature "The site is reachable over HTTP and runs PHP", "nginx denies hidden and backup files", "The php-fpm status and ping endpoints are wired" | +| HTTP: 200 + PHP executes, pretty URLs, 404, hidden/backup files denied, fpm ping/status, nginx_status | create.feature "The site is reachable over HTTP and runs PHP", "nginx denies hidden and backup files", "The php-fpm status and ping endpoints are wired" | ### `ee site info [] [--format=table|json]` (`PHP::info`) @@ -77,7 +77,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` |---|---| | table rows: Site, Site Root, DB Host/Name/User/Password, DB Root Password (local db only), Alias Domains, E-Mail, SSL, SSL Wildcard, Cache | lifecycle.feature 3 info scenarios, create/database/ssl/cache/alias create outputs | | `--format=json` | lifecycle.feature "site info --format=json dumps the site row" (+ db site) | -| site auto-detected from the current directory | lifecycle.feature "site info works from inside the site directory" | +| site auto-detected from the current directory | lifecycle.feature "site info works from inside the site directory" (the site root; `app/` is a symlink into the volume, so ee's physical `pwd` has no site name there) | | disabled site | lifecycle.feature "site info still works for a disabled php site" | | missing / unknown site | site.feature "Check site info sub command is present", lifecycle.feature "site info of an unknown site fails" | | "Access admin-tools" row | not covered: admin-tools-command's suite owns enabling admin tools | @@ -89,7 +89,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | no flag: nginx + php + local db restarted, postfix not | lifecycle.feature "site restart restarts nginx, php and the local db, not postfix" | | `--php` only | lifecycle.feature "site restart --php restarts only php" | | `--db` with local db | lifecycle.feature "site restart --db restarts the local db" | -| `--db` without local db | lifecycle.feature `@known-bug` (N12) | +| `--db` without local db | lifecycle.feature `@known-bug` (PHPT-13) | | `--all`, `--nginx` alone | not covered separately (same code path as no flag / `--php`) | ### `ee site reload [] [--all] [--nginx] [--php]` (`PHP::reload`) @@ -109,81 +109,72 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `update --php` (compose image, container version, backup dir, custom.ini kept, 5.6 <-> msmtp/sendmail) | php-version.feature 3 update scenarios | | `update --php` same version / invalid / `latest` / disabled / unknown site | php-version.feature 4 scenarios | | `update --add-alias-domains` / `--delete-alias-domains` (+ all error paths, wildcard) | alias-domains.feature 9 update scenarios | -| `update --ssl=self` / `--ssl=off` / already enabled / already disabled / `--ssl=off --wildcard` | ssl.feature 2 scenarios | -| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature `@known-bug` (N11) | +| `update --ssl=self` / `--ssl=off` / already enabled / already disabled / `--ssl=off --wildcard` (on a wildcard site) / normal to wildcard | ssl.feature 3 scenarios | +| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature `@known-bug` (PHPT-10) | | `update --ssl=le`, `--wildcard` | not covered (LE budget; wildcard needs DNS-01) | | `update --proxy-cache=on/off` (conf.d, vhost.d incl. alias, already-on error) | lifecycle.feature `@destructive` (restarts nginx-proxy) | | `--proxy-cache-max-size/-max-time/-key-zone-size` | not covered: site-command generic | | `enable` (containers, post-enable, already enabled, `--force`) | lifecycle.feature 2 scenarios | -| `enable --refresh` / `refresh` | lifecycle.feature "site refresh regenerates the docker-compose file" | +| `refresh` | lifecycle.feature "site refresh regenerates the docker-compose file" | +| `enable --refresh` | not covered separately (same regeneration) | | `enable --verify` | not covered: site-command generic | | `disable` (containers removed, volumes/files kept, HTTP down, idempotent) | lifecycle.feature 2 scenarios | | `list` (`--format=json/text`, `--enabled`, `--disabled`) | lifecycle.feature 2 scenarios | -| `delete` (row, dir, containers, volumes, network, hosts, redirect conf; global DB + user dropped; local db volumes) | lifecycle.feature "Delete a php site and everything it owns", database.feature 2 delete scenarios, redirect.feature | +| `delete` (row, dir, containers, volumes, network, hosts; redirect conf; global DB + user dropped; local db volumes) | lifecycle.feature "Delete a php site and everything it owns", redirect.feature "Deleting a site removes its redirect config" (asserts the conf existed first), database.feature 2 delete scenarios | | `delete` twice / unknown site | lifecycle.feature "Deleting a php site twice fails the second time" | -| `delete` of an LE site revokes and removes certs | ssl.feature `@le` | +| `delete` of an LE site removes certs | ssl.feature `@le` ("Revoking certificate." is logged before the attempt and revocation errors are swallowed, so revocation itself is not proven) | | `clean` without cache / `--proxy` without proxy cache | cache.feature "ee site clean on a site without cache" | -| `clean --page` on a php cache site | cache.feature `@known-bug` (N9, CACHE-1) | -| `clean` on a local-redis site | cache.feature `@known-bug` (N10) | -| `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares N9's path; `--proxy` runs `docker exec -it` (CACHE-1) | +| `clean --page` on a php cache site | cache.feature `@known-bug` (PHPT-8, CACHE-1) | +| `clean` on a local-redis site | cache.feature `@known-bug` (PHPT-9) | +| `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares PHPT-8's path; `--proxy` runs `docker exec -it` (CACHE-1) | | `ssl-verify`, `ssl-info`, `ssl-renew`, `share`, `clone`, `sync`, `backup`, `restore` | not covered here: generic, owned by the site-command suite (no php-specific code) | | `site_cleanup` hook on delete | indirectly (delete scenarios); hook consumers are tested in their packages | -Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). +Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). Several items counted before only passed vacuously (`--ssl=off --wildcard`, the directory auto-detect, rollback, the redirect delete, the combo cache checks); they now assert the real behaviour. -## Known bugs exercised (`@known-bug`, 23 scenarios) +## Known bugs exercised (`@known-bug`, 24 scenarios incl. outline examples) -Existing issues.md IDs: SEC-1, AUTH-1 (+ blank aliases, both fixed in the unmerged site-type-php #114), DB-1 (2), MIG-16, CACHE-1. +Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: SEC-1, AUTH-1 (+ blank aliases, both fixed in the unmerged site-type-php #114), DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): -New suspects found while writing this suite (for issues.md): - -| ID | Location | Issue | +| ID | Severity | Scenario(s) | |---|---|---| -| N1 | core `Dispatcher/Subcommand.php` option check + `PHP::create` | `--php` values are matched with loose `in_array()`, so `8`, `7`, `8.40` pass and the compose file references a non-existent `easyengine/php8` image; the create fails late (after containers/volumes were made) instead of up front. | -| N2 | site-command `Site_Command::convert_old_args_to_new_args()` | Without `--type`, `ee site create x --php=8.3` is taken as the EE3 `--php` type flag: the version is dropped and PHP 8.4 is used. | -| N3 | `PHP::create_site()` ~L812 | `maybe_verify_remote_db_connection()` is only called for `--local-db`, where it returns at once, so remote DB credentials are never checked, the remote DB is never created, and `--skip-check`/`--force` do nothing (site-type-wp calls it for every create). | -| N4 | `PHP::create()` + `catch_clean()` + site-command `cleanup_db()` | Data loss: `--dbname` of an existing database makes `create_user_in_db()` fail, and the rollback then DROPs that (other site's) database. | -| N5 | same, `cleanup_db_user()` | `--dbuser` of an existing user: the rollback DROPs the other site's DB user. | -| N6 | `PHP::create()` default db name | The default db name maps `.` and `-` to `_`, so `a-b.test` and `a.b.test` collide and the second create destroys the first site's DB (N4). | -| N7 | `PHP::create()` / site-command `check_alias_in_db()` | Create accepts an existing site's name as an alias (only other sites' aliases are checked), so two sites claim the same VIRTUAL_HOST. | -| N8 | `[--public-dir]` declared as a flag + site-command `get_public_dir()` | A bare `--public-dir` becomes `true`, i.e. the document root `/var/www/htdocs/1`. | -| N9 | `templates/config/nginx/main.conf.mustache` vs site-command `clean()` | php sites store pages as `:http…`, but `ee site clean` purges `_page*` (the WP key format), so the php page cache is never purged while Success is printed. | -| N10 | site-command `clean_site_cache()` | Always purges in the global redis container, never in a `--with-local-redis` site's redis. | -| N11 | site-command `update_ssl()` | A site created with `--ssl=self` stores `site_ssl_wildcard=1`, so `update --ssl=off` fails ("wildcard to normal SSL is not supported") and `--ssl=off --wildcard` fails too: SSL can't be turned off. (`update --ssl=self` stores 0, so the two paths are inconsistent.) | -| N12 | site-command `run_compose_command()` | Ignores the docker-compose exit code: `ee site restart --db` on a site without a local db prints "no such service" and exits 0. | -| N13 | core `EE_DOCKER::get_docker_style_prefix()` + `create_volumes()` | Volume names drop `.` and `-`, so `a-b.test` and `ab.test` share volume names; `create_volumes()` skips existing volumes and the second site mounts the first site's htdocs/config. | - -Suspected but not tested (no clean black-box assertion, or out of scope): - -- `PHP.php` renders `.env` and `php.ini` from `SITE_WP_TEMPLATE_ROOT`, so php sites hard-depend on site-type-wp being installed. -- site-command `check_www_or_non_www_domain()` uses `ltrim( $site_url, 'www.' )` (a character list), so `www.web.test` checks `eb.test`; `add_site_redirects()` has the same `ltrim( $site_url, '.www' )` pattern. -- site-command `check_parent_site_certs()` puts `var_dump( $parent_site )` in an exception message (dumps the object to stdout). -- `--ssl` is declared as a flag, so its `options:` list is never enforced; an unknown value is only rejected in `init_ssl()` after the whole site was built (then rolled back). -- custom and inherit SSL sites on local names get no `/etc/hosts` entry, so the create's status check fails unless the name already resolves (the suite maps the names itself). -- `--php=latest` maps to the php8.4 image, not the newest supported (8.5); may be intended. -- `update --add/--delete-alias-domains` doesn't regenerate `main.conf`, so `server_name` goes stale (harmless: one server block). -- A site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. -- The old suite's label assertion was inverted and its mysql checks passed vacuously (DB-1); both fixed in this context. +| PHPT-1 | High (data loss) | database.feature "Reusing another site's --dbname fails without dropping that database" | +| PHPT-2 | High (data loss) | database.feature "Two site names that map to the same default db name ..." (the pair also collides on volumes, PHPT-4) | +| PHPT-3 | High (data loss) | database.feature "Reusing another site's --dbuser fails without dropping that user" | +| PHPT-4 | High (data mixing) | create.feature "Two site names that differ only in dots and dashes get separate volumes" | +| PHPT-5 | Medium | database.feature "An unreachable remote db host fails the create and rolls back" | +| PHPT-6 | Medium | create.feature "--php= without --type keeps the requested version" | +| PHPT-7 | Medium | alias-domains.feature "An alias domain that is an existing site is refused on create" | +| PHPT-8 | Medium | cache.feature "ee site clean --page purges the php page cache in the global redis" | +| PHPT-9 | Medium | cache.feature "ee site clean purges the page cache of a local-redis site" | +| PHPT-10 | Medium | ssl.feature "SSL of a site created with --ssl=self can be disabled" | +| PHPT-11 | Low | create.feature outline "A --php value that only loosely matches ..." (4 examples) | +| PHPT-12 | Low | public-dir.feature "A bare --public-dir without a value does not create the document root 1" | +| PHPT-13 | Low | lifecycle.feature "site restart --db on a php site without a local db fails" | +| PHPT-14..18 | Low / cosmetic | not tested (no clean black-box assertion): php sites render `.env`/`php.ini` from site-type-wp's templates; site-command's `ltrim($url, 'www.')` character-list bug; `var_dump()` in the inherit error; `--ssl` options not enforced on create (PHPT-17 is documented by the `--ssl=bogus` rollback scenario); no `/etc/hosts` entry for custom/inherit SSL sites | + +Other notes from the code: `--php=latest` maps to the php8.4 image, not the newest supported (8.5), maybe intended; `update --add/--delete-alias-domains` doesn't regenerate `main.conf` (stale `server_name`, harmless); a site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. The old suite's label assertion was inverted and its mysql checks passed vacuously (DB-1); both fixed in this context. ## Tags -- `@known-bug` (23): expected to fail until fixed; exclude with `--tags='~@known-bug'`. -- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` and deletes the site in the same scenario. -- `@destructive` (1): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring). +- `@known-bug` (24): expected to fail until fixed; exclude with `--tags='~@known-bug'`. +- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). +- `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias known bugs render an invalid nginx-proxy config for the ~2 minutes until their create rolls back (proxy reloads for every site fail meanwhile). - `@slow` (12): the PHP version matrix (one site per version). -- `@security` (1): the SEC-1 payload (benign: `touch ./bhstpsec1`, removed after the scenario). +- `@security` (1): the SEC-1 payload (benign: `id>bhstpsec1` in the core root, removed after the scenario and by the cleanup). - `@remote`: none (no backups in this package). ## Environment rules followed -- Sites: `bh-site-type-php-.test` (and `sub.`/`sub2.` children, `--x` variants); aliases `bh-site-type-php--.test`; global-db names `bh_stp_*` or the derived `bh_site_type_php_*`. `FeatureContext::cleanupTestState()` runs before the suite, after each feature and after the suite, and only touches these names: sites (children first), orphaned containers/networks/volumes/site dirs/proxy files, `bh_stp_*` databases and users, and `/etc/hosts` lines with `bh-site-type-php-` or the `# behat:site-type-php` marker. +- Sites: `bh-site-type-php-.test` (and `sub.`/`sub2.` children, `--x` variants); aliases `bh-site-type-php--.test`; global-db names `bh_stp_*` or the derived `bh_site_type_php_*`. `FeatureContext::cleanupTestState()` runs before the suite, after each feature and after the suite, and only touches these names: sites (children first), orphaned containers/networks/volumes/site dirs/proxy files, the suite's redis page-cache keys, `bh_stp_*`/`bh_site_type_php_*` databases and `bh_stp_*`/`bh-site-type-php-*` users that no remaining site row references, and `/etc/hosts` lines with the `# behat:site-type-php` marker or whose every name is a suite name. +- Data-loss guards (PHPT-1..4): the suite refuses to start when a foreign site's volume prefix, derived db name, db name or db user could collide with the suite's names; the db setup steps only accept suite names; the data-loss known bugs only ever point the buggy rollback at databases and users the scenario itself created. - Commands run with stdin on `/dev/null`, so docker/compose never allocate a TTY (no CACHE-1 hangs; `docker exec -it` fails fast instead). -- HTTP checks for aliases/extra names pin the host to 127.0.0.1 with curl's `--resolve` (keeps SNI for HTTPS); 127.0.0.1 arrives at nginx-proxy as 10.1.0.1 and bypasses HTTP auth. Nothing here needs the public IP. +- HTTP checks for aliases/extra names pin the host to 127.0.0.1 with curl's `--resolve` (keeps SNI for HTTPS); 127.0.0.1 arrives at nginx-proxy as 10.1.0.1 and bypasses HTTP auth. Only the `@le` scenario goes through public DNS to the public IP (it would get a 401 if global HTTP auth were enabled). - Temporary files (custom certs) live in `/var/tmp/bh-site-type-php`, removed after the suite. ## Site number registry -1-9 create (8-1/81 volume collision), 10-26 database (22.x/22-x collision), 30-38 cache, 40-55 php versions, 60-65 public dir, 70-79 alias domains, 80-89 ssl (85 + sub/sub2 children), 90-93 labels/redirect, 100-106 lifecycle, 197-199 never created. LE: `bh-site-type-php-1.ee4-test.rt.gw`. +1-9 create, 10-26 database (22.x/22-x collision), 2-7/27 create (volume collision), 30-38 cache, 40-55 php versions, 60-65 public dir, 70-79 alias domains, 80-89 ssl (85 + sub/sub2 children), 90-93 labels/redirect, 100-106 lifecycle, 197-199 never created. LE: `bh-site-type-php-1.ee4-test.rt.gw`. ## Runtime estimate @@ -191,6 +182,6 @@ About 80 site creations (about 35 s each), plus deletes, updates and restarts: r ## Validation done -- `vendor/bin/behat --dry-run --strict` in a private core copy on `upstream/develop`: no undefined or ambiguous steps (158 scenarios, 1061 steps). Behat 3.8 exits 1 on `--dry-run --strict` because every step is "skipped"; the upstream suite does the same. +- `vendor/bin/behat --dry-run --strict` in a private core copy on `upstream/develop`: no undefined or ambiguous steps (159 scenarios, 1088 steps). Behat 3.8 exits 1 on `--dry-run --strict` because every step is "skipped"; the upstream suite does the same. - Copy the features with `cp -r`, not `cp -a`: Behat's Gherkin cache in `/tmp/behat_gherkin_cache` is keyed on mtime, so preserving older mtimes serves stale parses. - `php -l` of `features/bootstrap/*.php` on php:7.4-cli and php:8.5-cli. From 2d4103f3fcf89e354617764974345f1a1d165ef6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:46:13 +0530 Subject: [PATCH 15/35] test(site-type-php): take the LE issuer and resolver from the environment CI runs @le against Pebble: EE_BEHAT_LE_ISSUER is its intermediate CN and EE_BEHAT_LE_DNS_SERVER the runner's resolver. Defaults stay Let's Encrypt and 1.1.1.1. --- features/bootstrap/FeatureContext.php | 15 ++++++++++++++- features/ssl.feature | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index c208c05..edc9cce 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1350,6 +1350,16 @@ public function theCertificateServedShouldCover( $host, $name ) } } + /** + * CI runs @le against Pebble and sets EE_BEHAT_LE_ISSUER to its intermediate CN. + * + * @Then the certificate served for :host should be issued by the ACME CA + */ + public function theCertificateServedShouldBeIssuedByTheAcmeCa( $host ) + { + $this->theCertificateServedShouldBeIssuedBy( $host, getenv( 'EE_BEHAT_LE_ISSUER' ) ?: "Let's Encrypt" ); + } + /** * @Then the certificate served for :host should be issued by :issuer */ @@ -1387,11 +1397,14 @@ public function theHostsFileMaps( $host, $ip ) } /** + * EE_BEHAT_LE_DNS_SERVER overrides the resolver (CI points the test names at Pebble's DNS). + * * @Given the domain :domain resolves publicly */ public function theDomainResolvesPublicly( $domain ) { - $result = self::run( 'dig +short ' . escapeshellarg( $domain ) . ' @1.1.1.1' ); + $server = getenv( 'EE_BEHAT_LE_DNS_SERVER' ) ?: '1.1.1.1'; + $result = self::run( 'dig +short ' . escapeshellarg( $domain ) . ' ' . escapeshellarg( '@' . $server ) ); if ( '' === trim( $result->stdout ) ) { throw new Exception( "$domain does not resolve publicly; Let's Encrypt scenarios need the ee4-test.rt.gw DNS records." ); } diff --git a/features/ssl.feature b/features/ssl.feature index c07bdfe..ce4bc52 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -142,7 +142,7 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) And I wait until a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" returns 200 within 60 seconds And a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" with certificate verification should return 200 And the response should contain "Congratulations!" - And the certificate served for "bh-site-type-php-1.ee4-test.rt.gw" should be issued by "Let's Encrypt" + And the certificate served for "bh-site-type-php-1.ee4-test.rt.gw" should be issued by the ACME CA And a GET request to "http://bh-site-type-php-1.ee4-test.rt.gw/" should return 301 When I run "bin/ee site delete bh-site-type-php-1.ee4-test.rt.gw --yes" Then the command should succeed From 0e72ac549c08caa39a30c8088eb089059ac5441f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:48:46 +0530 Subject: [PATCH 16/35] test(site-type-php): always capture the negation group in step patterns Behat 3.8 drops a trailing unmatched ( not)? group, so every positive form of these steps failed with "Can not find a matching value for an argument $not". ( not|) always participates and yields an empty string. --- features/bootstrap/FeatureContext.php | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index edc9cce..e034598 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -610,7 +610,7 @@ public function theReturnCodeShouldBe( $code ) } /** - * @Then /^(STDOUT|STDERR) should( not)? contain:$/ + * @Then /^(STDOUT|STDERR) should( not|) contain:$/ */ public function streamShouldContainBlock( $stream, $not, PyStringNode $text ) { @@ -618,7 +618,7 @@ public function streamShouldContainBlock( $stream, $not, PyStringNode $text ) } /** - * @Then /^(STDOUT|STDERR) should( not)? contain "([^"]*)"$/ + * @Then /^(STDOUT|STDERR) should( not|) contain "([^"]*)"$/ */ public function streamShouldContainText( $stream, $not, $text ) { @@ -739,7 +739,7 @@ public function theSiteShouldBeEnabledOrDisabled( $site, $state ) } /** - * @Then /^the site "([^"]*)" should( not)? have the field "([^"]*)" set to "([^"]*)"$/ + * @Then /^the site "([^"]*)" should( not|) have the field "([^"]*)" set to "([^"]*)"$/ */ public function theSiteShouldHaveFieldSetTo( $site, $not, $field, $value ) { @@ -788,7 +788,7 @@ public function theSiteShouldHaveEmptyField( $site, $state, $field ) } /** - * @Then /^the (file|directory) "([^"]*)" should( not)? exist$/ + * @Then /^the (file|directory) "([^"]*)" should( not|) exist$/ */ public function thePathShouldExist( $type, $path, $not ) { @@ -811,7 +811,7 @@ public function thePathShouldBeASymlink( $path ) } /** - * @Then /^the file "([^"]*)" should( not)? contain:$/ + * @Then /^the file "([^"]*)" should( not|) contain:$/ */ public function theFileShouldContain( $path, $not, PyStringNode $text ) { @@ -819,7 +819,7 @@ public function theFileShouldContain( $path, $not, PyStringNode $text ) } /** - * @Then /^the file "([^"]*)" should( not)? contain "([^"]*)"$/ + * @Then /^the file "([^"]*)" should( not|) contain "([^"]*)"$/ */ public function theFileShouldContainText( $path, $not, $text ) { @@ -882,7 +882,7 @@ public function theDockerContainerShouldBeRunning( $name ) } /** - * @Then /^the "([^"]*)" service of site "([^"]*)" should( not)? be running$/ + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not|) be running$/ */ public function theServiceOfSiteShouldBeRunning( $service, $site, $not ) { @@ -926,7 +926,7 @@ public function theSiteShouldHaveDockerVolumes( $site, $count ) } /** - * @Then /^the docker network "([^"]*)" should( not)? exist$/ + * @Then /^the docker network "([^"]*)" should( not|) exist$/ */ public function theDockerNetworkShouldExist( $network, $not ) { @@ -949,7 +949,7 @@ public function theDockerNetworkShouldHaveTheLabel( $network, $label ) } /** - * @Then /^the "([^"]*)" service of site "([^"]*)" should( not)? be attached to the docker network "([^"]*)"$/ + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not|) be attached to the docker network "([^"]*)"$/ */ public function theServiceShouldBeAttachedToNetwork( $service, $site, $not, $network ) { @@ -1012,7 +1012,7 @@ public function iNoteTheStartTimeOfTheService( $service, $site ) } /** - * @Then /^the "([^"]*)" service of site "([^"]*)" should( not)? have been restarted$/ + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not|) have been restarted$/ */ public function theServiceShouldHaveBeenRestarted( $service, $site, $not ) { @@ -1041,7 +1041,7 @@ public function thePhpServiceShouldRunPhp( $site, $version ) /** * Connects the way site code would (mysqli), with the credentials ee stored for the site. * - * @Then /^the php service of site "([^"]*)" should( not)? be able to connect to the site database$/ + * @Then /^the php service of site "([^"]*)" should( not|) be able to connect to the site database$/ */ public function thePhpServiceShouldConnectToTheSiteDatabase( $site, $not ) { @@ -1071,7 +1071,7 @@ public function thePhpServiceShouldConnectToTheSiteDatabase( $site, $not ) } /** - * @Then /^the global database "([^"]*)" should( not)? exist$/ + * @Then /^the global database "([^"]*)" should( not|) exist$/ */ public function theGlobalDatabaseShouldExist( $db, $not ) { @@ -1083,7 +1083,7 @@ public function theGlobalDatabaseShouldExist( $db, $not ) } /** - * @Then /^the global database user "([^"]*)" should( not)? exist$/ + * @Then /^the global database user "([^"]*)" should( not|) exist$/ */ public function theGlobalDatabaseUserShouldExist( $user, $not ) { @@ -1128,7 +1128,7 @@ public function theGlobalDatabaseContainsTheTable( $db, $table ) } /** - * @Then /^the global database "([^"]*)" should( not)? contain the table "([^"]*)"$/ + * @Then /^the global database "([^"]*)" should( not|) contain the table "([^"]*)"$/ */ public function theGlobalDatabaseShouldContainTheTable( $db, $not, $table ) { @@ -1140,7 +1140,7 @@ public function theGlobalDatabaseShouldContainTheTable( $db, $not, $table ) } /** - * @Then /^the database "([^"]*)" should( not)? exist in the db service of site "([^"]*)"$/ + * @Then /^the database "([^"]*)" should( not|) exist in the db service of site "([^"]*)"$/ */ public function theDatabaseShouldExistInTheDbService( $db, $not, $site ) { @@ -1155,7 +1155,7 @@ public function theDatabaseShouldExistInTheDbService( $db, $not, $site ) } /** - * @Then /^the global redis should( not)? have keys matching "([^"]*)"$/ + * @Then /^the global redis should( not|) have keys matching "([^"]*)"$/ */ public function theGlobalRedisShouldHaveKeysMatching( $not, $pattern ) { @@ -1164,7 +1164,7 @@ public function theGlobalRedisShouldHaveKeysMatching( $not, $pattern ) } /** - * @Then /^the redis service of site "([^"]*)" should( not)? have keys matching "([^"]*)"$/ + * @Then /^the redis service of site "([^"]*)" should( not|) have keys matching "([^"]*)"$/ */ public function theSiteRedisShouldHaveKeysMatching( $site, $not, $pattern ) { @@ -1206,7 +1206,7 @@ public function aGetRequestShouldReturn( $url, $code ) } /** - * @Then /^a GET request to "([^"]*)" via "([^"]*)" should( not)? return (\d{3})$/ + * @Then /^a GET request to "([^"]*)" via "([^"]*)" should( not|) return (\d{3})$/ */ public function aGetRequestViaShouldReturn( $url, $ip, $not, $code ) { @@ -1269,7 +1269,7 @@ public function theResponseCodeShouldBe( $code ) } /** - * @Then /^the response should( not)? contain "([^"]*)"$/ + * @Then /^the response should( not|) contain "([^"]*)"$/ */ public function theResponseShouldContain( $not, $text ) { @@ -1280,7 +1280,7 @@ public function theResponseShouldContain( $not, $text ) } /** - * @Then /^the response header "([^"]*)" should( not)? contain "([^"]*)"$/ + * @Then /^the response header "([^"]*)" should( not|) contain "([^"]*)"$/ */ public function theResponseHeaderShouldContain( $header, $not, $text ) { @@ -1292,7 +1292,7 @@ public function theResponseHeaderShouldContain( $header, $not, $text ) } /** - * @Then /^the response should( not)? have the header "([^"]*)"$/ + * @Then /^the response should( not|) have the header "([^"]*)"$/ */ public function theResponseShouldHaveTheHeader( $not, $header ) { @@ -1373,7 +1373,7 @@ public function theCertificateServedShouldBeIssuedBy( $host, $issuer ) } /** - * @Then /^the hosts file should( not)? have an entry for "([^"]*)"$/ + * @Then /^the hosts file should( not|) have an entry for "([^"]*)"$/ */ public function theHostsFileShouldHaveAnEntryFor( $not, $host ) { From 4ee69b33612391dbdd59d71642cf2edbadee59b4 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:51:14 +0530 Subject: [PATCH 17/35] test(site-type-php): document the Pebble lane for the @le scenario --- features/COVERAGE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 692b22e..eb2bf73 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -158,7 +158,8 @@ Other notes from the code: `--php=latest` maps to the php8.4 image, not the newe ## Tags - `@known-bug` (24): expected to fail until fixed; exclude with `--tags='~@known-bug'`. -- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). +- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`behat/ci/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). +- `@le-real`: none; the `@le` scenario passes on Pebble. - `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias known bugs render an invalid nginx-proxy config for the ~2 minutes until their create rolls back (proxy reloads for every site fail meanwhile). - `@slow` (12): the PHP version matrix (one site per version). - `@security` (1): the SEC-1 payload (benign: `id>bhstpsec1` in the core root, removed after the scenario and by the cleanup). From c743ad0ec06d7fe7b2d35abe59876047ff49f040 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:53:44 +0530 Subject: [PATCH 18/35] test(site-type-php): wait for db init and the proxy reload, match the usage in two parts A fresh local MariaDB denies root until its first-start init is done, the first HTTPS request right after a --ssl=self create can still hit the previous proxy config, and the create synopsis has a stray double space before [--with-db]. --- features/bootstrap/FeatureContext.php | 18 +++++++++++++++++- features/site.feature | 7 +++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index e034598..cf8a094 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1144,7 +1144,12 @@ public function theGlobalDatabaseShouldContainTheTable( $db, $not, $table ) */ public function theDatabaseShouldExistInTheDbService( $db, $not, $site ) { - $result = $this->execInService( $site, 'db', sprintf( 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e %s', escapeshellarg( sprintf( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '%s'", addslashes( $db ) ) ) ) ); + $query = sprintf( 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e %s', escapeshellarg( sprintf( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '%s'", addslashes( $db ) ) ) ); + $deadline = time() + 60; + // Until its first-start init is done, a fresh local MariaDB denies root with the configured password. + while ( 0 !== ( $result = $this->execInService( $site, 'db', $query ) )->return_code && time() < $deadline ) { + sleep( 2 ); + } if ( 0 !== $result->return_code ) { throw new Exception( "Could not query the db service of $site.\n" . $this->describe( $result ) ); } @@ -1202,6 +1207,17 @@ public function iSendAGetRequestWithHost( $url, $host ) public function aGetRequestShouldReturn( $url, $code ) { $this->response = $this->httpRequest( $url ); + // ee returns before nginx-proxy's last `nginx -s reload` takes effect, so the first request can still hit the old config (500/502). + $first = $this->response['code']; + $started = time(); + $deadline = $started + 15; + while ( (int) $code < 500 && ( $this->response['code'] >= 500 || 0 === $this->response['code'] ) && time() < $deadline ) { + sleep( 1 ); + $this->response = $this->httpRequest( $url ); + } + if ( $first !== $this->response['code'] ) { + echo "First answer was HTTP $first; HTTP {$this->response['code']} after " . ( time() - $started ) . " s.\n"; + } $this->theResponseCodeShouldBe( $code ); } diff --git a/features/site.feature b/features/site.feature index 101c0a6..4256156 100644 --- a/features/site.feature +++ b/features/site.feature @@ -27,10 +27,9 @@ Feature: Site command presence and usage errors Scenario: php site create without a site name prints the php usage When I run "bin/ee site create --type=php" Then the return code should be 1 - And STDOUT should contain: - """ - usage: ee site create [--cache] [--admin-email=] [--with-db] [--local-db] [--php=] - """ + # The synopsis has a stray double space before [--with-db] (docblock indentation), so match the two halves. + And STDOUT should contain "usage: ee site create [--cache] [--admin-email=]" + And STDOUT should contain "[--with-db] [--local-db] [--php=] [--alias-domains=]" Scenario: Help for php site create lists every php option When I run "bin/ee help site create --type=php" From f4659f67a7b0e44ee74230ead36c7d08155aef65 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:56:17 +0530 Subject: [PATCH 19/35] test(site-type-php): run the cleanup site delete in the foreground under timeout Without --foreground, timeout puts ee in a background process group; the docker exec -it of a cache purge then stops on SIGTTOU under a TTY (with timeout itself), so the cleanup hangs. -k 30 because ee ignores SIGTERM (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 cf8a094..63fc806 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1890,7 +1890,8 @@ private static function cleanupTestState() return substr_count( $b, '.' ) - substr_count( $a, '.' ); } ); foreach ( $sites as $site ) { - self::run( sprintf( 'timeout 600 %s site delete %s --yes', $ee, escapeshellarg( $site ) ), realpath( EE_ROOT ) ); + // --foreground: in a background process group the `docker exec -it` of a cache purge stops on SIGTTOU (with timeout itself) and hangs forever. + self::run( sprintf( 'timeout --foreground -k 30 600 %s site delete %s --yes', $ee, escapeshellarg( $site ) ), realpath( EE_ROOT ) ); } $remaining = array_column( self::sqliteRows( 'SELECT site_url FROM sites' ), 'site_url' ); From 8d226b76d4625f7bcaedd33a7eaea68c58afde49 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 14:58:28 +0530 Subject: [PATCH 20/35] test(site-type-php): follow the merged alias domain validation (#114) The blank-entry and invalid-hostname alias scenarios pass on develop now, so they are regular regression scenarios (the @destructive tags stay: a regression would break the proxy config for every site). --- features/COVERAGE.md | 8 ++++---- features/alias-domains.feature | 10 ++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index eb2bf73..e30d68e 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -43,8 +43,8 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | alias owned by another site / site name that is an alias | alias-domains.feature scenarios 4-5 | | alias trimming | alias-domains.feature "Spaces around alias domains are trimmed" | | alias that is an existing site | alias-domains.feature `@known-bug` (PHPT-7) | -| blank alias entries | alias-domains.feature `@known-bug @destructive` (fixed in site-type-php #114; on develop it breaks the nginx-proxy config until the rollback) | -| invalid alias names (AUTH-1) | alias-domains.feature outline `@known-bug` (3 examples; the one with a space is also `@destructive`) | +| blank alias entries | alias-domains.feature `@destructive` regression (fixed by site-type-php #114, merged 2026-09-25; a regression breaks the nginx-proxy config until the rollback) | +| invalid alias names (AUTH-1) | alias-domains.feature outline, regression (fixed by #114; 3 examples, the one with a space is also `@destructive`) | | `--public-dir` (relative, full path, nested, traversal sanitised, with cache, outside files not served) | public-dir.feature | | bare `--public-dir` | public-dir.feature `@known-bug` (PHPT-12) | | `--skip-status-check` | create.feature "--admin-email is stored and shown, --skip-status-check skips the HTTP probe" | @@ -134,7 +134,7 @@ Estimated coverage: about 88% of the inventory items above (php-specific items: ## Known bugs exercised (`@known-bug`, 24 scenarios incl. outline examples) -Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: SEC-1, AUTH-1 (+ blank aliases, both fixed in the unmerged site-type-php #114), DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): +Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: SEC-1, DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): | ID | Severity | Scenario(s) | |---|---|---| @@ -160,7 +160,7 @@ Other notes from the code: `--php=latest` maps to the php8.4 image, not the newe - `@known-bug` (24): expected to fail until fixed; exclude with `--tags='~@known-bug'`. - `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`behat/ci/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). - `@le-real`: none; the `@le` scenario passes on Pebble. -- `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias known bugs render an invalid nginx-proxy config for the ~2 minutes until their create rolls back (proxy reloads for every site fail meanwhile). +- `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias regressions (fixed by #114, merged 2026-09-25; they passed on develop in https://github.com/mrrobot47/site-type-php/actions/runs/36099146918 and are regular scenarios since) would render an invalid nginx-proxy config for ~2 minutes if they regressed. - `@slow` (12): the PHP version matrix (one site per version). - `@security` (1): the SEC-1 payload (benign: `id>bhstpsec1` in the core root, removed after the scenario and by the cleanup). - `@remote`: none (no backups in this package). diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 4791f58..6b7d16b 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -53,10 +53,9 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And STDERR should contain "bh-site-type-php-74.test already exists as a site" And the site "bh-site-type-php-73.test" should not exist - # Blank entries are stored as-is on develop; fixed in site-type-php #114 (c7d7bc4, not merged). Until then the empty - # VIRTUAL_HOST entry renders an invalid nginx-proxy config (`upstream {`), so proxy reloads for every site fail until - # the status check rolls the site back (~2 minutes): hence @destructive. - @known-bug @destructive + # Regression for blank entries (fixed by site-type-php #114, merged 2026-09-25). A regression renders an invalid + # nginx-proxy config (`upstream {`) that breaks proxy reloads for every site for ~2 minutes: hence @destructive. + @destructive Scenario: Empty entries in --alias-domains are dropped When I run "bin/ee site create bh-site-type-php-75.test --type=php --alias-domains='bh-site-type-php-75-a.test,,bh-site-type-php-75-b.test,'" Then the command should succeed @@ -64,8 +63,7 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-75-a.test,bh-site-type-php-75-b.test" And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should not contain ",," - # AUTH-1: an alias like _wildcard. can overwrite proxy auth files; rejected by site-type-php #114 (268532a, not merged). - @known-bug + # AUTH-1 regression: an alias like _wildcard. could overwrite proxy auth files (fixed by site-type-php #114). Scenario Outline: An alias domain that is not a valid hostname is refused When I run "bin/ee site create --type=php --alias-domains=''" Then the return code should be 1 From b698dda4e93955d0dd06fa1212416440a653f845 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:01:18 +0530 Subject: [PATCH 21/35] test(site-type-php): make the PHPT-1..4 scenarios regular With the fix/site-name-collisions branches of site-command and site-type-php, --dbname/--dbuser of another site and a colliding volume prefix are refused before anything is created. The PHPT-4 scenario now asserts the refusal (existing sites keep their volume names), and a new scenario covers the numbered suffix for a taken default db name. --- features/COVERAGE.md | 13 +++++-------- features/create.feature | 11 ++++++----- features/database.feature | 38 +++++++++++++++++++++++++++----------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index e30d68e..d5de7f1 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -30,7 +30,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (only proves the create and port parsing: nothing probes today, PHPT-5) | | remote DB verification / DB auto-create / non-empty DB check | database.feature "An unreachable remote db host fails the create" `@known-bug` (PHPT-5); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | | `--force` (reset non-empty remote DB) | not covered: dead code today (PHPT-5), needs a reachable remote MariaDB | -| DB name / user collisions on rollback | database.feature 3 `@known-bug` scenarios (PHPT-1, PHPT-3, PHPT-2), each on its own sites | +| DB name / user collisions on rollback | database.feature: `--dbname`/`--dbuser` of another site refused (PHPT-1, PHPT-3), colliding default names refused by the volume check (PHPT-2), a taken default name gets a `_2` suffix, each on its own sites | | DB-1 (mysql CLI in php images vs MariaDB TLS) | database.feature 2 `@known-bug` scenarios | | `--php=` every supported version + `latest` | php-version.feature outline (12 examples, `@slow`) | | `--php` default 8.4 | create.feature "The site row holds the php defaults", "The compose file ..." | @@ -67,7 +67,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `.env`, `main.conf` (upstream, root, server_name, php vs redis blocks), `custom.ini`, `user.conf`, `admin-tools.conf`, `index.php`, `app/html` removed, `app` symlinked to the htdocs volume, `check.html` removed | create.feature "The site files are generated from the php templates" | | running containers (php, nginx, postfix; mailhog not started; + db/redis), labels, restart policy, networks | create.feature "The site containers, volumes and network are up", labels.feature, database/cache features | | docker volumes (8, or 11 with local db) and network labels | create.feature, labels.feature, database.feature | -| volume prefix collision between names differing only in `.`/`-` | create.feature `@known-bug` (PHPT-4) | +| volume prefix collision between names differing only in `.`/`-` | create.feature "A site name whose docker volumes collide with an existing site is refused" (PHPT-4) | | `/etc/hosts` entry (http and self-signed only) | create.feature, ssl.feature, alias-domains.feature | | HTTP: 200 + PHP executes, pretty URLs, 404, hidden/backup files denied, fpm ping/status, nginx_status | create.feature "The site is reachable over HTTP and runs PHP", "nginx denies hidden and backup files", "The php-fpm status and ping endpoints are wired" | @@ -132,16 +132,13 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). Several items counted before only passed vacuously (`--ssl=off --wildcard`, the directory auto-detect, rollback, the redirect delete, the combo cache checks); they now assert the real behaviour. -## Known bugs exercised (`@known-bug`, 24 scenarios incl. outline examples) +## Known bugs exercised (`@known-bug`, 20 scenarios incl. outline examples) Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: SEC-1, DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): | ID | Severity | Scenario(s) | |---|---|---| -| PHPT-1 | High (data loss) | database.feature "Reusing another site's --dbname fails without dropping that database" | -| PHPT-2 | High (data loss) | database.feature "Two site names that map to the same default db name ..." (the pair also collides on volumes, PHPT-4) | -| PHPT-3 | High (data loss) | database.feature "Reusing another site's --dbuser fails without dropping that user" | -| PHPT-4 | High (data mixing) | create.feature "Two site names that differ only in dots and dashes get separate volumes" | +| PHPT-1..4 | High | regular scenarios since the fixes on the `fix/site-name-collisions` branches of site-command and site-type-php (2026-09-26, local); they fail on develop until those are merged: database.feature "Reusing another site's --dbname ...", "Reusing another site's --dbuser ...", "Two site names that map to the same default db name ...", "A taken default db name gets a numbered suffix ..."; create.feature "A site name whose docker volumes collide with an existing site is refused" | | PHPT-5 | Medium | database.feature "An unreachable remote db host fails the create and rolls back" | | PHPT-6 | Medium | create.feature "--php= without --type keeps the requested version" | | PHPT-7 | Medium | alias-domains.feature "An alias domain that is an existing site is refused on create" | @@ -157,7 +154,7 @@ Other notes from the code: `--php=latest` maps to the php8.4 image, not the newe ## Tags -- `@known-bug` (24): expected to fail until fixed; exclude with `--tags='~@known-bug'`. +- `@known-bug` (20): expected to fail until fixed; exclude with `--tags='~@known-bug'`. - `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`behat/ci/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). - `@le-real`: none; the `@le` scenario passes on Pebble. - `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias regressions (fixed by #114, merged 2026-09-25; they passed on develop in https://github.com/mrrobot47/site-type-php/actions/runs/36099146918 and are regular scenarios since) would render an invalid nginx-proxy config for ~2 minutes if they regressed. diff --git a/features/create.feature b/features/create.feature index 44247d5..fdb6ea8 100644 --- a/features/create.feature +++ b/features/create.feature @@ -204,13 +204,14 @@ Feature: Create a plain php site And the file "bhstpsec1" should not exist # PHPT-4, core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to - # the prefix bhsitetypephp27test. create_volumes() skips volumes that already exist, and the second site mounts the - # first site's htdocs/config volumes. - @known-bug - Scenario: Two site names that differ only in dots and dashes get separate volumes + # the prefix bhsitetypephp27test. Existing sites keep their volume names, so the second create is refused. + Scenario: A site name whose docker volumes collide with an existing site is refused Given a php site "bh-site-type-php-2-7.test" exists When I run "bin/ee site create bh-site-type-php-27.test --type=php" - Then the site "bh-site-type-php-27.test" should have 8 docker volumes + Then the command should fail + And STDERR should contain "Site bh-site-type-php-27.test would share docker volumes (bhsitetypephp27test_*) with the existing site bh-site-type-php-2-7.test." + And the site "bh-site-type-php-27.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-27.test" should not exist And the site "bh-site-type-php-2-7.test" should have 8 docker volumes And a GET request to "http://bh-site-type-php-2-7.test/" should return 200 And the response should contain "/opt/easyengine/sites/bh-site-type-php-2-7.test/app/htdocs" diff --git a/features/database.feature b/features/database.feature index bf2ba90..81a813d 100644 --- a/features/database.feature +++ b/features/database.feature @@ -142,40 +142,56 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the site "bh-site-type-php-17.test" should not exist And the directory "/opt/easyengine/sites/bh-site-type-php-17.test" should not exist - # PHPT-1, data loss: create_user_in_db() fails on the existing database, and catch_clean() then runs - # cleanup_db() with the requested --dbname, dropping the OTHER site's database. - @known-bug + # PHPT-1: another site's --dbname is refused up front; the rollback only drops what the create itself made. Scenario: Reusing another site's --dbname fails without dropping that database Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists And the global database "bh_stp_shared20" contains the table "bh_marker" When I run "bin/ee site create bh-site-type-php-21.test --type=php --with-db --dbname=bh_stp_shared20" Then the command should fail + And STDERR should contain "Database `bh_stp_shared20` is already used by site bh-site-type-php-20.test on the global database." + And the directory "/opt/easyengine/sites/bh-site-type-php-21.test" should not exist And the site "bh-site-type-php-21.test" should not exist And the global database "bh_stp_shared20" should contain the table "bh_marker" And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database - # PHPT-3, data loss: same rollback path with --dbuser; cleanup_db_user() drops the other site's user. - # Own site 23 (not 20): the --dbname scenario above may already have dropped site 20's database. - @known-bug + # PHPT-3: another site's --dbuser is refused up front, so its user and password are untouched. Scenario: Reusing another site's --dbuser fails without dropping that user Given a php site "bh-site-type-php-23.test" created with "--with-db --dbname=bh_stp_db23 --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Pass-23" exists When I run "bin/ee site create bh-site-type-php-24.test --type=php --with-db --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Other-24" Then the command should fail + And STDERR should contain "Database user `bh_stp_user23` is already used by site bh-site-type-php-23.test on the global database." + And the global database "bh_site_type_php_24_test" should not exist And the site "bh-site-type-php-24.test" should not exist And the global database user "bh_stp_user23" should exist And the php service of site "bh-site-type-php-23.test" should be able to connect to the site database - # PHPT-2, data loss: the default db name replaces "." and "-" with "_", so these two names collide, - # and the failed second create drops the first site's database. The same pair also shares volume names (PHPT-4), - # so the second create is expected to fail even once PHPT-2 is fixed; only site 22.x's data is asserted. - @known-bug + # PHPT-2: the default db name replaces "." and "-" with "_", so these two names collide. The pair also shares volume + # names (PHPT-4), so the second create is refused before the database step. Scenario: Two site names that map to the same default db name do not destroy each other's database Given a php site "bh-site-type-php-22.x.test" created with "--with-db" exists And the global database "bh_site_type_php_22_x_test" contains the table "bh_marker" When I run "bin/ee site create bh-site-type-php-22-x.test --type=php --with-db" - Then the global database "bh_site_type_php_22_x_test" should contain the table "bh_marker" + Then the command should fail + And STDERR should contain "would share docker volumes (bhsitetypephp22xtest_*) with the existing site bh-site-type-php-22.x.test." + And the site "bh-site-type-php-22-x.test" should not exist + And the global database "bh_site_type_php_22_x_test" should contain the table "bh_marker" And the php service of site "bh-site-type-php-22.x.test" should be able to connect to the site database + # PHPT-2: a default db name that is already taken (here by a database no site owns) gets a numbered suffix. + Scenario: A taken default db name gets a numbered suffix and the existing database is kept + Given the global database "bh_site_type_php_28_test" with user "bh_stp_user28" identified by "Bh-Stp-Pass-28" exists + And the global database "bh_site_type_php_28_test" contains the table "bh_marker" + When I run "bin/ee site create bh-site-type-php-28.test --type=php --with-db" + Then the command should succeed + And STDOUT should contain "Database `bh_site_type_php_28_test` already exists, using `bh_site_type_php_28_test_2` instead." + And the site "bh-site-type-php-28.test" should have the field "db_name" set to "bh_site_type_php_28_test_2" + And the global database "bh_site_type_php_28_test" should contain the table "bh_marker" + And the php service of site "bh-site-type-php-28.test" should be able to connect to the site database + When I run "bin/ee site delete bh-site-type-php-28.test --yes" + Then the command should succeed + And the global database "bh_site_type_php_28_test_2" should not exist + And the global database "bh_site_type_php_28_test" should contain the table "bh_marker" + Scenario: Deleting a global-db site drops its database and user Given a php site "bh-site-type-php-25.test" created with "--with-db --dbuser=bh_stp_user25 --dbpass=Bh-Stp-Pass-25" exists And the global database "bh_site_type_php_25_test" should exist From d2d57a0d8bef4a31897c4c240d4e87997811d284 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:04:00 +0530 Subject: [PATCH 22/35] test(site-type-php): make the PHPT-10 scenario regular It passes with the SC-3 fix on site-command fix/proxy-cache-rollback-and-le-alias and fails on develop until it merges. --- features/COVERAGE.md | 6 +++--- features/ssl.feature | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index d5de7f1..be2959d 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -110,7 +110,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `update --php` same version / invalid / `latest` / disabled / unknown site | php-version.feature 4 scenarios | | `update --add-alias-domains` / `--delete-alias-domains` (+ all error paths, wildcard) | alias-domains.feature 9 update scenarios | | `update --ssl=self` / `--ssl=off` / already enabled / already disabled / `--ssl=off --wildcard` (on a wildcard site) / normal to wildcard | ssl.feature 3 scenarios | -| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature `@known-bug` (PHPT-10) | +| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature "SSL of a site created with --ssl=self can be disabled" (PHPT-10, regular since site-command `fix/proxy-cache-rollback-and-le-alias`; fails on develop until merged) | | `update --ssl=le`, `--wildcard` | not covered (LE budget; wildcard needs DNS-01) | | `update --proxy-cache=on/off` (conf.d, vhost.d incl. alias, already-on error) | lifecycle.feature `@destructive` (restarts nginx-proxy) | | `--proxy-cache-max-size/-max-time/-key-zone-size` | not covered: site-command generic | @@ -132,7 +132,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). Several items counted before only passed vacuously (`--ssl=off --wildcard`, the directory auto-detect, rollback, the redirect delete, the combo cache checks); they now assert the real behaviour. -## Known bugs exercised (`@known-bug`, 20 scenarios incl. outline examples) +## Known bugs exercised (`@known-bug`, 19 scenarios incl. outline examples) Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: SEC-1, DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): @@ -144,7 +144,7 @@ Every `@known-bug` scenario asserts the correct behaviour, so it fails until the | PHPT-7 | Medium | alias-domains.feature "An alias domain that is an existing site is refused on create" | | PHPT-8 | Medium | cache.feature "ee site clean --page purges the php page cache in the global redis" | | PHPT-9 | Medium | cache.feature "ee site clean purges the page cache of a local-redis site" | -| PHPT-10 | Medium | ssl.feature "SSL of a site created with --ssl=self can be disabled" | +| PHPT-10 | Medium | regular since the SC-3 fix on site-command `fix/proxy-cache-rollback-and-le-alias` (2026-09-27, local); fails on develop until merged: ssl.feature "SSL of a site created with --ssl=self can be disabled" | | PHPT-11 | Low | create.feature outline "A --php value that only loosely matches ..." (4 examples) | | PHPT-12 | Low | public-dir.feature "A bare --public-dir without a value does not create the document root 1" | | PHPT-13 | Low | lifecycle.feature "site restart --db on a php site without a local db fails" | diff --git a/features/ssl.feature b/features/ssl.feature index ce4bc52..7b1caf7 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -120,9 +120,7 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) Then the return code should be 1 And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." - # PHPT-10 (site-command): create --ssl=self stores site_ssl_wildcard=1, and update_ssl() then refuses - # --ssl=off ("wildcard to normal") and --ssl=off --wildcard ("cannot use --wildcard"), so SSL can never be turned off. - @known-bug + # PHPT-10 / SC-3 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): update_ssl() refused --ssl=off for self-signed creates, which store site_ssl_wildcard=1. Scenario: SSL of a site created with --ssl=self can be disabled Given a php site "bh-site-type-php-89.test" created with "--ssl=self" exists When I run "bin/ee site update bh-site-type-php-89.test --ssl=off" From b60cf49c40798b287d6fa7ef73360df559e8d7f5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:06:29 +0530 Subject: [PATCH 23/35] test(site-type-php): follow the v4.13.x SSL and site name fixes SEC-1 is fixed (site-type-php#117), so its scenario is regular. The wildcard-update refusal is checked with --ssl=le, since self-signed certificates are always wildcard (site-command 5c34be8). --- features/COVERAGE.md | 4 ++-- features/create.feature | 4 ++-- features/ssl.feature | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index be2959d..f7abe67 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -12,7 +12,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | name normalisation: `http://` stripped, trailing `/` stripped, lowercased (`https://` shares the code path, not tested) | create.feature "The site name is normalised" | | name without a dot gets `.test` | create.feature "A site name without a dot gets the .test TLD" | | duplicate site name refused, first site untouched | create.feature "Creating the same site twice fails" | -| site name validation (SEC-1) | create.feature "A site name with shell syntax is rejected" `@known-bug @security` | +| site name validation (SEC-1) | create.feature "A site name with shell syntax is rejected" `@security`, regular since the v4.13.2 fix (#117) | | unknown flag refused | create.feature "An unknown flag is rejected" | | unknown `--type` refused | site.feature "An unknown site type is rejected" | | `--cache` (global redis) | cache.feature scenarios 1-4 (DB flags, main.conf, networks, HIT from redis, BYPASS on query string) | @@ -134,7 +134,7 @@ Estimated coverage: about 88% of the inventory items above (php-specific items: ## Known bugs exercised (`@known-bug`, 19 scenarios incl. outline examples) -Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: SEC-1, DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): +Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): SEC-1 is fixed in v4.13.2 (site-type-php#117, site-command#509) and its scenario is regular since Behat r2 (2026-10-19). | ID | Severity | Scenario(s) | |---|---|---| diff --git a/features/create.feature b/features/create.feature index fdb6ea8..c401ebc 100644 --- a/features/create.feature +++ b/features/create.feature @@ -194,9 +194,9 @@ Feature: Create a plain php site And the site "bh-site-type-php-6.test" should have the field "site_type" set to "php" And the site "bh-site-type-php-6.test" should have the field "php_version" set to "8.3" - # SEC-1: the site name reaches shell sinks unescaped (volume commands run from the core root). Benign payload that + # SEC-1 (fixed in v4.13.2, site-type-php#117 and site-command#509), regression check: the site name used to reach shell sinks unescaped (volume commands run from the core root). Benign payload that # survives the lowercasing: writes ./bhstpsec1 there, which the scenario cleanup removes. - @known-bug @security + @security Scenario: A site name with shell syntax is rejected and never executed When I run "bin/ee site create 'bh-site-type-php-5$(id>bhstpsec1).test' --type=php" Then the command should fail diff --git a/features/ssl.feature b/features/ssl.feature index 7b1caf7..798b40f 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -114,9 +114,10 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) And STDERR should contain "You cannot use --wildcard flag with --ssl=off" And the site "bh-site-type-php-80.test" should have the field "site_ssl" set to "self" + # Self-signed certificates are always wildcard (site-command 5c34be8, v3.8.1), so the refusal is checked with LE; it fails before any ACME call. Scenario: Switching a normal SSL site to wildcard SSL is refused Given a php site "bh-site-type-php-88.test" exists - When I run "bin/ee site update bh-site-type-php-88.test --ssl=self --wildcard" + When I run "bin/ee site update bh-site-type-php-88.test --ssl=le --wildcard" Then the return code should be 1 And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." From 098908a584db08c21eceea4b3a458425504fbe3c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:08:45 +0530 Subject: [PATCH 24/35] test(site-type-php): check the wildcard refusal on a site that never had SSL --- features/ssl.feature | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/ssl.feature b/features/ssl.feature index 798b40f..7ae1c8d 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -115,9 +115,10 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) And the site "bh-site-type-php-80.test" should have the field "site_ssl" set to "self" # Self-signed certificates are always wildcard (site-command 5c34be8, v3.8.1), so the refusal is checked with LE; it fails before any ACME call. + # A site that never had SSL: one that had --ssl=self keeps site_ssl_wildcard=1 after --ssl=off, and would go on to request a wildcard certificate. Scenario: Switching a normal SSL site to wildcard SSL is refused - Given a php site "bh-site-type-php-88.test" exists - When I run "bin/ee site update bh-site-type-php-88.test --ssl=le --wildcard" + Given a php site "bh-site-type-php-200.test" exists + When I run "bin/ee site update bh-site-type-php-200.test --ssl=le --wildcard" Then the return code should be 1 And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." From a285b040075dfbcf274d3500a3109f5c388b3fe5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:11:26 +0530 Subject: [PATCH 25/35] test(site-type-php): 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 63fc806..b5638aa 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -220,8 +220,7 @@ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $ex { $command_output = $this->streamOf( $this->lastCommand(), $output_stream ); - $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); } } @@ -233,8 +232,7 @@ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, { $command_output = $this->streamOf( $this->commandAt( $index ), $output_stream ); - $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 7f1d528..e39d6b3 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 a1f746419bb6e7539a1743131b0e79fd3292cb9a Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:14:13 +0530 Subject: [PATCH 26/35] test(site-type-php): check each line of the missing-site errors The something-like step now compares the whole expected text, and the second line of these errors ends with a space, so each line is checked on its own. --- features/site.feature | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/features/site.feature b/features/site.feature index 4256156..2eba0fa 100644 --- a/features/site.feature +++ b/features/site.feature @@ -71,39 +71,27 @@ Feature: Site command presence and usage errors Scenario: Check site disable sub command is present When I run 'bin/ee site disable' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site disable command on. - Either pass it as an argument: `ee site disable ` - or run `ee site disable` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site disable command on." + And STDERR should contain "Either pass it as an argument: `ee site disable `" + And STDERR should contain "or run `ee site disable` from inside the site folder." Scenario: Check site enable sub command is present When I run 'bin/ee site enable' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site enable command on. - Either pass it as an argument: `ee site enable ` - or run `ee site enable` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site enable command on." + And STDERR should contain "Either pass it as an argument: `ee site enable `" + And STDERR should contain "or run `ee site enable` from inside the site folder." Scenario: Check site reload sub command is present When I run 'bin/ee site reload' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site reload command on. - Either pass it as an argument: `ee site reload ` - or run `ee site reload` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site reload command on." + And STDERR should contain "Either pass it as an argument: `ee site reload `" + And STDERR should contain "or run `ee site reload` from inside the site folder." Scenario: Check site info sub command is present When I run 'bin/ee site info' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site info command on. - Either pass it as an argument: `ee site info ` - or run `ee site info` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site info command on." + And STDERR should contain "Either pass it as an argument: `ee site info `" + And STDERR should contain "or run `ee site info` from inside the site folder." Scenario: Check site delete sub command is present When I run 'bin/ee site delete' From f7c3e7b08c98ebe4e6ae52530a37e4bc14ff8337 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:16:52 +0530 Subject: [PATCH 27/35] 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 | 124 +++++++++++ 2 files changed, 492 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:/// (HTTP-01 path) +# pebble-setup.sh logs print the Pebble, proxy and DNS logs +# pebble-setup.sh teardown undo everything setup did +# +# Needs root, docker, openssl, curl, php, iptables and systemd-resolved (GitHub's ubuntu runners have all of them). +# Pebble's root is trusted system-wide while it runs, so never leave it set up on a machine used for anything else. +set -euo pipefail + +PEBBLE_DIR=${PEBBLE_DIR:-/tmp/ee-pebble} +# 2.10+ also offers the token-less draft dns-persist-01 challenge, which EE's acmephp 1.3 client can't parse (SSL-10). +PEBBLE_VERSION=${PEBBLE_VERSION:-2.9.0} +PEBBLE_NET=${PEBBLE_NET:-ee-pebble} +PEBBLE_BRIDGE=${PEBBLE_BRIDGE:-ee-pebble0} +PEBBLE_SUBNET=${PEBBLE_SUBNET:-172.30.0.0/24} +PEBBLE_GATEWAY=${PEBBLE_GATEWAY:-172.30.0.1} +# EE's host resolves to the front proxy: Pebble serves its directory at /dir, EE requests /directory. +PEBBLE_IP=${PEBBLE_IP:-172.30.0.10} +PEBBLE_ACME_IP=${PEBBLE_ACME_IP:-172.30.0.12} +PEBBLE_PROXY_IMAGE=${PEBBLE_PROXY_IMAGE:-nginx:1.29-alpine} +PEBBLE_DNS_IP=${PEBBLE_DNS_IP:-172.30.0.11} +PEBBLE_DNS_IMAGE=${PEBBLE_DNS_IMAGE:-alpine:3.22} +# Every name under this domain resolves to PEBBLE_TARGET_IP, on the runner and for Pebble's VA. +PEBBLE_DOMAIN=${PEBBLE_DOMAIN:-ee4-test.rt.gw} +# The gateway is a host address, so both the runner and the VA reach nginx-proxy's published :80 through it. +PEBBLE_TARGET_IP=${PEBBLE_TARGET_IP:-$PEBBLE_GATEWAY} +ACME_HOST=acme-v02.api.letsencrypt.org +TOS_MARKER='Do%20what%20thou%20wilt' +PUBLIC_PROBE=api.wordpress.org +MARK='# ee-pebble' +RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/ee-pebble.conf +CA_LINK=/usr/local/share/ca-certificates/ee-pebble-ca.crt +ROOT_LINK=/usr/local/share/ca-certificates/ee-pebble-root.crt +STATE="$PEBBLE_DIR/state" + +log() { printf '[pebble] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +need_root() { [[ $EUID -eq 0 ]] || die 'run as root'; } + +# Records an undo command (one per line) for teardown. +remember() { printf '%s\n' "$*" >>"$STATE"; } + +public_ips() { + # $1 = ahostsv4|ahostsv6, $2 = name; resolved before any override is installed. + getent "$1" "$2" 2>/dev/null | awk '{print $1}' | sort -u || true +} + +block_ip() { + local ip=$1 tool=iptables + [[ $ip == *:* ]] && tool=ip6tables + command -v "$tool" >/dev/null || return 0 + "$tool" -I OUTPUT -d "$ip" -j REJECT + remember "$tool -D OUTPUT -d $ip -j REJECT" + if "$tool" -n -L DOCKER-USER >/dev/null 2>&1; then + "$tool" -I DOCKER-USER -d "$ip" -j REJECT + remember "$tool -D DOCKER-USER -d $ip -j REJECT" + fi + log "blocked egress to $ip" +} + +make_certs() { + local d=$PEBBLE_DIR + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj '/CN=EE Behat Pebble listener CA' \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign' \ + -keyout "$d/ca.key" -out "$d/ca.pem" 2>/dev/null + openssl req -newkey rsa:2048 -nodes -subj "/CN=$ACME_HOST" -keyout "$d/key.pem" -out "$d/cert.csr" 2>/dev/null + printf 'subjectAltName=DNS:%s,IP:%s\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\n' \ + "$ACME_HOST" "$PEBBLE_IP,IP:$PEBBLE_ACME_IP" >"$d/cert.ext" + openssl x509 -req -in "$d/cert.csr" -CA "$d/ca.pem" -CAkey "$d/ca.key" -CAcreateserial -days 2 \ + -extfile "$d/cert.ext" -out "$d/cert.pem" 2>/dev/null + rm -f "$d/ca.key" "$d/cert.csr" "$d/ca.srl" + # Pebble runs as root in a scratch image; the key only has to be readable by it. + chmod 644 "$d/key.pem" +} + +write_config() { + # The blocklist mirrors LE refusing reserved TLDs. + cat >"$PEBBLE_DIR/pebble-config.json" <<-EOF + { + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "/pebble/cert.pem", + "privateKey": "/pebble/key.pem", + "httpPort": 80, + "tlsPort": 443, + "ocspResponderURL": "", + "externalAccountBindingRequired": false, + "domainBlocklist": ["invalid", "test", "example", "localhost", "local"], + "retryAfter": { "authz": 3, "order": 5 }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { "description": "LE classic: 90 days, first name as CN", "validityPeriod": 7776000, "promoteCommonName": true } + } + } + } + EOF +} + +write_front_config() { + # Pebble builds its URLs from Host and X-Forwarded-Proto, so they come back as https://$ACME_HOST/... + cat >"$PEBBLE_DIR/front.conf" <<-EOF + server { + listen 443 ssl; + ssl_certificate /pebble/cert.pem; + ssl_certificate_key /pebble/key.pem; + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_redirect off; + proxy_ssl_server_name on; + proxy_ssl_name $ACME_HOST; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /pebble/ca.pem; + location = /directory { proxy_pass https://$PEBBLE_ACME_IP:14000/dir; } + # Boulder puts a blank line between chain PEMs and acmephp splits on it; Pebble puts none. + location /certZ/ { + proxy_pass https://$PEBBLE_ACME_IP:14000; + # A location-level proxy_set_header drops the inherited ones; Pebble checks the kid against Host. + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Accept-Encoding ""; + sub_filter_types application/pem-certificate-chain; + sub_filter_once off; + sub_filter "-----END CERTIFICATE----- + -----BEGIN CERTIFICATE-----" "-----END CERTIFICATE----- + + -----BEGIN CERTIFICATE-----"; + } + location / { proxy_pass https://$PEBBLE_ACME_IP:14000; } + } + EOF +} + +wait_for() { + # $1 = description, rest = command; up to 60 s. + local what=$1 i + shift + for ((i = 0; i < 60; i++)); do + "$@" >/dev/null 2>&1 && return 0 + sleep 1 + done + die "timed out waiting for $what" +} + +start_containers() { + docker network create --driver bridge --subnet "$PEBBLE_SUBNET" --gateway "$PEBBLE_GATEWAY" \ + -o "com.docker.network.bridge.name=$PEBBLE_BRIDGE" "$PEBBLE_NET" >/dev/null + remember "docker network rm $PEBBLE_NET" + # dnsmasq, not challtestsrv: it answers only the test domain and forwards everything else, because resolved's + # global DNS also ends up in the containers' resolv.conf (challtestsrv answers every name, even wordpress.org). + local upstream + upstream=$(awk '$1 == "nameserver" && $2 !~ /^127\./ {print $2; exit}' /run/systemd/resolve/resolv.conf 2>/dev/null) + [[ -n $upstream ]] || die 'no upstream DNS server found in /run/systemd/resolve/resolv.conf' + docker run -d --name ee-pebble-dns --network "$PEBBLE_NET" --ip "$PEBBLE_DNS_IP" "$PEBBLE_DNS_IMAGE" \ + sh -c "apk add -q --no-cache dnsmasq && exec dnsmasq -k --no-hosts --no-resolv --log-queries --log-facility=- \ + --server=$upstream --address=/$PEBBLE_DOMAIN/$PEBBLE_TARGET_IP" >/dev/null + remember 'docker rm -f ee-pebble-dns' + docker run -d --name ee-pebble --network "$PEBBLE_NET" --ip "$PEBBLE_ACME_IP" \ + -e PEBBLE_VA_NOSLEEP=1 -e PEBBLE_WFE_NONCEREJECT=0 -e PEBBLE_AUTHZREUSE=100 \ + -v "$PEBBLE_DIR:/pebble:ro" "ghcr.io/letsencrypt/pebble:$PEBBLE_VERSION" \ + -config /pebble/pebble-config.json -dnsserver "$PEBBLE_DNS_IP:53" >/dev/null + remember 'docker rm -f ee-pebble' + docker run -d --name ee-pebble-front --network "$PEBBLE_NET" --ip "$PEBBLE_IP" \ + -v "$PEBBLE_DIR:/pebble:ro" -v "$PEBBLE_DIR/front.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$PEBBLE_PROXY_IMAGE" >/dev/null + remember 'docker rm -f ee-pebble-front' +} + +allow_va_forwarding() { + # Docker's inter-network isolation may drop the VA's DNATed request to nginx-proxy's bridge; DOCKER-USER runs first. + iptables -n -L DOCKER-USER >/dev/null 2>&1 || return 0 + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -p tcp --dport 80 -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -p tcp --dport 80 -j ACCEPT" + # Site containers get the resolver from resolved's resolv.conf, which now lists the Pebble DNS. + local proto + for proto in udp tcp; do + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -d "$PEBBLE_DNS_IP" -p "$proto" --dport 53 -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -d $PEBBLE_DNS_IP -p $proto --dport 53 -j ACCEPT" + done +} + +redirect_acme_host() { + cp /etc/hosts "$PEBBLE_DIR/hosts.bak" + printf '%s\t%s %s\n' "$PEBBLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" +} + +route_test_domain() { + systemctl is-active --quiet systemd-resolved || die 'systemd-resolved is not active; cannot route *.'"$PEBBLE_DOMAIN" + mkdir -p "$(dirname "$RESOLVED_DROPIN")" + printf '[Resolve]\nDNS=%s\nDomains=~%s\n' "$PEBBLE_DNS_IP" "$PEBBLE_DOMAIN" >"$RESOLVED_DROPIN" + remember "rm -f $RESOLVED_DROPIN; systemctl restart systemd-resolved" + systemctl restart systemd-resolved + resolvectl flush-caches 2>/dev/null || true +} + +php_cafiles() { + # CA files PHP is configured to use instead of the system bundle, if any. + php <<-'PHP' | sort -u + /dev/null" + update-ca-certificates >/dev/null + # Guzzle (verify=true) and PHP streams fall back to these when set, so they must trust the CAs too. + while IFS= read -r f; do + [[ -f $f ]] || continue + [[ $(readlink -f "$f") == "$(readlink -f /etc/ssl/certs/ca-certificates.crt)" ]] && continue + cp "$f" "$f.ee-pebble.bak" + remember "mv -f '$f.ee-pebble.bak' '$f'" + for pem in "$PEBBLE_DIR/ca.pem" "$PEBBLE_DIR/pebble-root.pem"; do cat "$pem" >>"$f"; done + log "appended the CAs to PHP's $f" + done < <(php_cafiles) +} + +write_env() { + local issuer + issuer=$(openssl x509 -in "$PEBBLE_DIR/pebble-intermediate.pem" -noout -subject -nameopt multiline | + sed -n 's/^ *commonName *= *//p') + [[ -n $issuer ]] || die 'could not read the Pebble intermediate CN' + printf 'EE_BEHAT_LE_ISSUER=%s\nEE_BEHAT_LE_DNS_SERVER=127.0.0.53\nEE_BEHAT_PEBBLE_ROOT=%s\n' \ + "$issuer" "$PEBBLE_DIR/pebble-root.pem" >"$PEBBLE_DIR/env" + if [[ -n ${GITHUB_ENV:-} ]]; then + cat "$PEBBLE_DIR/env" >>"$GITHUB_ENV" + fi + log "issuer for the scenarios: $issuer" +} + +cmd_setup() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip + # Resolve before the overrides so the real addresses get blocked. + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") \ + $(public_ips ahostsv4 "$PEBBLE_DOMAIN") $(public_ips ahostsv6 "$PEBBLE_DOMAIN"); do + block_ip "$ip" + done + + make_certs + write_config + write_front_config + start_containers + allow_va_forwarding + redirect_acme_host + + wait_for 'the Pebble directory' curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$ACME_HOST/directory" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/roots/0" -o "$PEBBLE_DIR/pebble-root.pem" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/intermediates/0" -o "$PEBBLE_DIR/pebble-intermediate.pem" + + trust_cas + wait_for 'the Pebble DNS' docker exec ee-pebble-dns pidof dnsmasq + route_test_domain + write_env + cmd_verify + log 'setup done' +} + +cmd_verify() { + local probe resolved ip body + resolved=$(getent ahostsv4 "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not Pebble ($PEBBLE_IP)" + + body=$(curl -fsS "https://$ACME_HOST/directory") || die 'curl with the system trust store cannot reach Pebble' + [[ $body == *"$TOS_MARKER"* ]] || die "the directory at $ACME_HOST is not Pebble's: $body" + [[ $body == *"https://$ACME_HOST/"* ]] || die "Pebble's directory does not use $ACME_HOST in its URLs: $body" + + # The same two paths EE's ACME client can take: Guzzle over ext-curl (verify=true, no CA option) and PHP streams. + # Pebble rejects requests without a User-Agent (Guzzle always sends one). + ACME_HOST=$ACME_HOST TOS_MARKER=$TOS_MARKER php <<-'PHP' >&2 || die 'PHP does not reach Pebble with default TLS verification' + true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'ee-pebble-verify']); + $a = curl_exec($c); + if (false === $a || false === strpos($a, $m)) { + fwrite(STDERR, 'php curl: ' . json_encode(['error' => curl_error($c), 'url' => $u, 'marker' => $m, 'code' => curl_getinfo($c, CURLINFO_RESPONSE_CODE), 'ip' => curl_getinfo($c, CURLINFO_PRIMARY_IP), 'body' => substr((string) $a, 0, 300)]) . "\n"); + exit(1); + } + $b = @file_get_contents($u, false, stream_context_create(['http' => ['user_agent' => 'ee-pebble-verify']])); + if (false === $b || false === strpos($b, $m)) { fwrite(STDERR, 'php stream: ' . json_encode([error_get_last(), substr((string) $b, 0, 300)]) . "\n"); exit(1); } + echo 'php curl + stream reach Pebble; ', json_encode(['curl.cainfo' => ini_get('curl.cainfo'), 'openssl' => openssl_get_cert_locations()['default_cert_file'], 'ssl' => curl_version()['ssl_version']]), "\n"; + PHP + + probe="probe-$RANDOM.$PEBBLE_DOMAIN" + resolved=$(getent ahostsv4 "$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + resolved=$(getent ahostsv4 "www.$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "www.$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + + # Everything else must still resolve normally, on the host and in containers on other networks (like EE's sites). + resolved=$(getent ahostsv4 "$PUBLIC_PROBE" | awk 'NR==1{print $1}') + [[ -n $resolved && $resolved != "$PEBBLE_TARGET_IP" ]] || die "$PUBLIC_PROBE resolves to '$resolved' on the host" + log "resolved upstreams for containers: $(grep '^nameserver' /run/systemd/resolve/resolv.conf | tr '\n' ' ')" + docker network create ee-pebble-probe >/dev/null + body=$(docker run --rm --network ee-pebble-probe "$PEBBLE_DNS_IMAGE" sh -c \ + "nslookup $probe | tail -n +3; wget -q -O /dev/null https://$PUBLIC_PROBE/ && echo public-https-ok" 2>&1) || true + docker network rm ee-pebble-probe >/dev/null + log "container view of $probe: $(tr '\n' ' ' <<<"$body")" + [[ $body == *public-https-ok* ]] || die "containers can no longer reach https://$PUBLIC_PROBE/: $body" + + while read -r _ ip; do + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> Pebble $PEBBLE_IP (system trust), *.$PEBBLE_DOMAIN -> $PEBBLE_TARGET_IP, real LE blocked" +} + +cmd_reach() { + local code + code=$(docker run --rm --network "$PEBBLE_NET" "curlimages/curl:8.11.1" -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 10 "http://$PEBBLE_TARGET_IP/.well-known/acme-challenge/ee-pebble-probe") || + die "a container on $PEBBLE_NET cannot reach http://$PEBBLE_TARGET_IP/ (HTTP-01 would fail)" + log "container on $PEBBLE_NET -> http://$PEBBLE_TARGET_IP/ answered HTTP $code" +} + +cmd_logs() { + local c + for c in ee-pebble ee-pebble-front ee-pebble-dns; do + echo "===== $c" + docker logs "$c" 2>&1 || true + done +} + +cmd_teardown() { + need_root + [[ -f $STATE ]] || { log 'nothing to tear down'; return 0; } + local line + # Undo in reverse order; keep going on errors so a partial setup is still cleaned up. + while IFS= read -r line; do + bash -c "$line" >/dev/null 2>&1 || log "undo failed (ignored): $line" + done < <(tac "$STATE") + resolvectl flush-caches 2>/dev/null || true + rm -rf "$PEBBLE_DIR" + log 'teardown done' +} + +case ${1:-} in + setup) cmd_setup ;; + verify) cmd_verify ;; + reach) cmd_reach ;; + logs) cmd_logs ;; + teardown) cmd_teardown ;; + *) + sed -n '2,12p' "$0" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/behat-le.yml b/.github/workflows/behat-le.yml new file mode 100644 index 0000000..8e27dba --- /dev/null +++ b/.github/workflows/behat-le.yml @@ -0,0 +1,124 @@ +# @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 + strategy: + fail-fast: false + matrix: + php: ['8.4'] + # No @le scenario is a known bug here, so there is no known-bug lane. + lane: [main] + include: + - lane: main + 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: 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 07ad6af6980267f2006c2552309a2cdaa17fdfd2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:19:03 +0530 Subject: [PATCH 28/35] ci: run the behat suite on pull requests and develop Tests the package checkout inside a fresh core develop checkout through a Composer path repository. Lanes: main, slow (every PHP version image) and known-bug (continue-on-error). @le runs in behat-le.yml. --- .github/workflows/behat.yml | 169 ++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/behat.yml diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 0000000..e160b72 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,169 @@ +# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +name: Behat + +on: + pull_request: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + # Not RCLONE_*: rclone reads those variables as its own flags. + RCLONE_RELEASE: v1.75.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + # Local rclone alias written to EE's rclone-path; empty = no rclone. + EE_RCLONE_PATH: '' + 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 + 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 09643aa939378f648021e205a579bd890147bb2b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:21:34 +0530 Subject: [PATCH 29/35] 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 | 124 --------------------------------- .github/workflows/behat.yml | 56 ++++++++++++--- 2 files changed, 45 insertions(+), 135 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 8e27dba..0000000 --- a/.github/workflows/behat-le.yml +++ /dev/null @@ -1,124 +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 - strategy: - fail-fast: false - matrix: - php: ['8.4'] - # No @le scenario is a known bug here, so there is no known-bug lane. - lane: [main] - include: - - lane: main - 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: 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 e160b72..d19691d 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] include: - lane: main tags: '~@known-bug&&~@slow' @@ -47,6 +49,9 @@ jobs: tags: '@slow&&~@known-bug' - lane: known-bug tags: '@known-bug' + # @le against Pebble (.github/behat/pebble-setup.sh). + - lane: le + tags: '~@known-bug' steps: - name: Check out the package uses: actions/checkout@v7 @@ -83,6 +88,9 @@ jobs: docker-compose version openssl version echo "umask $(umask), under sudo $(sudo sh -c umask)" + # Suites that request the host's public IP use the first of these; EE picks a frontend subnet outside eth0's. + echo "hostname -I: $(hostname -I)" + ip -4 route if [ -n "$EE_RCLONE_PATH" ]; then curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" @@ -120,22 +128,38 @@ jobs: # The first non-cli command runs the initial setup (DB, config, image pulls). sudo -E bin/ee site list || true # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. - sudo -E bin/ee config set le-mail behat@example.com + sudo -E bin/ee config set le-mail behat-ci@example.org if [ -n "$EE_RCLONE_PATH" ]; then sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" sudo -E bin/ee config set no-of-backups 3 fi + # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. + docker-compose version + + + - name: Start Pebble and redirect Let's Encrypt to it + if: startsWith(matrix.lane, 'le') + run: | + sudo -E pkg/.github/behat/pebble-setup.sh setup + # Diagnostic only: nginx-proxy may not be up before the first site exists. + sudo docker ps --format '{{.Names}} {{.Ports}}' + sudo pkg/.github/behat/pebble-setup.sh reach || true - name: Run Behat working-directory: easyengine shell: 'script -q -e -c "bash {0}"' + # A hung step (e.g. a command stopped on the TTY) should still leave logs. + timeout-minutes: 75 run: | set -e mkdir -p "$RUNNER_TEMP/behat/junit" - sudo -E vendor/bin/behat --strict --tags="${EXCLUDE_TAGS}&&${{ matrix.tags }}" \ - --format=progress --out=std \ - --format=junit --out="$RUNNER_TEMP/behat/junit" \ - --format=pretty --out="$RUNNER_TEMP/behat/pretty.log" + base="$EXCLUDE_TAGS" + case "${{ matrix.lane }}" in le*) base="$LE_TAGS" ;; esac + behat=(sudo -E vendor/bin/behat --strict --tags="${base}&&${{ matrix.tags }}" + --format=progress --out=std + --format=junit --out="$RUNNER_TEMP/behat/junit" + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log") + "${behat[@]}" - name: Collect logs if: always() @@ -145,14 +169,20 @@ jobs: sudo cp /opt/easyengine/logs/ee.log "$out/" 2>/dev/null || echo 'No ee.log.' sudo stat -c '%a %U:%G %n' /opt/easyengine /opt/easyengine/logs > "$out/dirs.txt" 2>/dev/null || true sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true + if [[ "${{ matrix.lane }}" == le* ]]; then + sudo pkg/.github/behat/pebble-setup.sh logs > "$out/pebble.log" 2>&1 || true + tail -n 60 "$out/pebble.log" || true + fi sudo chown -R "$USER" "$out" # One block per failed scenario, from the junit files. python3 - "$out" <<'PY' - import glob, sys, xml.etree.ElementTree as ET + import glob, re, sys, xml.etree.ElementTree as ET out = sys.argv[1] lines = [] for f in sorted(glob.glob(out + '/junit/*.xml')): - for case in ET.parse(f).getroot().iter('testcase'): + # Behat copies raw output (docker logs with ANSI escapes) into the junit file; XML 1.0 forbids those bytes. + data = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', open(f, 'rb').read()) + for case in ET.fromstring(data).iter('testcase'): for bad in list(case.findall('failure')) + list(case.findall('error')): lines.append('### %s :: %s\n%s\n%s\n' % (case.get('classname'), case.get('name'), bad.get('message') or '', bad.text or '')) open(out + '/failures.txt', 'w').write('\n'.join(lines) or 'No failures.\n') @@ -160,6 +190,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 f3e5725da1cfa046b2c65d8d83f6e6a6392732e5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:24:15 +0530 Subject: [PATCH 30/35] 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 d19691d..8939e44 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -134,7 +134,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 cc9b96c30e59a7e76c7c50f73127812e4a32444c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:26:27 +0530 Subject: [PATCH 31/35] ci(site-type-php): 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 8939e44..1e7116d 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] include: - lane: main From 7bce70e353850d8930ae8023fed7c899d82ba77a Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:28:49 +0530 Subject: [PATCH 32/35] test(site-type-php): 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 b5638aa..ea1669c 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -508,6 +508,44 @@ public static function prepareSuite( BeforeSuiteScope $scope ) } } + /** + * On CI, EE's hardcoded Let's Encrypt host must point at Pebble or a blackhole (.github/behat/pebble-setup.sh), never the real service. + * + * @BeforeSuite + */ + public static function refuseRealAcmeOnCi() + { + if ( getenv( 'CI' ) && ! self::acme_host_is_local() ) { + throw new Exception( 'acme-v02.api.letsencrypt.org resolves to ' . gethostbyname( 'acme-v02.api.letsencrypt.org' ) . ' on CI; run `.github/behat/pebble-setup.sh setup` (le lanes) or `block` (every other lane) first.' ); + } + } + + /** + * @le issues certificates: only against Pebble, or on a real test host with EE_BEHAT_LE_REAL=1 (ignored on CI). + * + * @BeforeScenario @le + */ + public function requireTestAcme() + { + if ( getenv( 'EE_BEHAT_LE_ISSUER' ) && self::acme_host_is_local() ) { + return; + } + if ( '1' === getenv( 'EE_BEHAT_LE_REAL' ) && ! getenv( 'CI' ) ) { + return; + } + throw new Exception( '@le needs Pebble (.github/behat/pebble-setup.sh setup), or EE_BEHAT_LE_REAL=1 on a host with real DNS for *.ee4-test.rt.gw.' ); + } + + /** + * True when the LE host resolves to a private, loopback or TEST-NET address (Pebble's front proxy or the blackhole). + */ + private static function acme_host_is_local() + { + $ip = gethostbyname( 'acme-v02.api.letsencrypt.org' ); + + return (bool) preg_match( '/^(10\.|127\.|192\.168\.|192\.0\.2\.|172\.(1[6-9]|2\d|3[01])\.)/', $ip ); + } + /** * Deletes only this suite's sites; never "all sites" like the old context did. * From e85b4b1c94fb834a71420d0fc594a9640dccba2b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:31:29 +0530 Subject: [PATCH 33/35] 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:/// (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 1e7116d..e781dbe 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -1,4 +1,5 @@ # Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +# The main lane's job is named "Behat Tests - PHP " to match the required checks; update those with the matrix. name: Behat on: @@ -6,6 +7,7 @@ on: push: branches: - develop + - master workflow_dispatch: permissions: @@ -32,7 +34,7 @@ env: jobs: behat: - name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) + name: ${{ matrix.lane == 'main' && format('Behat Tests - PHP {0}', matrix.php) || format('Behat {0} (PHP {1})', matrix.lane, matrix.php) }} runs-on: ubuntu-latest timeout-minutes: 150 # Known bugs are expected to fail; they're reported, not gating. @@ -100,6 +102,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 @@ -141,6 +148,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') @@ -195,8 +211,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 50d07cd5e474c8cd4ced99a5ff592d03f45145f9 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:34:11 +0530 Subject: [PATCH 34/35] test(behat): leave the known-bug scenarios out until their fixes land --- .github/workflows/behat.yml | 6 +---- features/COVERAGE.md | 45 +++++++++++++--------------------- features/alias-domains.feature | 10 -------- features/cache.feature | 30 ----------------------- features/create.feature | 24 ------------------ features/database.feature | 22 ----------------- features/lifecycle.feature | 8 ------ features/public-dir.feature | 7 ------ 8 files changed, 18 insertions(+), 134 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index e781dbe..82d89da 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -37,20 +37,16 @@ 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] + 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' diff --git a/features/COVERAGE.md b/features/COVERAGE.md index f7abe67..b940e48 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -18,7 +18,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `--cache` (global redis) | cache.feature scenarios 1-4 (DB flags, main.conf, networks, HIT from redis, BYPASS on query string) | | `--with-local-redis` (with `--cache`) | cache.feature "--cache --with-local-redis", "Pages of a local-redis site", "Local redis and global db", "Local db and local redis" | | `--with-local-redis` without `--cache` (ignored) | cache.feature "--with-local-redis without --cache enables no cache" | -| local redis restart policy (MIG-16) | cache.feature `@known-bug` | +| local redis restart policy | not covered yet: the local redis service has no restart policy, unlike every other service (its scenario comes back with the fix) | | `--admin-email` (and default `admin@`) | create.feature "--admin-email is stored and shown", "The site row holds the php defaults" | | `--with-db` global db (DB/user created, creds stored, php connects) | database.feature scenarios 1-2 | | `--dbname` / `--dbuser` / `--dbpass` on global db | database.feature "--dbname, --dbuser and --dbpass are used for the global db" | @@ -28,25 +28,25 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | remote `--dbhost=host:port` with credentials | database.feature "A remote --dbhost with host:port and existing credentials" (uses `global-db:3306` as the "remote") | | remote `--dbhost` needs `--dbuser` and `--dbpass` | database.feature "A remote --dbhost needs --dbuser and --dbpass" | | `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (only proves the create and port parsing: nothing probes today, PHPT-5) | -| remote DB verification / DB auto-create / non-empty DB check | database.feature "An unreachable remote db host fails the create" `@known-bug` (PHPT-5); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | +| remote DB verification / DB auto-create / non-empty DB check | not covered yet: remote credentials are never checked, so an unreachable remote db host doesn't fail the create (its scenario comes back with the fix); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | | `--force` (reset non-empty remote DB) | not covered: dead code today (PHPT-5), needs a reachable remote MariaDB | | DB name / user collisions on rollback | database.feature: `--dbname`/`--dbuser` of another site refused (PHPT-1, PHPT-3), colliding default names refused by the volume check (PHPT-2), a taken default name gets a `_2` suffix, each on its own sites | -| DB-1 (mysql CLI in php images vs MariaDB TLS) | database.feature 2 `@known-bug` scenarios | +| mysql CLI in the php container against the global and local db | not covered yet: the MariaDB client in php8.2+ images demands TLS, which the EasyEngine MariaDB doesn't offer (its 2 scenarios come back with the fix) | | `--php=` every supported version + `latest` | php-version.feature outline (12 examples, `@slow`) | | `--php` default 8.4 | create.feature "The site row holds the php defaults", "The compose file ..." | | `--php` invalid values | create.feature outline "An unsupported --php value is rejected" (4 examples) | -| `--php` loosely matching values (`8`, `7`, `8.40`, `8.4.1`) | create.feature outline `@known-bug` (PHPT-11; `8.4.1` only passes validation on PHP 7) | -| `--php=` without `--type` (EE3 compat mapping) | create.feature `@known-bug` (PHPT-6) | +| `--php` loosely matching values (`8`, `7`, `8.40`, `8.4.1`) | not covered yet: a loose `in_array()` accepts them and the create fails late on a missing image (its outline, 4 examples, comes back with the fix) | +| `--php=` without `--type` (EE3 compat mapping) | not covered yet: the version is consumed as the EE3 `--php` type flag and dropped (its scenario comes back with the fix) | | EE3 `--mysql` compat flag | create.feature "The legacy --mysql flag creates a php site with a database" | | php 5.6 ini (sendmail) vs msmtp | php-version.feature outline `sendmail` column | | `--alias-domains` plain + wildcard (DB, compose VIRTUAL_HOST/CERT_NAME, main.conf server_name, HTTP) | alias-domains.feature scenarios 1-3 | | alias owned by another site / site name that is an alias | alias-domains.feature scenarios 4-5 | | alias trimming | alias-domains.feature "Spaces around alias domains are trimmed" | -| alias that is an existing site | alias-domains.feature `@known-bug` (PHPT-7) | +| alias that is an existing site | not covered yet: create checks aliases only against other sites' aliases, not their names (its scenario comes back with the fix) | | blank alias entries | alias-domains.feature `@destructive` regression (fixed by site-type-php #114, merged 2026-09-25; a regression breaks the nginx-proxy config until the rollback) | | invalid alias names (AUTH-1) | alias-domains.feature outline, regression (fixed by #114; 3 examples, the one with a space is also `@destructive`) | | `--public-dir` (relative, full path, nested, traversal sanitised, with cache, outside files not served) | public-dir.feature | -| bare `--public-dir` | public-dir.feature `@known-bug` (PHPT-12) | +| bare `--public-dir` | not covered yet: a bare `--public-dir` becomes the document root `1` (its scenario comes back with the fix) | | `--skip-status-check` | create.feature "--admin-email is stored and shown, --skip-status-check skips the HTTP probe" | | `--ssl=self` (+ wildcard flag stored, HTTPS, HTTP->HTTPS redirect, cert SANs) | ssl.feature scenarios 1-2 | | `--ssl=custom --ssl-key --ssl-crt` | ssl.feature "Create a php site with a custom certificate" | @@ -89,7 +89,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | no flag: nginx + php + local db restarted, postfix not | lifecycle.feature "site restart restarts nginx, php and the local db, not postfix" | | `--php` only | lifecycle.feature "site restart --php restarts only php" | | `--db` with local db | lifecycle.feature "site restart --db restarts the local db" | -| `--db` without local db | lifecycle.feature `@known-bug` (PHPT-13) | +| `--db` without local db | not covered yet: the compose exit code is ignored, so restarting a service the site doesn't have still exits 0 (its scenario comes back with the fix) | | `--all`, `--nginx` alone | not covered separately (same code path as no flag / `--php`) | ### `ee site reload [] [--all] [--nginx] [--php]` (`PHP::reload`) @@ -124,37 +124,26 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `delete` twice / unknown site | lifecycle.feature "Deleting a php site twice fails the second time" | | `delete` of an LE site removes certs | ssl.feature `@le` ("Revoking certificate." is logged before the attempt and revocation errors are swallowed, so revocation itself is not proven) | | `clean` without cache / `--proxy` without proxy cache | cache.feature "ee site clean on a site without cache" | -| `clean --page` on a php cache site | cache.feature `@known-bug` (PHPT-8, CACHE-1) | -| `clean` on a local-redis site | cache.feature `@known-bug` (PHPT-9) | +| `clean --page` on a php cache site | not covered yet: clean purges `_page*` but php sites store pages under `:http...`, so nothing is purged (its scenario comes back with the fix) | +| `clean` on a local-redis site | not covered yet: clean always targets the global redis, never the site's local redis (its scenario comes back with the fix) | | `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares PHPT-8's path; `--proxy` runs `docker exec -it` (CACHE-1) | | `ssl-verify`, `ssl-info`, `ssl-renew`, `share`, `clone`, `sync`, `backup`, `restore` | not covered here: generic, owned by the site-command suite (no php-specific code) | | `site_cleanup` hook on delete | indirectly (delete scenarios); hook consumers are tested in their packages | Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). Several items counted before only passed vacuously (`--ssl=off --wildcard`, the directory auto-detect, rollback, the redirect delete, the combo cache checks); they now assert the real behaviour. -## Known bugs exercised (`@known-bug`, 19 scenarios incl. outline examples) +## Scenarios -Every `@known-bug` scenario asserts the correct behaviour, so it fails until the bug is fixed, and names its issues.md ID in a comment: DB-1 (2), MIG-16, CACHE-1, and the site-type-php section `PHPT-*` (cross-referenced with the site-type-wp `WPT-*` entries for the shared DB-name collision): SEC-1 is fixed in v4.13.2 (site-type-php#117, site-command#509) and its scenario is regular since Behat r2 (2026-10-19). +146 scenarios (outline examples included): 133 regular, 12 `@slow`, 1 `@le`. The CI workflow runs them in three lanes: main (`~@slow`), slow and le (against Pebble). -| ID | Severity | Scenario(s) | -|---|---|---| -| PHPT-1..4 | High | regular scenarios since the fixes on the `fix/site-name-collisions` branches of site-command and site-type-php (2026-09-26, local); they fail on develop until those are merged: database.feature "Reusing another site's --dbname ...", "Reusing another site's --dbuser ...", "Two site names that map to the same default db name ...", "A taken default db name gets a numbered suffix ..."; create.feature "A site name whose docker volumes collide with an existing site is refused" | -| PHPT-5 | Medium | database.feature "An unreachable remote db host fails the create and rolls back" | -| PHPT-6 | Medium | create.feature "--php= without --type keeps the requested version" | -| PHPT-7 | Medium | alias-domains.feature "An alias domain that is an existing site is refused on create" | -| PHPT-8 | Medium | cache.feature "ee site clean --page purges the php page cache in the global redis" | -| PHPT-9 | Medium | cache.feature "ee site clean purges the page cache of a local-redis site" | -| PHPT-10 | Medium | regular since the SC-3 fix on site-command `fix/proxy-cache-rollback-and-le-alias` (2026-09-27, local); fails on develop until merged: ssl.feature "SSL of a site created with --ssl=self can be disabled" | -| PHPT-11 | Low | create.feature outline "A --php value that only loosely matches ..." (4 examples) | -| PHPT-12 | Low | public-dir.feature "A bare --public-dir without a value does not create the document root 1" | -| PHPT-13 | Low | lifecycle.feature "site restart --db on a php site without a local db fails" | -| PHPT-14..18 | Low / cosmetic | not tested (no clean black-box assertion): php sites render `.env`/`php.ini` from site-type-wp's templates; site-command's `ltrim($url, 'www.')` character-list bug; `var_dump()` in the inherit error; `--ssl` options not enforced on create (PHPT-17 is documented by the `--ssl=bogus` rollback scenario); no `/etc/hosts` entry for custom/inherit SSL sites | +## Known bugs left out + +Scenarios for open bugs aren't in the suite: each comes back with the PR that fixes its bug. The inventory above marks them "not covered yet". Bugs without a clean black-box assertion aren't tested: php sites render `.env`/`php.ini` from site-type-wp's templates; site-command's `ltrim($url, 'www.')` character-list bug; `var_dump()` in the inherit error; `--ssl` options not enforced on create (documented by the `--ssl=bogus` rollback scenario); no `/etc/hosts` entry for custom/inherit SSL sites. Other notes from the code: `--php=latest` maps to the php8.4 image, not the newest supported (8.5), maybe intended; `update --add/--delete-alias-domains` doesn't regenerate `main.conf` (stale `server_name`, harmless); a site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. The old suite's label assertion was inverted and its mysql checks passed vacuously (DB-1); both fixed in this context. ## Tags -- `@known-bug` (20): expected to fail until fixed; exclude with `--tags='~@known-bug'`. - `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`behat/ci/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). - `@le-real`: none; the `@le` scenario passes on Pebble. - `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias regressions (fixed by #114, merged 2026-09-25; they passed on develop in https://github.com/mrrobot47/site-type-php/actions/runs/36099146918 and are regular scenarios since) would render an invalid nginx-proxy config for ~2 minutes if they regressed. @@ -176,10 +165,10 @@ Other notes from the code: `--php=latest` maps to the php8.4 image, not the newe ## Runtime estimate -About 80 site creations (about 35 s each), plus deletes, updates and restarts: roughly 70-80 minutes for the whole suite, about 60 minutes with `--tags='~@slow'`, and about 55 minutes with `--tags='~@slow&&~@le&&~@known-bug'`. +About 80 site creations (about 35 s each), plus deletes, updates and restarts: roughly 70-80 minutes for the whole suite, about 60 minutes with `--tags='~@slow'`, and about 55 minutes with `--tags='~@slow&&~@le'`. ## Validation done -- `vendor/bin/behat --dry-run --strict` in a private core copy on `upstream/develop`: no undefined or ambiguous steps (159 scenarios, 1088 steps). Behat 3.8 exits 1 on `--dry-run --strict` because every step is "skipped"; the upstream suite does the same. +- `vendor/bin/behat --dry-run --strict` in a private core copy on `upstream/develop`: no undefined or ambiguous steps. Behat 3.8 exits 1 on `--dry-run --strict` because every step is "skipped"; the upstream suite does the same. - Copy the features with `cp -r`, not `cp -a`: Behat's Gherkin cache in `/tmp/behat_gherkin_cache` is keyed on mtime, so preserving older mtimes serves stale parses. - `php -l` of `features/bootstrap/*.php` on php:7.4-cli and php:8.5-cli. diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 6b7d16b..9ac749c 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -43,16 +43,6 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And the site "bh-site-type-php-78.test" should have the field "alias_domains" set to "bh-site-type-php-78.test,bh-site-type-php-78-a.test,bh-site-type-php-78-b.test" And a GET request to "http://bh-site-type-php-78-b.test/" via "127.0.0.1" should return 200 - # PHPT-7: create only checks aliases against other sites' aliases (check_alias_in_db), not against existing - # site names, so two sites end up claiming the same VIRTUAL_HOST. `site update --add-alias-domains` does check this. - @known-bug - Scenario: An alias domain that is an existing site is refused on create - Given a php site "bh-site-type-php-74.test" exists - When I run "bin/ee site create bh-site-type-php-73.test --type=php --alias-domains=bh-site-type-php-74.test" - Then the command should fail - And STDERR should contain "bh-site-type-php-74.test already exists as a site" - And the site "bh-site-type-php-73.test" should not exist - # Regression for blank entries (fixed by site-type-php #114, merged 2026-09-25). A regression renders an invalid # nginx-proxy config (`upstream {`) that breaks proxy reloads for every site for ~2 minutes: hence @destructive. @destructive diff --git a/features/cache.feature b/features/cache.feature index 6ba0c0a..b5f88fc 100644 --- a/features/cache.feature +++ b/features/cache.feature @@ -62,12 +62,6 @@ Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site And the global redis should not have keys matching "bh-site-type-php-31.test:*" And Check local redis cache for 'bh-site-type-php-31.test' - # MIG-16: the local redis service has no restart policy, unlike every other service. - @known-bug - Scenario: The local redis container restarts with Docker - Given a php site "bh-site-type-php-31.test" created with "--cache --with-local-redis" exists - Then the "redis" service of site "bh-site-type-php-31.test" should have the restart policy "always" - # Current behaviour: --with-local-redis only acts together with --cache. Scenario: --with-local-redis without --cache enables no cache When I run "bin/ee site create bh-site-type-php-32.test --type=php --with-local-redis" @@ -120,27 +114,3 @@ Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site When I run "timeout 120 bin/ee site clean bh-site-type-php-34.test --proxy" Then the return code should be 1 And STDERR should contain "Proxy cache is not enabled on site." - - # PHPT-8: clean purges "_page*" but php sites store pages under ":http...", so nothing is purged. - # CACHE-1: the purge also runs `docker exec -it`, which fails without a TTY (or hangs under nested ptys), hence the timeout. - @known-bug - Scenario: ee site clean --page purges the php page cache in the global redis - Given a php site "bh-site-type-php-35.test" created with "--cache" exists - When I send a GET request to "http://bh-site-type-php-35.test/" - And I send a GET request to "http://bh-site-type-php-35.test/" - Then the global redis should have keys matching "bh-site-type-php-35.test:*" - When I run "timeout 120 bin/ee site clean bh-site-type-php-35.test --page" - Then the command should succeed - And STDOUT should contain "Success: Page cache cleared for bh-site-type-php-35.test" - And the global redis should not have keys matching "bh-site-type-php-35.test:*" - - # PHPT-9: clean_site_cache() always targets the global redis container, never the site's local redis. - @known-bug - Scenario: ee site clean purges the page cache of a local-redis site - Given a php site "bh-site-type-php-36.test" created with "--cache --with-local-redis" exists - When I send a GET request to "http://bh-site-type-php-36.test/" - And I send a GET request to "http://bh-site-type-php-36.test/" - Then the redis service of site "bh-site-type-php-36.test" should have keys matching "bh-site-type-php-36.test:*" - When I run "timeout 120 bin/ee site clean bh-site-type-php-36.test" - Then the command should succeed - And the redis service of site "bh-site-type-php-36.test" should not have keys matching "bh-site-type-php-36.test:*" diff --git a/features/create.feature b/features/create.feature index c401ebc..6ef808d 100644 --- a/features/create.feature +++ b/features/create.feature @@ -162,22 +162,6 @@ Feature: Create a plain php site | 9.0 | | abc | - # PHPT-11: the --php option check uses a loose in_array(), so "8" == "8.0" and "8.40" == 8.4 pass (and "8.4.1" on - # PHP 7.x). The compose file then references a non-existent easyengine/php8 (or php8.40) image and the create fails late. - @known-bug - Scenario Outline: A --php value that only loosely matches a supported version is rejected up front - When I run "bin/ee site create bh-site-type-php-9.test --type=php --php=" - Then the command should fail - And STDERR should contain "Invalid value specified for 'php'" - And the site "bh-site-type-php-9.test" should not exist - - Examples: - | version | - | 8 | - | 7 | - | 8.40 | - | 8.4.1 | - # EE3 compat: --mysql maps to --type=php --with-db (Site_Command::convert_old_args_to_new_args). Scenario: The legacy --mysql flag creates a php site with a database When I run "bin/ee site create bh-site-type-php-7.test --mysql" @@ -186,14 +170,6 @@ Feature: Create a plain php site And the site "bh-site-type-php-7.test" should have the field "app_sub_type" set to "mysql" And the site "bh-site-type-php-7.test" should have the field "db_host" set to "global-db" - # PHPT-6: without --type, `--php=8.3` is consumed as the EE3 `--php` type flag and the version is dropped. - @known-bug - Scenario: --php= without --type keeps the requested version - When I run "bin/ee site create bh-site-type-php-6.test --php=8.3" - Then the command should succeed - And the site "bh-site-type-php-6.test" should have the field "site_type" set to "php" - And the site "bh-site-type-php-6.test" should have the field "php_version" set to "8.3" - # SEC-1 (fixed in v4.13.2, site-type-php#117 and site-command#509), regression check: the site name used to reach shell sinks unescaped (volume commands run from the core root). Benign payload that # survives the lowercasing: writes ./bhstpsec1 there, which the scenario cleanup removes. @security diff --git a/features/database.feature b/features/database.feature index 81a813d..6b40666 100644 --- a/features/database.feature +++ b/features/database.feature @@ -32,12 +32,6 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the file "/opt/easyengine/sites/bh-site-type-php-10.test/.env" should not contain "MYSQL_ROOT_PASSWORD" And a GET request to "http://bh-site-type-php-10.test/" should return 200 - # DB-1: the MariaDB 11.8 client in php8.2+ images demands TLS, which global-db does not offer. - @known-bug - Scenario: The mysql CLI inside the php container reaches the global db - Given a php site "bh-site-type-php-10.test" created with "--with-db" exists - Then Check global mysql database connection for 'bh-site-type-php-10.test' - Scenario: --dbname, --dbuser and --dbpass are used for the global db When I run "bin/ee site create bh-site-type-php-11.test --type=php --with-db --dbname=bh_stp_db11 --dbuser=bh_stp_user11 --dbpass=Bh-Stp-Pass-11" Then the command should succeed @@ -85,12 +79,6 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the php service of site "bh-site-type-php-12.test" should be able to connect to the site database And the "php" service of site "bh-site-type-php-12.test" should not be attached to the docker network "ee-global-backend-network" - # DB-1 (inferred): the local db runs the same easyengine/mariadb image as global-db, so the same TLS demand applies. - @known-bug - Scenario: The mysql CLI inside the php container reaches the local db - Given a php site "bh-site-type-php-12.test" created with "--with-db --local-db" exists - Then Check local mysql database connection for 'bh-site-type-php-12.test' - # Current behaviour: --local-db only acts inside --with-db, so alone it is silently ignored. Scenario: --local-db without --with-db creates no database at all When I run "bin/ee site create bh-site-type-php-13.test --type=php --local-db" @@ -132,16 +120,6 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the site "bh-site-type-php-18.test" should have an empty "db_root_password" And the "db" service of site "bh-site-type-php-18.test" should not exist - # PHPT-5: PHP::create_site() calls maybe_verify_remote_db_connection() only for --local-db, where it returns at once, - # so remote credentials are never checked and --skip-check/--force are dead options (site-type-wp calls it for every create). - @known-bug - Scenario: An unreachable remote db host fails the create and rolls back - When I run "bin/ee site create bh-site-type-php-17.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid --dbuser=bh_stp_u17 --dbpass=bh-stp-p17" - Then the command should fail - And STDOUT should contain "Verifying connection to remote database" - And the site "bh-site-type-php-17.test" should not exist - And the directory "/opt/easyengine/sites/bh-site-type-php-17.test" should not exist - # PHPT-1: another site's --dbname is refused up front; the rollback only drops what the create itself made. Scenario: Reusing another site's --dbname fails without dropping that database Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists diff --git a/features/lifecycle.feature b/features/lifecycle.feature index 739f8f9..d2c3231 100644 --- a/features/lifecycle.feature +++ b/features/lifecycle.feature @@ -166,14 +166,6 @@ Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and And STDOUT should contain "Restarting db" And the "db" service of site "bh-site-type-php-103.test" should have been restarted - # PHPT-13: run_compose_command() ignores the docker-compose exit code, so restarting a service the site - # does not have prints compose's "no such service" and still exits 0. - @known-bug - Scenario: site restart --db on a php site without a local db fails - Given a php site "bh-site-type-php-100.test" exists - When I run "bin/ee site restart bh-site-type-php-100.test --db" - Then the command should fail - Scenario: site reload reloads nginx and php-fpm without restarting containers Given a php site "bh-site-type-php-100.test" exists And I note the start time of the "nginx" service of site "bh-site-type-php-100.test" diff --git a/features/public-dir.feature b/features/public-dir.feature index a43fd1a..fb8141a 100644 --- a/features/public-dir.feature +++ b/features/public-dir.feature @@ -38,13 +38,6 @@ Feature: Custom document root of php sites (--public-dir) And the site "bh-site-type-php-63.test" should have the field "site_container_fs_path" set to "/var/www/htdocs/etc" And the file "/opt/easyengine/sites/bh-site-type-php-63.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs/etc;" - # PHPT-12: `[--public-dir]` is declared as a flag, so a bare --public-dir arrives as `true` and becomes the directory "1". - # Either fix (reject the bare flag, or keep the default root) passes: only the "1" document root is asserted against. - @known-bug - Scenario: A bare --public-dir without a value does not create the document root "1" - When I run "bin/ee site create bh-site-type-php-64.test --type=php --public-dir" - Then the directory "/opt/easyengine/sites/bh-site-type-php-64.test/app/htdocs/1" should not exist - Scenario: --public-dir works together with the redis page cache When I run "bin/ee site create bh-site-type-php-65.test --type=php --public-dir=public --cache" Then the command should succeed From a17a152b4c4dabef95dc2e7068ccd65458d167bb Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 15:37:15 +0530 Subject: [PATCH 35/35] docs(site-type-php): describe coverage gaps without workspace references --- .github/behat/pebble-setup.sh | 2 +- .github/workflows/behat.yml | 4 ++-- features/COVERAGE.md | 32 +++++++++++++-------------- features/alias-domains.feature | 5 ++--- features/bootstrap/FeatureContext.php | 16 +++++++------- features/create.feature | 6 ++--- features/database.feature | 12 +++++----- features/ssl.feature | 2 +- 8 files changed, 37 insertions(+), 42 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 b940e48..431c785 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -12,7 +12,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | name normalisation: `http://` stripped, trailing `/` stripped, lowercased (`https://` shares the code path, not tested) | create.feature "The site name is normalised" | | name without a dot gets `.test` | create.feature "A site name without a dot gets the .test TLD" | | duplicate site name refused, first site untouched | create.feature "Creating the same site twice fails" | -| site name validation (SEC-1) | create.feature "A site name with shell syntax is rejected" `@security`, regular since the v4.13.2 fix (#117) | +| site name validation | create.feature "A site name with shell syntax is rejected" `@security`, regression check for the v4.13.2 fix (EasyEngine/site-type-php#117, EasyEngine/site-command#509) | | unknown flag refused | create.feature "An unknown flag is rejected" | | unknown `--type` refused | site.feature "An unknown site type is rejected" | | `--cache` (global redis) | cache.feature scenarios 1-4 (DB flags, main.conf, networks, HIT from redis, BYPASS on query string) | @@ -27,10 +27,10 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `--local-db` without `--with-db` (ignored) | database.feature "--local-db without --with-db creates no database at all" | | remote `--dbhost=host:port` with credentials | database.feature "A remote --dbhost with host:port and existing credentials" (uses `global-db:3306` as the "remote") | | remote `--dbhost` needs `--dbuser` and `--dbpass` | database.feature "A remote --dbhost needs --dbuser and --dbpass" | -| `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (only proves the create and port parsing: nothing probes today, PHPT-5) | +| `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (only proves the create and port parsing: nothing probes a remote db today) | | remote DB verification / DB auto-create / non-empty DB check | not covered yet: remote credentials are never checked, so an unreachable remote db host doesn't fail the create (its scenario comes back with the fix); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | -| `--force` (reset non-empty remote DB) | not covered: dead code today (PHPT-5), needs a reachable remote MariaDB | -| DB name / user collisions on rollback | database.feature: `--dbname`/`--dbuser` of another site refused (PHPT-1, PHPT-3), colliding default names refused by the volume check (PHPT-2), a taken default name gets a `_2` suffix, each on its own sites | +| `--force` (reset non-empty remote DB) | not covered: dead code today (remote credentials are never checked), needs a reachable remote MariaDB | +| DB name / user collisions on rollback | database.feature: `--dbname`/`--dbuser` of another site refused, colliding default names refused by the volume check, a taken default name gets a `_2` suffix, each on its own sites (regression checks for EasyEngine/site-command#503 and EasyEngine/site-type-php#115) | | mysql CLI in the php container against the global and local db | not covered yet: the MariaDB client in php8.2+ images demands TLS, which the EasyEngine MariaDB doesn't offer (its 2 scenarios come back with the fix) | | `--php=` every supported version + `latest` | php-version.feature outline (12 examples, `@slow`) | | `--php` default 8.4 | create.feature "The site row holds the php defaults", "The compose file ..." | @@ -43,8 +43,8 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | alias owned by another site / site name that is an alias | alias-domains.feature scenarios 4-5 | | alias trimming | alias-domains.feature "Spaces around alias domains are trimmed" | | alias that is an existing site | not covered yet: create checks aliases only against other sites' aliases, not their names (its scenario comes back with the fix) | -| blank alias entries | alias-domains.feature `@destructive` regression (fixed by site-type-php #114, merged 2026-09-25; a regression breaks the nginx-proxy config until the rollback) | -| invalid alias names (AUTH-1) | alias-domains.feature outline, regression (fixed by #114; 3 examples, the one with a space is also `@destructive`) | +| blank alias entries | alias-domains.feature `@destructive` regression (fixed by EasyEngine/site-type-php#114; a regression breaks the nginx-proxy config until the rollback) | +| invalid alias names (e.g. `_wildcard.`, which could overwrite proxy auth files) | alias-domains.feature outline, regression (fixed by EasyEngine/site-type-php#114; 3 examples, the one with a space is also `@destructive`) | | `--public-dir` (relative, full path, nested, traversal sanitised, with cache, outside files not served) | public-dir.feature | | bare `--public-dir` | not covered yet: a bare `--public-dir` becomes the document root `1` (its scenario comes back with the fix) | | `--skip-status-check` | create.feature "--admin-email is stored and shown, --skip-status-check skips the HTTP probe" | @@ -52,7 +52,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `--ssl=custom --ssl-key --ssl-crt` | ssl.feature "Create a php site with a custom certificate" | | custom SSL argument errors (missing key/crt, missing file) | ssl.feature 2 scenarios | | `--ssl=inherit` (no parent, parent with wildcard, with `--wildcard`) | ssl.feature 3 scenarios | -| `--ssl=` (rejected only after the site is built, PHPT-17) | ssl.feature "--ssl with an unknown value fails and rolls back" | +| `--ssl=` (rejected only after the site is built) | ssl.feature "--ssl with an unknown value fails and rolls back" | | `--ssl=le` | ssl.feature `@le` (create, verified HTTPS, issuer, delete + revoke) | | `--wildcard` with `--ssl=le` | not covered: needs Cloudflare DNS-01 credentials (not configured here) | | rollback (`catch_clean`) leaves no site row, dir, volumes, containers or network | ssl.feature "--ssl with an unknown value" and "--ssl=inherit together with --wildcard" (real level-4 rollbacks); the other failing creates stop in validation and assert the row and dir only | @@ -67,7 +67,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `.env`, `main.conf` (upstream, root, server_name, php vs redis blocks), `custom.ini`, `user.conf`, `admin-tools.conf`, `index.php`, `app/html` removed, `app` symlinked to the htdocs volume, `check.html` removed | create.feature "The site files are generated from the php templates" | | running containers (php, nginx, postfix; mailhog not started; + db/redis), labels, restart policy, networks | create.feature "The site containers, volumes and network are up", labels.feature, database/cache features | | docker volumes (8, or 11 with local db) and network labels | create.feature, labels.feature, database.feature | -| volume prefix collision between names differing only in `.`/`-` | create.feature "A site name whose docker volumes collide with an existing site is refused" (PHPT-4) | +| volume prefix collision between names differing only in `.`/`-` | create.feature "A site name whose docker volumes collide with an existing site is refused" (regression check for EasyEngine/site-command#503) | | `/etc/hosts` entry (http and self-signed only) | create.feature, ssl.feature, alias-domains.feature | | HTTP: 200 + PHP executes, pretty URLs, 404, hidden/backup files denied, fpm ping/status, nginx_status | create.feature "The site is reachable over HTTP and runs PHP", "nginx denies hidden and backup files", "The php-fpm status and ping endpoints are wired" | @@ -110,7 +110,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `update --php` same version / invalid / `latest` / disabled / unknown site | php-version.feature 4 scenarios | | `update --add-alias-domains` / `--delete-alias-domains` (+ all error paths, wildcard) | alias-domains.feature 9 update scenarios | | `update --ssl=self` / `--ssl=off` / already enabled / already disabled / `--ssl=off --wildcard` (on a wildcard site) / normal to wildcard | ssl.feature 3 scenarios | -| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature "SSL of a site created with --ssl=self can be disabled" (PHPT-10, regular since site-command `fix/proxy-cache-rollback-and-le-alias`; fails on develop until merged) | +| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature "SSL of a site created with --ssl=self can be disabled" (regression check, fixed by EasyEngine/site-command#505) | | `update --ssl=le`, `--wildcard` | not covered (LE budget; wildcard needs DNS-01) | | `update --proxy-cache=on/off` (conf.d, vhost.d incl. alias, already-on error) | lifecycle.feature `@destructive` (restarts nginx-proxy) | | `--proxy-cache-max-size/-max-time/-key-zone-size` | not covered: site-command generic | @@ -126,7 +126,7 @@ Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, ` | `clean` without cache / `--proxy` without proxy cache | cache.feature "ee site clean on a site without cache" | | `clean --page` on a php cache site | not covered yet: clean purges `_page*` but php sites store pages under `:http...`, so nothing is purged (its scenario comes back with the fix) | | `clean` on a local-redis site | not covered yet: clean always targets the global redis, never the site's local redis (its scenario comes back with the fix) | -| `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares PHPT-8's path; `--proxy` runs `docker exec -it` (CACHE-1) | +| `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares the `clean --page` path, which purges the wrong keys; `--proxy` runs `docker exec -it`, which fails without a TTY | | `ssl-verify`, `ssl-info`, `ssl-renew`, `share`, `clone`, `sync`, `backup`, `restore` | not covered here: generic, owned by the site-command suite (no php-specific code) | | `site_cleanup` hook on delete | indirectly (delete scenarios); hook consumers are tested in their packages | @@ -140,22 +140,22 @@ Estimated coverage: about 88% of the inventory items above (php-specific items: Scenarios for open bugs aren't in the suite: each comes back with the PR that fixes its bug. The inventory above marks them "not covered yet". Bugs without a clean black-box assertion aren't tested: php sites render `.env`/`php.ini` from site-type-wp's templates; site-command's `ltrim($url, 'www.')` character-list bug; `var_dump()` in the inherit error; `--ssl` options not enforced on create (documented by the `--ssl=bogus` rollback scenario); no `/etc/hosts` entry for custom/inherit SSL sites. -Other notes from the code: `--php=latest` maps to the php8.4 image, not the newest supported (8.5), maybe intended; `update --add/--delete-alias-domains` doesn't regenerate `main.conf` (stale `server_name`, harmless); a site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. The old suite's label assertion was inverted and its mysql checks passed vacuously (DB-1); both fixed in this context. +Other notes from the code: `--php=latest` maps to the php8.4 image, not the newest supported (8.5), maybe intended; `update --add/--delete-alias-domains` doesn't regenerate `main.conf` (stale `server_name`, harmless); a site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. The old suite's label assertion was inverted and its mysql checks passed vacuously; both fixed in this context. ## Tags -- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`behat/ci/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). +- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`.github/behat/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). - `@le-real`: none; the `@le` scenario passes on Pebble. -- `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias regressions (fixed by #114, merged 2026-09-25; they passed on develop in https://github.com/mrrobot47/site-type-php/actions/runs/36099146918 and are regular scenarios since) would render an invalid nginx-proxy config for ~2 minutes if they regressed. +- `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias regressions (fixed by EasyEngine/site-type-php#114) would render an invalid nginx-proxy config for ~2 minutes if they regressed. - `@slow` (12): the PHP version matrix (one site per version). -- `@security` (1): the SEC-1 payload (benign: `id>bhstpsec1` in the core root, removed after the scenario and by the cleanup). +- `@security` (1): the shell-syntax site name payload (benign: `id>bhstpsec1` in the core root, removed after the scenario and by the cleanup). - `@remote`: none (no backups in this package). ## Environment rules followed - Sites: `bh-site-type-php-.test` (and `sub.`/`sub2.` children, `--x` variants); aliases `bh-site-type-php--.test`; global-db names `bh_stp_*` or the derived `bh_site_type_php_*`. `FeatureContext::cleanupTestState()` runs before the suite, after each feature and after the suite, and only touches these names: sites (children first), orphaned containers/networks/volumes/site dirs/proxy files, the suite's redis page-cache keys, `bh_stp_*`/`bh_site_type_php_*` databases and `bh_stp_*`/`bh-site-type-php-*` users that no remaining site row references, and `/etc/hosts` lines with the `# behat:site-type-php` marker or whose every name is a suite name. -- Data-loss guards (PHPT-1..4): the suite refuses to start when a foreign site's volume prefix, derived db name, db name or db user could collide with the suite's names; the db setup steps only accept suite names; the data-loss known bugs only ever point the buggy rollback at databases and users the scenario itself created. -- Commands run with stdin on `/dev/null`, so docker/compose never allocate a TTY (no CACHE-1 hangs; `docker exec -it` fails fast instead). +- Data-loss guards: the suite refuses to start when a foreign site's volume prefix, derived db name, db name or db user could collide with the suite's names; the db setup steps only accept suite names; the collision scenarios only ever point a failed create's rollback at databases and users the scenario itself created. +- Commands run with stdin on `/dev/null`, so docker/compose never allocate a TTY (no hangs under nested ptys; `docker exec -it` fails fast instead). - HTTP checks for aliases/extra names pin the host to 127.0.0.1 with curl's `--resolve` (keeps SNI for HTTPS); 127.0.0.1 arrives at nginx-proxy as 10.1.0.1 and bypasses HTTP auth. Only the `@le` scenario goes through public DNS to the public IP (it would get a 401 if global HTTP auth were enabled). - Temporary files (custom certs) live in `/var/tmp/bh-site-type-php`, removed after the suite. diff --git a/features/alias-domains.feature b/features/alias-domains.feature index 9ac749c..92a0668 100644 --- a/features/alias-domains.feature +++ b/features/alias-domains.feature @@ -43,8 +43,7 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And the site "bh-site-type-php-78.test" should have the field "alias_domains" set to "bh-site-type-php-78.test,bh-site-type-php-78-a.test,bh-site-type-php-78-b.test" And a GET request to "http://bh-site-type-php-78-b.test/" via "127.0.0.1" should return 200 - # Regression for blank entries (fixed by site-type-php #114, merged 2026-09-25). A regression renders an invalid - # nginx-proxy config (`upstream {`) that breaks proxy reloads for every site for ~2 minutes: hence @destructive. + # Regression check for blank entries (fixed by EasyEngine/site-type-php#114). A regression renders an invalid nginx-proxy config (`upstream {`) that breaks proxy reloads for every site for ~2 minutes: hence @destructive. @destructive Scenario: Empty entries in --alias-domains are dropped When I run "bin/ee site create bh-site-type-php-75.test --type=php --alias-domains='bh-site-type-php-75-a.test,,bh-site-type-php-75-b.test,'" @@ -53,7 +52,7 @@ Feature: Alias domains of php sites (create --alias-domains, update --add/--dele And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-75-a.test,bh-site-type-php-75-b.test" And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should not contain ",," - # AUTH-1 regression: an alias like _wildcard. could overwrite proxy auth files (fixed by site-type-php #114). + # Regression check: an alias like _wildcard. could overwrite proxy auth files (fixed by EasyEngine/site-type-php#114). Scenario Outline: An alias domain that is not a valid hostname is refused When I run "bin/ee site create --type=php --alias-domains=''" Then the return code should be 1 diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index ea1669c..1eec129 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -392,7 +392,7 @@ public function thereShouldBeContainersWithLabel($expected_running_containers, P } /** - * No `-it`: without a TTY `docker exec -it` fails, and under the harness's nested ptys it can hang (CACHE-1). + * No `-it`: without a TTY `docker exec -it` fails, and under the harness's nested ptys it can hang. * * @Then Check global redis cache for :site */ @@ -421,7 +421,7 @@ public function checkLocalRedisCacheOfSite($site) } /** - * Uses the mysql CLI of the php image; see DB-1 for the TLS failure against global-db on php 8.2+. + * Uses the mysql CLI of the php image; on php 8.2+ it fails against global-db, whose MariaDB doesn't offer the TLS that client demands. * * @Then Check local mysql database connection for :site */ @@ -441,7 +441,7 @@ public function checkGlobalDbConnection(string $site) /** * Checks the site's DB credentials with the mysql CLI inside its php container. * - * The old version passed whenever `ee shell` printed nothing to stdout, even on failure (DB-1). + * The old version passed whenever `ee shell` printed nothing to stdout, even on failure. * * @param string $site * @param string $db_type @@ -477,7 +477,7 @@ public function cleanupScenario(AfterScenarioScope $scope) $this->start_times = []; chdir($this->ee_path); - // Marker file a SEC-1 payload may create in the working directory. + // Marker file the shell-syntax site name payload may create in the working directory. if ( file_exists( EE_ROOT . '/bhstpsec1' ) ) { unlink( EE_ROOT . '/bhstpsec1' ); } @@ -488,7 +488,7 @@ public function cleanupScenario(AfterScenarioScope $scope) */ public static function prepareSuite( BeforeSuiteScope $scope ) { - // Volume prefixes drop "." and "-" and default db names map them to "_" (PHPT-1..4), and the create rollback drops + // Volume prefixes drop "." and "-" and default db names map them to "_", and the create rollback drops // colliding databases and users. Refuse to run next to a foreign site whose volumes, db or db user could collide. foreach ( self::sqliteRows( 'SELECT site_url, db_name, db_user FROM sites' ) as $row ) { if ( 1 === preg_match( self::SITE_PATTERN, $row['site_url'] ) ) { @@ -1552,7 +1552,7 @@ public function theSiteDoesNotExist( $site ) /** * Runs a shell command with stdin on /dev/null, so docker/compose never allocate a TTY: under the harness's - * nested ptys a TTY exec can hang (CACHE-1), and without one `docker exec -it` fails fast instead. + * nested ptys a TTY exec can hang, and without one `docker exec -it` fails fast instead. * * @return object command, stdout, stderr, return_code (same fields as EE's ProcessRun). */ @@ -1960,7 +1960,7 @@ private static function cleanupTestState() // Site roots and proxy files left by failed creates. foreach ( (array) glob( EE_SITE_ROOT . '/*' ) as $dir ) { $name = basename( $dir ); - // Any name, so a dir left by the SEC-1 scenario's `$(...)` site name goes too. + // Any name, so a dir left by the shell-syntax site name scenario's `$(...)` site name goes too. if ( $orphan( $name ) ) { self::run( 'rm -rf -- ' . escapeshellarg( $dir ) ); } @@ -2014,7 +2014,7 @@ private static function cleanupTestState() } } - // The SEC-1 payload can run again when that site is deleted above. + // The shell-syntax site name payload can run again when that site is deleted above. if ( file_exists( EE_ROOT . '/bhstpsec1' ) ) { unlink( EE_ROOT . '/bhstpsec1' ); } diff --git a/features/create.feature b/features/create.feature index 6ef808d..e41b53a 100644 --- a/features/create.feature +++ b/features/create.feature @@ -170,8 +170,7 @@ Feature: Create a plain php site And the site "bh-site-type-php-7.test" should have the field "app_sub_type" set to "mysql" And the site "bh-site-type-php-7.test" should have the field "db_host" set to "global-db" - # SEC-1 (fixed in v4.13.2, site-type-php#117 and site-command#509), regression check: the site name used to reach shell sinks unescaped (volume commands run from the core root). Benign payload that - # survives the lowercasing: writes ./bhstpsec1 there, which the scenario cleanup removes. + # Regression check (fixed in v4.13.2 by EasyEngine/site-type-php#117 and EasyEngine/site-command#509): the site name used to reach shell commands unescaped (volume commands run from the core root). The benign payload survives the lowercasing and writes ./bhstpsec1 there, which the scenario cleanup removes. @security Scenario: A site name with shell syntax is rejected and never executed When I run "bin/ee site create 'bh-site-type-php-5$(id>bhstpsec1).test' --type=php" @@ -179,8 +178,7 @@ Feature: Create a plain php site And STDERR should contain "Invalid site name" And the file "bhstpsec1" should not exist - # PHPT-4, core EE_DOCKER::get_docker_style_prefix(): volume names drop "." and "-", so both names below map to - # the prefix bhsitetypephp27test. Existing sites keep their volume names, so the second create is refused. + # Core's EE_DOCKER::get_docker_style_prefix() drops "." and "-" from volume names, so both names below map to the prefix bhsitetypephp27test; existing sites keep their volume names, so the second create is refused (EasyEngine/site-command#503). Scenario: A site name whose docker volumes collide with an existing site is refused Given a php site "bh-site-type-php-2-7.test" exists When I run "bin/ee site create bh-site-type-php-27.test --type=php" diff --git a/features/database.feature b/features/database.feature index 6b40666..2be067a 100644 --- a/features/database.feature +++ b/features/database.feature @@ -108,8 +108,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam Then the return code should be 1 And STDERR should contain "`--dbuser` and `--dbpass` are required for remote db host." - # PHPT-5: nothing probes a remote db today, with or without --skip-check, so this only proves the create and the port - # parsing; the skip itself becomes testable once PHPT-5 is fixed. + # Nothing probes a remote db today, with or without --skip-check, so this only proves the create and the port parsing; the skip itself becomes testable once remote credentials are checked. Scenario: --skip-check creates the site without probing the remote db, and the port is parsed When I run "bin/ee site create bh-site-type-php-18.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid:3307 --dbuser=bh_stp_u18 --dbpass=bh-stp-p18 --skip-check" Then the command should succeed @@ -120,7 +119,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the site "bh-site-type-php-18.test" should have an empty "db_root_password" And the "db" service of site "bh-site-type-php-18.test" should not exist - # PHPT-1: another site's --dbname is refused up front; the rollback only drops what the create itself made. + # Another site's --dbname is refused up front; the rollback only drops what the create itself made. Scenario: Reusing another site's --dbname fails without dropping that database Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists And the global database "bh_stp_shared20" contains the table "bh_marker" @@ -132,7 +131,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the global database "bh_stp_shared20" should contain the table "bh_marker" And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database - # PHPT-3: another site's --dbuser is refused up front, so its user and password are untouched. + # Another site's --dbuser is refused up front, so its user and password are untouched. Scenario: Reusing another site's --dbuser fails without dropping that user Given a php site "bh-site-type-php-23.test" created with "--with-db --dbname=bh_stp_db23 --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Pass-23" exists When I run "bin/ee site create bh-site-type-php-24.test --type=php --with-db --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Other-24" @@ -143,8 +142,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the global database user "bh_stp_user23" should exist And the php service of site "bh-site-type-php-23.test" should be able to connect to the site database - # PHPT-2: the default db name replaces "." and "-" with "_", so these two names collide. The pair also shares volume - # names (PHPT-4), so the second create is refused before the database step. + # The default db name replaces "." and "-" with "_", so these two names collide; the pair also shares volume names, so the second create is refused before the database step. Scenario: Two site names that map to the same default db name do not destroy each other's database Given a php site "bh-site-type-php-22.x.test" created with "--with-db" exists And the global database "bh_site_type_php_22_x_test" contains the table "bh_marker" @@ -155,7 +153,7 @@ Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbnam And the global database "bh_site_type_php_22_x_test" should contain the table "bh_marker" And the php service of site "bh-site-type-php-22.x.test" should be able to connect to the site database - # PHPT-2: a default db name that is already taken (here by a database no site owns) gets a numbered suffix. + # A default db name that is already taken (here by a database no site owns) gets a numbered suffix. Scenario: A taken default db name gets a numbered suffix and the existing database is kept Given the global database "bh_site_type_php_28_test" with user "bh_stp_user28" identified by "Bh-Stp-Pass-28" exists And the global database "bh_site_type_php_28_test" contains the table "bh_marker" diff --git a/features/ssl.feature b/features/ssl.feature index 7ae1c8d..d84dcea 100644 --- a/features/ssl.feature +++ b/features/ssl.feature @@ -122,7 +122,7 @@ Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) Then the return code should be 1 And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." - # PHPT-10 / SC-3 (fixed in site-command fix/proxy-cache-rollback-and-le-alias): update_ssl() refused --ssl=off for self-signed creates, which store site_ssl_wildcard=1. + # Fixed by EasyEngine/site-command#505: update_ssl() refused --ssl=off for self-signed creates, which store site_ssl_wildcard=1. Scenario: SSL of a site created with --ssl=self can be disabled Given a php site "bh-site-type-php-89.test" created with "--ssl=self" exists When I run "bin/ee site update bh-site-type-php-89.test --ssl=off"