diff options
-rw-r--r-- | build/acceptance/composer.json | 5 | ||||
-rw-r--r-- | build/acceptance/config/behat.yml | 2 | ||||
-rw-r--r-- | build/acceptance/features/core/NextcloudTestServerContext.php | 156 | ||||
-rw-r--r-- | build/acceptance/features/core/NextcloudTestServerDockerHelper.php | 206 | ||||
-rw-r--r-- | build/acceptance/features/core/Utils.php | 59 |
5 files changed, 428 insertions, 0 deletions
diff --git a/build/acceptance/composer.json b/build/acceptance/composer.json index a361adaa40d..87b6ba4a22c 100644 --- a/build/acceptance/composer.json +++ b/build/acceptance/composer.json @@ -5,5 +5,10 @@ "behat/mink-extension": "*", "behat/mink-selenium2-driver": "*", "phpunit/phpunit": "~4.6" + }, + "autoload": { + "psr-4": { + "": "features/core" + } } } diff --git a/build/acceptance/config/behat.yml b/build/acceptance/config/behat.yml index 01feef51608..2ac1c077537 100644 --- a/build/acceptance/config/behat.yml +++ b/build/acceptance/config/behat.yml @@ -6,6 +6,8 @@ default: paths: - %paths.base%/../features contexts: + - NextcloudTestServerContext + - FeatureContext extensions: Behat\MinkExtension: diff --git a/build/acceptance/features/core/NextcloudTestServerContext.php b/build/acceptance/features/core/NextcloudTestServerContext.php new file mode 100644 index 00000000000..cdd07dab168 --- /dev/null +++ b/build/acceptance/features/core/NextcloudTestServerContext.php @@ -0,0 +1,156 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; + +/** + * Behat context to run each scenario against a clean Nextcloud server. + * + * Before each scenario is run, this context sets up a fresh Nextcloud server + * with predefined data and configuration. Thanks to this every scenario is + * independent from the others and they all know the initial state of the + * server. + * + * This context is expected to be used along with RawMinkContext contexts (or + * subclasses). As the server address can be different for each scenario, this + * context automatically sets the "base_url" parameter of all its sibling + * RawMinkContexts; just add NextcloudTestServerContext to the context list of a + * suite in "behat.yml". + * + * The Nextcloud server is set up by running a new Docker container; the Docker + * image used by the container must provide a Nextcloud server ready to be used + * by the tests. By default, the image "nextcloud-local-test-acceptance" is + * used, although that can be customized using the "dockerImageName" parameter + * in "behat.yml". In the same way, the range of ports in which the Nextcloud + * server will be published in the local host (by default, "15000-16000") can be + * customized using the "hostPortRangeForContainer" parameter. + * + * Note that using Docker containers as a regular user requires giving access to + * the Docker daemon to that user. Unfortunately, that makes possible for that + * user to get root privileges for the system. Please see the + * NextcloudTestServerDockerHelper documentation for further information on this + * issue. + */ +class NextcloudTestServerContext implements Context { + + /** + * @var NextcloudTestServerDockerHelper + */ + private $dockerHelper; + + /** + * Creates a new NextcloudTestServerContext. + * + * @param string $dockerImageName the name of the Docker image that provides + * the Nextcloud test server. + * @param string $hostPortRangeForContainer the range of local ports in the + * host in which the port 80 of the container can be published. + */ + public function __construct($dockerImageName = "nextcloud-local-test-acceptance", $hostPortRangeForContainer = "15000-16000") { + $this->dockerHelper = new NextcloudTestServerDockerHelper($dockerImageName, $hostPortRangeForContainer); + } + + /** + * @BeforeScenario + * + * Sets up the Nextcloud test server before each scenario. + * + * It starts the Docker container and, once ready, it sets the "base_url" + * parameter of the sibling RawMinkContexts to "http://" followed by the IP + * address and port of the container; if the Docker container can not be + * started after some time an exception is thrown (as it is just a warning + * for the test runner and nothing to be explicitly catched a plain base + * Exception is used). + * + * @param \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope the + * BeforeScenario hook scope. + * @throws \Exception if the Docker container can not be started. + */ + public function startNextcloudTestServer(BeforeScenarioScope $scope) { + $this->dockerHelper->createAndStartContainer(); + + $serverAddress = $this->dockerHelper->getNextcloudTestServerAddress(); + + $isServerReadyCallback = function() use ($serverAddress) { + return $this->isServerReady($serverAddress); + }; + $timeout = 10; + $timeoutStep = 0.5; + if (!Utils::waitFor($isServerReadyCallback, $timeout, $timeoutStep)) { + throw new Exception("Docker container for Nextcloud could not be started"); + } + + $this->setBaseUrlInSiblingRawMinkContexts($scope, "http://" . $serverAddress . "/index.php"); + } + + /** + * @AfterScenario + * + * Cleans up the Nextcloud test server after each scenario. + * + * It stops and removes the Docker container; if the Docker container can + * not be removed after some time an exception is thrown (as it is just a + * warning for the test runner and nothing to be explicitly catched a plain + * base Exception is used). + * + * @throws \Exception if the Docker container can not be removed. + */ + public function stopNextcloudTestServer() { + $this->dockerHelper->stopAndRemoveContainer(); + + $wasContainerRemovedCallback = function() { + return !$this->dockerHelper->isContainerRegistered(); + }; + $timeout = 10; + $timeoutStep = 0.5; + if (!Utils::waitFor($wasContainerRemovedCallback, $timeout, $timeoutStep)) { + throw new Exception("Docker container for Nextcloud (" . $this->dockerHelper->getContainerName() . ") could not be removed"); + } + } + + private function isServerReady($serverAddress) { + $curlHandle = curl_init("http://" . $serverAddress); + + // Returning the transfer as the result of curl_exec prevents the + // transfer from being written to the output. + curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true); + + $transfer = curl_exec($curlHandle); + + curl_close($curlHandle); + + return $transfer !== false; + } + + private function setBaseUrlInSiblingRawMinkContexts(BeforeScenarioScope $scope, $baseUrl) { + $environment = $scope->getEnvironment(); + + foreach ($environment->getContexts() as $context) { + if ($context instanceof Behat\MinkExtension\Context\RawMinkContext) { + $context->setMinkParameter("base_url", $baseUrl); + } + } + } + +} diff --git a/build/acceptance/features/core/NextcloudTestServerDockerHelper.php b/build/acceptance/features/core/NextcloudTestServerDockerHelper.php new file mode 100644 index 00000000000..7ed159ead7b --- /dev/null +++ b/build/acceptance/features/core/NextcloudTestServerDockerHelper.php @@ -0,0 +1,206 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Helper to manage the Docker container for the Nextcloud test server. + * + * The NextcloudTestServerDockerHelper abstracts the calls to the Docker Command + * Line Interface (the "docker" command) to run, get information from, and + * destroy containers. It is not a generic abstraction, but one tailored + * specifically to the Nextcloud test server; a Docker image that provides an + * installed and ready to run Nextcloud server with the configuration and data + * expected by the acceptance tests must be available in the system. The + * Nextcloud server must use a local storage so all the changes it makes are + * confined to its running container. + * + * Also, the Nextcloud server installed in the Docker image is expected to see + * "127.0.0.1" as a trusted domain (which would be the case if it was installed + * by running "occ maintenance:install"). Therefore, the Nextcloud server is + * accessed through a local port in the host system mapped to the port 80 of the + * Docker container; if the Nextcloud server was instead accessed directly + * through its IP address it would complain that it was being accessed from an + * untrusted domain and refuse to work until the admin whitelisted it. The IP + * address and port to access the Nextcloud server can be got from + * "getNextcloudTestServerAddress". + * + * For better compatibility, Docker CLI commands used internally follow the + * pre-1.13 syntax (also available in 1.13 and newer). For example, + * "docker start" instead of "docker container start". + * + * In any case, the "docker" command requires special permissions to talk to the + * Docker daemon, and those permissions are typically available only to the root + * user. However, you should NOT run the acceptance tests as root, but as a + * regular user instead. Please see the Docker documentation to find out how to + * give access to a regular user to the Docker daemon: + * https://docs.docker.com/engine/installation/linux/linux-postinstall/ + * + * Note, however, that being able to communicate with the Docker daemon is the + * same as being able to get root privileges for the system. Therefore, you must + * give access to the Docker daemon (and thus run the acceptance tests as) ONLY + * to trusted and secure users: + * https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface + * + * All the public methods that use the 'docker' command throw an exception if + * the command can not be executed or if it does not have enough permissions to + * connect to the Docker daemon; as, due to the current use of this class, it is + * just a warning for the test runner and nothing to be explicitly catched a + * plain base Exception is used. + */ +class NextcloudTestServerDockerHelper { + + /** + * @var string + */ + private $imageName; + + /** + * @var string + */ + private $hostPortRangeForContainer; + + /** + * @var string + */ + private $containerName; + + /** + * Creates a new NextcloudTestServerDockerHelper. + * + * @param string $imageName the name of the Docker image that provides the + * Nextcloud test server. + * @param string $hostPortRangeForContainer the range of local ports in the + * host in which the port 80 of the container can be published. + */ + public function __construct($imageName = "nextcloud-local-test-acceptance", $hostPortRangeForContainer = "15000-16000") { + $this->imageName = $imageName; + $this->hostPortRangeForContainer = $hostPortRangeForContainer; + $this->containerName = null; + } + + /** + * Creates and starts the container. + * + * Note that, even if the container has started, the server it contains may + * not have started yet when this method returns. + * + * @throws \Exception if the Docker command failed to execute. + */ + public function createAndStartContainer() { + $moreEntropy = true; + $this->containerName = uniqid($this->imageName . "-", $moreEntropy); + + // There is no need to start the web server as root, so it is started + // directly as www-data instead. + // The port 80 of the container is mapped to a free port from a range in + // the host system; due to this it can be accessed from the host using + // the "127.0.0.1" IP address, which prevents Nextcloud from complaining + // that it is being accessed from an untrusted domain. + $this->executeDockerCommand("run --detach --user=www-data --publish 127.0.0.1:" . $this->hostPortRangeForContainer . ":80 --name=" . $this->containerName . " " . $this->imageName); + } + + /** + * Stops and removes the container. + * + * @throws \Exception if the Docker command failed to execute. + */ + public function stopAndRemoveContainer() { + // Although the Nextcloud image does not define a volume "--volumes" is + // used anyway just in case any of its ancestor images does. + $this->executeDockerCommand("rm --volumes --force " . $this->containerName); + } + + /** + * Returns the container name. + * + * If the container has not been created yet the container name will be + * null. + * + * @return string the container name. + */ + public function getContainerName() { + return $this->containerName; + } + + /** + * Returns the IP address and port of the Nextcloud test server (which is + * mapped to a local port in the host). + * + * @return string the IP address and port as "$ipAddress:$port". + * @throws \Exception if the Docker command failed to execute or the + * container is not running. + */ + public function getNextcloudTestServerAddress() { + return $this->executeDockerCommand("port " . $this->containerName . " 80"); + } + + /** + * Returns whether the container is running or not. + * + * @return boolean true if the container is running, false otherwise. + * @throws \Exception if the Docker command failed to execute. + */ + public function isContainerRunning() { + // By default, "docker ps" only shows running containers, and the + // "--quiet" option only shows the ID of the matching containers, + // without table headers. Therefore, if the container is not running the + // output will be empty (not even a new line, as the last line of output + // returned by "executeDockerCommand" does not include a trailing new + // line character). + return $this->executeDockerCommand("ps --quiet --filter 'name=" . $this->containerName . "'") !== ""; + } + + /** + * Returns whether the container exists (no matter its state) or not. + * + * @return boolean true if the container exists, false otherwise. + * @throws \Exception if the Docker command failed to execute. + */ + public function isContainerRegistered() { + // With the "--quiet" option "docker ps" only shows the ID of the + // matching containers, without table headers. Therefore, if the + // container does not exist the output will be empty (not even a new + // line, as the last line of output returned by "executeDockerCommand" + // does not include a trailing new line character). + return $this->executeDockerCommand("ps --all --quiet --filter 'name=" . $this->containerName . "'") !== ""; + } + + /** + * Executes the given Docker command. + * + * @return string the last line of output, without trailing new line + * character. + * @throws \Exception if the Docker command failed to execute. + */ + private function executeDockerCommand($dockerCommand) { + $output = array(); + $returnValue = 0; + $lastLine = exec("docker " . $dockerCommand . " 2>&1", $output, $returnValue); + + if ($returnValue !== 0) { + throw new Exception("Failed to execute 'docker " . $dockerCommand . "': " . implode("\n", $output)); + } + + return $lastLine; + } + +} diff --git a/build/acceptance/features/core/Utils.php b/build/acceptance/features/core/Utils.php new file mode 100644 index 00000000000..5dc52cd7377 --- /dev/null +++ b/build/acceptance/features/core/Utils.php @@ -0,0 +1,59 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +class Utils { + + /** + * Waits at most $timeout seconds for the given condition to be true, + * checking it again every $timeoutStep seconds. + * + * Note that the timeout is no longer taken into account when a condition is + * met; that is, true will be returned if the condition is met before the + * timeout expires, but also if it is met exactly when the timeout expires. + * For example, even if the timeout is set to 0, the condition will be + * checked at least once, and true will be returned in that case if the + * condition was met. + * + * @param \Closure $conditionCallback the condition to wait for, as a + * function that returns a boolean. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the condition to be true. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before checking the condition again. + * @return boolean true if the condition is met before (or exactly when) the + * timeout expires, false otherwise. + */ + public static function waitFor($conditionCallback, $timeout, $timeoutStep) { + $elapsedTime = 0; + $conditionMet = false; + + while (!($conditionMet = $conditionCallback()) && $elapsedTime < $timeout) { + usleep($timeoutStep * 1000000); + + $elapsedTime += $timeoutStep; + } + + return $conditionMet; + } + +} |