summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build/acceptance/composer.json5
-rw-r--r--build/acceptance/config/behat.yml2
-rw-r--r--build/acceptance/features/core/NextcloudTestServerContext.php156
-rw-r--r--build/acceptance/features/core/NextcloudTestServerDockerHelper.php206
-rw-r--r--build/acceptance/features/core/Utils.php59
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;
+ }
+
+}