From 7d152e2ad19af31976b4aa5c80deecb1e7e2d911 Mon Sep 17 00:00:00 2001 From: Kiran Parajuli Date: Wed, 21 Dec 2022 17:22:22 +0545 Subject: [PATCH] Copied acceptance tests infrastructures from oC/core Signed-off-by: Kiran Parajuli --- .codacy.yml | 2 +- .drone.star | 2 - Makefile | 4 +- composer.json | 3 +- tests/TestHelpers/GraphHelper.php | 2 +- tests/TestHelpers/HttpRequestHelper.php | 622 ++ tests/TestHelpers/OcisHelper.php | 364 + tests/TestHelpers/OcsApiHelper.php | 103 + tests/TestHelpers/SetupHelper.php | 1268 ++++ tests/TestHelpers/SharingHelper.php | 261 + tests/TestHelpers/SpaceNotFoundException.php | 14 + tests/TestHelpers/TranslationHelper.php | 46 + tests/TestHelpers/UploadHelper.php | 318 + tests/TestHelpers/UserHelper.php | 375 + tests/TestHelpers/WebDavHelper.php | 898 +++ tests/acceptance/config/behat.yml | 4 +- .../bootstrap/AppConfigurationContext.php | 591 ++ .../features/bootstrap/AuthContext.php | 1158 ++++ .../bootstrap/CapabilitiesContext.php | 223 + .../features/bootstrap/ChecksumContext.php | 464 ++ .../features/bootstrap/FavoritesContext.php | 361 + .../features/bootstrap/FeatureContext.php | 4488 ++++++++++++ .../bootstrap/FilesVersionsContext.php | 443 ++ .../features/bootstrap/OCSContext.php | 1011 +++ .../features/bootstrap/OccContext.php | 3748 ++++++++++ .../features/bootstrap/Provisioning.php | 6141 +++++++++++++++++ .../bootstrap/PublicWebDavContext.php | 1661 +++++ .../features/bootstrap/SearchContext.php | 192 + .../acceptance/features/bootstrap/Sharing.php | 4391 ++++++++++++ .../features/bootstrap/TUSContext.php | 489 ++ .../features/bootstrap/TrashbinContext.php | 1182 ++++ .../acceptance/features/bootstrap/WebDav.php | 5527 +++++++++++++++ .../bootstrap/WebDavPropertiesContext.php | 1396 ++++ .../features/bootstrap/bootstrap.php | 47 +- .../filesForUpload/'single'quotes.txt | 9 + tests/acceptance/filesForUpload/data.tar.gz | Bin 0 -> 4361 bytes tests/acceptance/filesForUpload/data.zip | Bin 0 -> 4919 bytes tests/acceptance/filesForUpload/davtest.txt | 1 + tests/acceptance/filesForUpload/example.gif | Bin 0 -> 231207 bytes .../filesForUpload/file_to_overwrite.txt | 1 + tests/acceptance/filesForUpload/lorem-big.txt | 93 + tests/acceptance/filesForUpload/lorem.txt | 7 + .../filesForUpload/new-'single'quotes.txt | 9 + .../acceptance/filesForUpload/new-data.tar.gz | Bin 0 -> 4361 bytes tests/acceptance/filesForUpload/new-data.zip | Bin 0 -> 4919 bytes .../filesForUpload/new-lorem-big.txt | 93 + tests/acceptance/filesForUpload/new-lorem.txt | 9 + .../new-strängé filename (duplicate #2 &).txt | 9 + tests/acceptance/filesForUpload/simple.odt | Bin 0 -> 8708 bytes tests/acceptance/filesForUpload/simple.pdf | Bin 0 -> 9622 bytes .../strängé filename (duplicate #2 &).txt | 9 + .../acceptance/filesForUpload/testavatar.jpg | Bin 0 -> 53092 bytes .../acceptance/filesForUpload/testavatar.png | Bin 0 -> 35323 bytes tests/acceptance/filesForUpload/textfile.txt | 3 + tests/acceptance/filesForUpload/zerobyte.txt | 0 .../zzzz-must-be-last-file-in-folder.txt | 11 + ...at-the-end-of-the-folder-when-uploaded.txt | 11 + tests/acceptance/lint-expected-failures.sh | 130 + tests/acceptance/run.sh | 1376 ++++ .../features/bootstrap/bootstrap.php | 12 +- vendor-bin/behat/composer.json | 1 + 61 files changed, 39555 insertions(+), 28 deletions(-) create mode 100644 tests/TestHelpers/HttpRequestHelper.php create mode 100644 tests/TestHelpers/OcisHelper.php create mode 100644 tests/TestHelpers/OcsApiHelper.php create mode 100644 tests/TestHelpers/SetupHelper.php create mode 100644 tests/TestHelpers/SharingHelper.php create mode 100644 tests/TestHelpers/SpaceNotFoundException.php create mode 100644 tests/TestHelpers/TranslationHelper.php create mode 100644 tests/TestHelpers/UploadHelper.php create mode 100644 tests/TestHelpers/UserHelper.php create mode 100644 tests/TestHelpers/WebDavHelper.php create mode 100644 tests/acceptance/features/bootstrap/AppConfigurationContext.php create mode 100644 tests/acceptance/features/bootstrap/AuthContext.php create mode 100644 tests/acceptance/features/bootstrap/CapabilitiesContext.php create mode 100644 tests/acceptance/features/bootstrap/ChecksumContext.php create mode 100644 tests/acceptance/features/bootstrap/FavoritesContext.php create mode 100644 tests/acceptance/features/bootstrap/FeatureContext.php create mode 100644 tests/acceptance/features/bootstrap/FilesVersionsContext.php create mode 100644 tests/acceptance/features/bootstrap/OCSContext.php create mode 100644 tests/acceptance/features/bootstrap/OccContext.php create mode 100644 tests/acceptance/features/bootstrap/Provisioning.php create mode 100644 tests/acceptance/features/bootstrap/PublicWebDavContext.php create mode 100644 tests/acceptance/features/bootstrap/SearchContext.php create mode 100644 tests/acceptance/features/bootstrap/Sharing.php create mode 100644 tests/acceptance/features/bootstrap/TUSContext.php create mode 100644 tests/acceptance/features/bootstrap/TrashbinContext.php create mode 100644 tests/acceptance/features/bootstrap/WebDav.php create mode 100644 tests/acceptance/features/bootstrap/WebDavPropertiesContext.php create mode 100644 tests/acceptance/filesForUpload/'single'quotes.txt create mode 100644 tests/acceptance/filesForUpload/data.tar.gz create mode 100644 tests/acceptance/filesForUpload/data.zip create mode 100644 tests/acceptance/filesForUpload/davtest.txt create mode 100644 tests/acceptance/filesForUpload/example.gif create mode 100644 tests/acceptance/filesForUpload/file_to_overwrite.txt create mode 100644 tests/acceptance/filesForUpload/lorem-big.txt create mode 100644 tests/acceptance/filesForUpload/lorem.txt create mode 100644 tests/acceptance/filesForUpload/new-'single'quotes.txt create mode 100644 tests/acceptance/filesForUpload/new-data.tar.gz create mode 100644 tests/acceptance/filesForUpload/new-data.zip create mode 100644 tests/acceptance/filesForUpload/new-lorem-big.txt create mode 100644 tests/acceptance/filesForUpload/new-lorem.txt create mode 100644 tests/acceptance/filesForUpload/new-strängé filename (duplicate #2 &).txt create mode 100644 tests/acceptance/filesForUpload/simple.odt create mode 100644 tests/acceptance/filesForUpload/simple.pdf create mode 100644 tests/acceptance/filesForUpload/strängé filename (duplicate #2 &).txt create mode 100644 tests/acceptance/filesForUpload/testavatar.jpg create mode 100644 tests/acceptance/filesForUpload/testavatar.png create mode 100644 tests/acceptance/filesForUpload/textfile.txt create mode 100644 tests/acceptance/filesForUpload/zerobyte.txt create mode 100644 tests/acceptance/filesForUpload/zzzz-must-be-last-file-in-folder.txt create mode 100644 tests/acceptance/filesForUpload/zzzz-zzzz-will-be-at-the-end-of-the-folder-when-uploaded.txt create mode 100755 tests/acceptance/lint-expected-failures.sh create mode 100755 tests/acceptance/run.sh diff --git a/.codacy.yml b/.codacy.yml index c9dd79b0b..1d94123ca 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -16,5 +16,5 @@ exclude_paths: - 'tests/acceptance/expected-failures-*.md' - 'tests/acceptance/features/bootstrap/**' - 'tests/TestHelpers/**' - + - 'tests/acceptance/run.sh' ... diff --git a/.drone.star b/.drone.star index b964328bd..fba5c14f2 100644 --- a/.drone.star +++ b/.drone.star @@ -716,7 +716,6 @@ def localApiTestPipeline(ctx): "steps": skipIfUnchanged(ctx, "acceptance-tests") + restoreBuildArtifactCache(ctx, "ocis-binary-amd64", "ocis/bin") + ocisServer(storage, params["accounts_hash_difficulty"], extra_server_environment = params["extraServerEnvironment"]) + - restoreBuildArtifactCache(ctx, "testrunner", dirs["core"]) + localApiTests(suite, storage, params["extraEnvironment"]) + failEarly(ctx, early_fail), "services": redisForOCStorage(storage), @@ -736,7 +735,6 @@ def localApiTests(suite, storage, extra_environment = {}): environment = { "TEST_WITH_GRAPH_API": "true", "PATH_TO_OCIS": dirs["base"], - "PATH_TO_CORE": "%s/%s" % (dirs["base"], dirs["core"]), "TEST_SERVER_URL": "https://ocis-server:9200", "OCIS_REVA_DATA_ROOT": "%s" % (dirs["ocisRevaDataRoot"] if storage == "owncloud" else ""), "OCIS_SKELETON_STRATEGY": "%s" % ("copy" if storage == "owncloud" else "upload"), diff --git a/Makefile b/Makefile index 51c56eb06..bac62842d 100644 --- a/Makefile +++ b/Makefile @@ -106,11 +106,11 @@ PARALLEL_BEHAT_YML=tests/parallelDeployAcceptance/config/behat.yml .PHONY: test-acceptance-api test-acceptance-api: vendor-bin/behat/vendor - BEHAT_BIN=$(BEHAT_BIN) $(PATH_TO_CORE)/tests/acceptance/run.sh --remote --type api + BEHAT_BIN=$(BEHAT_BIN) $(PWD)/tests/acceptance/run.sh --type api .PHONY: test-paralleldeployment-api test-paralleldeployment-api: vendor-bin/behat/vendor - BEHAT_BIN=$(BEHAT_BIN) BEHAT_YML=$(PARALLEL_BEHAT_YML) $(PATH_TO_CORE)/tests/acceptance/run.sh --type api + BEHAT_BIN=$(BEHAT_BIN) BEHAT_YML=$(PARALLEL_BEHAT_YML) $(PWD)/tests/acceptance/run.sh --type api vendor/bamarni/composer-bin-plugin: composer.lock composer install diff --git a/composer.json b/composer.json index dddfbd2bc..ddff8e347 100644 --- a/composer.json +++ b/composer.json @@ -9,9 +9,8 @@ "bamarni/composer-bin-plugin": true } }, - "require": { - }, "require-dev": { + "ext-simplexml": "*", "bamarni/composer-bin-plugin": "^1.4" }, "extra": { diff --git a/tests/TestHelpers/GraphHelper.php b/tests/TestHelpers/GraphHelper.php index 14335d6b8..d52b4cf55 100644 --- a/tests/TestHelpers/GraphHelper.php +++ b/tests/TestHelpers/GraphHelper.php @@ -84,7 +84,7 @@ class GraphHelper { */ public static function getFullUrl(string $baseUrl, string $path): string { $fullUrl = $baseUrl; - if (\substr($fullUrl, -1) !== '/') { + if (!str_ends_with($fullUrl, '/')) { $fullUrl .= '/'; } $fullUrl .= 'graph/v1.0/' . $path; diff --git a/tests/TestHelpers/HttpRequestHelper.php b/tests/TestHelpers/HttpRequestHelper.php new file mode 100644 index 000000000..c6b567b7a --- /dev/null +++ b/tests/TestHelpers/HttpRequestHelper.php @@ -0,0 +1,622 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ + +namespace TestHelpers; + +use Exception; +use GuzzleHttp\Client; +use GuzzleHttp\Cookie\CookieJar; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Request; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; +use SimpleXMLElement; +use Sabre\Xml\LibXMLException; +use Sabre\Xml\Reader; +use GuzzleHttp\Pool; + +/** + * Helper for HTTP requests + */ +class HttpRequestHelper { + public const HTTP_TOO_EARLY = 425; + + /** + * @var string + */ + private static $oCSelectorCookie = null; + + /** + * @return string + */ + public static function getOCSelectorCookie(): string { + return self::$oCSelectorCookie; + } + + /** + * @param string $oCSelectorCookie "owncloud-selector=oc10;path=/;" + * + * @return void + */ + public static function setOCSelectorCookie(string $oCSelectorCookie): void { + self::$oCSelectorCookie = $oCSelectorCookie; + } + + /** + * Some systems-under-test do async post-processing of operations like upload, + * move etc. If a client does a request on the resource before the post-processing + * is finished, then the server should return HTTP_TOO_EARLY "425". Clients are + * expected to retry the request "some time later" (tm). + * + * On such systems, when HTTP_TOO_EARLY status is received, the test code will + * retry the request at 1-second intervals until either some other HTTP status + * is received or the retry-limit is reached. + * + * @return int + */ + public static function numRetriesOnHttpTooEarly():int { + if (OcisHelper::isTestingOnOcisOrReva()) { + // Currently reva and oCIS may return HTTP_TOO_EARLY + // So try up to 10 times before giving up. + return 10; + } + return 0; + } + + /** + * + * @param string|null $url + * @param string|null $xRequestId + * @param string|null $method + * @param string|null $user + * @param string|null $password + * @param array|null $headers ['X-MyHeader' => 'value'] + * @param mixed $body + * @param array|null $config + * @param CookieJar|null $cookies + * @param bool $stream Set to true to stream a response rather + * than download it all up-front. + * @param int|null $timeout + * @param Client|null $client + * + * @return ResponseInterface + * @throws GuzzleException + */ + public static function sendRequest( + ?string $url, + ?string $xRequestId, + ?string $method = 'GET', + ?string $user = null, + ?string $password = null, + ?array $headers = null, + $body = null, + ?array $config = null, + ?CookieJar $cookies = null, + bool $stream = false, + ?int $timeout = 0, + ?Client $client = null + ):ResponseInterface { + if ($client === null) { + $client = self::createClient( + $user, + $password, + $config, + $cookies, + $stream, + $timeout + ); + } + /** + * @var RequestInterface $request + */ + $request = self::createRequest( + $url, + $xRequestId, + $method, + $headers, + $body + ); + + if ((\getenv('DEBUG_ACCEPTANCE_REQUESTS') !== false) || (\getenv('DEBUG_ACCEPTANCE_API_CALLS') !== false)) { + $debugRequests = true; + } else { + $debugRequests = false; + } + + if ((\getenv('DEBUG_ACCEPTANCE_RESPONSES') !== false) || (\getenv('DEBUG_ACCEPTANCE_API_CALLS') !== false)) { + $debugResponses = true; + } else { + $debugResponses = false; + } + + if ($debugRequests) { + self::debugRequest($request, $user, $password); + } + + $sendRetryLimit = self::numRetriesOnHttpTooEarly(); + $sendCount = 0; + $sendExceptionHappened = false; + + do { + // The exceptions that might happen here include: + // ConnectException - in that case there is no response. Don't catch the exception. + // RequestException - if there is something in the response then pass it back. + // otherwise re-throw the exception. + // GuzzleException - something else unexpected happened. Don't catch the exception. + try { + $response = $client->send($request); + } catch (RequestException $ex) { + $sendExceptionHappened = true; + $response = $ex->getResponse(); + + //if the response was null for some reason do not return it but re-throw + if ($response === null) { + throw $ex; + } + } + + if ($debugResponses) { + self::debugResponse($response); + } + $sendCount = $sendCount + 1; + $loopAgain = !$sendExceptionHappened && ($response->getStatusCode() === self::HTTP_TOO_EARLY) && ($sendCount <= $sendRetryLimit); + if ($loopAgain) { + // we need to repeat the send request, because we got HTTP_TOO_EARLY + // wait 1 second before sending again, to give the server some time + // to finish whatever post-processing it might be doing. + \sleep(1); + } + } while ($loopAgain); + + return $response; + } + + /** + * Print details about the request. + * + * @param RequestInterface|null $request + * @param string|null $user + * @param string|null $password + * + * @return void + */ + private static function debugRequest(?RequestInterface $request, ?string $user, ?string $password):void { + print("### AUTH: $user:$password\n"); + print("### REQUEST: " . $request->getMethod() . " " . $request->getUri() . "\n"); + self::printHeaders($request->getHeaders()); + self::printBody($request->getBody()); + print("\n### END REQUEST\n"); + } + + /** + * Print details about the response. + * + * @param ResponseInterface|null $response + * + * @return void + */ + private static function debugResponse(?ResponseInterface $response):void { + print("### RESPONSE\n"); + print("Status: " . $response->getStatusCode() . "\n"); + self::printHeaders($response->getHeaders()); + self::printBody($response->getBody()); + print("\n### END RESPONSE\n"); + } + + /** + * Print details about the headers. + * + * @param array|null $headers + * + * @return void + */ + private static function printHeaders(?array $headers):void { + if ($headers) { + print("Headers:\n"); + foreach ($headers as $header => $value) { + if (\is_array($value)) { + print($header . ": " . \implode(', ', $value) . "\n"); + } else { + print($header . ": " . $value . "\n"); + } + } + } else { + print("Headers: none\n"); + } + } + + /** + * Print details about the body. + * + * @param StreamInterface|null $body + * + * @return void + */ + private static function printBody(?StreamInterface $body):void { + print("Body:\n"); + \var_dump($body->getContents()); + // Rewind the stream so that later code can read from the start. + $body->rewind(); + } + + /** + * Send the requests to the server in parallel. + * This function takes an array of requests and an optional client. + * It will send all the requests to the server using the Pool object in guzzle. + * + * @param array|null $requests + * @param Client|null $client + * + * @return array + */ + public static function sendBatchRequest( + ?array $requests, + ?Client $client + ):array { + $results = Pool::batch($client, $requests); + return $results; + } + + /** + * Create a Guzzle Client + * This creates a client object that can be used later to send a request object(s) + * + * @param string|null $user + * @param string|null $password + * @param array|null $config + * @param CookieJar|null $cookies + * @param bool $stream Set to true to stream a response rather + * than download it all up-front. + * @param int|null $timeout + * + * @return Client + */ + public static function createClient( + ?string $user = null, + ?string $password = null, + ?array $config = null, + ?CookieJar $cookies = null, + ?bool $stream = false, + ?int $timeout = 0 + ):Client { + $options = []; + if ($user !== null) { + $options['auth'] = [$user, $password]; + } + if ($config !== null) { + $options['config'] = $config; + } + if ($cookies !== null) { + $options['cookies'] = $cookies; + } + $options['stream'] = $stream; + $options['verify'] = false; + $options['timeout'] = $timeout; + $client = new Client($options); + return $client; + } + + /** + * Create an http request based on given parameters. + * This creates a RequestInterface object that can be used with a client to send a request. + * This enables us to create multiple requests in advance so that we can send them to the server at once in parallel. + * + * @param string|null $url + * @param string|null $xRequestId + * @param string|null $method + * @param array|null $headers ['X-MyHeader' => 'value'] + * @param string|array $body either the actual string to send in the body, + * or an array of key-value pairs to be converted + * into a body with http_build_query. + * + * @return RequestInterface + */ + public static function createRequest( + ?string $url, + ?string $xRequestId = '', + ?string $method = 'GET', + ?array $headers = null, + $body = null + ):RequestInterface { + if ($headers === null) { + $headers = []; + } + if ($xRequestId !== '') { + $headers['X-Request-ID'] = $xRequestId; + } + if (\is_array($body)) { + // when creating the client, it is possible to set 'form_params' and + // the Client constructor sorts out doing this http_build_query stuff. + // But 'new Request' does not have the flexibility to do that. + // So we need to do it here. + $body = \http_build_query($body, '', '&'); + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + if (OcisHelper::isTestingParallelDeployment()) { + // oCIS cannot handle '/apps/testing' endpoints + // so those requests must be redirected to oC10 server + // change server to oC10 if the request url has `/apps/testing` + if (strpos($url, "/apps/testing") !== false) { + $oCISServerUrl = \getenv('TEST_SERVER_URL'); + $oC10ServerUrl = \getenv('TEST_OC10_URL'); + $url = str_replace($oCISServerUrl, $oC10ServerUrl, $url); + } else { + // set 'owncloud-server' selector cookie for oCIS requests + $headers['Cookie'] = self::getOCSelectorCookie(); + } + } + + $request = new Request( + $method, + $url, + $headers, + $body + ); + return $request; + } + + /** + * same as HttpRequestHelper::sendRequest() but with "GET" as method + * + * @param string|null $url + * @param string|null $xRequestId + * @param string|null $user + * @param string|null $password + * @param array|null $headers ['X-MyHeader' => 'value'] + * @param mixed $body + * @param array|null $config + * @param CookieJar|null $cookies + * @param boolean $stream + * + * @return ResponseInterface + * @throws GuzzleException + * @see HttpRequestHelper::sendRequest() + */ + public static function get( + ?string $url, + ?string $xRequestId, + ?string $user = null, + ?string $password = null, + ?array $headers = null, + $body = null, + ?array $config = null, + ?CookieJar $cookies = null, + ?bool $stream = false + ):ResponseInterface { + return self::sendRequest( + $url, + $xRequestId, + 'GET', + $user, + $password, + $headers, + $body, + $config, + $cookies, + $stream + ); + } + + /** + * same as HttpRequestHelper::sendRequest() but with "POST" as method + * + * @param string|null $url + * @param string|null $xRequestId + * @param string|null $user + * @param string|null $password + * @param array|null $headers ['X-MyHeader' => 'value'] + * @param mixed $body + * @param array|null $config + * @param CookieJar|null $cookies + * @param boolean $stream + * + * @return ResponseInterface + * @throws GuzzleException + * @see HttpRequestHelper::sendRequest() + */ + public static function post( + ?string $url, + ?string $xRequestId, + ?string $user = null, + ?string $password = null, + ?array $headers = null, + $body = null, + ?array $config = null, + ?CookieJar $cookies = null, + ?bool $stream = false + ):ResponseInterface { + return self::sendRequest( + $url, + $xRequestId, + 'POST', + $user, + $password, + $headers, + $body, + $config, + $cookies, + $stream + ); + } + + /** + * same as HttpRequestHelper::sendRequest() but with "PUT" as method + * + * @param string|null $url + * @param string|null $xRequestId + * @param string|null $user + * @param string|null $password + * @param array|null $headers ['X-MyHeader' => 'value'] + * @param mixed $body + * @param array|null $config + * @param CookieJar|null $cookies + * @param boolean $stream + * + * @return ResponseInterface + * @throws GuzzleException + * @see HttpRequestHelper::sendRequest() + */ + public static function put( + ?string $url, + ?string $xRequestId, + ?string $user = null, + ?string $password = null, + ?array $headers = null, + $body = null, + ?array $config = null, + ?CookieJar $cookies = null, + ?bool $stream = false + ):ResponseInterface { + return self::sendRequest( + $url, + $xRequestId, + 'PUT', + $user, + $password, + $headers, + $body, + $config, + $cookies, + $stream + ); + } + + /** + * same as HttpRequestHelper::sendRequest() but with "DELETE" as method + * + * @param string|null $url + * @param string|null $xRequestId + * @param string|null $user + * @param string|null $password + * @param array|null $headers ['X-MyHeader' => 'value'] + * @param mixed $body + * @param array|null $config + * @param CookieJar|null $cookies + * @param boolean $stream + * + * @return ResponseInterface + * @throws GuzzleException + * @see HttpRequestHelper::sendRequest() + * + */ + public static function delete( + ?string $url, + ?string $xRequestId, + ?string $user = null, + ?string $password = null, + ?array $headers = null, + $body = null, + ?array $config = null, + ?CookieJar $cookies = null, + ?bool $stream = false + ):ResponseInterface { + return self::sendRequest( + $url, + $xRequestId, + 'DELETE', + $user, + $password, + $headers, + $body, + $config, + $cookies, + $stream + ); + } + + /** + * Parses the response as XML and returns a SimpleXMLElement with these + * registered namespaces: + * | prefix | namespace | + * | d | DAV: | + * | oc | http://owncloud.org/ns | + * | ocs | http://open-collaboration-services.org/ns | + * + * @param ResponseInterface $response + * @param string|null $exceptionText text to put at the front of exception messages + * + * @return SimpleXMLElement + * @throws Exception + */ + public static function getResponseXml(ResponseInterface $response, ?string $exceptionText = ''):SimpleXMLElement { + // rewind just to make sure we can re-parse it in case it was parsed already... + $response->getBody()->rewind(); + $contents = $response->getBody()->getContents(); + try { + $responseXmlObject = new SimpleXMLElement($contents); + $responseXmlObject->registerXPathNamespace( + 'ocs', + 'http://open-collaboration-services.org/ns' + ); + $responseXmlObject->registerXPathNamespace( + 'oc', + 'http://owncloud.org/ns' + ); + $responseXmlObject->registerXPathNamespace( + 'd', + 'DAV:' + ); + return $responseXmlObject; + } catch (Exception $e) { + if ($exceptionText !== '') { + $exceptionText = $exceptionText . ' '; + } + if ($contents === '') { + throw new Exception($exceptionText . "Received empty response where XML was expected"); + } + $message = $exceptionText . "Exception parsing response body: \"" . $contents . "\""; + throw new Exception($message, 0, $e); + } + } + + /** + * parses the body content of $response and returns an array representing the XML + * This function returns an array with the following three elements: + * * name - The root element name. + * * value - The value for the root element. + * * attributes - An array of attributes. + * + * @param ResponseInterface $response + * + * @return array + */ + public static function parseResponseAsXml(ResponseInterface $response):array { + $body = $response->getBody()->getContents(); + $parsedResponse = []; + if ($body && \substr($body, 0, 1) === '<') { + try { + $reader = new Reader(); + $reader->xml($body); + $parsedResponse = $reader->parse(); + } catch (LibXMLException $e) { + // Sometimes the body can be a real page of HTML and text. + // So it may not be a complete ordinary piece of XML. + // The XML parse might fail with an exception message like: + // Opening and ending tag mismatch: link line 31 and head. + } + } + return $parsedResponse; + } +} diff --git a/tests/TestHelpers/OcisHelper.php b/tests/TestHelpers/OcisHelper.php new file mode 100644 index 000000000..e8627baba --- /dev/null +++ b/tests/TestHelpers/OcisHelper.php @@ -0,0 +1,364 @@ + + * @copyright Copyright (c) 2020 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ + +namespace TestHelpers; + +use Exception; +use GuzzleHttp\Exception\GuzzleException; + +/** + * Class OcisHelper + * + * Helper functions that are needed to run tests on OCIS + * + * @package TestHelpers + */ +class OcisHelper { + /** + * @return bool + */ + public static function isTestingOnOcis():bool { + return (\getenv("TEST_OCIS") === "true"); + } + + /** + * @return bool + */ + public static function isTestingOnReva():bool { + return (\getenv("TEST_REVA") === "true"); + } + + /** + * @return bool + */ + public static function isTestingOnOcisOrReva():bool { + return (self::isTestingOnOcis() || self::isTestingOnReva()); + } + + /** + * @return bool + */ + public static function isTestingOnOc10():bool { + return (!self::isTestingOnOcisOrReva()); + } + + /** + * @return bool + */ + public static function isTestingParallelDeployment(): bool { + return (\getenv("TEST_PARALLEL_DEPLOYMENT") === "true"); + } + + /** + * @return bool + */ + public static function isTestingWithGraphApi(): bool { + return \getenv('TEST_WITH_GRAPH_API') === 'true'; + } + + /** + * @return bool|string false if no command given or the command as string + */ + public static function getDeleteUserDataCommand() { + $cmd = \getenv("DELETE_USER_DATA_CMD"); + if ($cmd === false || \trim($cmd) === "") { + return false; + } + return $cmd; + } + + /** + * @return string + * @throws Exception + */ + public static function getStorageDriver():string { + $storageDriver = (\getenv("STORAGE_DRIVER")); + if ($storageDriver === false) { + return "OWNCLOUD"; + } + $storageDriver = \strtoupper($storageDriver); + if ($storageDriver !== "OCIS" && $storageDriver !== "EOS" && $storageDriver !== "OWNCLOUD" && $storageDriver !== "S3NG") { + throw new Exception( + "Invalid storage driver. " . + "STORAGE_DRIVER must be OCIS|EOS|OWNCLOUD|S3NG" + ); + } + return $storageDriver; + } + + /** + * @param string|null $user + * + * @return void + * @throws Exception + */ + public static function deleteRevaUserData(?string $user = ""):void { + $deleteCmd = self::getDeleteUserDataCommand(); + if ($deleteCmd === false) { + if (self::getStorageDriver() === "OWNCLOUD") { + self::recurseRmdir(self::getOcisRevaDataRoot() . $user); + } + return; + } + if (self::getStorageDriver() === "EOS") { + $deleteCmd = \str_replace( + "%s", + $user[0] . '/' . $user, + $deleteCmd + ); + } else { + $deleteCmd = \sprintf($deleteCmd, $user); + } + \exec($deleteCmd); + } + + /** + * Helper for Recursive Copy of file/folder + * For more info check this out https://gist.github.com/gserrano/4c9648ec9eb293b9377b + * + * @param string|null $source + * @param string|null $destination + * + * @return void + */ + public static function recurseCopy(?string $source, ?string $destination):void { + $dir = \opendir($source); + @\mkdir($destination); + while (($file = \readdir($dir)) !== false) { + if (($file != '.') && ($file != '..')) { + if (\is_dir($source . '/' . $file)) { + self::recurseCopy($source . '/' . $file, $destination . '/' . $file); + } else { + \copy($source . '/' . $file, $destination . '/' . $file); + } + } + } + \closedir($dir); + } + + /** + * Helper for Recursive Upload of file/folder + * + * @param string|null $baseUrl + * @param string|null $source + * @param string|null $userId + * @param string|null $password + * @param string|null $xRequestId + * @param string|null $destination + * + * @return void + * @throws Exception + */ + public static function recurseUpload( + ?string $baseUrl, + ?string $source, + ?string $userId, + ?string $password, + ?string $xRequestId = '', + ?string $destination = '' + ):void { + if ($destination !== '') { + $response = WebDavHelper::makeDavRequest( + $baseUrl, + $userId, + $password, + "MKCOL", + $destination, + [], + $xRequestId + ); + if ($response->getStatusCode() !== 201) { + throw new Exception("Could not create folder destination" . $response->getBody()->getContents()); + } + } + + $dir = \opendir($source); + while (($file = \readdir($dir)) !== false) { + if (($file != '.') && ($file != '..')) { + $sourcePath = $source . '/' . $file; + $destinationPath = $destination . '/' . $file; + if (\is_dir($sourcePath)) { + self::recurseUpload( + $baseUrl, + $sourcePath, + $userId, + $password, + $xRequestId, + $destinationPath + ); + } else { + $response = UploadHelper::upload( + $baseUrl, + $userId, + $password, + $sourcePath, + $destinationPath, + $xRequestId + ); + $responseStatus = $response->getStatusCode(); + if ($responseStatus !== 201) { + throw new Exception( + "Could not upload skeleton file $sourcePath to $destinationPath for user '$userId' status '$responseStatus' response body: '" + . $response->getBody()->getContents() . "'" + ); + } + } + } + } + \closedir($dir); + } + + /** + * @return int + */ + public static function getLdapPort():int { + $port = \getenv("REVA_LDAP_PORT"); + return $port ? (int)$port : 636; + } + + /** + * @return bool + */ + public static function useSsl():bool { + $useSsl = \getenv("REVA_LDAP_USESSL"); + if ($useSsl === false) { + return (self::getLdapPort() === 636); + } else { + return $useSsl === "true"; + } + } + + /** + * @return string + */ + public static function getBaseDN():string { + $dn = \getenv("REVA_LDAP_BASE_DN"); + return $dn ? $dn : "dc=owncloud,dc=com"; + } + + /** + * @return string + */ + public static function getGroupsOU():string { + $ou = \getenv("REVA_LDAP_GROUPS_OU"); + return $ou ? $ou : "TestGroups"; + } + + /** + * @return string + */ + public static function getUsersOU():string { + $ou = \getenv("REVA_LDAP_USERS_OU"); + return $ou ? $ou : "TestUsers"; + } + + /** + * @return string + */ + public static function getGroupSchema():string { + $schema = \getenv("REVA_LDAP_GROUP_SCHEMA"); + return $schema ? $schema : "rfc2307"; + } + /** + * @return string + */ + public static function getHostname():string { + $hostname = \getenv("REVA_LDAP_HOSTNAME"); + return $hostname ? $hostname : "localhost"; + } + + /** + * @return string + */ + public static function getBindDN():string { + $dn = \getenv("REVA_LDAP_BIND_DN"); + return $dn ? $dn : "cn=admin,dc=owncloud,dc=com"; + } + + /** + * @return string + */ + public static function getBindPassword():string { + $pw = \getenv("REVA_LDAP_BIND_PASSWORD"); + return $pw ? $pw : ""; + } + + /** + * @return string + */ + private static function getOcisRevaDataRoot():string { + $root = \getenv("OCIS_REVA_DATA_ROOT"); + if (($root === false || $root === "") && self::isTestingOnOcisOrReva()) { + $root = "/var/tmp/ocis/owncloud/"; + } + if (!\file_exists($root)) { + echo "WARNING: reva data root folder ($root) does not exist\n"; + } + return $root; + } + + /** + * @param string|null $dir + * + * @return bool + */ + private static function recurseRmdir(?string $dir):bool { + if (\file_exists($dir) === true) { + $files = \array_diff(\scandir($dir), ['.', '..']); + foreach ($files as $file) { + if (\is_dir("$dir/$file")) { + self::recurseRmdir("$dir/$file"); + } else { + \unlink("$dir/$file"); + } + } + return \rmdir($dir); + } + return true; + } + + /** + * On Eos storage backend when the user data is cleared after test run + * Running another test immediately fails. So Send this request to create user home directory + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $password + * @param string|null $xRequestId + * + * @return void + * @throws GuzzleException + */ + public static function createEOSStorageHome( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $xRequestId = '' + ):void { + HttpRequestHelper::get( + $baseUrl . "/ocs/v2.php/apps/notifications/api/v1/notifications", + $xRequestId, + $user, + $password + ); + } +} diff --git a/tests/TestHelpers/OcsApiHelper.php b/tests/TestHelpers/OcsApiHelper.php new file mode 100644 index 000000000..6e0623f42 --- /dev/null +++ b/tests/TestHelpers/OcsApiHelper.php @@ -0,0 +1,103 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ +namespace TestHelpers; + +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + +/** + * Helper to make requests to the OCS API + * + * @author Artur Neumann + * + */ +class OcsApiHelper { + /** + * @param string|null $baseUrl + * @param string|null $user if set to null no authentication header will be sent + * @param string|null $password + * @param string|null $method HTTP Method + * @param string|null $path + * @param string|null $xRequestId + * @param mixed $body array of key, value pairs e.g ['value' => 'yes'] + * @param int|null $ocsApiVersion (1|2) default 2 + * @param array|null $headers + * + * @return ResponseInterface + * @throws GuzzleException + */ + public static function sendRequest( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $method, + ?string $path, + ?string $xRequestId = '', + $body = [], + ?int $ocsApiVersion = 2, + ?array $headers = [] + ):ResponseInterface { + $fullUrl = $baseUrl; + if (\substr($fullUrl, -1) !== '/') { + $fullUrl .= '/'; + } + $fullUrl .= "ocs/v{$ocsApiVersion}.php" . $path; + $headers['OCS-APIREQUEST'] = true; + return HttpRequestHelper::sendRequest($fullUrl, $xRequestId, $method, $user, $password, $headers, $body); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $method HTTP Method + * @param string|null $path + * @param string|null $xRequestId + * @param mixed $body array of key, value pairs e.g ['value' => 'yes'] + * @param int|null $ocsApiVersion (1|2) default 2 + * @param array|null $headers + * + * @return RequestInterface + */ + public static function createOcsRequest( + ?string $baseUrl, + ?string $method, + ?string $path, + ?string $xRequestId = '', + $body = [], + ?int $ocsApiVersion = 2, + ?array $headers = [] + ):RequestInterface { + $fullUrl = $baseUrl; + if (\substr($fullUrl, -1) !== '/') { + $fullUrl .= '/'; + } + $fullUrl .= "ocs/v{$ocsApiVersion}.php" . $path; + return HttpRequestHelper::createRequest( + $fullUrl, + $xRequestId, + $method, + $headers, + $body + ); + } +} diff --git a/tests/TestHelpers/SetupHelper.php b/tests/TestHelpers/SetupHelper.php new file mode 100644 index 000000000..16e7d043e --- /dev/null +++ b/tests/TestHelpers/SetupHelper.php @@ -0,0 +1,1268 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ +namespace TestHelpers; + +use Behat\Testwork\Hook\Scope\HookScope; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\ServerException; +use Exception; +use Psr\Http\Message\ResponseInterface; +use SimpleXMLElement; + +/** + * Helper to setup UI / Integration tests + * + * @author Artur Neumann + * + */ +class SetupHelper extends \PHPUnit\Framework\Assert { + /** + * @var string + */ + private static $ocPath = null; + /** + * @var string + */ + private static $baseUrl = null; + /** + * @var string + */ + private static $adminUsername = null; + /** + * @var string + */ + private static $adminPassword = null; + + /** + * creates a user + * + * @param string|null $userName + * @param string|null $password + * @param string|null $xRequestId + * @param string|null $displayName + * @param string|null $email + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function createUser( + ?string $userName, + ?string $password, + ?string $xRequestId = '', + ?string $displayName = null, + ?string $email = null + ):array { + $occCommand = ['user:add', '--password-from-env']; + if ($displayName !== null) { + $occCommand = \array_merge($occCommand, ["--display-name", $displayName]); + } + if ($email !== null) { + $occCommand = \array_merge($occCommand, ["--email", $email]); + } + \putenv("OC_PASS=" . $password); + return self::runOcc( + \array_merge($occCommand, [$userName]), + $xRequestId + ); + } + + /** + * deletes a user + * + * @param string|null $userName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function deleteUser( + ?string $userName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['user:delete', $userName], + $xRequestId + ); + } + + /** + * + * @param string|null $userName + * @param string|null $app + * @param string|null $key + * @param string|null $value + * @param string|null $xRequestId + * + * @return string[] + * @throws Exception + */ + public static function changeUserSetting( + ?string $userName, + ?string $app, + ?string $key, + ?string $value, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['user:setting', '--value ' . $value, $userName, $app, $key], + $xRequestId + ); + } + + /** + * creates a group + * + * @param string|null $groupName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function createGroup( + ?string $groupName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['group:add', $groupName], + $xRequestId + ); + } + + /** + * adds an existing user to a group, creating the group if it does not exist + * + * @param string|null $groupName + * @param string|null $userName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function addUserToGroup( + ?string $groupName, + ?string $userName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['group:add-member', '--member', $userName, $groupName], + $xRequestId + ); + } + + /** + * removes a user from a group + * + * @param string|null $groupName + * @param string|null $userName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function removeUserFromGroup( + ?string $groupName, + ?string $userName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['group:remove-member', '--member', $userName, $groupName], + $xRequestId + ); + } + + /** + * deletes a group + * + * @param string|null $groupName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function deleteGroup( + ?string $groupName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['group:delete', $groupName], + $xRequestId + ); + } + + /** + * + * @param string|null $xRequestId + * + * @return string[] + * @throws Exception + */ + public static function getGroups( + ?string $xRequestId = '' + ):array { + return \json_decode( + self::runOcc( + ['group:list', '--output=json'], + $xRequestId + )['stdOut'] + ); + } + /** + * + * @param HookScope $scope + * + * @return array of suite context parameters + */ + public static function getSuiteParameters(HookScope $scope):array { + return $scope->getEnvironment()->getSuite() + ->getSettings() ['context'] ['parameters']; + } + + /** + * Fixup OC path so that it always starts with a "/" and does not end with + * a "/". + * + * @param string|null $ocPath + * + * @return string + */ + private static function normaliseOcPath(?string $ocPath):string { + return '/' . \trim($ocPath, '/'); + } + + /** + * + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return void + */ + public static function init( + ?string $adminUsername, + ?string $adminPassword, + ?string $baseUrl, + ?string $ocPath + ): void { + foreach (\func_get_args() as $variableToCheck) { + if (!\is_string($variableToCheck)) { + throw new \InvalidArgumentException( + "mandatory argument missing or wrong type ($variableToCheck => " + . \gettype($variableToCheck) . ")" + ); + } + } + self::$adminUsername = $adminUsername; + self::$adminPassword = $adminPassword; + self::$baseUrl = \rtrim($baseUrl, '/'); + self::$ocPath = self::normaliseOcPath($ocPath); + } + + /** + * + * @return string path to the testing app occ endpoint + * @throws Exception if ocPath has not been set yet + */ + public static function getOcPath():?string { + if (self::$ocPath === null) { + throw new Exception( + "getOcPath called before ocPath is set by init" + ); + } + + return self::$ocPath; + } + + /** + * + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $xRequestId + * + * @return SimpleXMLElement + * @throws GuzzleException + */ + public static function getSysInfo( + ?string $baseUrl, + ?string $adminUsername, + ?string $adminPassword, + ?string $xRequestId = '' + ):SimpleXMLElement { + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "GET", + "/apps/testing/api/v1/sysinfo", + $xRequestId + ); + if ($result->getStatusCode() !== 200) { + throw new \Exception( + "could not get sysinfo " . $result->getReasonPhrase() + ); + } + return HttpRequestHelper::getResponseXml($result, __METHOD__)->data; + } + + /** + * + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $xRequestId + * + * @return string + * @throws GuzzleException + */ + public static function getServerRoot( + ?string $baseUrl, + ?string $adminUsername, + ?string $adminPassword, + ?string $xRequestId = '' + ):string { + $sysInfo = self::getSysInfo( + $baseUrl, + $adminUsername, + $adminPassword, + $xRequestId + ); + // server_root is a SimpleXMLElement object that "wraps" a string. + /// We want the bare string. + return (string) $sysInfo->server_root; + } + + /** + * @param string|null $adminUsername + * @param string|null $callerName + * + * @return string + * @throws Exception + */ + private static function checkAdminUsername(?string $adminUsername, ?string $callerName):?string { + if (self::$adminUsername === null + && $adminUsername === null + ) { + throw new Exception( + "$callerName called without adminUsername - pass the username or call SetupHelper::init" + ); + } + if ($adminUsername === null) { + $adminUsername = self::$adminUsername; + } + return $adminUsername; + } + + /** + * @param string|null $adminPassword + * @param string|null $callerName + * + * @return string + * @throws Exception + */ + private static function checkAdminPassword(?string $adminPassword, ?string $callerName):string { + if (self::$adminPassword === null + && $adminPassword === null + ) { + throw new Exception( + "$callerName called without adminPassword - pass the password or call SetupHelper::init" + ); + } + if ($adminPassword === null) { + $adminPassword = self::$adminPassword; + } + return $adminPassword; + } + + /** + * @param string|null $baseUrl + * @param string|null $callerName + * + * @return string + * @throws Exception + */ + private static function checkBaseUrl(?string $baseUrl, ?string $callerName):?string { + if (self::$baseUrl === null + && $baseUrl === null + ) { + throw new Exception( + "$callerName called without baseUrl - pass the baseUrl or call SetupHelper::init" + ); + } + if ($baseUrl === null) { + $baseUrl = self::$baseUrl; + } + return $baseUrl; + } + + /** + * + * @param string|null $dirPathFromServerRoot e.g. 'apps2/myapp/appinfo' + * @param string|null $xRequestId + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * + * @return void + * @throws GuzzleException + * @throws Exception + */ + public static function mkDirOnServer( + ?string $dirPathFromServerRoot, + ?string $xRequestId = '', + ?string $baseUrl = null, + ?string $adminUsername = null, + ?string $adminPassword = null + ):void { + $baseUrl = self::checkBaseUrl($baseUrl, "mkDirOnServer"); + $adminUsername = self::checkAdminUsername($adminUsername, "mkDirOnServer"); + $adminPassword = self::checkAdminPassword($adminPassword, "mkDirOnServer"); + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "POST", + "/apps/testing/api/v1/dir", + $xRequestId, + ['dir' => $dirPathFromServerRoot] + ); + + if ($result->getStatusCode() !== 200) { + throw new \Exception( + "could not create directory $dirPathFromServerRoot " . $result->getReasonPhrase() + ); + } + } + + /** + * + * @param string|null $dirPathFromServerRoot e.g. 'apps2/myapp/appinfo' + * @param string|null $xRequestId + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * + * @return void + * @throws GuzzleException + * @throws Exception + */ + public static function rmDirOnServer( + ?string $dirPathFromServerRoot, + ?string $xRequestId = '', + ?string $baseUrl = null, + ?string $adminUsername = null, + ?string $adminPassword = null + ):void { + $baseUrl = self::checkBaseUrl($baseUrl, "rmDirOnServer"); + $adminUsername = self::checkAdminUsername($adminUsername, "rmDirOnServer"); + $adminPassword = self::checkAdminPassword($adminPassword, "rmDirOnServer"); + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "DELETE", + "/apps/testing/api/v1/dir", + $xRequestId, + ['dir' => $dirPathFromServerRoot] + ); + + if ($result->getStatusCode() !== 200) { + throw new \Exception( + "could not delete directory $dirPathFromServerRoot " . $result->getReasonPhrase() + ); + } + } + + /** + * + * @param string|null $filePathFromServerRoot e.g. 'app2/myapp/appinfo/info.xml' + * @param string|null $content + * @param string|null $xRequestId + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * + * @return void + * @throws GuzzleException + */ + public static function createFileOnServer( + ?string $filePathFromServerRoot, + ?string $content, + ?string $xRequestId = '', + ?string $baseUrl = null, + ?string $adminUsername = null, + ?string $adminPassword = null + ):void { + $baseUrl = self::checkBaseUrl($baseUrl, "createFileOnServer"); + $adminUsername = self::checkAdminUsername($adminUsername, "createFileOnServer"); + $adminPassword = self::checkAdminPassword($adminPassword, "createFileOnServer"); + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "POST", + "/apps/testing/api/v1/file", + $xRequestId, + [ + 'file' => $filePathFromServerRoot, + 'content' => $content + ] + ); + + if ($result->getStatusCode() !== 200) { + throw new \Exception( + "could not create file $filePathFromServerRoot " . $result->getReasonPhrase() + ); + } + } + + /** + * + * @param string|null $filePathFromServerRoot e.g. 'app2/myapp/appinfo/info.xml' + * @param string|null $xRequestId + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * + * @return void + * @throws GuzzleException + * @throws Exception + */ + public static function deleteFileOnServer( + ?string $filePathFromServerRoot, + ?string $xRequestId = '', + ?string $baseUrl = null, + ?string $adminUsername = null, + ?string $adminPassword = null + ):void { + $baseUrl = self::checkBaseUrl($baseUrl, "deleteFileOnServer"); + $adminUsername = self::checkAdminUsername($adminUsername, "deleteFileOnServer"); + $adminPassword = self::checkAdminPassword($adminPassword, "deleteFileOnServer"); + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "DELETE", + "/apps/testing/api/v1/file", + $xRequestId, + [ + 'file' => $filePathFromServerRoot + ] + ); + + if ($result->getStatusCode() !== 200) { + throw new \Exception( + "could not delete file $filePathFromServerRoot " . $result->getReasonPhrase() + ); + } + } + + /** + * @param string|null $fileInCore e.g. 'app2/myapp/appinfo/info.xml' + * @param string|null $xRequestId + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * + * @return string + * @throws GuzzleException + * @throws Exception + */ + public static function readFileFromServer( + ?string $fileInCore, + ?string $xRequestId = '', + ?string $baseUrl = null, + ?string $adminUsername = null, + ?string $adminPassword = null + ):string { + $baseUrl = self::checkBaseUrl($baseUrl, "readFile"); + $adminUsername = self::checkAdminUsername( + $adminUsername, + "readFile" + ); + $adminPassword = self::checkAdminPassword( + $adminPassword, + "readFile" + ); + + $response = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + 'GET', + "/apps/testing/api/v1/file?file={$fileInCore}", + $xRequestId + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Failed to read the file {$fileInCore}" + ); + $localContent = HttpRequestHelper::getResponseXml($response, __METHOD__); + $localContent = (string)$localContent->data->element->contentUrlEncoded; + return \urldecode($localContent); + } + + /** + * returns the content of a file in a skeleton folder + * + * @param string|null $fileInSkeletonFolder + * @param string|null $xRequestId + * @param string|null $baseUrl + * @param string|null $adminUsername + * @param string|null $adminPassword + * + * @return string content of the file + * @throws GuzzleException + * @throws Exception + */ + public static function readSkeletonFile( + ?string $fileInSkeletonFolder, + ?string $xRequestId = '', + ?string $baseUrl = null, + ?string $adminUsername = null, + ?string $adminPassword = null + ):string { + $baseUrl = self::checkBaseUrl($baseUrl, "readSkeletonFile"); + $adminUsername = self::checkAdminUsername( + $adminUsername, + "readSkeletonFile" + ); + $adminPassword = self::checkAdminPassword( + $adminPassword, + "readSkeletonFile" + ); + + //find the absolute path of the currently set skeletondirectory + $occResponse = self::runOcc( + ['config:system:get', 'skeletondirectory'], + $xRequestId, + $adminUsername, + $adminPassword, + $baseUrl + ); + if ((int) $occResponse['code'] !== 0) { + throw new \Exception( + "could not get current skeletondirectory. " . $occResponse['stdErr'] + ); + } + $skeletonRoot = \trim($occResponse['stdOut']); + + $fileInSkeletonFolder = \rawurlencode("$skeletonRoot/$fileInSkeletonFolder"); + $response = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + 'GET', + "/apps/testing/api/v1/file?file={$fileInSkeletonFolder}&absolute=true", + $xRequestId + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Failed to read the file {$fileInSkeletonFolder}" + ); + $localContent = HttpRequestHelper::getResponseXml($response, __METHOD__); + $localContent = (string)$localContent->data->element->contentUrlEncoded; + $localContent = \urldecode($localContent); + return $localContent; + } + + /** + * enables an app + * + * @param string|null $appName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function enableApp( + ?string $appName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['app:enable', $appName], + $xRequestId + ); + } + + /** + * disables an app + * + * @param string|null $appName + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws Exception + */ + public static function disableApp( + ?string $appName, + ?string $xRequestId = '' + ):array { + return self::runOcc( + ['app:disable', $appName], + $xRequestId + ); + } + + /** + * checks if an app is currently enabled + * + * @param string|null $appName + * @param string|null $xRequestId + * + * @return bool true if enabled, false if disabled or nonexistent + * @throws Exception + */ + public static function isAppEnabled( + ?string $appName, + ?string $xRequestId = '' + ):bool { + $result = self::runOcc( + ['app:list', '^' . $appName . '$'], + $xRequestId + ); + return \strtolower(\substr($result['stdOut'], 0, 7)) === 'enabled'; + } + + /** + * lists app status (enabled or disabled) + * + * @param string|null $appName + * @param string|null $xRequestId + * + * @return bool true if the app needed to be enabled, false otherwise + * @throws Exception + */ + public static function enableAppIfNotEnabled( + ?string $appName, + ?string $xRequestId = '' + ):bool { + if (!self::isAppEnabled($appName, $xRequestId)) { + self::enableApp( + $appName, + $xRequestId + ); + return true; + } + + return false; + } + + /** + * Runs a list of occ commands at once + * + * @param array|null $commands + * @param string|null $xRequestId + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * + * @return array + * @throws GuzzleException + * @throws Exception + */ + public static function runBulkOcc( + ?array $commands, + ?string $xRequestId = '', + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null + ):array { + if (OcisHelper::isTestingOnOcisOrReva()) { + return []; + } + $baseUrl = self::checkBaseUrl($baseUrl, "runOcc"); + $adminUsername = self::checkAdminUsername($adminUsername, "runOcc"); + $adminPassword = self::checkAdminPassword($adminPassword, "runOcc"); + + if (!\is_array($commands)) { + throw new Exception("commands must be an array"); + } + + $isTestingAppEnabledText = "Is the testing app installed and enabled?\n"; + $bodies = []; + + foreach ($commands as $occ) { + if (!\array_key_exists('command', $occ)) { + throw new \InvalidArgumentException("command key is missing in array passed to runBulkOcc"); + } + + $body = [ + 'command' => \implode(' ', $occ['command']) + ]; + + if (isset($occ['envVariables'])) { + $body['env_variables'] = $occ['envVariables']; + } + \array_push($bodies, $body); + } + try { + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "POST", + "/apps/testing/api/v1/occ/bulk?format=json", + $xRequestId, + \json_encode($bodies) + ); + } catch (ServerException $e) { + throw new Exception( + "Could not execute 'occ'. " . + $isTestingAppEnabledText . + $e->getResponse()->getBody() + ); + } + $result = \json_decode($result->getBody()->getContents()); + + return $result->ocs->data; + } + + /** + * invokes an OCC command + * + * @param array|null $args anything behind "occ". + * For example: "files:transfer-ownership" + * @param string|null $xRequestId + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * @param array|null $envVariables + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws GuzzleException + * @throws Exception + */ + public static function runOcc( + ?array $args, + ?string $xRequestId = '', + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null, + ?array $envVariables = null + ):array { + if (OcisHelper::isTestingOnOcisOrReva() && !OcisHelper::isTestingParallelDeployment()) { + return ['code' => '', 'stdOut' => '', 'stdErr' => '' ]; + } + $baseUrl = self::checkBaseUrl($baseUrl, "runOcc"); + $adminUsername = self::checkAdminUsername($adminUsername, "runOcc"); + $adminPassword = self::checkAdminPassword($adminPassword, "runOcc"); + if (self::$ocPath === null + && $ocPath === null + ) { + throw new Exception( + "runOcc called without ocPath - pass the ocPath or call SetupHelper::init" + ); + } + if ($ocPath === null) { + $ocPath = self::$ocPath; + } else { + $ocPath = self::normaliseOcPath($ocPath); + } + + $body = []; + $argsString = \implode(' ', $args); + $body['command'] = $argsString; + + if ($envVariables !== null) { + $body['env_variables'] = $envVariables; + } + + $isTestingAppEnabledText = "Is the testing app installed and enabled?\n"; + + try { + $result = OcsApiHelper::sendRequest( + $baseUrl, + $adminUsername, + $adminPassword, + "POST", + $ocPath, + $xRequestId, + $body + ); + } catch (ServerException $e) { + throw new Exception( + "Could not execute 'occ'. " . + $isTestingAppEnabledText . + $e->getResponse()->getBody() + ); + } + + $return = []; + $contents = $result->getBody()->getContents(); + $resultXml = \simplexml_load_string($contents); + + if ($resultXml === false) { + $status = $result->getStatusCode(); + throw new Exception( + "Response is not valid XML after executing 'occ $argsString'. " . + "HTTP status was $status. " . + $isTestingAppEnabledText . + "Response contents were '$contents'" + ); + } + + $return['code'] = $resultXml->xpath("//ocs/data/code"); + $return['stdOut'] = $resultXml->xpath("//ocs/data/stdOut"); + $return['stdErr'] = $resultXml->xpath("//ocs/data/stdErr"); + + if (!isset($return['code'][0])) { + throw new Exception( + "Return code not found after executing 'occ $argsString'. " . + $isTestingAppEnabledText . + $contents + ); + } + + if (!isset($return['stdOut'][0])) { + throw new Exception( + "Return stdOut not found after executing 'occ $argsString'. " . + $isTestingAppEnabledText . + $contents + ); + } + + if (!isset($return['stdErr'][0])) { + throw new Exception( + "Return stdErr not found after executing 'occ $argsString'. " . + $isTestingAppEnabledText . + $contents + ); + } + + if (!\is_a($return['code'][0], "SimpleXMLElement")) { + throw new Exception( + "Return code is not a SimpleXMLElement after executing 'occ $argsString'. " . + $isTestingAppEnabledText . + $contents + ); + } + + if (!\is_a($return['stdOut'][0], "SimpleXMLElement")) { + throw new Exception( + "Return stdOut is not a SimpleXMLElement after executing 'occ $argsString'. " . + $isTestingAppEnabledText . + $contents + ); + } + + if (!\is_a($return['stdErr'][0], "SimpleXMLElement")) { + throw new Exception( + "Return stdErr is not a SimpleXMLElement after executing 'occ $argsString'. " . + $isTestingAppEnabledText . + $contents + ); + } + + $return['code'] = $return['code'][0]->__toString(); + $return['stdOut'] = $return['stdOut'][0]->__toString(); + $return['stdErr'] = $return['stdErr'][0]->__toString(); + self::resetOpcache( + $baseUrl, + $adminUsername, + $adminPassword, + $xRequestId + ); + return $return; + } + + /** + * @param string $baseUrl + * @param string $user + * @param string $password + * @param string $xRequestId + * + * @return ResponseInterface|null + * @throws GuzzleException + */ + public static function resetOpcache( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $xRequestId = '' + ):?ResponseInterface { + try { + return OcsApiHelper::sendRequest( + $baseUrl, + $user, + $password, + "DELETE", + "/apps/testing/api/v1/opcache", + $xRequestId + ); + } catch (ServerException $e) { + echo "could not reset opcache, if tests fail try to set " . + "'opcache.revalidate_freq=0' in the php.ini file\n"; + } + return null; + } + + /** + * Create local storage mount + * + * @param string|null $mount (name of local storage mount) + * @param string|null $xRequestId + * + * @return string[] associated array with "code", "stdOut", "stdErr", "storageId" + * @throws GuzzleException + */ + public static function createLocalStorageMount( + ?string $mount, + ?string $xRequestId = '' + ):array { + $mountPath = TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/$mount"; + SetupHelper::mkDirOnServer( + $mountPath, + $xRequestId + ); + // files_external:create requires absolute path + $serverRoot = self::getServerRoot( + self::$baseUrl, + self::$adminUsername, + self::$adminPassword, + $xRequestId + ); + $result = self::runOcc( + [ + 'files_external:create', + $mount, + 'local', + 'null::null', + '-c', + 'datadir=' . $serverRoot . '/' . $mountPath + ], + $xRequestId + ); + // stdOut should have a string like "Storage created with id 65" + $storageIdWords = \explode(" ", \trim($result['stdOut'])); + if (\array_key_exists(4, $storageIdWords)) { + $result['storageId'] = (int)$storageIdWords[4]; + } else { + // presumably something went wrong with the files_external:create command + // so return "unknown" to the caller. The result array has the command exit + // code and stdErr output etc., so the caller can process what it likes + // of that information to work out what went wrong. + $result['storageId'] = "unknown"; + } + return $result; + } + + /** + * Get a system config setting, including status code, output and standard + * error output. + * + * @param string|null $key + * @param string|null $xRequestId + * @param string|null $output e.g. json + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws GuzzleException + */ + public static function getSystemConfig( + ?string $key, + ?string $xRequestId = '', + ?string $output = null, + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null + ):array { + $args = []; + $args[] = 'config:system:get'; + $args[] = $key; + + if ($output !== null) { + $args[] = '--output'; + $args[] = $output; + } + + $args[] = '--no-ansi'; + + return self::runOcc( + $args, + $xRequestId, + $adminUsername, + $adminPassword, + $baseUrl, + $ocPath + ); + } + + /** + * Set a system config setting + * + * @param string|null $key + * @param string|null $value + * @param string|null $xRequestId + * @param string|null $type e.g. boolean or json + * @param string|null $output e.g. json + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws GuzzleException + */ + public static function setSystemConfig( + ?string $key, + ?string $value, + ?string $xRequestId = '', + ?string $type = null, + ?string $output = null, + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null + ):array { + $args = []; + $args[] = 'config:system:set'; + $args[] = $key; + $args[] = '--value'; + $args[] = $value; + + if ($type !== null) { + $args[] = '--type'; + $args[] = $type; + } + + if ($output !== null) { + $args[] = '--output'; + $args[] = $output; + } + + $args[] = '--no-ansi'; + if ($baseUrl === null) { + $baseUrl = self::$baseUrl; + } + return self::runOcc( + $args, + $xRequestId, + $adminUsername, + $adminPassword, + $baseUrl, + $ocPath + ); + } + + /** + * Get the value of a system config setting + * + * @param string|null $key + * @param string|null $xRequestId + * @param string|null $output e.g. json + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return string + * @throws GuzzleException if parameters have not been provided yet or the testing app is not enabled + */ + public static function getSystemConfigValue( + ?string $key, + ?string $xRequestId = '', + ?string $output = null, + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null + ):string { + if ($baseUrl === null) { + $baseUrl = self::$baseUrl; + } + return self::getSystemConfig( + $key, + $xRequestId, + $output, + $adminUsername, + $adminPassword, + $baseUrl, + $ocPath + )['stdOut']; + } + + /** + * Finds all lines containing the given text + * + * @param string|null $input stdout or stderr output + * @param string|null $text text to search for + * + * @return array array of lines that matched + */ + public static function findLines(?string $input, ?string $text):array { + $results = []; + foreach (\explode("\n", $input) as $line) { + if (\strpos($line, $text) !== false) { + $results[] = $line; + } + } + return $results; + } + + /** + * Delete a system config setting + * + * @param string|null $key + * @param string|null $xRequestId + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return string[] associated array with "code", "stdOut", "stdErr" + * @throws GuzzleException if parameters have not been provided yet or the testing app is not enabled + */ + public static function deleteSystemConfig( + ?string $key, + ?string $xRequestId = '', + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null + ):array { + $args = []; + $args[] = 'config:system:delete'; + $args[] = $key; + + $args[] = '--no-ansi'; + if ($baseUrl === null) { + $baseUrl = self::$baseUrl; + } + return SetupHelper::runOcc( + $args, + $xRequestId, + $adminUsername, + $adminPassword, + $baseUrl, + $ocPath + ); + } +} diff --git a/tests/TestHelpers/SharingHelper.php b/tests/TestHelpers/SharingHelper.php new file mode 100644 index 000000000..9fcc9154e --- /dev/null +++ b/tests/TestHelpers/SharingHelper.php @@ -0,0 +1,261 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ +namespace TestHelpers; + +use Exception; +use InvalidArgumentException; +use Psr\Http\Message\ResponseInterface; +use SimpleXMLElement; + +/** + * manage Shares via OCS API + * + * @author Artur Neumann + * + */ +class SharingHelper { + public const PERMISSION_TYPES = [ + 'read' => 1, + 'update' => 2, + 'create' => 4, + 'delete' => 8, + 'share' => 16, + ]; + + public const SHARE_TYPES = [ + 'user' => 0, + 'group' => 1, + 'public_link' => 3, + 'federated' => 6, + ]; + + public const SHARE_STATES = [ + 'accepted' => 0, + 'pending' => 1, + 'rejected' => 2, + 'declined' => 2, // declined is a synonym for rejected + ]; + + /** + * + * @param string $baseUrl baseURL of the ownCloud installation without /ocs. + * @param string $user user that creates the share. + * @param string $password password of the user that creates the share. + * @param string $path The path to the file or folder which should be shared. + * @param string|int $shareType The type of the share. This can be one of: + * 0 = user, 1 = group, 3 = public (link), + * 6 = federated (cloud share). + * Pass either the number or the keyword. + * @param string $xRequestId + * @param string|null $shareWith The user or group id with which the file should + * be shared. + * @param boolean|null $publicUpload Whether to allow public upload to a public + * shared folder. + * @param string|null $sharePassword The password to protect the public link + * share with. + * @param string|int|string[]|int[]|null $permissions The permissions to set on the share. + * 1 = read; 2 = update; 4 = create; + * 8 = delete; 16 = share + * (default: 31, for public shares: 1) + * Pass either the (total) number or array of numbers, + * or any of the above keywords or array of keywords. + * @param string|null $linkName A (human-readable) name for the share, + * which can be up to 64 characters in length. + * @param string|null $expireDate An expire date for public link shares. + * This argument expects a date string + * in the format 'YYYY-MM-DD'. + * @param int $ocsApiVersion + * @param int $sharingApiVersion + * @param string $sharingApp + * + * @return ResponseInterface + * @throws InvalidArgumentException + */ + public static function createShare( + string $baseUrl, + string $user, + string $password, + string $path, + $shareType, + string $xRequestId = '', + ?string $shareWith = null, + ?bool $publicUpload = false, + string $sharePassword = null, + $permissions = null, + ?string $linkName = null, + ?string $expireDate = null, + int $ocsApiVersion = 1, + int $sharingApiVersion = 1, + string $sharingApp = 'files_sharing' + ): ResponseInterface { + $fd = []; + foreach ([$path, $baseUrl, $user, $password] as $variableToCheck) { + if (!\is_string($variableToCheck)) { + throw new InvalidArgumentException( + "mandatory argument missing or wrong type ($variableToCheck => " + . \gettype($variableToCheck) . ")" + ); + } + } + + if ($permissions !== null) { + $fd['permissions'] = self::getPermissionSum($permissions); + } + + if (!\in_array($ocsApiVersion, [1, 2], true)) { + throw new InvalidArgumentException( + "invalid ocsApiVersion ($ocsApiVersion)" + ); + } + if (!\in_array($sharingApiVersion, [1, 2], true)) { + throw new InvalidArgumentException( + "invalid sharingApiVersion ($sharingApiVersion)" + ); + } + + $fullUrl = $baseUrl; + if (\substr($fullUrl, -1) !== '/') { + $fullUrl .= '/'; + } + $fullUrl .= "ocs/v{$ocsApiVersion}.php/apps/{$sharingApp}/api/v{$sharingApiVersion}/shares"; + + $fd['path'] = $path; + $fd['shareType'] = self::getShareType($shareType); + + if ($shareWith !== null) { + $fd['shareWith'] = $shareWith; + } + if ($publicUpload !== null) { + $fd['publicUpload'] = (bool) $publicUpload; + } + if ($sharePassword !== null) { + $fd['password'] = $sharePassword; + } + if ($linkName !== null) { + $fd['name'] = $linkName; + } + if ($expireDate !== null) { + $fd['expireDate'] = $expireDate; + } + $headers = ['OCS-APIREQUEST' => 'true']; + + return HttpRequestHelper::post( + $fullUrl, + $xRequestId, + $user, + $password, + $headers, + $fd + ); + } + + /** + * calculates the permission sum (int) from given permissions + * permissions can be passed in as int, string or array of int or string + * 'read' => 1 + * 'update' => 2 + * 'create' => 4 + * 'delete' => 8 + * 'share' => 16 + * + * @param string[]|string|int|int[] $permissions + * + * @return int + * @throws InvalidArgumentException + * + */ + public static function getPermissionSum($permissions):int { + if (\is_numeric($permissions)) { + // Allow any permission number so that test scenarios can + // specifically test invalid permission values + return (int) $permissions; + } + if (!\is_array($permissions)) { + $permissions = [$permissions]; + } + $permissionSum = 0; + foreach ($permissions as $permission) { + if (\array_key_exists($permission, self::PERMISSION_TYPES)) { + $permissionSum += self::PERMISSION_TYPES[$permission]; + } elseif (\in_array($permission, self::PERMISSION_TYPES, true)) { + $permissionSum += (int) $permission; + } else { + throw new InvalidArgumentException( + "invalid permission type ($permission)" + ); + } + } + if ($permissionSum < 1 || $permissionSum > 31) { + throw new InvalidArgumentException( + "invalid permission total ($permissionSum)" + ); + } + return $permissionSum; + } + + /** + * returns the share type number corresponding to the share type keyword + * + * @param string|int $shareType a keyword from SHARE_TYPES or a share type number + * + * @return int + * @throws InvalidArgumentException + * + */ + public static function getShareType($shareType):int { + if (\array_key_exists($shareType, self::SHARE_TYPES)) { + return self::SHARE_TYPES[$shareType]; + } else { + if (\ctype_digit($shareType)) { + $shareType = (int) $shareType; + } + $key = \array_search($shareType, self::SHARE_TYPES, true); + if ($key !== false) { + return self::SHARE_TYPES[$key]; + } + throw new InvalidArgumentException( + "invalid share type ($shareType)" + ); + } + } + + /** + * + * @param SimpleXMLElement $responseXmlObject + * @param string $errorMessage + * + * @return string + * @throws Exception + * + */ + public static function getLastShareIdFromResponse( + SimpleXMLElement $responseXmlObject, + string $errorMessage = "cannot find share id in response" + ):string { + $xmlPart = $responseXmlObject->xpath("//data/element[last()]/id"); + + if (!\is_array($xmlPart) || (\count($xmlPart) === 0)) { + throw new Exception($errorMessage); + } + return $xmlPart[0]->__toString(); + } +} diff --git a/tests/TestHelpers/SpaceNotFoundException.php b/tests/TestHelpers/SpaceNotFoundException.php new file mode 100644 index 000000000..4a576e391 --- /dev/null +++ b/tests/TestHelpers/SpaceNotFoundException.php @@ -0,0 +1,14 @@ + + * + */ +namespace TestHelpers; +use Exception; + +/** + * Class SpaceNotFoundException + * Exception when space id for a user is not found + */ +class SpaceNotFoundException extends Exception { +} diff --git a/tests/TestHelpers/TranslationHelper.php b/tests/TestHelpers/TranslationHelper.php new file mode 100644 index 000000000..bc262b473 --- /dev/null +++ b/tests/TestHelpers/TranslationHelper.php @@ -0,0 +1,46 @@ + + * @copyright Copyright (c) 2021 Talank Baral talank@jankaritech.com + * + * This code 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 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 + * + */ + +namespace TestHelpers; + +/** + * Class TranslationHelper + * + * Helper functions that are needed to run tests on different languages + * + * @package TestHelpers + */ +class TranslationHelper { + /** + * @param string|null $language + * + * @return string|null + */ + public static function getLanguage(?string $language): ?string { + if (!isset($language)) { + if (\getenv('OC_LANGUAGE') !== false) { + $language = \getenv('OC_LANGUAGE'); + } + } + return $language; + } +} diff --git a/tests/TestHelpers/UploadHelper.php b/tests/TestHelpers/UploadHelper.php new file mode 100644 index 000000000..1ff63cfa5 --- /dev/null +++ b/tests/TestHelpers/UploadHelper.php @@ -0,0 +1,318 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ +namespace TestHelpers; + +use Psr\Http\Message\ResponseInterface; + +/** + * Helper for Uploads + * + * @author Artur Neumann + * + */ +class UploadHelper extends \PHPUnit\Framework\Assert { + /** + * + * @param string|null $baseUrl URL of owncloud + * e.g. http://localhost:8080 + * should include the subfolder + * if owncloud runs in a subfolder + * e.g. http://localhost:8080/owncloud-core + * @param string|null $user + * @param string|null $password + * @param string|null $source + * @param string|null $destination + * @param string|null $xRequestId + * @param array|null $headers + * @param int|null $davPathVersionToUse (1|2) + * @param int|null $chunkingVersion (1|2|null) + * if set to null chunking will not be used + * @param int|null $noOfChunks how many chunks do we want to upload + * + * @return ResponseInterface + */ + public static function upload( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $source, + ?string $destination, + ?string $xRequestId = '', + ?array $headers = [], + ?int $davPathVersionToUse = 1, + ?int $chunkingVersion = null, + ?int $noOfChunks = 1 + ): ResponseInterface { + //simple upload with no chunking + if ($chunkingVersion === null) { + $data = \file_get_contents($source); + return WebDavHelper::makeDavRequest( + $baseUrl, + $user, + $password, + "PUT", + $destination, + $headers, + $xRequestId, + $data, + $davPathVersionToUse + ); + } else { + //prepare chunking + $chunks = self::chunkFile($source, $noOfChunks); + $chunkingId = 'chunking-' . (string)\rand(1000, 9999); + $v2ChunksDestination = '/uploads/' . $user . '/' . $chunkingId; + } + + //prepare chunking version specific stuff + if ($chunkingVersion === 1) { + $headers['OC-Chunked'] = '1'; + } elseif ($chunkingVersion === 2) { + $result = WebDavHelper::makeDavRequest( + $baseUrl, + $user, + $password, + 'MKCOL', + $v2ChunksDestination, + $headers, + $xRequestId, + null, + $davPathVersionToUse, + "uploads" + ); + if ($result->getStatusCode() >= 400) { + return $result; + } + } + + //upload chunks + foreach ($chunks as $index => $chunk) { + if ($chunkingVersion === 1) { + $filename = $destination . "-" . $chunkingId . "-" . + \count($chunks) . '-' . ( string ) $index; + $davRequestType = "files"; + } elseif ($chunkingVersion === 2) { + $filename = $v2ChunksDestination . '/' . (string)($index); + $davRequestType = "uploads"; + } + $result = WebDavHelper::makeDavRequest( + $baseUrl, + $user, + $password, + "PUT", + $filename, + $headers, + $xRequestId, + $chunk, + $davPathVersionToUse, + $davRequestType + ); + if ($result->getStatusCode() >= 400) { + return $result; + } + } + //finish upload for new chunking + if ($chunkingVersion === 2) { + $source = $v2ChunksDestination . '/.file'; + $headers['Destination'] = $baseUrl . "/" . + WebDavHelper::getDavPath($user, $davPathVersionToUse) . + $destination; + $result = WebDavHelper::makeDavRequest( + $baseUrl, + $user, + $password, + 'MOVE', + $source, + $headers, + $xRequestId, + null, + $davPathVersionToUse, + "uploads" + ); + if ($result->getStatusCode() >= 400) { + return $result; + } + } + return $result; + } + + /** + * Upload the same file multiple times with different mechanisms. + * + * @param string|null $baseUrl URL of owncloud + * @param string|null $user user who uploads + * @param string|null $password + * @param string|null $source source file path + * @param string|null $destination destination path on the server + * @param string|null $xRequestId + * @param bool $overwriteMode when false creates separate files to test uploading brand new files, + * when true it just overwrites the same file over and over again with the same name + * @param string|null $exceptChunkingType empty string or "old" or "new" + * + * @return array of ResponseInterface + */ + public static function uploadWithAllMechanisms( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $source, + ?string $destination, + ?string $xRequestId = '', + ?bool $overwriteMode = false, + ?string $exceptChunkingType = '' + ):array { + $responses = []; + foreach ([1, 2] as $davPathVersion) { + if ($davPathVersion === 1) { + $davHuman = 'old'; + } else { + $davHuman = 'new'; + } + + switch ($exceptChunkingType) { + case 'old': + $exceptChunkingVersion = 1; + break; + case 'new': + $exceptChunkingVersion = 2; + break; + default: + $exceptChunkingVersion = -1; + break; + } + + foreach ([null, 1, 2] as $chunkingVersion) { + if ($chunkingVersion === $exceptChunkingVersion) { + continue; + } + $valid = WebDavHelper::isValidDavChunkingCombination( + $davPathVersion, + $chunkingVersion + ); + if ($valid === false) { + continue; + } + $finalDestination = $destination; + if (!$overwriteMode && $chunkingVersion !== null) { + $finalDestination .= "-{$davHuman}dav-{$davHuman}chunking"; + } elseif (!$overwriteMode && $chunkingVersion === null) { + $finalDestination .= "-{$davHuman}dav-regular"; + } + $responses[] = self::upload( + $baseUrl, + $user, + $password, + $source, + $finalDestination, + $xRequestId, + [], + $davPathVersion, + $chunkingVersion, + 2 + ); + } + } + return $responses; + } + + /** + * cut the file in multiple chunks + * returns an array of chunks with the content of the file + * + * @param string|null $file + * @param int|null $noOfChunks + * + * @return array $string + */ + public static function chunkFile(?string $file, ?int $noOfChunks = 1):array { + $size = \filesize($file); + $chunkSize = \ceil($size / $noOfChunks); + $chunks = []; + $fp = \fopen($file, 'r'); + while (!\feof($fp) && \ftell($fp) < $size) { + $chunks[] = \fread($fp, (int)$chunkSize); + } + \fclose($fp); + if (\count($chunks) === 0) { + // chunk an empty file + $chunks[] = ''; + } + return $chunks; + } + + /** + * creates a File with a specific size + * + * @param string|null $name full path of the file to create + * @param int|null $size + * + * @return void + */ + public static function createFileSpecificSize(?string $name, ?int $size):void { + if (\file_exists($name)) { + \unlink($name); + } + $file = \fopen($name, 'w'); + \fseek($file, \max($size - 1, 0), SEEK_CUR); + if ($size) { + \fwrite($file, 'a'); // write a dummy char at SIZE position + } + \fclose($file); + self::assertEquals( + 1, + \file_exists($name) + ); + self::assertEquals( + $size, + \filesize($name) + ); + } + + /** + * creates a File with a specific text content + * + * @param string|null $name full path of the file to create + * @param string|null $text + * + * @return void + */ + public static function createFileWithText(?string $name, ?string $text):void { + $file = \fopen($name, 'w'); + \fwrite($file, $text); + \fclose($file); + self::assertEquals( + 1, + \file_exists($name) + ); + } + + /** + * get the path of a file from FilesForUpload directory + * + * @param string|null $name name of the file to upload + * + * @return string + */ + public static function getUploadFilesDir(?string $name):string { + return \getenv("FILES_FOR_UPLOAD") . $name; + } +} diff --git a/tests/TestHelpers/UserHelper.php b/tests/TestHelpers/UserHelper.php new file mode 100644 index 000000000..8dda97fdc --- /dev/null +++ b/tests/TestHelpers/UserHelper.php @@ -0,0 +1,375 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ +namespace TestHelpers; + +use Exception; +use GuzzleHttp\Exception\ClientException; +use Psr\Http\Message\ResponseInterface; + +/** + * Helper to administrate users (and groups) through the provisioning API + * + * @author Artur Neumann + * + */ +class UserHelper { + /** + * + * @param string|null $baseUrl + * @param string $user + * @param string $key + * @param string $value + * @param string $adminUser + * @param string $adminPassword + * @param string $xRequestId + * @param int|null $ocsApiVersion + * + * @return ResponseInterface + */ + public static function editUser( + ?string $baseUrl, + string $user, + string $key, + string $value, + string $adminUser, + string $adminPassword, + string $xRequestId = '', + ?int $ocsApiVersion = 2 + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "PUT", + "/cloud/users/" . $user, + $xRequestId, + ["key" => $key, "value" => $value], + $ocsApiVersion + ); + } + + /** + * Send batch requests to edit the user. + * This will send multiple requests in parallel to the server which will be faster in comparison to waiting for each request to complete. + * + * @param string|null $baseUrl + * @param array|null $editData ['user' => '', 'key' => '', 'value' => ''] + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param int|null $ocsApiVersion + * + * @return array + * @throws Exception + */ + public static function editUserBatch( + ?string $baseUrl, + ?array $editData, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?int $ocsApiVersion = 2 + ):array { + $requests = []; + $client = HttpRequestHelper::createClient( + $adminUser, + $adminPassword + ); + + foreach ($editData as $data) { + $path = "/cloud/users/" . $data['user']; + $body = ["key" => $data['key'], 'value' => $data["value"]]; + // Create the OCS API requests and push them to an array. + \array_push( + $requests, + OcsApiHelper::createOcsRequest( + $baseUrl, + 'PUT', + $path, + $xRequestId, + $body + ) + ); + } + // Send the array of requests at once in parallel. + $results = HttpRequestHelper::sendBatchRequest($requests, $client); + + foreach ($results as $e) { + if ($e instanceof ClientException) { + $httpStatusCode = $e->getResponse()->getStatusCode(); + $reasonPhrase = $e->getResponse()->getReasonPhrase(); + throw new Exception( + "Unexpected failure when editing a user: HTTP status $httpStatusCode HTTP reason $reasonPhrase" + ); + } + } + return $results; + } + + /** + * + * @param string|null $baseUrl + * @param string|null $userName + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param int|null $ocsApiVersion + * + * @return ResponseInterface + */ + public static function getUser( + ?string $baseUrl, + ?string $userName, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?int $ocsApiVersion = 2 + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "GET", + "/cloud/users/" . $userName, + $xRequestId, + [], + $ocsApiVersion + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $userName + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param int|null $ocsApiVersion + * + * @return ResponseInterface + */ + public static function deleteUser( + ?string $baseUrl, + ?string $userName, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?int $ocsApiVersion = 2 + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "DELETE", + "/cloud/users/" . $userName, + $xRequestId, + [], + $ocsApiVersion + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $group + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * + * @return ResponseInterface + */ + public static function createGroup( + ?string $baseUrl, + ?string $group, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '' + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "POST", + "/cloud/groups", + $xRequestId, + ['groupid' => $group] + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $group + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param int|null $ocsApiVersion + * + * @return ResponseInterface + */ + public static function deleteGroup( + ?string $baseUrl, + ?string $group, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?int $ocsApiVersion = 2 + ):ResponseInterface { + $group = \rawurlencode($group); + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "DELETE", + "/cloud/groups/" . $group, + $xRequestId, + [], + $ocsApiVersion + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $group + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param int|null $ocsApiVersion (1|2) + * + * @return ResponseInterface + */ + public static function addUserToGroup( + ?string $baseUrl, + ?string $user, + ?string $group, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?int $ocsApiVersion = 2 + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "POST", + "/cloud/users/" . $user . "/groups", + $xRequestId, + ['groupid' => $group], + $ocsApiVersion + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $group + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param int|null $ocsApiVersion (1|2) + * + * @return ResponseInterface + */ + public static function removeUserFromGroup( + ?string $baseUrl, + ?string $user, + ?string $group, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId, + ?int $ocsApiVersion = 2 + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "DELETE", + "/cloud/users/" . $user . "/groups", + $xRequestId, + ['groupid' => $group], + $ocsApiVersion + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param string|null $search + * + * @return ResponseInterface + */ + public static function getGroups( + ?string $baseUrl, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?string $search ="" + ):ResponseInterface { + return OcsApiHelper::sendRequest( + $baseUrl, + $adminUser, + $adminPassword, + "GET", + "/cloud/groups?search=" . $search, + $xRequestId + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $adminUser + * @param string|null $adminPassword + * @param string|null $xRequestId + * @param string|null $search + * + * @return string[] + * @throws Exception + */ + public static function getGroupsAsArray( + ?string $baseUrl, + ?string $adminUser, + ?string $adminPassword, + ?string $xRequestId = '', + ?string $search = "" + ):array { + $result = self::getGroups( + $baseUrl, + $adminUser, + $adminPassword, + $xRequestId, + $search + ); + $groups = HttpRequestHelper::getResponseXml($result, __METHOD__)->xpath(".//groups")[0]; + $return = []; + foreach ($groups as $group) { + $return[] = $group->__toString(); + } + return $return; + } +} diff --git a/tests/TestHelpers/WebDavHelper.php b/tests/TestHelpers/WebDavHelper.php new file mode 100644 index 000000000..e8d0c9905 --- /dev/null +++ b/tests/TestHelpers/WebDavHelper.php @@ -0,0 +1,898 @@ + + * @copyright Copyright (c) 2017 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ +namespace TestHelpers; + +use Exception; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use InvalidArgumentException; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; +use DateTime; +use TestHelpers\SpaceNotFoundException; + +/** + * Helper to make WebDav Requests + * + * @author Artur Neumann + * + */ +class WebDavHelper { + public const DAV_VERSION_OLD = 1; + public const DAV_VERSION_NEW = 2; + public const DAV_VERSION_SPACES = 3; + public static $SPACE_ID_FROM_OCIS = ''; + + /** + * @var array of users with their different spaces ids + */ + public static $spacesIdRef = []; + + /** + * clear space id reference for user + * + * @param string|null $user + * + * @return void + * @throws Exception + */ + public static function removeSpaceIdReferenceForUser( + ?string $user + ):void { + if (\array_key_exists($user, self::$spacesIdRef)) { + unset(self::$spacesIdRef[$user]); + } + } + + /** + * returns the id of a file + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $password + * @param string|null $path + * @param string|null $xRequestId + * @param int|null $davPathVersionToUse + * + * @return string + * @throws Exception + */ + public static function getFileIdForPath( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $path, + ?string $xRequestId = '', + ?int $davPathVersionToUse = self::DAV_VERSION_NEW + ): string { + $body + = ' + + + + + '; + $response = self::makeDavRequest( + $baseUrl, + $user, + $password, + "PROPFIND", + $path, + null, + $xRequestId, + $body, + $davPathVersionToUse + ); + \preg_match( + '/\([^\<]*)\<\/oc:fileid\>/', + $response->getBody()->getContents(), + $matches + ); + + if (!isset($matches[1])) { + throw new Exception("could not find fileId of $path"); + } + + return $matches[1]; + } + + /** + * returns body for propfind + * + * @param array|null $properties + * + * @return string + * @throws Exception + */ + public static function getBodyForPropfind(?array $properties): string { + $propertyBody = ""; + $extraNamespaces = ""; + foreach ($properties as $namespaceString => $property) { + if (\is_int($namespaceString)) { + //default namespace prefix if the property has no array key + //also used if no prefix is given in the property value + $namespacePrefix = "d"; + } else { + //calculate the namespace prefix and namespace from the array key + $matches = []; + \preg_match("/^(.*)='(.*)'$/", $namespaceString, $matches); + $nameSpace = $matches[2]; + $namespacePrefix = $matches[1]; + $extraNamespaces .= " xmlns:$namespacePrefix=\"$nameSpace\" "; + } + //if a namespace prefix is given in the property value use that + if (\strpos($property, ":") !== false) { + $propertyParts = \explode(":", $property); + $namespacePrefix = $propertyParts[0]; + $property = $propertyParts[1]; + } + $propertyBody .= "<$namespacePrefix:$property/>"; + } + $body = " + + $propertyBody + "; + return $body; + } + + /** + * sends a PROPFIND request + * with these registered namespaces: + * | prefix | namespace | + * | d | DAV: | + * | oc | http://owncloud.org/ns | + * | ocs | http://open-collaboration-services.org/ns | + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $password + * @param string|null $path + * @param string[] $properties + * string can contain namespace prefix, + * if no prefix is given 'd:' is used as prefix + * if associated array is used then the key will be used as namespace + * @param string|null $xRequestId + * @param string|null $folderDepth + * @param string|null $type + * @param int|null $davPathVersionToUse + * @param string|null $doDavRequestAsUser + * + * @return ResponseInterface + */ + public static function propfind( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $path, + ?array $properties, + ?string $xRequestId = '', + ?string $folderDepth = '0', + ?string $type = "files", + ?int $davPathVersionToUse = self::DAV_VERSION_NEW, + ?string $doDavRequestAsUser = null + ):ResponseInterface { + $body = self::getBodyForPropfind($properties); + $folderDepth = (string) $folderDepth; + if ($folderDepth !== '0' && $folderDepth !== '1' && $folderDepth !== 'infinity') { + throw new InvalidArgumentException('Invalid depth value ' . $folderDepth); + } + $headers = ['Depth' => $folderDepth]; + return self::makeDavRequest( + $baseUrl, + $user, + $password, + "PROPFIND", + $path, + $headers, + $xRequestId, + $body, + $davPathVersionToUse, + $type, + null, + null, + false, + null, + null, + [], + $doDavRequestAsUser + ); + } + + /** + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $password + * @param string|null $path + * @param string|null $propertyName + * @param string|null $propertyValue + * @param string|null $xRequestId + * @param string|null $namespaceString string containing prefix and namespace + * e.g "x1='http://whatever.org/ns'" + * @param int|null $davPathVersionToUse + * @param string|null $type + * + * @return ResponseInterface + */ + public static function proppatch( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $path, + ?string $propertyName, + ?string $propertyValue, + ?string $xRequestId = '', + ?string $namespaceString = "oc='http://owncloud.org/ns'", + ?int $davPathVersionToUse = self::DAV_VERSION_NEW, + ?string $type="files" + ):ResponseInterface { + $matches = []; + \preg_match("/^(.*)='(.*)'$/", $namespaceString, $matches); + $namespace = $matches[2]; + $namespacePrefix = $matches[1]; + $propertyBody = "<$namespacePrefix:$propertyName" . + " xmlns:$namespacePrefix=\"$namespace\">" . + "$propertyValue" . + ""; + $body = " + + + $propertyBody + + "; + return self::makeDavRequest( + $baseUrl, + $user, + $password, + "PROPPATCH", + $path, + [], + $xRequestId, + $body, + $davPathVersionToUse, + $type + ); + } + + /** + * gets namespace-prefix, namespace url and propName from provided namespaceString or property + * or otherwise use default + * + * @param string|null $namespaceString + * @param string|null $property + * + * @return array + */ + public static function getPropertyWithNamespaceInfo(?string $namespaceString = "", ?string $property = ""):array { + $namespace = ""; + $namespacePrefix = ""; + if (\is_int($namespaceString)) { + //default namespace prefix if the property has no array key + //also used if no prefix is given in the property value + $namespacePrefix = "d"; + $namespace = "DAV:"; + } elseif ($namespaceString) { + //calculate the namespace prefix and namespace from the array key + $matches = []; + \preg_match("/^(.*)='(.*)'$/", $namespaceString, $matches); + $namespacePrefix = $matches[1]; + $namespace = $matches[2]; + } + //if a namespace prefix is given in the property value use that + if ($property && \strpos($property, ":")) { + $propertyParts = \explode(":", $property); + $namespacePrefix = $propertyParts[0]; + $property = $propertyParts[1]; + } + return [$namespacePrefix, $namespace, $property]; + } + + /** + * sends HTTP request PROPPATCH method with multiple properties + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $password + * @param string $path + * @param array|null $propertiesArray + * @param string|null $xRequestId + * @param int|null $davPathVersion + * @param string|null $namespaceString + * @param string|null $type + * + * @return ResponseInterface + * @throws GuzzleException + */ + public static function proppatchWithMultipleProps( + ?string $baseUrl, + ?string $user, + ?string $password, + string $path, + ?array $propertiesArray, + ?string $xRequestId = '', + ?int $davPathVersion = null, + ?string $namespaceString = "oc='http://owncloud.org/ns'", + ?string $type="files" + ):ResponseInterface { + $propertyBody = ""; + foreach ($propertiesArray as $propertyArray) { + $property = $propertyArray["propertyName"]; + $value = $propertyArray["propertyValue"]; + [$namespacePrefix, $namespace, $property] = self::getPropertyWithNamespaceInfo( + $namespaceString, + $property + ); + $propertyBody .= "\n\t<$namespacePrefix:$property>" . + "$value" . + ""; + } + $body = " + + + $propertyBody + + + "; + return self::makeDavRequest( + $baseUrl, + $user, + $password, + "PROPPATCH", + $path, + [], + $xRequestId, + $body, + $davPathVersion, + $type + ); + } + + /** + * returns the response to listing a folder (collection) + * + * @param string|null $baseUrl + * @param string|null $user + * @param string|null $password + * @param string|null $path + * @param string|null $folderDepth + * @param string|null $xRequestId + * @param string[] $properties + * @param string|null $type + * @param int|null $davPathVersionToUse + * + * @return ResponseInterface + */ + public static function listFolder( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $path, + ?string $folderDepth, + ?string $xRequestId = '', + ?array $properties = null, + ?string $type = "files", + ?int $davPathVersionToUse = self::DAV_VERSION_NEW + ):ResponseInterface { + if (!$properties) { + $properties = [ + 'getetag', 'resourcetype' + ]; + } + return self::propfind( + $baseUrl, + $user, + $password, + $path, + $properties, + $xRequestId, + $folderDepth, + $type, + $davPathVersionToUse + ); + } + + /** + * Generates UUIDv4 + * Example: 123e4567-e89b-12d3-a456-426614174000 + * + * @return string + * @throws Exception + */ + public static function generateUUIDv4():string { + // generate 16 bytes (128 bits) of random data or use the data passed into the function. + $data = random_bytes(16); + \assert(\strlen($data) == 16); + + $data[6] = \chr(\ord($data[6]) & 0x0f | 0x40); // set version to 0100 + $data[8] = \chr(\ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10 + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + /** + * fetches personal space id for provided user + * + * @param string $baseUrl + * @param string $user + * @param string $password + * @param string $xRequestId + * + * @return string + * @throws GuzzleException + * @throws Exception + */ + public static function getPersonalSpaceIdForUser(string $baseUrl, string $user, string $password, string $xRequestId):string { + if (\array_key_exists($user, self::$spacesIdRef) && \array_key_exists("personal", self::$spacesIdRef[$user])) { + return self::$spacesIdRef[$user]["personal"]; + } + $trimmedBaseUrl = \trim($baseUrl, "/"); + $drivesPath = '/graph/v1.0/me/drives'; + $fullUrl = $trimmedBaseUrl . $drivesPath; + $response = HttpRequestHelper::get( + $fullUrl, + $xRequestId, + $user, + $password + ); + $bodyContents = $response->getBody()->getContents(); + $json = \json_decode($bodyContents); + $personalSpaceId = ''; + if ($json === null) { + // the graph endpoint did not give a useful answer + // try getting the information from the webdav endpoint + $fullUrl = $trimmedBaseUrl . '/remote.php/webdav'; + $response = HttpRequestHelper::sendRequest( + $fullUrl, + $xRequestId, + 'PROPFIND', + $user, + $password + ); + // we expect to get a multipart XML response with status 207 + $status = $response->getStatusCode(); + if ($status === 401) { + throw new SpaceNotFoundException(__METHOD__ . " Personal space not found for user " . $user); + } elseif ($status !== 207) { + throw new Exception( + __METHOD__ . " webdav propfind for user $user failed with status $status - so the personal space id cannot be discovered" + ); + } + $responseXmlObject = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $xmlPart = $responseXmlObject->xpath("/d:multistatus/d:response[1]/d:propstat/d:prop/oc:id"); + if ($xmlPart === false) { + throw new Exception( + __METHOD__ . " oc:id not found in webdav propfind for user $user - so the personal space id cannot be discovered" + ); + } + $ocIdRawString = $xmlPart[0]->__toString(); + $separator = "!"; + if (\strpos($ocIdRawString, $separator) !== false) { + // The string is not base64-encoded, because the exclamation mark is not in the base64 alphabet. + // We expect to have a string with 2 parts separated by the exclamation mark. + // This is the format introduced in 2022-02 + // oc:id should be something like: + // "7464caf6-1799-103c-9046-c7b74deb5f63!7464caf6-1799-103c-9046-c7b74deb5f63" + // There is no encoding to decode. + $decodedId = $ocIdRawString; + } else { + // fall-back to assuming that the oc:id is base64-encoded + // That is the format used before and up to 2022-02 + // This can be removed after both the edge and master branches of cs3org/reva are using the new format. + // oc:id should be some base64 encoded string like: + // "NzQ2NGNhZjYtMTc5OS0xMDNjLTkwNDYtYzdiNzRkZWI1ZjYzOjc0NjRjYWY2LTE3OTktMTAzYy05MDQ2LWM3Yjc0ZGViNWY2Mw==" + // That should decode to something like: + // "7464caf6-1799-103c-9046-c7b74deb5f63:7464caf6-1799-103c-9046-c7b74deb5f63" + $decodedId = base64_decode($ocIdRawString); + $separator = ":"; + } + $ocIdParts = \explode($separator, $decodedId); + if (\count($ocIdParts) !== 2) { + throw new Exception( + __METHOD__ . " the oc:id $decodedId for user $user does not have 2 parts separated by '$separator', so the personal space id cannot be discovered" + ); + } + $personalSpaceId = $ocIdParts[0]; + } else { + foreach ($json->value as $spaces) { + if ($spaces->driveType === "personal") { + $personalSpaceId = $spaces->id; + break; + } + } + } + if ($personalSpaceId) { + // If env var LOG_PERSONAL_SPACE_ID is defined, then output the details of the personal space id. + // This is a useful debugging tool to have confidence that the personal space id is found correctly. + if (\getenv('LOG_PERSONAL_SPACE_ID') !== false) { + echo __METHOD__ . " personal space id of user $user is $personalSpaceId\n"; + } + self::$spacesIdRef[$user] = []; + self::$spacesIdRef[$user]["personal"] = $personalSpaceId; + return $personalSpaceId; + } else { + throw new SpaceNotFoundException(__METHOD__ . " Personal space not found for user " . $user); + } + } + + /** + * First checks if a user exist to return its space ID + * In case of any exception, it returns a fake space ID + * + * @param string $baseUrl + * @param string $user + * @param string $password + * @param string $xRequestId + * + * @return string + * @throws Exception + */ + public static function getPersonalSpaceIdForUserOrFakeIfNotFound(string $baseUrl, string $user, string $password, string $xRequestId):string { + try { + $spaceId = self::getPersonalSpaceIdForUser( + $baseUrl, + $user, + $password, + $xRequestId, + ); + } catch (SpaceNotFoundException $e) { + // if the fetch fails, and the user is not found, then a fake space id is prepared + // this is useful for testing when the personal space is of a non-existing user + $fakeSpaceId = self::generateUUIDv4(); + self::$spacesIdRef[$user]["personal"] = $fakeSpaceId; + $spaceId = $fakeSpaceId; + } + return $spaceId; + } + + /** + * sends a DAV request + * + * @param string|null $baseUrl + * URL of owncloud e.g. http://localhost:8080 + * should include the subfolder if owncloud runs in a subfolder + * e.g. http://localhost:8080/owncloud-core + * @param string|null $user + * @param string|null $password or token when bearer auth is used + * @param string|null $method PUT, GET, DELETE, etc. + * @param string|null $path + * @param array|null $headers + * @param string|null $xRequestId + * @param string|null|resource|StreamInterface $body + * @param int|null $davPathVersionToUse (1|2|3) + * @param string|null $type of request + * @param string|null $sourceIpAddress to initiate the request from + * @param string|null $authType basic|bearer + * @param bool $stream Set to true to stream a response rather + * than download it all up-front. + * @param int|null $timeout + * @param Client|null $client + * @param array|null $urlParameter to concatenate with path + * @param string|null $doDavRequestAsUser run the DAV as this user, if null its same as $user + * + * @return ResponseInterface + * @throws GuzzleException + * @throws Exception + */ + public static function makeDavRequest( + ?string $baseUrl, + ?string $user, + ?string $password, + ?string $method, + ?string $path, + ?array $headers, + ?string $xRequestId = '', + $body = null, + ?int $davPathVersionToUse = self::DAV_VERSION_OLD, + ?string $type = "files", + ?string $sourceIpAddress = null, + ?string $authType = "basic", + ?bool $stream = false, + ?int $timeout = 0, + ?Client $client = null, + ?array $urlParameter = [], + ?string $doDavRequestAsUser = null + ):ResponseInterface { + $baseUrl = self::sanitizeUrl($baseUrl, true); + + // We need to manipulate and use path as a string. + // So ensure that it is a string to avoid any type-conversion errors. + if ($path === null) { + $path = ""; + } + + // get space id if testing with spaces dav + if (self::$SPACE_ID_FROM_OCIS === '' && $davPathVersionToUse === self::DAV_VERSION_SPACES) { + $spaceId = self::getPersonalSpaceIdForUserOrFakeIfNotFound( + $baseUrl, + $doDavRequestAsUser ?? $user, + $password, + $xRequestId + ); + } else { + $spaceId = self::$SPACE_ID_FROM_OCIS; + } + + $davPath = self::getDavPath($doDavRequestAsUser ?? $user, $davPathVersionToUse, $type, $spaceId); + + //replace %, # and ? and in the path, Guzzle will not encode them + $urlSpecialChar = [['%', '#', '?'], ['%25', '%23', '%3F']]; + $path = \str_replace($urlSpecialChar[0], $urlSpecialChar[1], $path); + + if (!empty($urlParameter)) { + $urlParameter = \http_build_query($urlParameter, '', '&'); + $path .= '?' . $urlParameter; + } + $fullUrl = self::sanitizeUrl($baseUrl . $davPath . $path); + + if ($authType === 'bearer') { + $headers['Authorization'] = 'Bearer ' . $password; + $user = null; + $password = null; + } + if ($type === "public-files-new") { + if ($password === null || $password === "") { + $user = null; + } else { + $user = "public"; + } + } + $config = null; + if ($sourceIpAddress !== null) { + $config = [ 'curl' => [ CURLOPT_INTERFACE => $sourceIpAddress ]]; + } + + if ($headers !== null) { + foreach ($headers as $key => $value) { + //? and # need to be encoded in the Destination URL + if ($key === "Destination") { + $headers[$key] = \str_replace( + $urlSpecialChar[0], + $urlSpecialChar[1], + $value + ); + break; + } + } + } + + //Clear the space ID from ocis after each request + self::$SPACE_ID_FROM_OCIS = ''; + return HttpRequestHelper::sendRequest( + $fullUrl, + $xRequestId, + $method, + $user, + $password, + $headers, + $body, + $config, + null, + $stream, + $timeout, + $client + ); + } + + /** + * get the dav path + * + * @param string|null $user + * @param int|null $davPathVersionToUse (1|2) + * @param string|null $type + * @param string|null $spaceId + * + * @return string + */ + public static function getDavPath( + ?string $user, + ?int $davPathVersionToUse = null, + ?string $type = "files", + ?string $spaceId = null + ):string { + $newTrashbinDavPath = "remote.php/dav/trash-bin/$user/"; + if ($type === "public-files" || $type === "public-files-old") { + return "public.php/webdav/"; + } + if ($type === "public-files-new") { + return "remote.php/dav/public-files/$user/"; + } + if ($type === "archive") { + return "remote.php/dav/archive/$user/files"; + } + if ($type === "customgroups") { + return "remote.php/dav/"; + } + if ($davPathVersionToUse === self::DAV_VERSION_SPACES) { + if (($spaceId === null) || (\strlen($spaceId) === 0)) { + throw new InvalidArgumentException( + __METHOD__ . " A spaceId must be passed when using DAV path version 3 (spaces)" + ); + } + if ($type === "trash-bin") { + return "/remote.php/dav/spaces/trash-bin/" . $spaceId . '/'; + } + return "dav/spaces/" . $spaceId . '/'; + } else { + if ($davPathVersionToUse === self::DAV_VERSION_OLD) { + if ($type === "trash-bin") { + // Since there is no trash bin endpoint for old dav version, new dav version's endpoint is used here. + return $newTrashbinDavPath; + } + return "remote.php/webdav/"; + } elseif ($davPathVersionToUse === self::DAV_VERSION_NEW) { + if ($type === "files") { + $path = 'remote.php/dav/files/'; + return $path . $user . '/'; + } elseif ($type === "trash-bin") { + return $newTrashbinDavPath; + } else { + return "remote.php/dav"; + } + } else { + throw new InvalidArgumentException( + "DAV path version $davPathVersionToUse is unknown" + ); + } + } + } + + /** + * make sure there are no double slash in the URL + * + * @param string|null $url + * @param bool|null $trailingSlash forces a trailing slash + * + * @return string + */ + public static function sanitizeUrl(?string $url, ?bool $trailingSlash = false):string { + if ($trailingSlash === true) { + $url = $url . "/"; + } else { + $url = \rtrim($url, "/"); + } + $url = \preg_replace("/([^:]\/)\/+/", '$1', $url); + return $url; + } + + /** + * decides if the proposed dav version and chunking version are + * a valid combination. + * If no chunkingVersion is specified, then any dav version is valid. + * If a chunkingVersion is specified, then it has to match the dav version. + * Note: in future the dav and chunking versions might or might not + * move together and/or be supported together. So a more complex + * matrix could be needed here. + * + * @param string|int $davPathVersion + * @param string|int|null $chunkingVersion + * + * @return boolean is this a valid combination + */ + public static function isValidDavChunkingCombination( + $davPathVersion, + $chunkingVersion + ): bool { + if ($davPathVersion === self::DAV_VERSION_SPACES) { + // allow only old chunking version when using the spaces dav + return $chunkingVersion === 1; + } + return ( + ($chunkingVersion === 'no' || $chunkingVersion === null) || + ($davPathVersion === $chunkingVersion) + ); + } + + /** + * get Mtime of File in a public link share + * + * @param string|null $baseUrl + * @param string|null $fileName + * @param string|null $token + * @param string|null $xRequestId + * @param int|null $davVersionToUse + * + * @return string + * @throws Exception + */ + public static function getMtimeOfFileinPublicLinkShare( + ?string $baseUrl, + ?string $fileName, + ?string $token, + ?string $xRequestId = '', + ?int $davVersionToUse = self::DAV_VERSION_NEW + ):string { + $response = self::propfind( + $baseUrl, + null, + null, + "/public-files/{$token}/{$fileName}", + ['d:getlastmodified'], + $xRequestId, + '1', + null, + $davVersionToUse + ); + $responseXmlObject = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $xmlPart = $responseXmlObject->xpath("//d:getlastmodified"); + + return $xmlPart[0]->__toString(); + } + + /** + * get Mtime of a resource + * + * @param string|null $user + * @param string|null $password + * @param string|null $baseUrl + * @param string|null $resource + * @param string|null $xRequestId + * @param int|null $davPathVersionToUse + * + * @return string + * @throws Exception + */ + public static function getMtimeOfResource( + ?string $user, + ?string $password, + ?string $baseUrl, + ?string $resource, + ?string $xRequestId = '', + ?int $davPathVersionToUse = self::DAV_VERSION_NEW + ):string { + $response = self::propfind( + $baseUrl, + $user, + $password, + $resource, + ["getlastmodified"], + $xRequestId, + "0", + "files", + $davPathVersionToUse + ); + $responseXmlObject = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $xmlpart = $responseXmlObject->xpath("//d:getlastmodified"); + Assert::assertArrayHasKey( + 0, + $xmlpart, + __METHOD__ . " XML part does not have key 0. Expected a value at index 0 of 'xmlPart' but, found: " . (string) json_encode($xmlpart) + ); + $mtime = new DateTime($xmlpart[0]->__toString()); + return $mtime->format('U'); + } +} diff --git a/tests/acceptance/config/behat.yml b/tests/acceptance/config/behat.yml index 6527404c1..4c83ec685 100644 --- a/tests/acceptance/config/behat.yml +++ b/tests/acceptance/config/behat.yml @@ -73,7 +73,7 @@ default: - WebDavPropertiesContext: - TUSContext: - SpacesTUSContext: - + apiContract: paths: - '%paths.base%/../features/apiContract' @@ -152,4 +152,6 @@ default: - TrashbinContext: extensions: + rdx\behatvars\BehatVariablesExtension: ~ + Cjm\Behat\StepThroughExtension: ~ diff --git a/tests/acceptance/features/bootstrap/AppConfigurationContext.php b/tests/acceptance/features/bootstrap/AppConfigurationContext.php new file mode 100644 index 000000000..f44177d8c --- /dev/null +++ b/tests/acceptance/features/bootstrap/AppConfigurationContext.php @@ -0,0 +1,591 @@ + + * @author Sergio Bertolin + * @author Phillip Davis + * @copyright Copyright (c) 2018, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use GuzzleHttp\Exception\GuzzleException; +use PHPUnit\Framework\Assert; +use TestHelpers\AppConfigHelper; +use TestHelpers\OcsApiHelper; +use Behat\Gherkin\Node\TableNode; +use Behat\Behat\Context\Context; + +/** + * AppConfiguration trait + */ +class AppConfigurationContext implements Context { + /** + * @var FeatureContext + */ + private $featureContext; + + /** + * @When /^the administrator sets parameter "([^"]*)" of app "([^"]*)" to ((?:'[^']*')|(?:"[^"]*"))$/ + * + * @param string $parameter + * @param string $app + * @param string $value + * + * @return void + * @throws Exception + */ + public function adminSetsServerParameterToUsingAPI( + string $parameter, + string $app, + string $value + ):void { + // The capturing group of the regex always includes the quotes at each + // end of the captured string, so trim them. + $value = \trim($value, $value[0]); + $this->modifyAppConfig($app, $parameter, $value); + } + + /** + * @Given /^parameter "([^"]*)" of app "([^"]*)" has been set to ((?:'[^']*')|(?:"[^"]*"))$/ + * + * @param string $parameter + * @param string $app + * @param string $value + * + * @return void + * @throws Exception + */ + public function serverParameterHasBeenSetTo(string $parameter, string $app, string $value):void { + // The capturing group of the regex always includes the quotes at each + // end of the captured string, so trim them. + if (\TestHelpers\OcisHelper::isTestingOnOcisOrReva()) { + return; + } + $value = \trim($value, $value[0]); + $this->modifyAppConfig($app, $parameter, $value); + $this->featureContext->clearStatusCodeArrays(); + } + + /** + * @Then the capabilities setting of :capabilitiesApp path :capabilitiesPath should be :expectedValue + * @Given the capabilities setting of :capabilitiesApp path :capabilitiesPath has been confirmed to be :expectedValue + * + * @param string $capabilitiesApp the "app" name in the capabilities response + * @param string $capabilitiesPath the path to the element + * @param string $expectedValue + * + * @return void + * @throws Exception + */ + public function theCapabilitiesSettingOfAppParameterShouldBe( + string $capabilitiesApp, + string $capabilitiesPath, + string $expectedValue + ):void { + $this->theAdministratorGetsCapabilitiesCheckResponse(); + $actualValue = $this->getAppParameter($capabilitiesApp, $capabilitiesPath); + + Assert::assertEquals( + $expectedValue, + $actualValue, + __METHOD__ + . " $capabilitiesApp path $capabilitiesPath should be $expectedValue but is $actualValue" + ); + } + + /** + * @param string $capabilitiesApp the "app" name in the capabilities response + * @param string $capabilitiesPath the path to the element + * + * @return string + * @throws Exception + */ + public function getAppParameter(string $capabilitiesApp, string $capabilitiesPath):string { + return $this->getParameterValueFromXml( + $this->getCapabilitiesXml(__METHOD__), + $capabilitiesApp, + $capabilitiesPath + ); + } + + /** + * @When user :username retrieves the capabilities using the capabilities API + * + * @param string $username + * + * @return void + * @throws GuzzleException + * @throws JsonException + */ + public function userGetsCapabilities(string $username):void { + $user = $this->featureContext->getActualUsername($username); + $password = $this->featureContext->getPasswordForUser($user); + $this->featureContext->setResponse( + OcsApiHelper::sendRequest( + $this->featureContext->getBaseUrl(), + $user, + $password, + 'GET', + '/cloud/capabilities', + $this->featureContext->getStepLineRef(), + [], + $this->featureContext->getOcsApiVersion() + ) + ); + } + + /** + * @Given user :username has retrieved the capabilities + * + * @param string $username + * + * @return void + * @throws Exception + */ + public function userGetsCapabilitiesCheckResponse(string $username):void { + $this->userGetsCapabilities($username); + $statusCode = $this->featureContext->getResponse()->getStatusCode(); + if ($statusCode !== 200) { + throw new \Exception( + __METHOD__ + . " user $username returned unexpected status $statusCode" + ); + } + } + + /** + * @When the user retrieves the capabilities using the capabilities API + * + * @return void + */ + public function theUserGetsCapabilities():void { + $this->userGetsCapabilities($this->featureContext->getCurrentUser()); + } + + /** + * @Given the user has retrieved the capabilities + * + * @return void + * @throws Exception + */ + public function theUserGetsCapabilitiesCheckResponse():void { + $this->userGetsCapabilitiesCheckResponse($this->featureContext->getCurrentUser()); + } + + /** + * @return string + * @throws Exception + */ + public function getAdminUsernameForCapabilitiesCheck():string { + if (\TestHelpers\OcisHelper::isTestingOnReva()) { + // When testing on reva we don't have a user called "admin" to use + // to access the capabilities. So create an ordinary user on-the-fly + // with a default password. That user should be able to get a + // capabilities response that the test can process. + $adminUsername = "PseudoAdminForRevaTest"; + $createdUsers = $this->featureContext->getCreatedUsers(); + if (!\array_key_exists($adminUsername, $createdUsers)) { + $this->featureContext->createUser($adminUsername); + } + } else { + $adminUsername = $this->featureContext->getAdminUsername(); + } + return $adminUsername; + } + + /** + * @When the administrator retrieves the capabilities using the capabilities API + * + * @return void + */ + public function theAdministratorGetsCapabilities():void { + $this->userGetsCapabilities($this->getAdminUsernameForCapabilitiesCheck()); + } + + /** + * @Given the administrator has retrieved the capabilities + * + * @return void + * @throws Exception + */ + public function theAdministratorGetsCapabilitiesCheckResponse():void { + $this->userGetsCapabilitiesCheckResponse($this->getAdminUsernameForCapabilitiesCheck()); + } + + /** + * @param string $exceptionText text to put at the front of exception messages + * + * @return SimpleXMLElement latest retrieved capabilities in XML format + * @throws Exception + */ + public function getCapabilitiesXml(string $exceptionText = ''): SimpleXMLElement { + if ($exceptionText === '') { + $exceptionText = __METHOD__; + } + return $this->featureContext->getResponseXml(null, $exceptionText)->data->capabilities; + } + + /** + * @param string $exceptionText text to put at the front of exception messages + * + * @return SimpleXMLElement latest retrieved version data in XML format + * @throws Exception + */ + public function getVersionXml(string $exceptionText = ''): SimpleXMLElement { + if ($exceptionText === '') { + $exceptionText = __METHOD__; + } + return $this->featureContext->getResponseXml(null, $exceptionText)->data->version; + } + + /** + * @param SimpleXMLElement $xml of the capabilities + * @param string $capabilitiesApp the "app" name in the capabilities response + * @param string $capabilitiesPath the path to the element + * + * @return string + */ + public function getParameterValueFromXml( + SimpleXMLElement $xml, + string $capabilitiesApp, + string $capabilitiesPath + ):string { + $path_to_element = \explode('@@@', $capabilitiesPath); + $answeredValue = $xml->{$capabilitiesApp}; + foreach ($path_to_element as $element) { + $nameIndexParts = \explode('[', $element); + if (isset($nameIndexParts[1])) { + // This part of the path should be something like "some_element[1]" + // Separately extract the name and the index + $name = $nameIndexParts[0]; + $index = (int) \explode(']', $nameIndexParts[1])[0]; + // and use those to construct the reference into the next XML level + $answeredValue = $answeredValue->{$name}[$index]; + } else { + if ($element !== "") { + $answeredValue = $answeredValue->{$element}; + } + } + } + + return (string) $answeredValue; + } + + /** + * @param SimpleXMLElement $xml of the capabilities + * @param string $capabilitiesApp the "app" name in the capabilities response + * @param string $capabilitiesPath the path to the element + * + * @return boolean + */ + public function parameterValueExistsInXml( + SimpleXMLElement $xml, + string $capabilitiesApp, + string $capabilitiesPath + ):bool { + $path_to_element = \explode('@@@', $capabilitiesPath); + $answeredValue = $xml->{$capabilitiesApp}; + + foreach ($path_to_element as $element) { + $nameIndexParts = \explode('[', $element); + if (isset($nameIndexParts[1])) { + // This part of the path should be something like "some_element[1]" + // Separately extract the name and the index + $name = $nameIndexParts[0]; + $index = (int) \explode(']', $nameIndexParts[1])[0]; + // and use those to construct the reference into the next XML level + if (isset($answeredValue->{$name}[$index])) { + $answeredValue = $answeredValue->{$name}[$index]; + } else { + // The path ends at this level + return false; + } + } else { + if (isset($answeredValue->{$element})) { + $answeredValue = $answeredValue->{$element}; + } else { + // The path ends at this level + return false; + } + } + } + + return true; + } + + /** + * @param string $app + * @param string $parameter + * @param string $value + * + * @return void + * @throws Exception + */ + public function modifyAppConfig(string $app, string $parameter, string $value):void { + AppConfigHelper::modifyAppConfig( + $this->featureContext->getBaseUrl(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $app, + $parameter, + $value, + $this->featureContext->getStepLineRef(), + $this->featureContext->getOcsApiVersion() + ); + } + + /** + * @param array $appParameterValues + * + * @return void + * @throws Exception + */ + public function modifyAppConfigs(array $appParameterValues):void { + AppConfigHelper::modifyAppConfigs( + $this->featureContext->getBaseUrl(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $appParameterValues, + $this->featureContext->getStepLineRef(), + $this->featureContext->getOcsApiVersion() + ); + } + + /** + * @When the administrator adds url :url as trusted server using the testing API + * + * @param string $url + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorAddsUrlAsTrustedServerUsingTheTestingApi(string $url):void { + $adminUser = $this->featureContext->getAdminUsername(); + $response = OcsApiHelper::sendRequest( + $this->featureContext->getBaseUrl(), + $adminUser, + $this->featureContext->getAdminPassword(), + 'POST', + "/apps/testing/api/v1/trustedservers", + $this->featureContext->getStepLineRef(), + ['url' => $this->featureContext->substituteInLineCodes($url)] + ); + $this->featureContext->setResponse($response); + $this->featureContext->pushToLastStatusCodesArrays(); + } + + /** + * Return text that contains the details of the URL, including any differences due to inline codes + * + * @param string $url + * + * @return string + */ + private function getUrlStringForMessage(string $url):string { + $text = $url; + $expectedUrl = $this->featureContext->substituteInLineCodes($url); + if ($expectedUrl !== $url) { + $text .= " ($expectedUrl)"; + } + return $text; + } + + /** + * @param string $url + * + * @return string + */ + private function getNotTrustedServerMessage(string $url):string { + return + "URL " + . $this->getUrlStringForMessage($url) + . " is not a trusted server but should be"; + } + + /** + * @Then url :url should be a trusted server + * + * @param string $url + * + * @return void + * @throws Exception + */ + public function urlShouldBeATrustedServer(string $url):void { + $trustedServers = $this->featureContext->getTrustedServers(); + foreach ($trustedServers as $server => $id) { + if ($server === $this->featureContext->substituteInLineCodes($url)) { + return; + } + } + Assert::fail($this->getNotTrustedServerMessage($url)); + } + + /** + * @Then the trusted server list should include these urls: + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theTrustedServerListShouldIncludeTheseUrls(TableNode $table):void { + $trustedServers = $this->featureContext->getTrustedServers(); + $expected = $table->getColumnsHash(); + + foreach ($expected as $server) { + $found = false; + foreach ($trustedServers as $url => $id) { + if ($url === $this->featureContext->substituteInLineCodes($server['url'])) { + $found = true; + break; + } + } + if (!$found) { + Assert::fail($this->getNotTrustedServerMessage($server['url'])); + } + } + } + + /** + * @Given the administrator has added url :url as trusted server + * + * @param string $url + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function theAdministratorHasAddedUrlAsTrustedServer(string $url):void { + $this->theAdministratorAddsUrlAsTrustedServerUsingTheTestingApi($url); + $status = $this->featureContext->getResponse()->getStatusCode(); + if ($status !== 201) { + throw new \Exception( + __METHOD__ . + " Could not add trusted server " . $this->getUrlStringForMessage($url) + . ". The request failed with status $status" + ); + } + } + + /** + * @When the administrator deletes url :url from trusted servers using the testing API + * + * @param string $url + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorDeletesUrlFromTrustedServersUsingTheTestingApi(string $url):void { + $adminUser = $this->featureContext->getAdminUsername(); + $response = OcsApiHelper::sendRequest( + $this->featureContext->getBaseUrl(), + $adminUser, + $this->featureContext->getAdminPassword(), + 'DELETE', + "/apps/testing/api/v1/trustedservers", + $this->featureContext->getStepLineRef(), + ['url' => $this->featureContext->substituteInLineCodes($url)] + ); + $this->featureContext->setResponse($response); + } + + /** + * @Then url :url should not be a trusted server + * + * @param string $url + * + * @return void + * @throws Exception + */ + public function urlShouldNotBeATrustedServer(string $url):void { + $trustedServers = $this->featureContext->getTrustedServers(); + foreach ($trustedServers as $server => $id) { + if ($server === $this->featureContext->substituteInLineCodes($url)) { + Assert::fail( + "URL " . $this->getUrlStringForMessage($url) + . " is a trusted server but is not expected to be" + ); + } + } + } + + /** + * @When the administrator deletes all trusted servers using the testing API + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorDeletesAllTrustedServersUsingTheTestingApi():void { + $adminUser = $this->featureContext->getAdminUsername(); + $response = OcsApiHelper::sendRequest( + $this->featureContext->getBaseUrl(), + $adminUser, + $this->featureContext->getAdminPassword(), + 'DELETE', + "/apps/testing/api/v1/trustedservers/all", + $this->featureContext->getStepLineRef() + ); + $this->featureContext->setResponse($response); + } + + /** + * @Given the trusted server list is cleared + * + * @return void + * @throws Exception + */ + public function theTrustedServerListIsCleared():void { + $this->theAdministratorDeletesAllTrustedServersUsingTheTestingApi(); + $statusCode = $this->featureContext->getResponse()->getStatusCode(); + if ($statusCode !== 204) { + $contents = $this->featureContext->getResponse()->getBody()->getContents(); + throw new \Exception( + __METHOD__ + . " Failed to clear all trusted servers" . $contents + ); + } + } + + /** + * @Then the trusted server list should be empty + * + * @return void + * @throws Exception + */ + public function theTrustedServerListShouldBeEmpty():void { + $trustedServers = $this->featureContext->getTrustedServers(); + Assert::assertEmpty( + $trustedServers, + __METHOD__ . " Trusted server list is not empty" + ); + } + + /** + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function setUpScenario(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/AuthContext.php b/tests/acceptance/features/bootstrap/AuthContext.php new file mode 100644 index 000000000..24c57b657 --- /dev/null +++ b/tests/acceptance/features/bootstrap/AuthContext.php @@ -0,0 +1,1158 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use TestHelpers\HttpRequestHelper; +use Behat\Gherkin\Node\TableNode; +use Behat\Behat\Context\Context; +use TestHelpers\SetupHelper; + +/** + * Authentication functions + */ +class AuthContext implements Context { + /** + * @var string + */ + private $clientToken; + + /** + * @var string + */ + private $appToken; + + /** + * @var array + */ + private $appTokens; + + /** + * @var boolean + */ + private $tokenAuthHasBeenSet = false; + + /** + * @var FeatureContext + */ + private $featureContext; + + /** + * @var string 'true' or 'false' or '' + */ + private $tokenAuthHasBeenSetTo = ''; + + /** + * @return string + */ + public function getTokenAuthHasBeenSetTo():string { + return $this->tokenAuthHasBeenSetTo; + } + + /** + * get the client token that was last generated + * app acceptance tests that have their own step code may need to use this + * + * @return string client token + */ + public function getClientToken():string { + return $this->clientToken; + } + + /** + * get the app token that was last generated + * app acceptance tests that have their own step code may need to use this + * + * @return string app token + */ + public function getAppToken():string { + return $this->appToken; + } + + /** + * get the app token that was last generated + * app acceptance tests that have their own step code may need to use this + * + * @return array app tokens + */ + public function getAppTokens():array { + return $this->appTokens; + } + + /** + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function setUpScenario(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + + // Reset ResponseXml + $this->featureContext->setResponseXml([]); + + // Initialize SetupHelper class + SetupHelper::init( + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + } + + /** + * @When a user requests :url with :method and no authentication + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userRequestsURLWith(string $url, string $method):void { + $this->sendRequest($url, $method); + } + + /** + * @When user :user requests :url with :method and no authentication + * + * @param string $user + * @param string $url + * @param string $method + * + * @return void + * @throws JsonException + */ + public function userRequestsURLWithNoAuth(string $user, string $url, string $method):void { + $userRenamed = $this->featureContext->getActualUsername($user); + $url = $this->featureContext->substituteInLineCodes($url, $userRenamed); + $this->sendRequest($url, $method); + } + + /** + * @Given a user has requested :url with :method and no authentication + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userHasRequestedURLWith(string $url, string $method):void { + $this->sendRequest($url, $method); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * Verifies status code + * + * @param string $ocsCode + * @param string $httpCode + * @param string $endPoint + * + * @return void + * @throws Exception + */ + public function verifyStatusCode(string $ocsCode, string $httpCode, string $endPoint):void { + if ($ocsCode !== null) { + $this->featureContext->ocsContext->theOCSStatusCodeShouldBe( + $ocsCode, + $message = "Got unexpected OCS code while sending request to endpoint " . $endPoint + ); + } + $this->featureContext->theHTTPStatusCodeShouldBe( + $httpCode, + $message = "Got unexpected HTTP code while sending request to endpoint " . $endPoint + ); + } + + /** + * @When a user requests these endpoints with :method with body :body and no authentication about user :user + * + * @param string $method + * @param ?string $body + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithBodyAndNoAuthThenStatusCodeAboutUser(string $method, ?string $body, ?string $ofUser, TableNode $table):void { + $ofUser = \strtolower($this->featureContext->getActualUsername($ofUser)); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastOCSStatusCodesArray(); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $ofUser + ); + $this->sendRequest($row['endpoint'], $method, null, false, $body); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When a user requests these endpoints with :method with no authentication about user :user + * + * @param string $method + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithoutBodyAndNoAuth(string $method, string $ofUser, TableNode $table):void { + $this->userRequestsEndpointsWithBodyAndNoAuthThenStatusCodeAboutUser( + $method, + null, + $ofUser, + $table + ); + } + + /** + * @When a user requests these endpoints with :method and no authentication + * + * @param string $method + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithNoAuthentication(string $method, TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastOCSStatusCodesArray(); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + foreach ($table->getHash() as $row) { + $this->sendRequest($row['endpoint'], $method); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When the user :user requests these endpoints with :method with basic auth + * + * @param string $user + * @param string $method + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithBasicAuth(string $user, string $method, TableNode $table):void { + $user = $this->featureContext->getActualUsername($user); + $this->userRequestsEndpointsWithPassword($user, $method, null, $table); + } + + /** + * @When the user :user requests these endpoints with :method using basic auth and generated app password about user :ofUser + * + * @param string $user + * @param string $method + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithBasicAuthAndGeneratedPassword(string $user, string $method, string $ofUser, TableNode $table):void { + $this->requestEndpointsWithBasicAuthAndGeneratedPassword($user, $method, $ofUser, $table); + } + + /** + * @When /^the user "([^"]*)" requests these endpoints with "([^"]*)" to (?:get|set) property "([^"]*)" using basic auth and generated app password about user "([^"]*)"$/ + * + * @param string $user + * @param string $method + * @param string $property + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithBasicAuthAndGeneratedPasswordWithProperty( + string $user, + string $method, + string $property, + string $ofUser, + TableNode $table + ):void { + $this->requestEndpointsWithBasicAuthAndGeneratedPassword( + $user, + $method, + $ofUser, + $table, + null, + $property + ); + } + + /** + * @When /^the user "([^"]*)" requests these endpoints with "([^"]*)" to (?:get|set) property "([^"]*)" with password "([^"]*)" about user "([^"]*)"$/ + * + * @param string $user + * @param string $method + * @param string $property + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithPasswordWithProperty( + string $user, + string $method, + string $property, + string $ofUser, + TableNode $table + ):void { + $this->userRequestsEndpointsWithPassword( + $user, + $method, + $ofUser, + $table, + $property + ); + } + + /** + * @When the user :user requests these endpoints with :method with body :body using basic auth and generated app password about user :ofUser + * + * @param string $user + * @param string $method + * @param string $body + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithBasicAuthAndGeneratedPasswordWithBody( + string $user, + string $method, + string $body, + string $ofUser, + TableNode $table + ):void { + $header = []; + if ($method === 'MOVE' || $method === 'COPY') { + $header['Destination'] = '/path/to/destination'; + } + + $this->requestEndpointsWithBasicAuthAndGeneratedPassword( + $user, + $method, + $ofUser, + $table, + $body, + null, + $header, + ); + } + + /** + * @param string $user requesting user + * @param string $method http method + * @param string $ofUser resource owner + * @param TableNode $table endpoints table + * @param string|null $body body for request + * @param string|null $property property to get + * @param Array|null $header request header + * + * @return void + * @throws Exception + */ + public function requestEndpointsWithBasicAuthAndGeneratedPassword( + string $user, + string $method, + string $ofUser, + TableNode $table, + ?string $body = null, + ?string $property = null, + ?array $header = null + ):void { + $user = $this->featureContext->getActualUsername($user); + $ofUser = \strtolower($this->featureContext->getActualUsername($ofUser)); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastOCSStatusCodesArray(); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + if ($body === null && $property !== null) { + $body = $this->featureContext->getBodyForOCSRequest($method, $property); + } + + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $ofUser + ); + $this->userRequestsURLWithUsingBasicAuth($user, $row['endpoint'], $method, $this->appToken, $body, $header); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When user :user requests these endpoints with :method using password :password + * + * @param string $user + * @param string $method + * @param string|null $password + * @param TableNode $table + * @param string|null $property + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsWithPassword( + string $user, + string $method, + ?string $password, + TableNode $table, + ?string $property = null + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->featureContext->emptyLastOCSStatusCodesArray(); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + foreach ($table->getHash() as $row) { + $body = null; + if ($property !== null) { + $body = $this->featureContext->getBodyForOCSRequest($method, $property); + } + $this->userRequestsURLWithUsingBasicAuth($user, $row['endpoint'], $method, $password, $body); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When the administrator requests these endpoint with :method + * + * @param string $method + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function adminRequestsEndpoint(string $method, TableNode $table):void { + $this->adminRequestsEndpointsWithBodyWithPassword($method, null, null, null, $table); + } + + /** + * @When the administrator requests these endpoints with :method with body :body using password :password about user :ofUser + * + * @param string|null $method + * @param string|null $body + * @param string|null $password + * @param string|null $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function adminRequestsEndpointsWithBodyWithPassword( + ?string $method, + ?string $body, + ?string $password, + ?string $ofUser, + TableNode $table + ):void { + $ofUser = $this->featureContext->getActualUsername($ofUser); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $ofUser + ); + $this->userRequestsURLWithUsingBasicAuth( + $this->featureContext->getAdminUsername(), + $row['endpoint'], + $method, + $password, + $body + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When the administrator requests these endpoints with :method using password :password about user :ofUser + * + * @param string $method + * @param string $password + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function adminRequestsEndpointsWithPassword( + string $method, + string $password, + string $ofUser, + TableNode $table + ):void { + $this->adminRequestsEndpointsWithBodyWithPassword($method, null, $password, $ofUser, $table); + } + + /** + * @When user :user requests these endpoints with :method using basic token auth + * + * @param string $user + * @param string $method + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function whenUserWithNewClientTokenRequestsForEndpointUsingBasicTokenAuth(string $user, string $method, TableNode $table):void { + $user = $this->featureContext->getActualUsername($user); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $this->userRequestsURLWithUsingBasicTokenAuth($user, $row['endpoint'], $method); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When the user requests these endpoints with :method using a new browser session + * + * @param string $method + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsTheseEndpointsUsingNewBrowserSession(string $method, TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $this->userRequestsURLWithBrowserSession($row['endpoint'], $method); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When the user requests these endpoints with :method using the generated app password about user :user + * + * @param string $method + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsUsingTheGeneratedAppPasswordThenStatusCodeAboutUser(string $method, string $user, TableNode $table):void { + $user = \strtolower($this->featureContext->getActualUsername($user)); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $user + ); + $this->userRequestsURLWithUsingAppPassword($row['endpoint'], $method); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When the user requests these endpoints with :method using the generated app password + * + * @param string $method + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsEndpointsUsingTheGeneratedAppPassword(string $method, TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $this->userRequestsURLWithUsingAppPassword($row['endpoint'], $method); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @param string $url + * @param string $method + * @param string|null $authHeader + * @param bool $useCookies + * @param string|null $body + * @param array|null $headers + * + * @return void + */ + public function sendRequest( + string $url, + string $method, + ?string $authHeader = null, + bool $useCookies = false, + ?string $body = null, + ?array $headers = [] + ):void { + // reset responseXml + $this->featureContext->setResponseXml([]); + + $fullUrl = $this->featureContext->getBaseUrl() . $url; + + $cookies = null; + if ($useCookies) { + $cookies = $this->featureContext->getCookieJar(); + } + + if ($authHeader) { + $headers['Authorization'] = $authHeader; + } + $headers['OCS-APIREQUEST'] = 'true'; + if (isset($this->requestToken)) { + $headers['requesttoken'] = $this->featureContext->getRequestToken(); + } + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $fullUrl, + $this->featureContext->getStepLineRef(), + $method, + null, + null, + $headers, + $body, + null, + $cookies + ) + ); + } + + /** + * Use the private API to generate an app password + * + * @param string $name + * + * @return void + */ + public function userGeneratesNewAppPasswordNamed(string $name):void { + $url = $this->featureContext->getBaseUrl() . '/index.php/settings/personal/authtokens'; + $body = ['name' => $name]; + $headers = [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'OCS-APIREQUEST' => 'true', + 'requesttoken' => $this->featureContext->getRequestToken(), + 'X-Requested-With' => 'XMLHttpRequest' + ]; + $this->featureContext->setResponse( + HttpRequestHelper::post( + $url, + $this->featureContext->getStepLineRef(), + null, + null, + $headers, + $body, + null, + $this->featureContext->getCookieJar() + ) + ); + $token = \json_decode($this->featureContext->getResponse()->getBody()->getContents()); + $this->appToken = $token->token; + $this->appTokens[$token->deviceToken->name] + = ["id" => $token->deviceToken->id, "token" => $token->token]; + } + + /** + * Use the private API to generate an app password + * + * @param string $name + * + * @return void + */ + public function userDeletesAppPasswordNamed(string $name):void { + $url = $this->featureContext->getBaseUrl() . '/index.php/settings/personal/authtokens/' . $this->appTokens[$name]["id"]; + $headers = [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'OCS-APIREQUEST' => 'true', + 'requesttoken' => $this->featureContext->getRequestToken(), + 'X-Requested-With' => 'XMLHttpRequest' + ]; + $this->featureContext->setResponse( + HttpRequestHelper::delete( + $url, + $this->featureContext->getStepLineRef(), + null, + null, + $headers, + null, + null, + $this->featureContext->getCookieJar() + ) + ); + } + + /** + * @Given the user has generated a new app password named :name + * + * @param string $name + * + * @return void + */ + public function aNewAppPasswordHasBeenGenerated(string $name):void { + $this->userGeneratesNewAppPasswordNamed($name); + $this->featureContext->theHTTPStatusCodeShouldBe(200); + } + + /** + * @Given the user has deleted the app password named :name + * + * @param string $name + * + * @return void + */ + public function aNewAppPasswordHasBeenDeleted(string $name):void { + $this->userDeletesAppPasswordNamed($name); + $this->featureContext->theHTTPStatusCodeShouldBe(200); + } + + /** + * @When user :user generates a new client token using the token API + * @Given a new client token for :user has been generated + * + * @param string $user + * + * @return void + */ + public function aNewClientTokenHasBeenGenerated(string $user):void { + $user = $this->featureContext->getActualUsername($user); + $body = \json_encode( + [ + 'user' => $this->featureContext->getActualUsername($user), + 'password' => $this->featureContext->getPasswordForUser($user), + ] + ); + $headers = ['Content-Type' => 'application/json']; + $url = $this->featureContext->getBaseUrl() . '/token/generate'; + $this->featureContext->setResponse( + HttpRequestHelper::post( + $url, + $this->featureContext->getStepLineRef(), + null, + null, + $headers, + $body + ) + ); + $this->featureContext->theHTTPStatusCodeShouldBe("200"); + $this->clientToken + = \json_decode($this->featureContext->getResponse()->getBody()->getContents())->token; + } + + /** + * @When the administrator generates a new client token using the token API + * @Given a new client token for the administrator has been generated + * + * @return void + */ + public function aNewClientTokenForTheAdministratorHasBeenGenerated():void { + $admin = $this->featureContext->getAdminUsername(); + $this->aNewClientTokenHasBeenGenerated($admin); + } + + /** + * @When user :user requests :url with :method using basic auth + * + * @param string $user + * @param string $url + * @param string $method + * @param string|null $password + * @param string|null $body + * @param array|null $header + * + * @return void + */ + public function userRequestsURLWithUsingBasicAuth( + string $user, + string $url, + string $method, + ?string $password = null, + ?string $body = null, + ?array $header = null + ):void { + $userRenamed = $this->featureContext->getActualUsername($user); + $url = $this->featureContext->substituteInLineCodes( + $url, + $userRenamed + ); + if ($password === null) { + $authString = "$userRenamed:" . $this->featureContext->getPasswordForUser($user); + } else { + $authString = $userRenamed . ":" . $this->featureContext->getActualPassword($password); + } + $this->sendRequest( + $url, + $method, + 'basic ' . \base64_encode($authString), + false, + $body, + $header + ); + } + + /** + * @When user :user requests :url with :method using basic auth and with headers + * + * @param string $user + * @param string $url + * @param string $method + * @param TableNode $headersTable + * + * @return void + * @throws Exception + */ + public function userRequestsURLWithUsingBasicAuthAndDepthHeader(string $user, string $url, string $method, TableNode $headersTable):void { + $user = $this->featureContext->getActualUsername($user); + $authString = "$user:" . $this->featureContext->getPasswordForUser($user); + $url = $this->featureContext->substituteInLineCodes( + $url, + $user + ); + $this->featureContext->verifyTableNodeColumns( + $headersTable, + ['header', 'value'] + ); + $headers = []; + foreach ($headersTable as $row) { + $headers[$row['header']] = $row ['value']; + } + $this->sendRequest( + $url, + $method, + 'basic ' . \base64_encode($authString), + false, + null, + $headers + ); + } + + /** + * @Given user :user has requested :url with :method using basic auth + * + * @param string $user + * @param string $url + * @param string $method + * @param string|null $password + * @param string|null $body + * + * @return void + */ + public function userHasRequestedURLWithUsingBasicAuth( + string $user, + string $url, + string $method, + ?string $password = null, + ?string $body = null + ):void { + $this->userRequestsURLWithUsingBasicAuth( + $user, + $url, + $method, + $password, + $body + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the administrator requests :url with :method using basic auth + * + * @param string $url + * @param string $method + * @param string|null $password + * + * @return void + */ + public function administratorRequestsURLWithUsingBasicAuth(string $url, string $method, ?string $password = null):void { + $this->userRequestsURLWithUsingBasicAuth( + $this->featureContext->getAdminUsername(), + $url, + $method, + $password + ); + } + + /** + * @When user :user requests :url with :method using basic token auth + * + * @param string $user + * @param string $url + * @param string $method + * + * @return void + */ + public function userRequestsURLWithUsingBasicTokenAuth(string $user, string $url, string $method):void { + $user = $this->featureContext->getActualUsername($user); + $this->sendRequest( + $url, + $method, + 'basic ' . \base64_encode("$user:" . $this->clientToken) + ); + } + + /** + * @Given user :user has requested :url with :method using basic token auth + * + * @param string $user + * @param string $url + * @param string $method + * + * @return void + */ + public function userHasRequestedURLWithUsingBasicTokenAuth(string $user, string $url, string $method):void { + $this->userRequestsURLWithUsingBasicTokenAuth($user, $url, $method); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the user requests :url with :method using the generated client token + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userRequestsURLWithUsingAClientToken(string $url, string $method):void { + $this->sendRequest($url, $method, 'token ' . $this->clientToken); + } + + /** + * @Given the user has requested :url with :method using the generated client token + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userHasRequestedURLWithUsingAClientToken(string $url, string $method):void { + $this->userRequestsURLWithUsingAClientToken($url, $method); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the user requests :url with :method using the generated app password + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userRequestsURLWithUsingAppPassword(string $url, string $method):void { + $this->sendRequest($url, $method, 'token ' . $this->appToken); + } + + /** + * @When the user requests :url with :method using app password named :tokenName + * + * @param string $url + * @param string $method + * @param string $tokenName + * + * @return void + */ + public function theUserRequestsWithUsingAppPasswordNamed(string $url, string $method, string $tokenName):void { + $this->sendRequest($url, $method, 'token ' . $this->appTokens[$tokenName]['token']); + } + + /** + * @Given the user has requested :url with :method using the generated app password + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userHasRequestedURLWithUsingAppPassword(string $url, string $method):void { + $this->userRequestsURLWithUsingAppPassword($url, $method); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the user requests :url with :method using the browser session + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userRequestsURLWithBrowserSession(string $url, string $method):void { + $this->sendRequest($url, $method, null, true); + } + + /** + * @Given the user has requested :url with :method using the browser session + * + * @param string $url + * @param string $method + * + * @return void + */ + public function userHasRequestedURLWithBrowserSession(string $url, string $method):void { + $this->userRequestsURLWithBrowserSession($url, $method); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Given a new browser session for :user has been started + * + * @param string $user + * + * @return void + */ + public function aNewBrowserSessionForHasBeenStarted(string $user):void { + $user = $this->featureContext->getActualUsername($user); + $loginUrl = $this->featureContext->getBaseUrl() . '/index.php/login'; + // Request a new session and extract CSRF token + $this->featureContext->setResponse( + HttpRequestHelper::get( + $loginUrl, + $this->featureContext->getStepLineRef(), + null, + null, + null, + null, + null, + $this->featureContext->getCookieJar() + ) + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + $this->featureContext->extractRequestTokenFromResponse($this->featureContext->getResponse()); + + // Login and extract new token + $body = [ + 'user' => $this->featureContext->getActualUsername($user), + 'password' => $this->featureContext->getPasswordForUser($user), + 'requesttoken' => $this->featureContext->getRequestToken() + ]; + $this->featureContext->setResponse( + HttpRequestHelper::post( + $loginUrl, + $this->featureContext->getStepLineRef(), + null, + null, + null, + $body, + null, + $this->featureContext->getCookieJar() + ) + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + $this->featureContext->extractRequestTokenFromResponse($this->featureContext->getResponse()); + } + + /** + * @Given a new browser session for the administrator has been started + * + * @return void + */ + public function aNewBrowserSessionForTheAdministratorHasBeenStarted():void { + $admin = $this->featureContext->getAdminUsername(); + $this->aNewBrowserSessionForHasBeenStarted($admin); + } + + /** + * @When /^the administrator (enforces|does not enforce)\s?token auth$/ + * @Given /^token auth has (not|)\s?been enforced$/ + * + * @param string $hasOrNot + * + * @return void + * @throws Exception + */ + public function tokenAuthHasBeenEnforced(string $hasOrNot):void { + $enforce = (($hasOrNot !== "not") && ($hasOrNot !== "does not enforce")); + if ($enforce) { + $value = 'true'; + } else { + $value = 'false'; + } + $occStatus = SetupHelper::setSystemConfig( + 'token_auth_enforced', + $value, + $this->featureContext->getStepLineRef(), + 'boolean' + ); + if ($occStatus['code'] !== "0") { + throw new \Exception("setSystemConfig token_auth_enforced returned error code " . $occStatus['code']); + } + + // Remember that we set this value, so it can be removed after the scenario + $this->tokenAuthHasBeenSet = true; + $this->tokenAuthHasBeenSetTo = $value; + } + + /** + * + * @return string + */ + public function generateAuthTokenForAdmin():string { + $this->aNewBrowserSessionForHasBeenStarted($this->featureContext->getAdminUsername()); + $this->userGeneratesNewAppPasswordNamed('acceptance-test ' . \microtime()); + return $this->appToken; + } + + /** + * delete token_auth_enforced if it was set in the scenario + * + * @AfterScenario + * + * @return void + * @throws Exception + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function deleteTokenAuthEnforcedAfterScenario():void { + if ($this->tokenAuthHasBeenSet) { + if ($this->tokenAuthHasBeenSetTo === 'true') { + // Because token auth is enforced, we have to use a token + // (app password) as the password to send to the testing app + // so it will authenticate us. + $appTokenForOccCommand = $this->generateAuthTokenForAdmin(); + } else { + $appTokenForOccCommand = null; + } + SetupHelper::deleteSystemConfig( + 'token_auth_enforced', + $this->featureContext->getStepLineRef(), + null, + $appTokenForOccCommand, + null, + null + ); + $this->tokenAuthHasBeenSet = false; + $this->tokenAuthHasBeenSetTo = ''; + } + } +} diff --git a/tests/acceptance/features/bootstrap/CapabilitiesContext.php b/tests/acceptance/features/bootstrap/CapabilitiesContext.php new file mode 100644 index 000000000..bfdb36995 --- /dev/null +++ b/tests/acceptance/features/bootstrap/CapabilitiesContext.php @@ -0,0 +1,223 @@ + + * @author Sergio Bertolin + * @author Phillip Davis + * @copyright Copyright (c) 2018, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +require_once 'bootstrap.php'; + +/** + * Capabilities context. + */ +class CapabilitiesContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * @Then the capabilities should contain + * + * @param TableNode|null $formData + * + * @return void + * @throws Exception + */ + public function checkCapabilitiesResponse(TableNode $formData):void { + $capabilitiesXML = $this->featureContext->appConfigurationContext->getCapabilitiesXml(__METHOD__); + $assertedSomething = false; + + $this->featureContext->verifyTableNodeColumns($formData, ['value', 'path_to_element', 'capability']); + + foreach ($formData->getHash() as $row) { + $row['value'] = $this->featureContext->substituteInLineCodes($row['value']); + Assert::assertEquals( + $row['value'] === "EMPTY" ? '' : $row['value'], + $this->featureContext->appConfigurationContext->getParameterValueFromXml( + $capabilitiesXML, + $row['capability'], + $row['path_to_element'] + ), + "Failed field {$row['capability']} {$row['path_to_element']}" + ); + $assertedSomething = true; + } + + Assert::assertTrue( + $assertedSomething, + 'there was nothing in the table of expected capabilities' + ); + } + + /** + * @Then the version data in the response should contain + * + * @param TableNode|null $formData + * + * @return void + * @throws Exception + */ + public function checkVersionResponse(TableNode $formData):void { + $versionXML = $this->featureContext->appConfigurationContext->getVersionXml(__METHOD__); + $assertedSomething = false; + + $this->featureContext->verifyTableNodeColumns($formData, ['name', 'value']); + + foreach ($formData->getHash() as $row) { + $row['value'] = $this->featureContext->substituteInLineCodes($row['value']); + $actualValue = $versionXML->{$row['name']}; + + Assert::assertEquals( + $row['value'] === "EMPTY" ? '' : $row['value'], + $actualValue, + "Failed field {$row['name']}" + ); + $assertedSomething = true; + } + + Assert::assertTrue( + $assertedSomething, + 'there was nothing in the table of expected version data' + ); + } + + /** + * @Then the major-minor-micro version data in the response should match the version string + * + * @return void + * @throws Exception + */ + public function checkVersionMajorMinorMicroResponse():void { + $versionXML = $this->featureContext->appConfigurationContext->getVersionXml(__METHOD__); + $versionString = (string) $versionXML->string; + // We expect that versionString will be in a format like "10.9.2 beta" or "10.9.2-alpha" or "10.9.2" + $result = \preg_match('/^[0-9]+\.[0-9]+\.[0-9]+/', $versionString, $matches); + Assert::assertSame( + 1, + $result, + __METHOD__ . " version string '$versionString' does not start with a semver version" + ); + // semVerParts should have an array with the 3 semver components of the version, e.g. "1", "9" and "2". + $semVerParts = \explode('.', $matches[0]); + $expectedMajor = $semVerParts[0]; + $expectedMinor = $semVerParts[1]; + $expectedMicro = $semVerParts[2]; + $actualMajor = (string) $versionXML->major; + $actualMinor = (string) $versionXML->minor; + $actualMicro = (string) $versionXML->micro; + Assert::assertSame( + $expectedMajor, + $actualMajor, + __METHOD__ . "'major' data item does not match with major version in string '$versionString'" + ); + Assert::assertSame( + $expectedMinor, + $actualMinor, + __METHOD__ . "'minor' data item does not match with minor version in string '$versionString'" + ); + Assert::assertSame( + $expectedMicro, + $actualMicro, + __METHOD__ . "'micro' data item does not match with micro (patch) version in string '$versionString'" + ); + } + + /** + * @Then the :pathToElement capability of files sharing app should be :value + * + * @param string $pathToElement + * @param string $value + * + * @return void + * @throws Exception + */ + public function theCapabilityOfFilesSharingAppShouldBe( + string $pathToElement, + string $value + ):void { + $this->featureContext->appConfigurationContext->userGetsCapabilitiesCheckResponse( + $this->featureContext->getCurrentUser() + ); + $capabilitiesXML = $this->featureContext->appConfigurationContext->getCapabilitiesXml(__METHOD__); + $actualValue = $this->featureContext->appConfigurationContext->getParameterValueFromXml( + $capabilitiesXML, + "files_sharing", + $pathToElement + ); + Assert::assertEquals( + $value === "EMPTY" ? '' : $value, + $actualValue, + "Expected {$pathToElement} capability of files sharing app to be {$value}, but got {$actualValue}" + ); + } + + /** + * @Then the capabilities should not contain + * + * @param TableNode|null $formData + * + * @return void + */ + public function theCapabilitiesShouldNotContain(TableNode $formData):void { + $capabilitiesXML = $this->featureContext->appConfigurationContext->getCapabilitiesXml(__METHOD__); + $assertedSomething = false; + + foreach ($formData->getHash() as $row) { + Assert::assertFalse( + $this->featureContext->appConfigurationContext->parameterValueExistsInXml( + $capabilitiesXML, + $row['capability'], + $row['path_to_element'] + ), + "Capability {$row['capability']} {$row['path_to_element']} exists but it should not exist" + ); + $assertedSomething = true; + } + + Assert::assertTrue( + $assertedSomething, + 'there was nothing in the table of not expected capabilities' + ); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/ChecksumContext.php b/tests/acceptance/features/bootstrap/ChecksumContext.php new file mode 100644 index 000000000..cc7d52827 --- /dev/null +++ b/tests/acceptance/features/bootstrap/ChecksumContext.php @@ -0,0 +1,464 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use PHPUnit\Framework\Assert; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * Checksum functions + */ +class ChecksumContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * @When user :user uploads file :source to :destination with checksum :checksum using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * @param string $checksum + * + * @return void + */ + public function userUploadsFileToWithChecksumUsingTheAPI( + string $user, + string $source, + string $destination, + string $checksum + ):void { + $file = \file_get_contents( + $this->featureContext->acceptanceTestsDirLocation() . $source + ); + $response = $this->featureContext->makeDavRequest( + $user, + 'PUT', + $destination, + ['OC-Checksum' => $checksum], + $file, + "files" + ); + $this->featureContext->setResponse($response); + } + + /** + * @Given user :user has uploaded file :source to :destination with checksum :checksum + * + * @param string $user + * @param string $source + * @param string $destination + * @param string $checksum + * + * @return void + */ + public function userHasUploadedFileToWithChecksumUsingTheAPI( + string $user, + string $source, + string $destination, + string $checksum + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->userUploadsFileToWithChecksumUsingTheAPI( + $user, + $source, + $destination, + $checksum + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When user :user uploads file with content :content and checksum :checksum to :destination using the WebDAV API + * + * @param string $user + * @param string $content + * @param string $checksum + * @param string $destination + * + * @return void + */ + public function userUploadsFileWithContentAndChecksumToUsingTheAPI( + string $user, + string $content, + string $checksum, + string $destination + ):void { + $response = $this->featureContext->makeDavRequest( + $user, + 'PUT', + $destination, + ['OC-Checksum' => $checksum], + $content, + "files" + ); + $this->featureContext->setResponse($response); + } + + /** + * @Given user :user has uploaded file with content :content and checksum :checksum to :destination + * + * @param string $user + * @param string $content + * @param string $checksum + * @param string $destination + * + * @return void + */ + public function userHasUploadedFileWithContentAndChecksumToUsingTheAPI( + string $user, + string $content, + string $checksum, + string $destination + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->userUploadsFileWithContentAndChecksumToUsingTheAPI( + $user, + $content, + $checksum, + $destination + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When user :user requests the checksum of :path via propfind + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userRequestsTheChecksumOfViaPropfind(string $user, string $path):void { + $user = $this->featureContext->getActualUsername($user); + $body = ' + + + + + '; + $password = $this->featureContext->getPasswordForUser($user); + $response = WebDavHelper::makeDavRequest( + $this->featureContext->getBaseUrl(), + $user, + $password, + 'PROPFIND', + $path, + null, + $this->featureContext->getStepLineRef(), + $body, + $this->featureContext->getDavPathVersion() + ); + $this->featureContext->setResponse($response); + } + + /** + * @Then the webdav checksum should match :expectedChecksum + * + * @param string $expectedChecksum + * + * @return void + * @throws Exception + */ + public function theWebdavChecksumShouldMatch(string $expectedChecksum):void { + $service = new Sabre\Xml\Service(); + $bodyContents = $this->featureContext->getResponse()->getBody()->getContents(); + $parsed = $service->parse($bodyContents); + + /* + * Fetch the checksum array + * The checksums are way down in the array: + * $checksums = $parsed[0]['value'][1]['value'][0]['value'][0]; + * And inside is the actual checksum string: + * $checksums['value'][0]['value'] + * The Asserts below check the existence of the expected key at every level + * of the nested array. This helps to see what happened if a test fails + * because the response structure is not as expected. + */ + + Assert::assertIsArray( + $parsed, + __METHOD__ . " could not parse response as XML. Expected parsed XML to be an array but found " . $bodyContents + ); + Assert::assertArrayHasKey( + 0, + $parsed, + __METHOD__ . " parsed XML does not have key 0" + ); + $parsed0 = $parsed[0]; + Assert::assertArrayHasKey( + 'value', + $parsed0, + __METHOD__ . " parsed XML parsed0 does not have key value" + ); + $parsed0Value = $parsed0['value']; + Assert::assertArrayHasKey( + 1, + $parsed0Value, + __METHOD__ . " parsed XML parsed0Value does not have key 1" + ); + $parsed0Value1 = $parsed0Value[1]; + Assert::assertArrayHasKey( + 'value', + $parsed0Value1, + __METHOD__ . " parsed XML parsed0Value1 does not have key value after key 1" + ); + $parsed0Value1Value = $parsed0Value1['value']; + Assert::assertArrayHasKey( + 0, + $parsed0Value1Value, + __METHOD__ . " parsed XML parsed0Value1Value does not have key 0" + ); + $parsed0Value1Value0 = $parsed0Value1Value[0]; + Assert::assertArrayHasKey( + 'value', + $parsed0Value1Value0, + __METHOD__ . " parsed XML parsed0Value1Value0 does not have key value" + ); + $parsed0Value1Value0Value = $parsed0Value1Value0['value']; + Assert::assertArrayHasKey( + 0, + $parsed0Value1Value0Value, + __METHOD__ . " parsed XML parsed0Value1Value0Value does not have key 0" + ); + $checksums = $parsed0Value1Value0Value[0]; + Assert::assertArrayHasKey( + 'value', + $checksums, + __METHOD__ . " parsed XML checksums does not have key value" + ); + $checksumsValue = $checksums['value']; + Assert::assertArrayHasKey( + 0, + $checksumsValue, + __METHOD__ . " parsed XML checksumsValue does not have key 0" + ); + $checksumsValue0 = $checksumsValue[0]; + Assert::assertArrayHasKey( + 'value', + $checksumsValue0, + __METHOD__ . " parsed XML checksumsValue0 does not have key value" + ); + $actualChecksum = $checksumsValue0['value']; + Assert::assertEquals( + $expectedChecksum, + $actualChecksum, + "Expected: webDav checksum should be {$expectedChecksum} but got {$actualChecksum}" + ); + } + + /** + * @Then as user :user the webdav checksum of :path via propfind should match :expectedChecksum + * + * @param string $user + * @param string $path + * @param string $expectedChecksum + * + * @return void + * @throws Exception + */ + public function theWebdavChecksumOfViaPropfindShouldMatch(string $user, string $path, string $expectedChecksum):void { + $user = $this->featureContext->getActualUsername($user); + $this->userRequestsTheChecksumOfViaPropfind($user, $path); + $this->theWebdavChecksumShouldMatch($expectedChecksum); + } + + /** + * @Then the header checksum should match :expectedChecksum + * + * @param string $expectedChecksum + * + * @return void + * @throws Exception + */ + public function theHeaderChecksumShouldMatch(string $expectedChecksum):void { + $headerChecksums + = $this->featureContext->getResponse()->getHeader('OC-Checksum'); + + Assert::assertIsArray( + $headerChecksums, + __METHOD__ . " getHeader('OC-Checksum') did not return an array" + ); + + Assert::assertNotEmpty( + $headerChecksums, + __METHOD__ . " getHeader('OC-Checksum') returned an empty array. No checksum header was found." + ); + + $checksumCount = \count($headerChecksums); + + Assert::assertTrue( + $checksumCount === 1, + __METHOD__ . " Expected 1 checksum in the header but found $checksumCount checksums" + ); + + $headerChecksum + = $headerChecksums[0]; + Assert::assertEquals( + $expectedChecksum, + $headerChecksum, + "Expected: header checksum should match {$expectedChecksum} but got {$headerChecksum}" + ); + } + + /** + * @Then the header checksum when user :arg1 downloads file :arg2 using the WebDAV API should match :arg3 + * + * @param string $user + * @param string $fileName + * @param string $expectedChecksum + * + * @return void + * @throws Exception + */ + public function theHeaderChecksumWhenUserDownloadsFileUsingTheWebdavApiShouldMatch(string $user, string $fileName, string $expectedChecksum):void { + $this->featureContext->userDownloadsFileUsingTheAPI($user, $fileName); + $this->theHeaderChecksumShouldMatch($expectedChecksum); + } + + /** + * @Then the webdav checksum should be empty + * + * @return void + * @throws Exception + */ + public function theWebdavChecksumShouldBeEmpty():void { + $service = new Sabre\Xml\Service(); + $parsed = $service->parse( + $this->featureContext->getResponse()->getBody()->getContents() + ); + + /* + * Fetch the checksum array + * Maybe we want to do this a bit cleaner ;) + */ + $status = $parsed[0]['value'][1]['value'][1]['value']; + $expectedStatus = 'HTTP/1.1 404 Not Found'; + Assert::assertEquals( + $expectedStatus, + $status, + "Expected status to be {$expectedStatus} but got {$status}" + ); + } + + /** + * @Then the OC-Checksum header should not be there + * + * @return void + * @throws Exception + */ + public function theOcChecksumHeaderShouldNotBeThere():void { + $isHeader = $this->featureContext->getResponse()->hasHeader('OC-Checksum'); + Assert::assertFalse( + $isHeader, + "Expected no checksum header but got " + . $this->featureContext->getResponse()->getHeader('OC-Checksum') + ); + } + + /** + * @When user :user uploads chunk file :num of :total with :data to :destination with checksum :expectedChecksum using the WebDAV API + * + * @param string $user + * @param int $num + * @param int $total + * @param string $data + * @param string $destination + * @param string $expectedChecksum + * + * @return void + */ + public function userUploadsChunkFileOfWithToWithChecksum( + string $user, + int $num, + int $total, + string $data, + string $destination, + string $expectedChecksum + ):void { + $user = $this->featureContext->getActualUsername($user); + $num -= 1; + $file = "$destination-chunking-42-$total-$num"; + $response = $this->featureContext->makeDavRequest( + $user, + 'PUT', + $file, + ['OC-Checksum' => $expectedChecksum, 'OC-Chunked' => '1'], + $data, + "files" + ); + $this->featureContext->setResponse($response); + } + + /** + * @Given user :user has uploaded chunk file :num of :total with :data to :destination with checksum :expectedChecksum + * + * @param string $user + * @param int $num + * @param int $total + * @param string $data + * @param string $destination + * @param string $expectedChecksum + * + * @return void + */ + public function userHasUploadedChunkFileOfWithToWithChecksum( + string $user, + int $num, + int $total, + string $data, + string $destination, + string $expectedChecksum + ):void { + $this->userUploadsChunkFileOfWithToWithChecksum( + $user, + $num, + $total, + $data, + $destination, + $expectedChecksum + ); + $this->featureContext->theHTTPStatusCodeShouldBeOr(201, 206); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/FavoritesContext.php b/tests/acceptance/features/bootstrap/FavoritesContext.php new file mode 100644 index 000000000..1164dc26a --- /dev/null +++ b/tests/acceptance/features/bootstrap/FavoritesContext.php @@ -0,0 +1,361 @@ + + * @copyright Copyright (c) 2018 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use Psr\Http\Message\ResponseInterface; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * context containing favorites related API steps + */ +class FavoritesContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * + * @var WebDavPropertiesContext + */ + private $webDavPropertiesContext; + + /** + * @param string$user + * @param string $path + * + * @return void + */ + public function userFavoritesElement(string $user, string $path):void { + $response = $this->changeFavStateOfAnElement( + $user, + $path, + 1 + ); + $this->featureContext->setResponse($response); + } + + /** + * @When user :user favorites element :path using the WebDAV API + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userFavoritesElementUsingWebDavApi(string $user, string $path):void { + $this->userFavoritesElement($user, $path); + } + + /** + * @Given user :user has favorited element :path + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userHasFavoritedElementUsingWebDavApi(string $user, string $path):void { + $this->userFavoritesElement($user, $path); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the user favorites element :path using the WebDAV API + * + * @param string $path + * + * @return void + */ + public function theUserFavoritesElement(string $path):void { + $this->userFavoritesElement( + $this->featureContext->getCurrentUser(), + $path + ); + } + + /** + * @Given the user has favorited element :path + * + * @param string $path + * + * @return void + */ + public function theUserHasFavoritedElement(string $path):void { + $this->userFavoritesElement( + $this->featureContext->getCurrentUser(), + $path + ); + $this->featureContext->theHTTPStatusCodeShouldBe( + 207, + "Expected response status code to be 207 (Multi-status), but not found! " + ); + } + + /** + * @param $user + * @param $path + * + * @return void + */ + public function userUnfavoritesElement(string $user, string $path):void { + $response = $this->changeFavStateOfAnElement( + $user, + $path, + 0 + ); + $this->featureContext->setResponse($response); + } + + /** + * @When user :user unfavorites element :path using the WebDAV API + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userUnfavoritesElementUsingWebDavApi(string $user, string $path):void { + $this->userUnfavoritesElement($user, $path); + } + + /** + * @Given user :user has unfavorited element :path + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userHasUnfavoritedElementUsingWebDavApi(string $user, string $path):void { + $this->userUnfavoritesElement($user, $path); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^user "([^"]*)" should (not|)\s?have favorited the following elements$/ + * + * @param string $user + * @param string $shouldOrNot (not|) + * @param TableNode $expectedElements + * + * @return void + */ + public function checkFavoritedElements( + string $user, + string $shouldOrNot, + TableNode $expectedElements + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->userListsFavorites($user, null); + $this->featureContext->propfindResultShouldContainEntries( + $shouldOrNot, + $expectedElements, + $user + ); + } + + /** + * @When /^user "([^"]*)" lists the favorites and limits the result to ([\d*]) elements using the WebDAV API$/ + * + * @param string $user + * @param int|null $limit + * + * @return void + */ + public function userListsFavorites(string $user, ?int $limit = null):void { + $renamedUser = $this->featureContext->getActualUsername($user); + $baseUrl = $this->featureContext->getBaseUrl(); + $password = $this->featureContext->getPasswordForUser($user); + $body + = "\n" . + " \n" . + " \n" . + " 1\n"; + + if ($limit !== null) { + $body .= " \n" . + " $limit\n" . + " \n"; + } + + $body .= " "; + $response = WebDavHelper::makeDavRequest( + $baseUrl, + $renamedUser, + $password, + "REPORT", + "/", + null, + $this->featureContext->getStepLineRef(), + $body, + $this->featureContext->getDavPathVersion() + ); + $this->featureContext->setResponse($response); + } + + /** + * @param string $path + * + * @return void + */ + public function theUserUnfavoritesElement(string $path):void { + $this->userUnfavoritesElement( + $this->featureContext->getCurrentUser(), + $path + ); + } + + /** + * @When the user unfavorites element :path using the WebDAV API + * + * @param string $path + * + * @return void + */ + public function theUserUnfavoritesElementUsingWebDavApi(string $path):void { + $this->theUserUnfavoritesElement($path); + } + + /** + * @Given the user has unfavorited element :path + * + * @param string $path + * + * @return void + */ + public function theUserHasUnfavoritedElementUsingWebDavApi(string $path):void { + $this->theUserUnfavoritesElement($path); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^as user "([^"]*)" (?:file|folder|entry) "([^"]*)" should be favorited$/ + * + * @param string $user + * @param string $path + * @param integer $expectedValue 0|1 + * + * @return void + */ + public function asUserFileOrFolderShouldBeFavorited(string $user, string $path, int $expectedValue = 1):void { + $property = "oc:favorite"; + $this->webDavPropertiesContext->asUserFolderShouldContainAPropertyWithValue( + $user, + $path, + $property, + (string)$expectedValue + ); + } + + /** + * @Then /^as user "([^"]*)" (?:file|folder|entry) "([^"]*)" should not be favorited$/ + * + * @param string $user + * @param string $path + * + * @return void + */ + public function asUserFileShouldNotBeFavorited(string $user, string $path):void { + $this->asUserFileOrFolderShouldBeFavorited($user, $path, 0); + } + + /** + * @Then /^as the user (?:file|folder|entry) "([^"]*)" should be favorited$/ + * + * @param string $path + * @param integer $expectedValue 0|1 + * + * @return void + */ + public function asTheUserFileOrFolderShouldBeFavorited(string $path, int $expectedValue = 1):void { + $this->asUserFileOrFolderShouldBeFavorited( + $this->featureContext->getCurrentUser(), + $path, + $expectedValue + ); + } + + /** + * @Then /^as the user (?:file|folder|entry) "([^"]*)" should not be favorited$/ + * + * @param string $path + * + * @return void + */ + public function asTheUserFileOrFolderShouldNotBeFavorited(string $path):void { + $this->asTheUserFileOrFolderShouldBeFavorited($path, 0); + } + + /** + * Set the elements of a proppatch + * + * @param string $user + * @param string $path + * @param int|null $favOrUnfav 1 = favorite, 0 = unfavorite + * + * @return ResponseInterface + */ + public function changeFavStateOfAnElement( + string $user, + string $path, + ?int $favOrUnfav + ):ResponseInterface { + $renamedUser = $this->featureContext->getActualUsername($user); + return WebDavHelper::proppatch( + $this->featureContext->getBaseUrl(), + $renamedUser, + $this->featureContext->getPasswordForUser($user), + $path, + 'favorite', + (string)$favOrUnfav, + $this->featureContext->getStepLineRef(), + "oc='http://owncloud.org/ns'", + $this->featureContext->getDavPathVersion() + ); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + $this->webDavPropertiesContext = $environment->getContext( + 'WebDavPropertiesContext' + ); + } +} diff --git a/tests/acceptance/features/bootstrap/FeatureContext.php b/tests/acceptance/features/bootstrap/FeatureContext.php new file mode 100644 index 000000000..b98986319 --- /dev/null +++ b/tests/acceptance/features/bootstrap/FeatureContext.php @@ -0,0 +1,4488 @@ + + * @author Phillip Davis + * @copyright Copyright (c) 2018, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Hook\Scope\BeforeStepScope; +use GuzzleHttp\Exception\GuzzleException; +use rdx\behatvars\BehatVariablesContext; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode; +use Behat\Gherkin\Node\TableNode; +use Behat\Testwork\Hook\Scope\BeforeSuiteScope; +use GuzzleHttp\Cookie\CookieJar; +use Psr\Http\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use TestHelpers\AppConfigHelper; +use TestHelpers\OcsApiHelper; +use TestHelpers\SetupHelper; +use TestHelpers\HttpRequestHelper; +use TestHelpers\UploadHelper; +use TestHelpers\OcisHelper; +use Laminas\Ldap\Ldap; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * Features context. + */ +class FeatureContext extends BehatVariablesContext { + use Provisioning; + use Sharing; + use WebDav; + + /** + * @var int Unix timestamp seconds + */ + private $scenarioStartTime; + + /** + * @var string + */ + private $adminUsername = ''; + + /** + * @var string + */ + private $adminPassword = ''; + + /** + * @var string + */ + private $adminDisplayName = ''; + + /** + * @var string + */ + private $adminEmailAddress = ''; + + /** + * @var string + */ + private $originalAdminPassword = ''; + + /** + * An array of values of replacement values of user attributes. + * These are only referenced when creating a user. After that, the + * run-time values are maintained and referenced in the $createdUsers array. + * + * Key is the username, value is an array of user attributes + * + * @var array|null + */ + private $userReplacements = null; + + /** + * @var string + */ + private $regularUserPassword = ''; + + /** + * @var string + */ + private $alt1UserPassword = ''; + + /** + * @var string + */ + private $alt2UserPassword = ''; + + /** + * @var string + */ + private $alt3UserPassword = ''; + + /** + * @var string + */ + private $alt4UserPassword = ''; + + /** + * The password to use in tests that create a sub-admin user + * + * @var string + */ + private $subAdminPassword = ''; + + /** + * The password to use in tests that create another admin user + * + * @var string + */ + private $alternateAdminPassword = ''; + + /** + * The password to use in tests that create public link shares + * + * @var string + */ + private $publicLinkSharePassword = ''; + + /** + * @var string + */ + private $ocPath = ''; + + /** + * @var string location of the root folder of ownCloud on the local server under test + */ + private $localServerRoot = null; + + /** + * @var string + */ + private $currentUser = ''; + + /** + * @var string + */ + private $currentServer = ''; + + /** + * The base URL of the current server under test, + * without any terminating slash + * e.g. http://localhost:8080 + * + * @var string + */ + private $baseUrl = ''; + + /** + * The base URL of the local server under test, + * without any terminating slash + * e.g. http://localhost:8080 + * + * @var string + */ + private $localBaseUrl = ''; + + /** + * The base URL of the remote (federated) server under test, + * without any terminating slash + * e.g. http://localhost:8180 + * + * @var string + */ + private $remoteBaseUrl = ''; + + /** + * The suite name, feature name and scenario line number. + * Example: apiComments/createComments.feature:24 + * + * @var string + */ + private $scenarioString = ''; + + /** + * A full unique reference to the step that is currently executing. + * Example: apiComments/createComments.feature:24-28 + * That is line 28, in the scenario at line 24, in the createComments feature + * in the apiComments suite. + * + * @var string + */ + private $stepLineRef = ''; + + /** + * @var bool|null + */ + private $sendStepLineRef = null; + + /** + * + * + * @var boolean true if TEST_SERVER_FED_URL is defined + */ + private $federatedServerExists = false; + + /** + * @var int + */ + private $ocsApiVersion = 1; + + /** + * @var ResponseInterface + */ + private $response = null; + + /** + * @var string + */ + private $responseUser = ""; + + /** + * @var string + */ + private $responseBodyContent = null; + + /** + * @var array + */ + private $userResponseBodyContents = []; + + /** + * @var array + */ + public $emailRecipients = []; + + /** + * @var CookieJar + */ + private $cookieJar; + + /** + * @var string + */ + private $requestToken; + + /** + * @var array + */ + private $storageIds = []; + + /** + * @var array + */ + private $createdFiles = []; + + /** + * The local source IP address from which to initiate API actions. + * Defaults to system-selected address matching IP address family and scope. + * + * @var string|null + */ + private $sourceIpAddress = null; + + private $guzzleClientHeaders = []; + + /** + * + * @var OCSContext + */ + public $ocsContext; + + /** + * + * @var AuthContext + */ + public $authContext; + + /** + * + * @var GraphContext + */ + public $graphContext; + + /** + * + * @var AppConfigurationContext + */ + public $appConfigurationContext; + + /** + * @var array saved configuration of the system before test runs as reported + * by occ config:list + */ + private $savedConfigList = []; + + /** + * @var array + */ + private $initialTrustedServer; + + /** + * @var int return code of last command + */ + private $occLastCode; + /** + * @var string stdout of last command + */ + private $lastStdOut; + /** + * @var string stderr of last command + */ + private $lastStdErr; + /** + * The codes are stored as strings, even though they are numbers + * + * @var array last http status codes + */ + private $lastHttpStatusCodesArray = []; + /** + * @var array last ocs status codes + */ + private $lastOCSStatusCodesArray = []; + + /** + * @var bool + * + * this is set true for db conversion tests + */ + private $dbConversion = false; + + /** + * @param bool $value + * + * @return void + */ + public function setDbConversionState(bool $value): void { + $this->dbConversion = $value; + } + + /** + * @return bool + */ + public function isRunningForDbConversion(): bool { + return $this->dbConversion; + } + + /** + * @var string + */ + private $oCSelector; + + /** + * @param string $selector + * + * @return void + */ + public function setOCSelector(string $selector): void { + $this->oCSelector = $selector; + } + + /** + * @return string + */ + public function getOCSelector(): string { + return $this->oCSelector; + } + + /** + * @return void + */ + public function resetOccLastCode(): void { + $this->occLastCode = null; + } + + /** + * @param int $statusCode + * + * @return void + */ + public function setOccLastCode(?int $statusCode = null): void { + $this->occLastCode = $statusCode; + } + + /** + * @param string|null $httpStatusCode + * + * @return void + */ + public function pushToLastHttpStatusCodesArray(?string $httpStatusCode = null): void { + if ($httpStatusCode !== null) { + $this->lastHttpStatusCodesArray[] = $httpStatusCode; + } elseif ($this->getResponse()->getStatusCode() !== null) { + $this->lastHttpStatusCodesArray[] = (string)$this->getResponse()->getStatusCode(); + } + } + + /** + * @return void + */ + public function emptyLastHTTPStatusCodesArray(): void { + $this->lastHttpStatusCodesArray = []; + } + + /** + * @return void + */ + public function emptyLastOCSStatusCodesArray(): void { + $this->lastOCSStatusCodesArray = []; + } + + /** + * @return void + */ + public function clearStatusCodeArrays(): void { + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + } + + /** + * @param string $ocsStatusCode + * + * @return void + */ + public function pushToLastOcsCodesArray(string $ocsStatusCode): void { + array_push($this->lastOCSStatusCodesArray, $ocsStatusCode); + } + + /** + * Add HTTP and OCS status code of the last response to the respective status code array + * + * @return void + */ + public function pushToLastStatusCodesArrays(): void { + $this->pushToLastHttpStatusCodesArray( + (string)$this->getResponse()->getStatusCode() + ); + try { + $this->pushToLastOcsCodesArray( + $this->ocsContext->getOCSResponseStatusCode( + $this->getResponse() + ) + ); + } catch (Exception $exception) { + // if response couldn't be converted into xml then push "notset" to last ocs status codes array + $this->pushToLastOcsCodesArray("notset"); + } + } + + /** + * @param string $emailAddress + * + * @return void + */ + public function pushEmailRecipientAsMailBox(string $emailAddress): void { + $mailBox = explode("@", $emailAddress)[0]; + if (!\in_array($mailBox, $this->emailRecipients)) { + $this->emailRecipients[] = $mailBox; + } + } + + /* + * @var Ldap + */ + private $ldap; + /** + * @var string + */ + private $ldapBaseDN; + /** + * @var string + */ + private $ldapHost; + /** + * @var int + */ + private $ldapPort; + /** + * @var string + */ + private $ldapAdminUser; + /** + * @var string + */ + private $ldapAdminPassword = ""; + /** + * @var string + */ + private $ldapUsersOU; + /** + * @var string + */ + private $ldapGroupsOU; + /** + * @var string + */ + private $ldapGroupSchema; + /** + * @var bool + */ + private $skipImportLdif; + /** + * @var array + */ + private $toDeleteDNs = []; + private $ldapCreatedUsers = []; + private $ldapCreatedGroups = []; + private $toDeleteLdapConfigs = []; + private $oldLdapConfig = []; + + /** + * @return Ldap + */ + public function getLdap(): Ldap { + return $this->ldap; + } + + /** + * @param string $configId + * + * @return void + */ + public function setToDeleteLdapConfigs(string $configId): void { + $this->toDeleteLdapConfigs[] = $configId; + } + + /** + * @return array + */ + public function getToDeleteLdapConfigs(): array { + return $this->toDeleteLdapConfigs; + } + + /** + * @param string $setValue + * + * @return void + */ + public function setToDeleteDNs(string $setValue): void { + $this->toDeleteDNs[] = $setValue; + } + + /** + * @return string + */ + public function getLdapBaseDN(): string { + return $this->ldapBaseDN; + } + + /** + * @return string + */ + public function getLdapUsersOU(): string { + return $this->ldapUsersOU; + } + + /** + * @return string + */ + public function getLdapGroupsOU(): string { + return $this->ldapGroupsOU; + } + + /** + * @return array + */ + public function getOldLdapConfig(): array { + return $this->oldLdapConfig; + } + + /** + * @param string $configId + * @param string $configKey + * @param string $value + * + * @return void + */ + public function setOldLdapConfig(string $configId, string $configKey, string $value): void { + $this->oldLdapConfig[$configId][$configKey] = $value; + } + + /** + * @return string + */ + public function getLdapHost(): string { + return $this->ldapHost; + } + + /** + * @return string + */ + public function getLdapHostWithoutScheme(): string { + return $this->removeSchemeFromUrl($this->ldapHost); + } + + /** + * @return integer + */ + public function getLdapPort(): int { + return $this->ldapPort; + } + + /** + * @return bool + */ + public function isTestingWithLdap(): bool { + return (\getenv("TEST_WITH_LDAP") === "true"); + } + + /** + * @return bool + */ + public function sendScenarioLineReferencesInXRequestId(): ?bool { + if ($this->sendStepLineRef === null) { + $this->sendStepLineRef = (\getenv("SEND_SCENARIO_LINE_REFERENCES") === "true"); + } + return $this->sendStepLineRef; + } + + /** + * @return bool + */ + public function isTestingReplacingUsernames(): bool { + return (\getenv('REPLACE_USERNAMES') === "true"); + } + + /** + * @return array|null + */ + public function usersToBeReplaced(): ?array { + if (($this->userReplacements === null) && $this->isTestingReplacingUsernames()) { + $this->userReplacements = \json_decode( + \file_get_contents("./tests/acceptance/usernames.json"), + true + ); + // Loop through the user replacements, and make entries for the lower + // and upper case forms. This allows for steps that specifically + // want to test that usernames like "alice", "Alice" and "ALICE" all work. + // Such steps will make useful replacements for each form. + foreach ($this->userReplacements as $key => $value) { + $lowerKey = \strtolower($key); + if ($lowerKey !== $key) { + $this->userReplacements[$lowerKey] = $value; + $this->userReplacements[$lowerKey]['username'] = \strtolower( + $this->userReplacements[$lowerKey]['username'] + ); + } + $upperKey = \strtoupper($key); + if ($upperKey !== $key) { + $this->userReplacements[$upperKey] = $value; + $this->userReplacements[$upperKey]['username'] = \strtoupper( + $this->userReplacements[$upperKey]['username'] + ); + } + } + } + return $this->userReplacements; + } + + /** + * BasicStructure constructor. + * + * @param string $baseUrl + * @param string $adminUsername + * @param string $adminPassword + * @param string $regularUserPassword + * @param string $ocPath + * + */ + public function __construct( + string $baseUrl, + string $adminUsername, + string $adminPassword, + string $regularUserPassword, + string $ocPath + ) { + // Initialize your context here + $this->baseUrl = \rtrim($baseUrl, '/'); + $this->adminUsername = $adminUsername; + $this->adminPassword = $adminPassword; + $this->regularUserPassword = $regularUserPassword; + $this->localBaseUrl = $this->baseUrl; + $this->currentServer = 'LOCAL'; + $this->cookieJar = new CookieJar(); + $this->ocPath = $ocPath; + + // PARALLEL DEPLOYMENT: ownCloud selector + $this->oCSelector = "oc10"; + + // These passwords are referenced in tests and can be overridden by + // setting environment variables. + $this->alt1UserPassword = "1234"; + $this->alt2UserPassword = "AaBb2Cc3Dd4"; + $this->alt3UserPassword = "aVeryLongPassword42TheMeaningOfLife"; + $this->alt4UserPassword = "ThisIsThe4thAlternatePwd"; + $this->subAdminPassword = "IamAJuniorAdmin42"; + $this->alternateAdminPassword = "IHave99LotsOfPriv"; + $this->publicLinkSharePassword = "publicPwd1"; + + // in case of CI deployment we take the server url from the environment + $testServerUrl = \getenv('TEST_SERVER_URL'); + if ($testServerUrl !== false) { + $this->baseUrl = \rtrim($testServerUrl, '/'); + $this->localBaseUrl = $this->baseUrl; + } + + // federated server url from the environment + $testRemoteServerUrl = \getenv('TEST_SERVER_FED_URL'); + if ($testRemoteServerUrl !== false) { + $this->remoteBaseUrl = \rtrim($testRemoteServerUrl, '/'); + $this->federatedServerExists = true; + } else { + $this->remoteBaseUrl = $this->localBaseUrl; + $this->federatedServerExists = false; + } + + // get the admin username from the environment (if defined) + $adminUsernameFromEnvironment = $this->getAdminUsernameFromEnvironment(); + if ($adminUsernameFromEnvironment !== false) { + $this->adminUsername = $adminUsernameFromEnvironment; + } + + // get the admin password from the environment (if defined) + $adminPasswordFromEnvironment = $this->getAdminPasswordFromEnvironment(); + if ($adminPasswordFromEnvironment !== false) { + $this->adminPassword = $adminPasswordFromEnvironment; + } + + // get the regular user password from the environment (if defined) + $regularUserPasswordFromEnvironment = $this->getRegularUserPasswordFromEnvironment(); + if ($regularUserPasswordFromEnvironment !== false) { + $this->regularUserPassword = $regularUserPasswordFromEnvironment; + } + + // get the alternate(1) user password from the environment (if defined) + $alt1UserPasswordFromEnvironment = $this->getAlt1UserPasswordFromEnvironment(); + if ($alt1UserPasswordFromEnvironment !== false) { + $this->alt1UserPassword = $alt1UserPasswordFromEnvironment; + } + + // get the alternate(2) user password from the environment (if defined) + $alt2UserPasswordFromEnvironment = $this->getAlt2UserPasswordFromEnvironment(); + if ($alt2UserPasswordFromEnvironment !== false) { + $this->alt2UserPassword = $alt2UserPasswordFromEnvironment; + } + + // get the alternate(3) user password from the environment (if defined) + $alt3UserPasswordFromEnvironment = $this->getAlt3UserPasswordFromEnvironment(); + if ($alt3UserPasswordFromEnvironment !== false) { + $this->alt3UserPassword = $alt3UserPasswordFromEnvironment; + } + + // get the alternate(4) user password from the environment (if defined) + $alt4UserPasswordFromEnvironment = $this->getAlt4UserPasswordFromEnvironment(); + if ($alt4UserPasswordFromEnvironment !== false) { + $this->alt4UserPassword = $alt4UserPasswordFromEnvironment; + } + + // get the sub-admin password from the environment (if defined) + $subAdminPasswordFromEnvironment = $this->getSubAdminPasswordFromEnvironment(); + if ($subAdminPasswordFromEnvironment !== false) { + $this->subAdminPassword = $subAdminPasswordFromEnvironment; + } + + // get the alternate admin password from the environment (if defined) + $alternateAdminPasswordFromEnvironment = $this->getAlternateAdminPasswordFromEnvironment(); + if ($alternateAdminPasswordFromEnvironment !== false) { + $this->alternateAdminPassword = $alternateAdminPasswordFromEnvironment; + } + + // get the public link share password from the environment (if defined) + $publicLinkSharePasswordFromEnvironment = $this->getPublicLinkSharePasswordFromEnvironment(); + if ($publicLinkSharePasswordFromEnvironment !== false) { + $this->publicLinkSharePassword = $publicLinkSharePasswordFromEnvironment; + } + $this->originalAdminPassword = $this->adminPassword; + } + + /** + * @param string $appTestCodeFullPath + * + * @return string the relative path from the core tests/acceptance dir + * to the equivalent dir in the app + */ + public function getPathFromCoreToAppAcceptanceTests( + string $appTestCodeFullPath + ): string { + // $appTestCodeFullPath is something like: + // '/somedir/anotherdir/core/apps/guests/tests/acceptance/features/bootstrap' + // and we want to know the 'apps/guests/tests/acceptance' part + + $path = \dirname($appTestCodeFullPath, 2); + $acceptanceDir = \basename($path); + $path = \dirname($path); + $testsDir = \basename($path); + $path = \dirname($path); + $appNameDir = \basename($path); + $path = \dirname($path); + // We specially are not sure about the name of the directory 'apps' + // Sometimes the app could be installed in some alternate apps directory + // like, for example, `apps-external`. So this really does need to be + // resolved here at run-time. + $appsDir = \basename($path); + // To get from core tests/acceptance we go up 2 levels then down through + // the above app dirs. + return "../../$appsDir/$appNameDir/$testsDir/$acceptanceDir"; + } + + /** + * Get the externally-defined admin username, if any + * + * @return string|false + */ + private static function getAdminUsernameFromEnvironment() { + return \getenv('ADMIN_USERNAME'); + } + + /** + * Get the externally-defined admin password, if any + * + * @return string|false + */ + private static function getAdminPasswordFromEnvironment() { + return \getenv('ADMIN_PASSWORD'); + } + + /** + * Get the externally-defined regular user password, if any + * + * @return string|false + */ + private static function getRegularUserPasswordFromEnvironment() { + return \getenv('REGULAR_USER_PASSWORD'); + } + + /** + * Get the externally-defined alternate(1) user password, if any + * + * @return string|false + */ + private static function getAlt1UserPasswordFromEnvironment() { + return \getenv('ALT1_USER_PASSWORD'); + } + + /** + * Get the externally-defined alternate(2) user password, if any + * + * @return string|false + */ + private static function getAlt2UserPasswordFromEnvironment() { + return \getenv('ALT2_USER_PASSWORD'); + } + + /** + * Get the externally-defined alternate(3) user password, if any + * + * @return string|false + */ + private static function getAlt3UserPasswordFromEnvironment() { + return \getenv('ALT3_USER_PASSWORD'); + } + + /** + * Get the externally-defined alternate(4) user password, if any + * + * @return string|false + */ + private static function getAlt4UserPasswordFromEnvironment() { + return \getenv('ALT4_USER_PASSWORD'); + } + + /** + * Get the externally-defined sub-admin password, if any + * + * @return string|false + */ + private static function getSubAdminPasswordFromEnvironment() { + return \getenv('SUB_ADMIN_PASSWORD'); + } + + /** + * Get the externally-defined alternate admin password, if any + * + * @return string|false + */ + private static function getAlternateAdminPasswordFromEnvironment() { + return \getenv('ALTERNATE_ADMIN_PASSWORD'); + } + + /** + * Get the externally-defined public link share password, if any + * + * @return string|false + */ + private static function getPublicLinkSharePasswordFromEnvironment() { + return \getenv('PUBLIC_LINK_SHARE_PASSWORD'); + } + + /** + * removes the scheme "http(s)://" (if any) from the front of a URL + * note: only needs to handle http or https + * + * @param string $url + * + * @return string + */ + public function removeSchemeFromUrl(string $url): string { + return \preg_replace( + "(^https?://)", + "", + $url + ); + } + + /** + * @return string + */ + public function getOcPath(): string { + return $this->ocPath; + } + + /** + * @return CookieJar + */ + public function getCookieJar(): CookieJar { + return $this->cookieJar; + } + + /** + * @return string + */ + public function getRequestToken(): string { + return $this->requestToken; + } + + /** + * returns the base URL (which is without a slash at the end) + * + * @return string + */ + public function getBaseUrl(): string { + return $this->baseUrl; + } + + /** + * returns the path of the base URL + * e.g. owncloud-core/10 if the baseUrl is http://localhost/owncloud-core/10 + * the path is without a slash at the end and without a slash at the beginning + * + * @return string + */ + public function getBasePath(): string { + $parsedUrl = \parse_url($this->getBaseUrl(), PHP_URL_PATH); + // If the server-under-test is at the "top" of the domain then parse_url returns null. + // For example, testing a server at http://localhost:8080 or http://example.com + if ($parsedUrl === null) { + $parsedUrl = ''; + } + return \ltrim($parsedUrl, "/"); + } + + /** + * returns the OCS path + * the path is without a slash at the end and without a slash at the beginning + * + * @param string $ocsApiVersion + * + * @return string + */ + public function getOCSPath(string $ocsApiVersion): string { + return \ltrim($this->getBasePath() . "/ocs/v$ocsApiVersion.php", "/"); + } + + /** + * returns the complete DAV path including the base path e.g. owncloud-core/remote.php/dav + * + * @return string + */ + public function getDAVPathIncludingBasePath(): string { + return \ltrim($this->getBasePath() . "/" . $this->getDavPath(), "/"); + } + + /** + * returns the base URL but without "http(s)://" in front of it + * + * @return string + */ + public function getBaseUrlWithoutScheme(): string { + return $this->removeSchemeFromUrl($this->getBaseUrl()); + } + + /** + * returns the local base URL (which is without a slash at the end) + * + * @return string + */ + public function getLocalBaseUrl(): string { + return $this->localBaseUrl; + } + + /** + * returns the local base URL but without "http(s)://" in front of it + * + * @return string + */ + public function getLocalBaseUrlWithoutScheme(): string { + return $this->removeSchemeFromUrl($this->getLocalBaseUrl()); + } + + /** + * returns the remote base URL (which is without a slash at the end) + * + * @return string + */ + public function getRemoteBaseUrl(): string { + return $this->remoteBaseUrl; + } + + /** + * returns the remote base URL but without "http(s)://" in front of it + * + * @return string + */ + public function getRemoteBaseUrlWithoutScheme(): string { + return $this->removeSchemeFromUrl($this->getRemoteBaseUrl()); + } + + /** + * returns the reference to the current line being executed. + * + * @return string + */ + public function getStepLineRef(): string { + if (!$this->sendStepLineRef) { + return ''; + } + + // If we are in BeforeScenario and possibly before any particular step + // is being executed, then stepLineRef might be empty. In that case + // return just the string for the scenario. + if ($this->stepLineRef === '') { + return $this->scenarioString; + } + return $this->stepLineRef; + } + + /** + * get the exit status of the last occ command + * app acceptance tests that have their own step code may need to process this + * + * @return int exit status code of the last occ command + */ + public function getExitStatusCodeOfOccCommand(): ?int { + return $this->occLastCode; + } + + /** + * get the normal output of the last occ command + * app acceptance tests that have their own step code may need to process this + * + * @return string normal output of the last occ command + */ + public function getStdOutOfOccCommand(): string { + return $this->lastStdOut; + } + + /** + * set the normal output of the last occ command + * + * @param string $stdOut + * + * @return void + */ + public function setStdOutOfOccCommand(string $stdOut): void { + $this->lastStdOut = $stdOut; + } + + /** + * get the error output of the last occ command + * app acceptance tests that have their own step code may need to process this + * + * @return string error output of the last occ command + */ + public function getStdErrOfOccCommand(): string { + return $this->lastStdErr; + } + + /** + * returns the base URL without any sub-path e.g. http://localhost:8080 + * of the base URL http://localhost:8080/owncloud + * + * @return string + */ + public function getBaseUrlWithoutPath(): string { + $parts = \parse_url($this->getBaseUrl()); + $url = $parts ["scheme"] . "://" . $parts["host"]; + if (isset($parts["port"])) { + $url = "$url:" . $parts["port"]; + } + return $url; + } + + /** + * @return int + */ + public function getOcsApiVersion(): int { + return $this->ocsApiVersion; + } + + /** + * @return string|null + */ + public function getSourceIpAddress(): ?string { + return $this->sourceIpAddress; + } + + /** + * @return array|null + */ + public function getStorageIds(): ?array { + return $this->storageIds; + } + + /** + * @param string $storageName + * + * @return integer + * @throws Exception + */ + public function getStorageId(string $storageName): int { + $storageIds = $this->getStorageIds(); + $storageId = \array_search($storageName, $storageIds); + Assert::assertNotFalse( + $storageId, + "Could not find storageId with storage name $storageName" + ); + return $storageId; + } + + /** + * @param string $storageName + * @param integer $storageId + * + * @return void + */ + public function addStorageId(string $storageName, int $storageId): void { + $this->storageIds[$storageId] = $storageName; + } + + /** + * @param integer $storageId + * + * @return void + */ + public function popStorageId(int $storageId): void { + unset($this->storageIds[$storageId]); + } + + /** + * @param string $sourceIpAddress + * + * @return void + */ + public function setSourceIpAddress(string $sourceIpAddress): void { + $this->sourceIpAddress = $sourceIpAddress; + } + + /** + * @return array + */ + public function getGuzzleClientHeaders(): array { + return $this->guzzleClientHeaders; + } + + /** + * @param array $guzzleClientHeaders ['X-Foo' => 'Bar'] + * + * @return void + */ + public function setGuzzleClientHeaders(array $guzzleClientHeaders): void { + $this->guzzleClientHeaders = $guzzleClientHeaders; + } + + /** + * @param array $guzzleClientHeaders ['X-Foo' => 'Bar'] + * + * @return void + */ + public function addGuzzleClientHeaders(array $guzzleClientHeaders): void { + $this->guzzleClientHeaders = \array_merge( + $this->guzzleClientHeaders, + $guzzleClientHeaders + ); + } + + /** + * @Given /^using OCS API version "([^"]*)"$/ + * + * @param string $version + * + * @return void + */ + public function usingOcsApiVersion(string $version): void { + $this->ocsApiVersion = (int)$version; + } + + /** + * @Given /^as user "([^"]*)"$/ + * + * @param string $user + * + * @return void + */ + public function asUser(string $user): void { + $this->currentUser = $this->getActualUsername($user); + } + + /** + * @Given as the administrator + * + * @return void + */ + public function asTheAdministrator(): void { + $this->currentUser = $this->getAdminUsername(); + } + + /** + * @return string + */ + public function getCurrentUser(): string { + return $this->currentUser; + } + + /** + * @param string $user + * + * @return void + */ + public function setCurrentUser(string $user): void { + $this->currentUser = $user; + } + + /** + * returns $this->response + * some steps use that private var to store the response for other steps + * + * @return ResponseInterface + */ + public function getResponse(): ?ResponseInterface { + return $this->response; + } + + /** + * let this class remember a response that was received elsewhere + * so that steps in this class can be used to examine the response + * + * @param ResponseInterface|null $response + * @param string $username of the user that received the response + * + * @return void + */ + public function setResponse( + ?ResponseInterface $response, + string $username = "" + ): void { + $this->response = $response; + //after a new response reset the response xml + $this->responseXml = []; + //after a new response reset the response xml object + $this->responseXmlObject = null; + // remember the user that received the response + $this->responseUser = $username; + } + + /** + * @return string + */ + public function getCurrentServer(): string { + return $this->currentServer; + } + + /** + * @Given /^using server "(LOCAL|REMOTE)"$/ + * + * @param string|null $server + * + * @return string Previous used server + */ + public function usingServer(?string $server): string { + $previousServer = $this->currentServer; + if ($server === 'LOCAL') { + $this->baseUrl = $this->localBaseUrl; + $this->currentServer = 'LOCAL'; + } else { + $this->baseUrl = $this->remoteBaseUrl; + $this->currentServer = 'REMOTE'; + } + return $previousServer; + } + + /** + * + * @return boolean + */ + public function federatedServerExists(): bool { + return $this->federatedServerExists; + } + + /** + * disable CSRF + * + * @return string the previous setting of csrf.disabled + * @throws Exception + */ + public function disableCSRF(): string { + return $this->setCSRFDotDisabled('true'); + } + + /** + * enable CSRF + * + * @return string the previous setting of csrf.disabled + * @throws Exception + */ + public function enableCSRF(): string { + return $this->setCSRFDotDisabled('false'); + } + + /** + * set csrf.disabled + * + * @param string $setting "true", "false" or "" to delete the setting + * + * @return string the previous setting of csrf.disabled + * @throws Exception + */ + public function setCSRFDotDisabled(string $setting): string { + $oldCSRFSetting = SetupHelper::getSystemConfigValue( + 'csrf.disabled', + $this->getStepLineRef() + ); + + if ($setting === "") { + SetupHelper::deleteSystemConfig( + 'csrf.disabled', + $this->getStepLineRef() + ); + } elseif (($setting === 'true') || ($setting === 'false')) { + SetupHelper::setSystemConfig( + 'csrf.disabled', + $setting, + $this->getStepLineRef(), + 'boolean' + ); + } else { + throw new \http\Exception\InvalidArgumentException( + 'setting must be "true", "false" or ""' + ); + } + return \trim($oldCSRFSetting); + } + + /** + * Parses the response as XML + * + * @param ResponseInterface|null $response + * @param string|null $exceptionText text to put at the front of exception messages + * + * @return SimpleXMLElement + * @throws Exception + */ + public function getResponseXml(?ResponseInterface $response = null, ?string $exceptionText = ''): SimpleXMLElement { + if ($response === null) { + $response = $this->response; + } + + if ($exceptionText === '') { + $exceptionText = __METHOD__; + } + return HttpRequestHelper::getResponseXml($response, $exceptionText); + } + + /** + * Parses the xml answer to get the requested key and sub-key + * + * @param ResponseInterface $response + * @param string $key1 + * @param string $key2 + * + * @return string + * @throws Exception + */ + public function getXMLKey1Key2Value(ResponseInterface $response, string $key1, string $key2): string { + return (string)$this->getResponseXml($response, __METHOD__)->$key1->$key2; + } + + /** + * Parses the xml answer to get the requested key sequence + * + * @param ResponseInterface $response + * @param string $key1 + * @param string $key2 + * @param string $key3 + * + * @return string + * @throws Exception + */ + public function getXMLKey1Key2Key3Value( + ResponseInterface $response, + string $key1, + string $key2, + string $key3 + ): string { + return (string)$this->getResponseXml($response, __METHOD__)->$key1->$key2->$key3; + } + + /** + * Parses the xml answer to get the requested attribute value + * + * @param ResponseInterface $response + * @param string $key1 + * @param string $key2 + * @param string $key3 + * @param string $attribute + * + * @return string + * @throws Exception + */ + public function getXMLKey1Key2Key3AttributeValue( + ResponseInterface $response, + string $key1, + string $key2, + string $key3, + string $attribute + ): string { + return (string)$this->getResponseXml($response, __METHOD__)->$key1->$key2->$key3->attributes()->$attribute; + } + + /** + * This function is needed to use a vertical fashion in the gherkin tables. + * + * @param array $arrayOfArrays + * + * @return array + */ + public function simplifyArray(array $arrayOfArrays): array { + $a = \array_map( + function ($subArray) { + return $subArray[0]; + }, + $arrayOfArrays + ); + return $a; + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to URL "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * + * @return void + */ + public function userSendsHTTPMethodToUrl(string $user, string $verb, string $url): void { + $user = $this->getActualUsername($user); + $this->sendingToWithDirectUrl($user, $verb, $url, null); + } + + /** + * @Given /^user "([^"]*)" has sent HTTP method "([^"]*)" to URL "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * + * @return void + */ + public function userHasSentHTTPMethodToUrl(string $user, string $verb, string $url): void { + $this->userSendsHTTPMethodToUrl($user, $verb, $url); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to URL "([^"]*)" with password "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param string $password + * + * @return void + */ + public function userSendsHTTPMethodToUrlWithPassword(string $user, string $verb, string $url, string $password): void { + $this->sendingToWithDirectUrl($user, $verb, $url, null, $password); + } + + /** + * @Given /^user "([^"]*)" has sent HTTP method "([^"]*)" to URL "([^"]*)" with password "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param string $password + * + * @return void + */ + public function userHasSentHTTPMethodToUrlWithPassword(string $user, string $verb, string $url, string $password): void { + $this->userSendsHTTPMethodToUrlWithPassword($user, $verb, $url, $password); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string|null $user + * @param string|null $verb + * @param string|null $url + * @param TableNode|null $body + * @param string|null $password + * + * @return void + * @throws GuzzleException + */ + public function sendingToWithDirectUrl(?string $user, ?string $verb, ?string $url, ?TableNode $body, ?string $password = null): void { + $fullUrl = $this->getBaseUrl() . $url; + + if ($password === null) { + $password = $this->getPasswordForUser($user); + } + + $headers = $this->guzzleClientHeaders; + + $config = null; + if ($this->sourceIpAddress !== null) { + $config = [ + 'curl' => [ + CURLOPT_INTERFACE => $this->sourceIpAddress + ] + ]; + } + + $cookies = null; + if (!empty($this->cookieJar->toArray())) { + $cookies = $this->cookieJar; + } + + $bodyRows = null; + if ($body instanceof TableNode) { + $bodyRows = $body->getRowsHash(); + } + + if (isset($this->requestToken)) { + $headers['requesttoken'] = $this->requestToken; + } + + $this->response = HttpRequestHelper::sendRequest( + $fullUrl, + $this->getStepLineRef(), + $verb, + $user, + $password, + $headers, + $bodyRows, + $config, + $cookies + ); + } + + /** + * @param string $url + * + * @return bool + */ + public function isAPublicLinkUrl(string $url): bool { + if (OcisHelper::isTestingOnReva()) { + $urlEnding = \ltrim($url, '/'); + } else { + if (\substr($url, 0, 4) !== "http") { + return false; + } + $urlEnding = \substr($url, \strlen($this->getBaseUrl() . '/')); + } + + if (OcisHelper::isTestingOnOcisOrReva()) { + $matchResult = \preg_match("%^(#/)?s/([a-zA-Z0-9]{15})$%", $urlEnding); + } else { + $matchResult = \preg_match("%^(index.php/)?s/([a-zA-Z0-9]{15})$%", $urlEnding); + } + + // preg_match returns (int) 1 for a match, we want to return a boolean. + if ($matchResult === 1) { + $isPublicLinkUrl = true; + } else { + $isPublicLinkUrl = false; + } + return $isPublicLinkUrl; + } + + /** + * Check that the status code in the saved response is the expected status + * code, or one of the expected status codes. + * + * @param int|int[]|string|string[] $expectedStatusCode + * @param string|null $message + * + * @return void + */ + public function theHTTPStatusCodeShouldBe($expectedStatusCode, ?string $message = ""): void { + $actualStatusCode = $this->response->getStatusCode(); + if (\is_array($expectedStatusCode)) { + if ($message === "") { + $message = "HTTP status code $actualStatusCode is not one of the expected values " . \implode(" or ", $expectedStatusCode); + } + + Assert::assertContainsEquals( + $actualStatusCode, + $expectedStatusCode, + $message + ); + } else { + if ($message === "") { + $message = "HTTP status code $actualStatusCode is not the expected value $expectedStatusCode"; + } + + Assert::assertEquals( + $expectedStatusCode, + $actualStatusCode, + $message + ); + } + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @Then /^the HTTP status code should be "([^"]*)"$/ + * + * @param int|string $statusCode + * + * @return void + */ + public function thenTheHTTPStatusCodeShouldBe($statusCode): void { + $this->theHTTPStatusCodeShouldBe($statusCode); + } + + /** + * @Then /^the HTTP status code should be "([^"]*)" or "([^"]*)"$/ + * + * @param int|string $statusCode1 + * @param int|string $statusCode2 + * + * @return void + */ + public function theHTTPStatusCodeShouldBeOr($statusCode1, $statusCode2): void { + $this->theHTTPStatusCodeShouldBe( + [$statusCode1, $statusCode2] + ); + } + + /** + * @Then /^the HTTP status code should be between "(\d+)" and "(\d+)"$/ + * + * @param int|string $minStatusCode + * @param int|string $maxStatusCode + * + * @return void + */ + public function theHTTPStatusCodeShouldBeBetween( + $minStatusCode, + $maxStatusCode + ): void { + $statusCode = $this->response->getStatusCode(); + $message = "The HTTP status code $statusCode is not between $minStatusCode and $maxStatusCode"; + Assert::assertGreaterThanOrEqual( + $minStatusCode, + $statusCode, + $message + ); + Assert::assertLessThanOrEqual( + $maxStatusCode, + $statusCode, + $message + ); + } + + /** + * @Then the HTTP status code should be success + * + * @return void + */ + public function theHTTPStatusCodeShouldBeSuccess(): void { + $this->theHTTPStatusCodeShouldBeBetween(200, 299); + } + + /** + * @Then the HTTP status code should be failure + * + * @return void + */ + public function theHTTPStatusCodeShouldBeFailure(): void { + $statusCode = $this->response->getStatusCode(); + $message = "The HTTP status code $statusCode is not greater than or equals to 400"; + Assert::assertGreaterThanOrEqual( + 400, + $statusCode, + $message + ); + } + + /** + * + * @return bool + */ + public function theHTTPStatusCodeWasSuccess(): bool { + $statusCode = $this->response->getStatusCode(); + return (($statusCode >= 200) && ($statusCode <= 299)); + } + + /** + * Check the text in an HTTP responseXml message + * + * @Then /^the HTTP response message should be "([^"]*)"$/ + * + * @param string $expectedMessage + * + * @return void + * @throws Exception + */ + public function theHttpResponseMessageShouldBe(string $expectedMessage): void { + $actualMessage = $this->responseXml['value'][1]['value']; + Assert::assertEquals( + $expectedMessage, + $actualMessage, + "Expected $expectedMessage HTTP response message but got $actualMessage" + ); + } + + /** + * Check the text in an HTTP reason phrase + * + * @Then /^the HTTP reason phrase should be "([^"]*)"$/ + * + * @param string $reasonPhrase + * + * @return void + */ + public function theHTTPReasonPhraseShouldBe(string $reasonPhrase): void { + Assert::assertEquals( + $reasonPhrase, + $this->getResponse()->getReasonPhrase(), + 'Unexpected HTTP reason phrase in response' + ); + } + + /** + * Check the text in an HTTP reason phrase + * Use this step form if the expected text contains double quotes, + * single quotes and other content that theHTTPReasonPhraseShouldBe() + * cannot handle. + * + * After the step, write the expected text in PyString form like: + * + * """ + * File "abc.txt" can't be shared due to reason "xyz" + * """ + * + * @Then /^the HTTP reason phrase should be:$/ + * + * @param PyStringNode $reasonPhrase + * + * @return void + */ + public function theHTTPReasonPhraseShouldBePyString( + PyStringNode $reasonPhrase + ): void { + Assert::assertEquals( + $reasonPhrase->getRaw(), + $this->getResponse()->getReasonPhrase(), + 'Unexpected HTTP reason phrase in response' + ); + } + + /** + * @Then /^the XML "([^"]*)" "([^"]*)" value should be "([^"]*)"$/ + * + * @param string $key1 + * @param string $key2 + * @param string $idText + * + * @return void + * @throws Exception + */ + public function theXMLKey1Key2ValueShouldBe(string $key1, string $key2, string $idText): void { + $actualValue = $this->getXMLKey1Key2Value($this->response, $key1, $key2); + Assert::assertEquals( + $idText, + $actualValue, + "Expected $idText but got " + . $actualValue + ); + } + + /** + * @Then /^the XML "([^"]*)" "([^"]*)" "([^"]*)" value should be "([^"]*)"$/ + * + * @param string $key1 + * @param string $key2 + * @param string $key3 + * @param string $idText + * + * @return void + * @throws Exception + */ + public function theXMLKey1Key2Key3ValueShouldBe( + string $key1, + string $key2, + string $key3, + string $idText + ) { + $actualValue = $this->getXMLKey1Key2Key3Value($this->response, $key1, $key2, $key3); + Assert::assertEquals( + $idText, + $actualValue, + "Expected $idText but got " + . $actualValue + ); + } + + /** + * @Then /^the XML "([^"]*)" "([^"]*)" "([^"]*)" "([^"]*)" attribute value should be a valid version string$/ + * + * @param string $key1 + * @param string $key2 + * @param string $key3 + * @param string $attribute + * + * @return void + * @throws Exception + */ + public function theXMLKey1Key2AttributeValueShouldBe( + string $key1, + string $key2, + string $key3, + string $attribute + ): void { + $value = $this->getXMLKey1Key2Key3AttributeValue( + $this->response, + $key1, + $key2, + $key3, + $attribute + ); + Assert::assertTrue( + \version_compare($value, '0.0.1') >= 0, + "attribute $attribute value $value is not a valid version string" + ); + } + + /** + * @param ResponseInterface $response + * + * @return void + */ + public function extractRequestTokenFromResponse(ResponseInterface $response): void { + $this->requestToken = \substr( + \preg_replace( + '/(.*)data-requesttoken="(.*)">(.*)/sm', + '\2', + $response->getBody()->getContents() + ), + 0, + 89 + ); + } + + /** + * @Given /^user "([^"]*)" has logged in to a web-style session$/ + * + * @param string $user + * + * @return void + * @throws GuzzleException + * @throws JsonException + */ + public function userHasLoggedInToAWebStyleSessionUsingTheAPI(string $user): void { + $user = $this->getActualUsername($user); + $loginUrl = $this->getBaseUrl() . '/login'; + // Request a new session and extract CSRF token + + $config = null; + if ($this->sourceIpAddress !== null) { + $config = [ + 'curl' => [ + CURLOPT_INTERFACE => $this->sourceIpAddress + ] + ]; + } + + $this->response = HttpRequestHelper::get( + $loginUrl, + $this->getStepLineRef(), + null, + null, + $this->guzzleClientHeaders, + null, + $config, + $this->cookieJar + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + $this->extractRequestTokenFromResponse($this->response); + + // Login and extract new token + $password = $this->getPasswordForUser($user); + $body = [ + 'user' => $user, + 'password' => $password, + 'requesttoken' => $this->requestToken + ]; + $this->response = HttpRequestHelper::post( + $loginUrl, + $this->getStepLineRef(), + null, + null, + $this->guzzleClientHeaders, + $body, + $config, + $this->cookieJar + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + $this->extractRequestTokenFromResponse($this->response); + } + + /** + * @When the client sends a :method to :url of user :user with requesttoken + * + * @param string $method + * @param string $url + * @param string $user + * + * @return void + * @throws GuzzleException + * @throws JsonException + */ + public function sendingAToWithRequesttoken( + string $method, + string $url, + string $user + ): void { + $headers = $this->guzzleClientHeaders; + + $config = null; + if ($this->sourceIpAddress !== null) { + $config = [ + 'curl' => [ + CURLOPT_INTERFACE => $this->sourceIpAddress + ] + ]; + } + + $headers['requesttoken'] = $this->requestToken; + + $user = \strtolower($this->getActualUsername($user)); + $url = $this->getBaseUrl() . $url; + $url = $this->substituteInLineCodes($url, $user); + $this->response = HttpRequestHelper::sendRequest( + $url, + $this->getStepLineRef(), + $method, + null, + null, + $headers, + null, + $config, + $this->cookieJar + ); + } + + /** + * @Given the client has sent a :method to :url of user :user with requesttoken + * + * @param string $method + * @param string $url + * @param string $user + * + * @return void + */ + public function theClientHasSentAToWithRequesttoken( + string $method, + string $url, + string $user + ): void { + $this->sendingAToWithRequesttoken($method, $url, $user); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the client sends a :method to :url of user :user without requesttoken + * + * @param string $method + * @param string $url + * @param string|null $user + * + * @return void + * @throws JsonException + */ + public function sendingAToWithoutRequesttoken(string $method, string $url, ?string $user = null): void { + $config = null; + if ($this->sourceIpAddress !== null) { + $config = [ + 'curl' => [ + CURLOPT_INTERFACE => $this->sourceIpAddress + ] + ]; + } + + $user = \strtolower($this->getActualUsername($user)); + $url = $this->getBaseUrl() . $url; + $url = $this->substituteInLineCodes($url, $user); + $this->response = HttpRequestHelper::sendRequest( + $url, + $this->getStepLineRef(), + $method, + null, + null, + $this->guzzleClientHeaders, + null, + $config, + $this->cookieJar + ); + } + + /** + * @Given the client has sent a :method to :url without requesttoken + * + * @param string $method + * @param string $url + * + * @return void + */ + public function theClientHasSentAToWithoutRequesttoken(string $method, string $url): void { + $this->sendingAToWithoutRequesttoken($method, $url); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $path + * @param string $filename + * + * @return void + */ + public static function removeFile(string $path, string $filename): void { + if (\file_exists("$path$filename")) { + \unlink("$path$filename"); + } + } + + /** + * Creates a file locally in the file system of the test runner + * The file will be available to upload to the server + * + * @param string $name + * @param string $size + * @param string $endData + * + * @return void + */ + public function createLocalFileOfSpecificSize(string $name, string $size, string $endData = 'a'): void { + $folder = $this->workStorageDirLocation(); + if (!\is_dir($folder)) { + \mkDir($folder); + } + $file = \fopen($folder . $name, 'w'); + \fseek($file, $size - \strlen($endData), SEEK_CUR); + \fwrite($file, $endData); // write the end data to force the file size + \fclose($file); + } + + /** + * Make a directory under the server root on the ownCloud server + * + * @param string $dirPathFromServerRoot e.g. 'apps2/myapp/appinfo' + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function mkDirOnServer(string $dirPathFromServerRoot): void { + SetupHelper::mkDirOnServer( + $dirPathFromServerRoot, + $this->getStepLineRef(), + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + } + + /** + * @param string $filePathFromServerRoot + * @param string $content + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function createFileOnServerWithContent( + string $filePathFromServerRoot, + string $content + ): void { + SetupHelper::createFileOnServer( + $filePathFromServerRoot, + $content, + $this->getStepLineRef(), + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + } + + /** + * @Given file :filename with text :text has been created in local storage on the server + * + * @param string $filename + * @param string $text + * + * @return void + * @throws Exception + */ + public function fileHasBeenCreatedInLocalStorageWithText(string $filename, string $text): void { + $this->createFileOnServerWithContent( + LOCAL_STORAGE_DIR_ON_REMOTE_SERVER . "/$filename", + $text + ); + } + + /** + * @Given file :filename has been deleted from local storage on the server + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function fileHasBeenDeletedInLocalStorage(string $filename): void { + SetupHelper::deleteFileOnServer( + LOCAL_STORAGE_DIR_ON_REMOTE_SERVER . "/$filename", + $this->getStepLineRef(), + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + } + + /** + * @param string $user + * + * @return boolean + */ + public function isAdminUsername(string $user): bool { + return ($user === $this->getAdminUsername()); + } + + /** + * @return string + */ + public function getAdminUsername(): string { + return $this->adminUsername; + } + + /** + * @return string + */ + public function getAdminPassword(): string { + return $this->adminPassword; + } + + /** + * @param string $password + * + * @return void + */ + public function rememberNewAdminPassword(string $password): void { + $this->adminPassword = $password; + } + + /** + * @param string|null $userName + * + * @return string + */ + public function getPasswordForUser(?string $userName): string { + $userNameNormalized = $this->normalizeUsername($userName); + $username = $this->getActualUsername($userNameNormalized); + if ($username === $this->getAdminUsername()) { + return $this->getAdminPassword(); + } elseif (\array_key_exists($username, $this->createdUsers)) { + return (string)$this->createdUsers[$username]['password']; + } elseif (\array_key_exists($username, $this->createdRemoteUsers)) { + return (string)$this->createdRemoteUsers[$username]['password']; + } + + // The user has not been created yet, see if there is a replacement + // defined for the user. + $usernameReplacements = $this->usersToBeReplaced(); + if (isset($usernameReplacements)) { + if (isset($usernameReplacements[$userNameNormalized])) { + return $usernameReplacements[$userNameNormalized]['password']; + } + } + + // Fall back to the default password used for the well-known users. + if ($username === 'regularuser') { + return $this->regularUserPassword; + } elseif ($username === 'alice') { + return $this->regularUserPassword; + } elseif ($username === 'brian') { + return $this->alt1UserPassword; + } elseif ($username === 'carol') { + return $this->alt2UserPassword; + } elseif ($username === 'david') { + return $this->alt3UserPassword; + } elseif ($username === 'emily') { + return $this->alt4UserPassword; + } elseif ($username === 'usergrp') { + return $this->regularUserPassword; + } elseif ($username === 'sharee1') { + return $this->regularUserPassword; + } + + // The user has not been created yet and is not one of the pre-known + // users. So let the caller have the default password. + return (string)$this->getActualPassword($this->regularUserPassword); + } + + /** + * Get the display name of the user. + * + * For users that have already been created, return their display name. + * For special known usernames, return the display name that is also used by LDAP tests. + * For other users, return null. They will not be assigned any particular + * display name by this function. + * + * @param string $userName + * + * @return string|null + */ + public function getDisplayNameForUser(string $userName): ?string { + $userNameNormalized = $this->normalizeUsername($userName); + $username = $this->getActualUsername($userNameNormalized); + if (\array_key_exists($username, $this->createdUsers)) { + if (isset($this->createdUsers[$username]['displayname'])) { + return (string)$this->createdUsers[$username]['displayname']; + } + return $userName; + } + if (\array_key_exists($username, $this->createdRemoteUsers)) { + if (isset($this->createdRemoteUsers[$username]['displayname'])) { + return (string)$this->createdRemoteUsers[$username]['displayname']; + } + return $userName; + } + + // The user has not been created yet, see if there is a replacement + // defined for the user. + $usernameReplacements = $this->usersToBeReplaced(); + if (isset($usernameReplacements)) { + if (isset($usernameReplacements[$userNameNormalized])) { + return $usernameReplacements[$userNameNormalized]['displayname']; + } elseif (isset($usernameReplacements[$userName])) { + return $usernameReplacements[$userName]['displayname']; + } + } + + // Fall back to the default display name used for the well-known users. + if ($username === 'regularuser') { + return 'Regular User'; + } elseif ($username === 'alice') { + return 'Alice Hansen'; + } elseif ($username === 'brian') { + return 'Brian Murphy'; + } elseif ($username === 'carol') { + return 'Carol King'; + } elseif ($username === 'david') { + return 'David Lopez'; + } elseif ($username === 'emily') { + return 'Emily Wagner'; + } elseif ($username === 'usergrp') { + return 'User Grp'; + } elseif ($username === 'sharee1') { + return 'Sharee One'; + } elseif ($username === 'sharee2') { + return 'Sharee Two'; + } elseif (\in_array($username, ["grp1", "***redacted***"])) { + return $username; + } + return null; + } + + /** + * Get the email address of the user. + * + * For users that have already been created, return their email address. + * For special known usernames, return the email address that is also used by LDAP tests. + * For other users, return null. They will not be assigned any particular + * email address by this function. + * + * @param string $userName + * + * @return string|null + */ + public function getEmailAddressForUser(string $userName): ?string { + $userNameNormalized = $this->normalizeUsername($userName); + $username = $this->getActualUsername($userNameNormalized); + if (\array_key_exists($username, $this->createdUsers)) { + return (string)$this->createdUsers[$username]['email']; + } + if (\array_key_exists($username, $this->createdRemoteUsers)) { + return (string)$this->createdRemoteUsers[$username]['email']; + } + + // The user has not been created yet, see if there is a replacement + // defined for the user. + $usernameReplacements = $this->usersToBeReplaced(); + if (isset($usernameReplacements)) { + if (isset($usernameReplacements[$userNameNormalized])) { + return $usernameReplacements[$userNameNormalized]['email']; + } elseif (isset($usernameReplacements[$userName])) { + return $usernameReplacements[$userName]['email']; + } + } + + // Fall back to the default display name used for the well-known users. + if ($username === 'regularuser') { + return 'regularuser@example.org'; + } elseif ($username === 'alice') { + return 'alice@example.org'; + } elseif ($username === 'brian') { + return 'brian@example.org'; + } elseif ($username === 'carol') { + return 'carol@example.org'; + } elseif ($username === 'david') { + return 'david@example.org'; + } elseif ($username === 'emily') { + return 'emily@example.org'; + } elseif ($username === 'usergrp') { + return 'usergrp@example.org'; + } elseif ($username === 'sharee1') { + return 'sharee1@example.org'; + } else { + return null; + } + } + + // TODO do similar for other usernames for e.g. %regularuser% or %test-user-1% + + /** + * @param string|null $functionalUsername + * + * @return string|null + * @throws JsonException + */ + public function getActualUsername(?string $functionalUsername): ?string { + if ($functionalUsername === null) { + return null; + } + $usernames = $this->usersToBeReplaced(); + if (isset($usernames)) { + if (isset($usernames[$functionalUsername])) { + return $usernames[$functionalUsername]['username']; + } + $normalizedUsername = $this->normalizeUsername($functionalUsername); + if (isset($usernames[$normalizedUsername])) { + return $usernames[$normalizedUsername]['username']; + } + } + if ($functionalUsername === "%admin%") { + return $this->getAdminUsername(); + } + return $functionalUsername; + } + + /** + * @param string|null $functionalPassword + * + * @return string|null + */ + public function getActualPassword(?string $functionalPassword): ?string { + if ($functionalPassword === "%regular%") { + return $this->regularUserPassword; + } elseif ($functionalPassword === "%alt1%") { + return $this->alt1UserPassword; + } elseif ($functionalPassword === "%alt2%") { + return $this->alt2UserPassword; + } elseif ($functionalPassword === "%alt3%") { + return $this->alt3UserPassword; + } elseif ($functionalPassword === "%alt4%") { + return $this->alt4UserPassword; + } elseif ($functionalPassword === "%subadmin%") { + return $this->subAdminPassword; + } elseif ($functionalPassword === "%admin%") { + return $this->getAdminPassword(); + } elseif ($functionalPassword === "%altadmin%") { + return $this->alternateAdminPassword; + } elseif ($functionalPassword === "%public%") { + return $this->publicLinkSharePassword; + } elseif ($functionalPassword === "%remove%") { + return ""; + } else { + return $functionalPassword; + } + } + + /** + * @param string $userName + * + * @return array + */ + public function getAuthOptionForUser(string $userName): array { + return [$userName, $this->getPasswordForUser($userName)]; + } + + /** + * @return array + */ + public function getAuthOptionForAdmin(): array { + return $this->getAuthOptionForUser($this->getAdminUsername()); + } + + /** + * @When the administrator requests status.php + * + * @return void + */ + public function theAdministratorRequestsStatusPhp(): void { + $this->response = $this->getStatusPhp(); + } + + /** + * @When the administrator creates file :path with content :content in local storage using the testing API + * + * @param string $path + * @param string $content + * + * @return void + */ + public function theAdministratorCreatesFileUsingTheTestingApi(string $path, string $content): void { + $this->theAdministratorCreatesFileWithContentInLocalStorageUsingTheTestingApi( + $path, + $content, + 'local_storage' + ); + } + + /** + * @Given the administrator has created file :path with content :content in local storage using the testing API + * + * @param string $path + * @param string $content + * + * @return void + */ + public function theAdministratorHasCreatedFileUsingTheTestingApi(string $path, string $content): void { + $this->theAdministratorHasCreatedFileWithContentInLocalStorageUsingTheTestingApi( + $path, + $content, + 'local_storage' + ); + } + + /** + * @When the administrator creates file :path with content :content in local storage :mountPoint using the testing API + * + * @param string $path + * @param string $content + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdministratorCreatesFileWithContentInLocalStorageUsingTheTestingApi( + string $path, + string $content, + string $mountPoint + ): void { + $response = $this->copyContentToFileInTemporaryStorageOnSystemUnderTest( + "$mountPoint/$path", + $content + ); + $this->setResponse($response); + } + + /** + * @Given the administrator has created a file :path in temporary storage with the last exported content using the testing API + * + * @param string $path + * + * @return void + * @throws Exception + */ + public function theAdministratorHasCreatedAFileInTemporaryStorageWithLastExportedContent( + string $path + ): void { + $commandOutput = $this->getStdOutOfOccCommand(); + $this->copyContentToFileInTemporaryStorageOnSystemUnderTest($path, $commandOutput); + $this->theFileWithContentShouldExistInTheServerRoot( + TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/$path", + $commandOutput + ); + } + + /** + * @Given the administrator has created file :path with content :content in local storage :mountPoint + * + * @param string $path + * @param string $content + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdministratorHasCreatedFileWithContentInLocalStorageUsingTheTestingApi( + string $path, + string $content, + string $mountPoint + ): void { + $this->theAdministratorCreatesFileWithContentInLocalStorageUsingTheTestingApi( + $path, + $content, + $mountPoint + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * Copy a file from the test-runner to the temporary storage directory on + * the system-under-test. This uses the testing app to push the file into + * the backend of the server, where it can be seen by occ commands done in + * the server-under-test. + * + * @Given the administrator has copied file :localPath to :destination in temporary storage on the system under test + * + * @param string $localPath relative to the core "root" folder + * @param string $destination + * + * @return void + * @throws Exception + */ + public function theAdministratorHasCopiedFileToTemporaryStorageOnTheSystemUnderTest( + string $localPath, + string $destination + ): void { + // FeatureContext is in tests/acceptance/features/bootstrap so go up 4 + // levels to the test-runner root + $testRunnerRoot = \dirname(__DIR__, 4); + // The local path is specified down from the root - e.g. tests/data/file.txt + $content = \file_get_contents("$testRunnerRoot/$localPath"); + Assert::assertNotFalse( + $content, + "Local file $localPath cannot be read" + ); + $this->copyContentToFileInTemporaryStorageOnSystemUnderTest($destination, $content); + $this->theFileWithContentShouldExistInTheServerRoot(TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/$destination", $content); + } + + /** + * @param string $destination + * @param string $content + * + * @return ResponseInterface + * @throws Exception + */ + public function copyContentToFileInTemporaryStorageOnSystemUnderTest( + string $destination, + string $content + ): ResponseInterface { + $this->mkDirOnServer(TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER); + + return OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + 'POST', + "/apps/testing/api/v1/file", + $this->getStepLineRef(), + [ + 'file' => TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/$destination", + 'content' => $content + ], + $this->getOcsApiVersion() + ); + } + + /** + * @When the administrator deletes file :path in local storage using the testing API + * + * @param string $path + * + * @return void + */ + public function theAdministratorDeletesFileInLocalStorageUsingTheTestingApi(string $path): void { + $user = $this->getAdminUsername(); + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getAdminPassword(), + 'DELETE', + "/apps/testing/api/v1/file", + $this->getStepLineRef(), + ['file' => LOCAL_STORAGE_DIR_ON_REMOTE_SERVER . "/$path"], + $this->getOcsApiVersion() + ); + $this->setResponse($response); + } + + /** + * @Given a file with the size of :size bytes and the name :name has been created locally + * + * @param int $size if not int given it will be cast to int + * @param string $name + * + * @return void + * @throws InvalidArgumentException + */ + public function aFileWithSizeAndNameHasBeenCreatedLocally(int $size, string $name): void { + $fullPath = UploadHelper::getUploadFilesDir($name); + if (\file_exists($fullPath)) { + throw new InvalidArgumentException( + __METHOD__ . " could not create '$fullPath' file exists" + ); + } + UploadHelper::createFileSpecificSize($fullPath, $size); + $this->createdFiles[] = $fullPath; + } + + /** + * + * @return ResponseInterface + */ + public function getStatusPhp(): ResponseInterface { + $fullUrl = $this->getBaseUrl() . "/status.php"; + + $config = null; + if ($this->sourceIpAddress !== null) { + $config = [ + 'curl' => [ + CURLOPT_INTERFACE => $this->sourceIpAddress + ] + ]; + } + + return HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->guzzleClientHeaders, + null, + $config + ); + } + + /** + * @Then the json responded should match with + * + * @param PyStringNode $jsonExpected + * + * @return void + */ + public function jsonRespondedShouldMatch(PyStringNode $jsonExpected): void { + $jsonExpectedEncoded = \json_encode($jsonExpected->getRaw()); + $jsonRespondedEncoded = \json_encode((string)$this->response->getBody()); + Assert::assertEquals( + $jsonExpectedEncoded, + $jsonRespondedEncoded, + "The json responded: $jsonRespondedEncoded does not match with json expected: $jsonExpectedEncoded" + ); + } + + /** + * @Then the status.php response should include + * + * @param PyStringNode $jsonExpected + * + * @return void + * @throws Exception + */ + public function statusPhpRespondedShouldMatch(PyStringNode $jsonExpected): void { + $jsonExpectedDecoded = \json_decode($jsonExpected->getRaw(), true); + $jsonRespondedDecoded = $this->getJsonDecodedResponse(); + + $this->appConfigurationContext->theAdministratorGetsCapabilitiesCheckResponse(); + $edition = $this->appConfigurationContext->getParameterValueFromXml( + $this->appConfigurationContext->getCapabilitiesXml(__METHOD__), + 'core', + 'status@@@edition' + ); + + if (!\strlen($edition)) { + Assert::fail( + "Cannot get edition from core capabilities" + ); + } + + $product = $this->appConfigurationContext->getParameterValueFromXml( + $this->appConfigurationContext->getCapabilitiesXml(__METHOD__), + 'core', + 'status@@@product' + ); + if (!\strlen($product)) { + Assert::fail( + "Cannot get product from core capabilities" + ); + } + + $productName = $this->appConfigurationContext->getParameterValueFromXml( + $this->appConfigurationContext->getCapabilitiesXml(__METHOD__), + 'core', + 'status@@@productname' + ); + + if (!\strlen($productName)) { + Assert::fail( + "Cannot get productname from core capabilities" + ); + } + + $jsonExpectedDecoded['edition'] = $edition; + $jsonExpectedDecoded['product'] = $product; + $jsonExpectedDecoded['productname'] = $productName; + + if (OcisHelper::isTestingOnOc10()) { + // On oC10 get the expected version values by parsing the output of "occ status" + $runOccStatus = $this->runOcc(['status']); + if ($runOccStatus === 0) { + $output = \explode("- ", $this->lastStdOut); + $version = \explode(": ", $output[3]); + Assert::assertEquals( + "version", + $version[0], + "Expected 'version' but got $version[0]" + ); + $versionString = \explode(": ", $output[4]); + Assert::assertEquals( + "versionstring", + $versionString[0], + "Expected 'versionstring' but got $versionString[0]" + ); + $jsonExpectedDecoded['version'] = \trim($version[1]); + $jsonExpectedDecoded['versionstring'] = \trim($versionString[1]); + } else { + Assert::fail( + "Cannot get version variables from occ - status $runOccStatus" + ); + } + } else { + // We are on oCIS or reva or some other implementation. We cannot do "occ status". + // So get the expected version values by looking in the capabilities response. + $version = $this->appConfigurationContext->getParameterValueFromXml( + $this->appConfigurationContext->getCapabilitiesXml(__METHOD__), + 'core', + 'status@@@version' + ); + + if (!\strlen($version)) { + Assert::fail( + "Cannot get version from core capabilities" + ); + } + + $versionString = $this->appConfigurationContext->getParameterValueFromXml( + $this->appConfigurationContext->getCapabilitiesXml(__METHOD__), + 'core', + 'status@@@versionstring' + ); + + if (!\strlen($versionString)) { + Assert::fail( + "Cannot get versionstring from core capabilities" + ); + } + + $jsonExpectedDecoded['version'] = $version; + $jsonExpectedDecoded['versionstring'] = $versionString; + } + $errorMessage = ""; + $errorFound = false; + foreach ($jsonExpectedDecoded as $key => $expectedValue) { + if (\array_key_exists($key, $jsonRespondedDecoded)) { + $actualValue = $jsonRespondedDecoded[$key]; + if ($actualValue !== $expectedValue) { + $errorMessage .= "$key expected value was $expectedValue but actual value was $actualValue\n"; + $errorFound = true; + } + } else { + $errorMessage .= "$key was not found in the status response\n"; + $errorFound = true; + } + } + Assert::assertFalse($errorFound, $errorMessage); + // We have checked that the status.php response has data that matches up with + // data found in the capabilities response and/or the "occ status" command output. + // But the output might be reported wrongly in all of these in the same way. + // So check that the values also seem "reasonable". + $version = $jsonExpectedDecoded['version']; + $versionString = $jsonExpectedDecoded['versionstring']; + Assert::assertMatchesRegularExpression( + "/^\d+\.\d+\.\d+\.\d+$/", + $version, + "version should be in a form like 10.9.8.1 but is $version" + ); + if (\preg_match("/^(\d+\.\d+\.\d+)\.\d+(-[0-9A-Za-z-]+)?(\+[0-9A-Za-z-]+)?$/", $version, $matches)) { + // We should have matched something like 10.9.8 - the first 3 numbers in the version. + // Ignore pre-releases and meta information + Assert::assertArrayHasKey( + 1, + $matches, + "version $version could not match the pattern Major.Minor.Patch" + ); + $majorMinorPatchVersion = $matches[1]; + } else { + Assert::fail("version '$version' does not start in a form like 10.9.8"); + } + Assert::assertStringStartsWith( + $majorMinorPatchVersion, + $versionString, + "versionstring should start with $majorMinorPatchVersion but is $versionString" + ); + } + + /** + * send request to read a server file for core + * + * @param string $path + * + * @return void + */ + public function readFileInServerRootForCore(string $path): void { + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + 'GET', + "/apps/testing/api/v1/file?file=$path", + $this->getStepLineRef() + ); + $this->setResponse($response); + } + + /** + * read a server file for ocis + * + * @param string $path + * + * @return string + * @throws Exception + */ + public function readFileInServerRootForOCIS(string $path): string { + $pathToOcis = \getenv("PATH_TO_OCIS"); + $targetFile = \rtrim($pathToOcis, "/") . "/" . "services/web/assets" . "/" . ltrim($path, '/'); + if (!\file_exists($targetFile)) { + throw new Exception('Target File ' . $targetFile . ' could not be found'); + } + return \file_get_contents($targetFile); + } + + /** + * send request to list a server file + * + * @param string $path + * + * @return void + */ + public function listTrashbinFileInServerRoot(string $path): void { + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + 'GET', + "/apps/testing/api/v1/dir?dir=$path", + $this->getStepLineRef() + ); + $this->setResponse($response); + } + + /** + * move file in server root + * + * @param string $path + * @param string $target + * + * @return void + */ + public function moveFileInServerRoot(string $path, string $target): void { + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + "MOVE", + "/apps/testing/api/v1/file", + $this->getStepLineRef(), + [ + 'source' => $path, + 'target' => $target + ] + ); + + $this->setResponse($response); + } + + /** + * @When the local storage mount for :mount is renamed to :target + * + * @param string $mount + * @param string $target + * + * @return void + */ + public function theLocalStorageMountForIsRenamedTo(string $mount, string $target): void { + $mountPath = TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/" . ltrim($mount, '/'); + $targetPath = TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/" . ltrim($target, '/'); + + $this->moveFileInServerRoot($mountPath, $targetPath); + } + + /** + * @Then the file :path with content :content should exist in the server root + * + * @param string $path + * @param string $content + * + * @return void + * @throws Exception + */ + public function theFileWithContentShouldExistInTheServerRoot(string $path, string $content): void { + if (OcisHelper::isTestingOnOcis()) { + $fileContent = $this->readFileInServerRootForOCIS($path); + } else { + $this->readFileInServerRootForCore($path); + $this->theHTTPStatusCodeShouldBe(200, 'Failed to read the file $path'); + $fileContent = $this->getResponseXml(); + $fileContent = (string)$fileContent->data->element->contentUrlEncoded; + $fileContent = \urldecode($fileContent); + } + Assert::assertSame( + $content, + $fileContent, + "The content of the file does not match with '$content'" + ); + } + + /** + * @Then /^the content in the response should match with the content of file "([^"]*)" in the server root$/ + * + * @param string $path + * + * @return void + * @throws Exception + */ + public function theContentInTheRespShouldMatchWithFileInTheServerRoot(string $path): void { + $content = $this->getResponse()->getBody()->getContents(); + $this->theFileWithContentShouldExistInTheServerRoot($path, $content); + } + + /** + * @Then the file :path should not exist in the server root + * + * @param string $path + * + * @return void + */ + public function theFileShouldNotExistInTheServerRoot(string $path): void { + $this->readFileInServerRootForCore($path); + Assert::assertSame( + 404, + $this->getResponse()->getStatusCode(), + "The file '$path' exists in the server root but was not expected to exist" + ); + } + + /** + * @Then the body of the response should be empty + * + * @return void + */ + public function theResponseBodyShouldBeEmpty(): void { + Assert::assertEmpty( + $this->getResponse()->getBody()->getContents(), + "The response body was expected to be empty but got " + . $this->getResponse()->getBody()->getContents() + ); + } + + /** + * @param ResponseInterface|null $response + * + * @return array + */ + public function getJsonDecodedResponse(?ResponseInterface $response = null): array { + if ($response === null) { + $response = $this->getResponse(); + } + return \json_decode( + (string)$response->getBody(), + true + ); + } + + /** + * + * @return array + */ + public function getJsonDecodedStatusPhp(): array { + return $this->getJsonDecodedResponse( + $this->getStatusPhp() + ); + } + + /** + * @return string + */ + public function getEditionFromStatus(): string { + $decodedResponse = $this->getJsonDecodedStatusPhp(); + if (isset($decodedResponse['edition'])) { + return $decodedResponse['edition']; + } + return ''; + } + + /** + * @return string|null + */ + public function getProductNameFromStatus(): ?string { + $decodedResponse = $this->getJsonDecodedStatusPhp(); + if (isset($decodedResponse['productname'])) { + return $decodedResponse['productname']; + } + return ''; + } + + /** + * @return string|null + */ + public function getVersionFromStatus(): ?string { + $decodedResponse = $this->getJsonDecodedStatusPhp(); + if (isset($decodedResponse['version'])) { + return $decodedResponse['version']; + } + return ''; + } + + /** + * @return string|null + */ + public function getVersionStringFromStatus(): ?string { + $decodedResponse = $this->getJsonDecodedStatusPhp(); + if (isset($decodedResponse['versionstring'])) { + return $decodedResponse['versionstring']; + } + return ''; + } + + /** + * returns a string that can be used to check a url of comments with + * regular expression (without delimiter) + * + * @return string + */ + public function getCommentUrlRegExp(): string { + $basePath = \ltrim($this->getBasePath() . "/", "/"); + return "/{$basePath}remote.php/dav/comments/files/([0-9]+)"; + } + + /** + * substitutes codes like %base_url% with the value + * if the given value does not have anything to be substituted + * then it is returned unmodified + * + * @param string|null $value + * @param string|null $user + * @param array|null $functions associative array of functions and parameters to be + * called on every replacement string before the + * replacement + * function name has to be the key and the parameters an + * own array + * the replacement itself will be used as first parameter + * e.g. substituteInLineCodes($value, ['preg_quote' => ['/']]) + * @param array|null $additionalSubstitutions + * array of additional substitution configurations + * [ + * [ + * "code" => "%my_code%", + * "function" => [ + * $myClass, + * "myFunction" + * ], + * "parameter" => [] + * ], + * ] + * + * @return string + */ + public function substituteInLineCodes( + ?string $value, + ?string $user = null, + ?array $functions = [], + ?array $additionalSubstitutions = [] + ): ?string { + $substitutions = [ + [ + "code" => "%base_url%", + "function" => [ + $this, + "getBaseUrl" + ], + "parameter" => [] + ], + [ + "code" => "%base_url_without_scheme%", + "function" => [ + $this, + "getBaseUrlWithoutScheme" + ], + "parameter" => [] + ], + [ + "code" => "%remote_server%", + "function" => [ + $this, + "getRemoteBaseUrl" + ], + "parameter" => [] + ], + [ + "code" => "%remote_server_without_scheme%", + "function" => [ + $this, + "getRemoteBaseUrlWithoutScheme" + ], + "parameter" => [] + ], + [ + "code" => "%local_server%", + "function" => [ + $this, + "getLocalBaseUrl" + ], + "parameter" => [] + ], + [ + "code" => "%local_server_without_scheme%", + "function" => [ + $this, + "getLocalBaseUrlWithoutScheme" + ], + "parameter" => [] + ], + [ + "code" => "%base_path%", + "function" => [ + $this, + "getBasePath" + ], + "parameter" => [] + ], + [ + "code" => "%dav_path%", + "function" => [ + $this, + "getDAVPathIncludingBasePath" + ], + "parameter" => [] + ], + [ + "code" => "%ocs_path_v1%", + "function" => [ + $this, + "getOCSPath" + ], + "parameter" => ["1"] + ], + [ + "code" => "%ocs_path_v2%", + "function" => [ + $this, + "getOCSPath" + ], + "parameter" => ["2"] + ], + [ + "code" => "%productname%", + "function" => [ + $this, + "getProductNameFromStatus" + ], + "parameter" => [] + ], + [ + "code" => "%edition%", + "function" => [ + $this, + "getEditionFromStatus" + ], + "parameter" => [] + ], + [ + "code" => "%version%", + "function" => [ + $this, + "getVersionFromStatus" + ], + "parameter" => [] + ], + [ + "code" => "%versionstring%", + "function" => [ + $this, + "getVersionStringFromStatus" + ], + "parameter" => [] + ], + [ + "code" => "%a_comment_url%", + "function" => [ + $this, + "getCommentUrlRegExp" + ], + "parameter" => [] + ], + [ + "code" => "%last_share_id%", + "function" => [ + $this, + "getLastShareId" + ], + "parameter" => [] + ], + [ + "code" => "%last_public_share_token%", + "function" => [ + $this, + "getLastPublicShareToken" + ], + "parameter" => [] + ] + ]; + if ($user !== null) { + array_push( + $substitutions, + [ + "code" => "%username%", + "function" => [ + $this, + "getActualUsername" + ], + "parameter" => [$user] + ], + [ + "code" => "%displayname%", + "function" => [ + $this, + "getDisplayNameForUser" + ], + "parameter" => [$user] + ], + [ + "code" => "%password%", + "function" => [ + $this, + "getPasswordForUser" + ], + "parameter" => [$user] + ], + [ + "code" => "%emailaddress%", + "function" => [ + $this, + "getEmailAddressForUser" + ], + "parameter" => [$user] + ], + [ + "code" => "%spaceid%", + "function" => [ + $this, + "getPersonalSpaceIdForUser", + ], + "parameter" => [$user, true] + ] + ); + } + + if (!empty($additionalSubstitutions)) { + $substitutions = \array_merge($substitutions, $additionalSubstitutions); + } + + foreach ($substitutions as $substitution) { + if (strpos($value, $substitution['code']) === false) { + continue; + } + + $replacement = \call_user_func_array( + $substitution["function"], + $substitution["parameter"] + ); + foreach ($functions as $function => $parameters) { + $replacement = \call_user_func_array( + $function, + \array_merge([$replacement], $parameters) + ); + } + $value = \str_replace( + $substitution["code"], + $replacement, + $value + ); + } + return $value; + } + + /** + * returns personal space id for user if the test is using the spaces dav path + * or if alwaysDoIt is set to true, + * otherwise it returns null. + * + * @param string $user + * @param bool $alwaysDoIt default false. Set to true + * + * @return string|null + * @throws GuzzleException + */ + public function getPersonalSpaceIdForUser(string $user, bool $alwaysDoIt = false): ?string { + if ($alwaysDoIt || ($this->getDavPathVersion() === WebDavHelper::DAV_VERSION_SPACES)) { + return WebDavHelper::getPersonalSpaceIdForUserOrFakeIfNotFound( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + $this->getStepLineRef() + ); + } + return null; + } + + /** + * @return string + */ + public function temporaryStorageSubfolderName(): string { + return "work_tmp"; + } + + /** + * @return string + */ + public function acceptanceTestsDirLocation(): string { + return \dirname(__FILE__) . "/../../"; + } + + /** + * @return string + */ + public function workStorageDirLocation(): string { + return $this->acceptanceTestsDirLocation() . $this->temporaryStorageSubfolderName() . "/"; + } + + /** + * Get the path of the ownCloud server root directory + * + * @return string + * @throws Exception + */ + public function getServerRoot(): string { + if ($this->localServerRoot === null) { + $this->localServerRoot = SetupHelper::getServerRoot( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef() + ); + } + return $this->localServerRoot; + } + + /** + * @Then the config key :key of app :appID should have value :value + * + * @param string $key + * @param string $appID + * @param string $value + * + * @return void + * @throws Exception + */ + public function theConfigKeyOfAppShouldHaveValue(string $key, string $appID, string $value): void { + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + 'GET', + "/apps/testing/api/v1/app/$appID/$key", + $this->getStepLineRef(), + [], + $this->getOcsApiVersion() + ); + $configkeyValue = (string)$this->getResponseXml($response, __METHOD__)->data[0]->element->value; + Assert::assertEquals( + $value, + $configkeyValue, + "The config key $key of app $appID was expected to have value $value but got $configkeyValue" + ); + } + + /** + * Parse list of config keys from the given XML response + * + * @param SimpleXMLElement $responseXml + * + * @return array + */ + public function parseConfigListFromResponseXml(SimpleXMLElement $responseXml): array { + $configkeyData = \json_decode(\json_encode($responseXml->data), true); + if (isset($configkeyData['element'])) { + $configkeyData = $configkeyData['element']; + } else { + // There are no keys for the app + return []; + } + if (isset($configkeyData[0])) { + $configkeyValues = $configkeyData; + } else { + // There is just 1 key for the app + $configkeyValues[0] = $configkeyData; + } + return $configkeyValues; + } + + /** + * Returns a list of config keys for the given app + * + * @param string $appID + * @param string $exceptionText text to put at the front of exception messages + * + * @return array + * @throws Exception + */ + public function getConfigKeyList(string $appID, string $exceptionText = ''): array { + if ($exceptionText === '') { + $exceptionText = __METHOD__; + } + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + 'GET', + "/apps/testing/api/v1/app/$appID", + $this->getStepLineRef(), + [], + $this->getOcsApiVersion() + ); + return $this->parseConfigListFromResponseXml( + $this->getResponseXml($response, $exceptionText) + ); + } + + /** + * Check if given config key is present for given app + * + * @param string $key + * @param string $appID + * + * @return bool + * @throws Exception + */ + public function checkConfigKeyInApp(string $key, string $appID): bool { + $configkeyList = $this->getConfigKeyList($appID); + foreach ($configkeyList as $config) { + if ($config['configkey'] === $key) { + return true; + } + } + return false; + } + + /** + * @Then /^app ((?:'[^']*')|(?:"[^"]*")) should (not|)\s?have config key ((?:'[^']*')|(?:"[^"]*"))$/ + * + * @param string $appID + * @param string $shouldOrNot + * @param string $key + * + * @return void + * @throws Exception + */ + public function appShouldHaveConfigKey(string $appID, string $shouldOrNot, string $key): void { + $appID = \trim($appID, $appID[0]); + $key = \trim($key, $key[0]); + + $should = ($shouldOrNot !== "not"); + + if ($should) { + Assert::assertTrue( + $this->checkConfigKeyInApp($key, $appID), + "App $appID does not have config key $key" + ); + } else { + Assert::assertFalse( + $this->checkConfigKeyInApp($key, $appID), + "App $appID has config key $key but was not expected to" + ); + } + } + + /** + * @Then /^following config keys should (not|)\s?exist$/ + * + * @param string $shouldOrNot + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function followingConfigKeysShouldExist(string $shouldOrNot, TableNode $table): void { + $should = ($shouldOrNot !== "not"); + if ($should) { + foreach ($table as $item) { + Assert::assertTrue( + $this->checkConfigKeyInApp($item['configkey'], $item['appid']), + "{$item['appid']} was expected to have config key {$item['configkey']} but does not" + ); + } + } else { + foreach ($table as $item) { + Assert::assertFalse( + $this->checkConfigKeyInApp($item['configkey'], $item['appid']), + "Expected : {$item['appid']} should not have config key {$item['configkey']}" + ); + } + } + } + + /** + * @param string $user + * @param string|null $asUser + * @param string|null $password + * + * @return void + */ + public function sendUserSyncRequest(string $user, ?string $asUser = null, ?string $password = null): void { + $user = $this->getActualUsername($user); + $asUser = $asUser ?? $this->getAdminUsername(); + $password = $password ?? $this->getPasswordForUser($asUser); + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $asUser, + $password, + 'POST', + "/cloud/user-sync/$user", + $this->getStepLineRef(), + [], + $this->getOcsApiVersion() + ); + $this->setResponse($response); + } + + /** + * @When the administrator tries to sync user :user using the OCS API + * + * @param string $user + * + * @return void + */ + public function theAdministratorTriesToSyncUserUsingTheOcsApi(string $user): void { + $this->sendUserSyncRequest($user); + } + + /** + * @When user :asUser tries to sync user :user using the OCS API + * + * @param string $asUser + * @param string $user + * + * @return void + */ + public function userTriesToSyncUserUsingTheOcsApi(string $asUser, string $user): void { + $asUser = $this->getActualUsername($asUser); + $user = $this->getActualUsername($user); + $this->sendUserSyncRequest($user, $asUser); + } + + /** + * @When the administrator tries to sync user :user using password :password and the OCS API + * + * @param string|null $user + * @param string|null $password + * + * @return void + */ + public function theAdministratorTriesToSyncUserUsingPasswordAndTheOcsApi(?string $user, ?string $password): void { + $this->sendUserSyncRequest($user, null, $password); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + * @throws Exception + */ + public function before(BeforeScenarioScope $scope): void { + $this->scenarioStartTime = \time(); + // Get the environment + $environment = $scope->getEnvironment(); + // registers context in every suite, as every suite has FeatureContext + // that calls BasicStructure.php + $this->ocsContext = new OCSContext(); + $this->authContext = new AuthContext(); + $this->appConfigurationContext = new AppConfigurationContext(); + $this->ocsContext->before($scope); + $this->authContext->setUpScenario($scope); + $this->appConfigurationContext->setUpScenario($scope); + $environment->registerContext($this->ocsContext); + $environment->registerContext($this->authContext); + $environment->registerContext($this->appConfigurationContext); + $scenarioLine = $scope->getScenario()->getLine(); + $featureFile = $scope->getFeature()->getFile(); + $suiteName = $scope->getSuite()->getName(); + $featureFileName = \basename($featureFile); + + if ($this->sendScenarioLineReferencesInXRequestId()) { + $this->scenarioString = $suiteName . '/' . $featureFileName . ':' . $scenarioLine; + } else { + $this->scenarioString = ''; + } + + // Initialize SetupHelper + SetupHelper::init( + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getBaseUrl(), + $this->getOcPath() + ); + + if ($this->isTestingWithLdap()) { + $suiteParameters = SetupHelper::getSuiteParameters($scope); + $this->connectToLdap($suiteParameters); + } + + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext = new GraphContext(); + $this->graphContext->before($scope); + $environment->registerContext($this->graphContext); + } + } + + /** + * This will run before EVERY step. + * + * @BeforeStep + * + * @param BeforeStepScope $scope + * + * @return void + */ + public function beforeEachStep(BeforeStepScope $scope): void { + if ($this->sendScenarioLineReferencesInXRequestId()) { + $this->stepLineRef = $this->scenarioString . '-' . $scope->getStep()->getLine(); + } else { + $this->stepLineRef = ''; + } + } + + /** + * @BeforeScenario @local_storage + * + * @return void + * @throws Exception + */ + public function setupLocalStorageBefore(): void { + $storageName = "local_storage"; + $result = SetupHelper::createLocalStorageMount( + $storageName, + $this->getStepLineRef() + ); + $storageId = $result['storageId']; + if (!is_numeric($storageId)) { + throw new Exception( + __METHOD__ . " storageId '$storageId' is not numeric" + ); + } + $this->addStorageId($storageName, (int)$storageId); + SetupHelper::runOcc( + [ + 'files_external:option', + $storageId, + 'enable_sharing', + 'true' + ], + $this->getStepLineRef() + ); + } + + /** + * @AfterScenario + * + * @return void + */ + public function restoreAdminPassword(): void { + if ($this->adminPassword !== $this->originalAdminPassword) { + $this->resetUserPasswordAsAdminUsingTheProvisioningApi( + $this->getAdminUsername(), + $this->originalAdminPassword + ); + $this->adminPassword = $this->originalAdminPassword; + } + } + + /** + * @AfterScenario + * + * @return void + */ + public function deleteAllResourceCreatedByAdmin(): void { + foreach ($this->adminResources as $resource) { + $this->userDeletesFile("admin", $resource); + } + } + + /** + * deletes all created storages + * + * @return void + * @throws Exception + */ + public function deleteAllStorages(): void { + $allStorageIds = \array_keys($this->getStorageIds()); + foreach ($allStorageIds as $storageId) { + SetupHelper::runOcc( + [ + 'files_external:delete', + '-y', + $storageId + ], + $this->getStepLineRef() + ); + } + $this->storageIds = []; + } + + /** + * @AfterScenario @local_storage + * + * @return void + * @throws Exception + */ + public function removeLocalStorageAfter(): void { + $this->removeExternalStorage(); + $this->removeTemporaryStorageOnServerAfter(); + } + + /** + * This will remove test created external mount points + * + * @AfterScenario @external_storage + * + * @return void + * @throws Exception + */ + public function removeExternalStorage(): void { + if ($this->getStorageIds() !== null) { + $this->deleteAllStorages(); + } + } + + /** + * @BeforeScenario @temporary_storage_on_server + * + * @return void + * @throws Exception + */ + public function makeTemporaryStorageOnServerBefore(): void { + $this->mkDirOnServer( + TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER + ); + } + + /** + * @AfterScenario @temporary_storage_on_server + * + * @return void + * @throws Exception + */ + public function removeTemporaryStorageOnServerAfter(): void { + SetupHelper::rmDirOnServer( + TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER, + $this->getStepLineRef() + ); + } + + /** + * @AfterScenario + * + * @return void + */ + public function removeCreatedFilesAfter(): void { + foreach ($this->createdFiles as $file) { + \unlink($file); + } + } + + /** + * + * @param string $serverUrl + * + * @return void + */ + public function clearFileLocksForServer(string $serverUrl): void { + $response = OcsApiHelper::sendRequest( + $serverUrl, + $this->getAdminUsername(), + $this->getAdminPassword(), + 'delete', + "/apps/testing/api/v1/lockprovisioning", + $this->getStepLineRef(), + ["global" => "true"] + ); + Assert::assertEquals("200", $response->getStatusCode()); + } + + /** + * After Scenario. clear file locks + * + * @AfterScenario + * + * @return void + * @throws Exception + */ + public function clearFileLocks(): void { + if (!OcisHelper::isTestingOnOcisOrReva()) { + $this->authContext->deleteTokenAuthEnforcedAfterScenario(); + $this->clearFileLocksForServer($this->getBaseUrl()); + if ($this->remoteBaseUrl !== $this->localBaseUrl) { + $this->clearFileLocksForServer($this->getRemoteBaseUrl()); + } + } + } + + /** + * @AfterScenario + * + * clear space id reference + * + * @return void + * @throws Exception + */ + public function clearSpaceId(): void { + if (\count(WebDavHelper::$spacesIdRef) > 0) { + WebDavHelper::$spacesIdRef = []; + } + } + + /** + * @BeforeSuite + * + * @param BeforeSuiteScope $scope + * + * @return void + * @throws Exception + */ + public static function useBigFileIDs(BeforeSuiteScope $scope): void { + if (OcisHelper::isTestingOnOcisOrReva()) { + return; + } + $fullUrl = \getenv('TEST_SERVER_URL'); + if (\substr($fullUrl, -1) !== '/') { + $fullUrl .= '/'; + } + $fullUrl .= "ocs/v1.php/apps/testing/api/v1/increasefileid"; + $suiteSettingsContexts = $scope->getSuite()->getSettings()['contexts']; + $adminUsername = null; + $adminPassword = null; + foreach ($suiteSettingsContexts as $context) { + if (isset($context[__CLASS__])) { + $adminUsername = $context[__CLASS__]['adminUsername']; + $adminPassword = $context[__CLASS__]['adminPassword']; + break; + } + } + + // get the admin username from the environment (if defined) + $adminUsernameFromEnvironment = self::getAdminUsernameFromEnvironment(); + if ($adminUsernameFromEnvironment !== false) { + $adminUsername = $adminUsernameFromEnvironment; + } + + // get the admin password from the environment (if defined) + $adminPasswordFromEnvironment = self::getAdminPasswordFromEnvironment(); + if ($adminPasswordFromEnvironment !== false) { + $adminPassword = $adminPasswordFromEnvironment; + } + + if (($adminUsername === null) || ($adminPassword === null)) { + throw new Exception( + "Could not find adminUsername and/or adminPassword in useBigFileIDs" + ); + } + + HttpRequestHelper::post( + $fullUrl, + '', + $adminUsername, + $adminPassword + ); + } + + /** + * runs a function on every server (LOCAL & REMOTE). + * The callable function receives the server (LOCAL or REMOTE) as first argument + * + * @param callable $callback + * + * @return array + */ + public function runFunctionOnEveryServer(callable $callback): array { + $previousServer = $this->getCurrentServer(); + $result = []; + foreach (['LOCAL', 'REMOTE'] as $server) { + $this->usingServer($server); + if (($server === 'LOCAL') + || $this->federatedServerExists() + ) { + $result[$server] = \call_user_func($callback, $server); + } + } + $this->usingServer($previousServer); + return $result; + } + + /** + * Verify that the tableNode contains expected headers + * + * @param TableNode $table + * @param array|null $requiredHeader + * @param array|null $allowedHeader + * + * @return void + * @throws Exception + */ + public function verifyTableNodeColumns(TableNode $table, ?array $requiredHeader = [], ?array $allowedHeader = []): void { + if (\count($table->getHash()) < 1) { + throw new Exception("Table should have at least one row."); + } + $tableHeaders = $table->getRows()[0]; + $allowedHeader = \array_unique(\array_merge($requiredHeader, $allowedHeader)); + if ($requiredHeader != []) { + foreach ($requiredHeader as $element) { + if (!\in_array($element, $tableHeaders)) { + throw new Exception("Row with header '$element' expected to be in table but not found"); + } + } + } + + if ($allowedHeader != []) { + foreach ($tableHeaders as $element) { + if (!\in_array($element, $allowedHeader)) { + throw new Exception("Row with header '$element' is not allowed in table but found"); + } + } + } + } + + /** + * Verify that the tableNode contains expected rows + * + * @param TableNode $table + * @param array $requiredRows + * @param array $allowedRows + * + * @return void + * @throws Exception + */ + public function verifyTableNodeRows(TableNode $table, array $requiredRows = [], array $allowedRows = []): void { + if (\count($table->getRows()) < 1) { + throw new Exception("Table should have at least one row."); + } + $tableHeaders = $table->getColumn(0); + $allowedRows = \array_unique(\array_merge($requiredRows, $allowedRows)); + if ($requiredRows != []) { + foreach ($requiredRows as $element) { + if (!\in_array($element, $tableHeaders)) { + throw new Exception("Row with name '$element' expected to be in table but not found"); + } + } + } + + if ($allowedRows != []) { + foreach ($tableHeaders as $element) { + if (!\in_array($element, $allowedRows)) { + throw new Exception("Row with name '$element' is not allowed in table but found"); + } + } + } + } + + /** + * Verify that the tableNode contains expected number of columns + * + * @param TableNode $table + * @param int $count + * + * @return void + * @throws Exception + */ + public function verifyTableNodeColumnsCount(TableNode $table, int $count): void { + if (\count($table->getRows()) < 1) { + throw new Exception("Table should have at least one row."); + } + $rowCount = \count($table->getRows()[0]); + if ($count !== $rowCount) { + throw new Exception("Table expected to have $count rows but found $rowCount"); + } + } + + /** + * @return void + */ + public function resetAppConfigs(): void { + // Set the required starting values for testing + $this->setCapabilities($this->getCommonSharingConfigs()); + } + + /** + * @Given the administrator has set the last login date for user :user to :days days ago + * @When the administrator sets the last login date for user :user to :days days ago using the testing API + * + * @param string $user + * @param string $days + * + * @return void + */ + public function theAdministratorSetsTheLastLoginDateForUserToDaysAgoUsingTheTestingApi(string $user, string $days): void { + $user = $this->getActualUsername($user); + $adminUser = $this->getAdminUsername(); + $baseUrl = "/apps/testing/api/v1/lastlogindate/$user"; + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $adminUser, + $this->getAdminPassword(), + 'POST', + $baseUrl, + $this->getStepLineRef(), + ['days' => $days], + $this->getOcsApiVersion() + ); + $this->setResponse($response); + } + + /** + * @param array $capabilitiesArray with each array entry containing keys for: + * ['capabilitiesApp'] the "app" name in the capabilities response + * ['capabilitiesParameter'] the parameter name in the capabilities response + * ['testingApp'] the "app" name as understood by "testing" + * ['testingParameter'] the parameter name as understood by "testing" + * ['testingState'] boolean state the parameter must be set to for the test + * + * @return void + */ + public function setCapabilities(array $capabilitiesArray): void { + AppConfigHelper::setCapabilities( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + $capabilitiesArray, + $this->getStepLineRef() + ); + } + + /** + * After Scenario. restore trusted servers + * + * @AfterScenario @federation-app-required + * + * @return void + */ + public function restoreTrustedServersAfterScenario(): void { + $this->restoreTrustedServers('LOCAL'); + if ($this->federatedServerExists()) { + $this->restoreTrustedServers('REMOTE'); + } + } + + /** + * Invokes an OCC command + * + * @param array|null $args of the occ command + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return int exit code + * @throws Exception if ocPath has not been set yet or the testing app is not enabled + */ + public function runOcc( + ?array $args = [], + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null + ): int { + return $this->runOccWithEnvVariables( + $args, + null, + $adminUsername, + $adminPassword, + $baseUrl, + $ocPath + ); + } + + /** + * Invokes an OCC command with an optional array of environment variables + * + * @param array|null $args of the occ command + * @param array|null $envVariables to be defined before the command is run + * @param string|null $adminUsername + * @param string|null $adminPassword + * @param string|null $baseUrl + * @param string|null $ocPath + * + * @return int exit code + * @throws Exception if ocPath has not been set yet or the testing app is not enabled + */ + public function runOccWithEnvVariables( + ?array $args = [], + ?array $envVariables = null, + ?string $adminUsername = null, + ?string $adminPassword = null, + ?string $baseUrl = null, + ?string $ocPath = null + ): int { + $args[] = '--no-ansi'; + if ($baseUrl == null) { + $baseUrl = $this->getBaseUrl(); + } + $return = SetupHelper::runOcc( + $args, + $this->getStepLineRef(), + $adminUsername, + $adminPassword, + $baseUrl, + $ocPath, + $envVariables + ); + $this->lastStdOut = $return['stdOut']; + $this->lastStdErr = $return['stdErr']; + $occStatusCode = (int)$return['code']; + return $occStatusCode; + } + + /** + * Find exception texts in stderr + * + * @return array of exception texts + */ + public function findExceptions(): array { + $exceptions = []; + $captureNext = false; + // the exception text usually appears after an "[Exception]" row + foreach (\explode("\n", $this->lastStdErr) as $line) { + if (\preg_match('/\[Exception\]/', $line)) { + $captureNext = true; + continue; + } + if ($captureNext) { + $exceptions[] = \trim($line); + $captureNext = false; + } + } + + return $exceptions; + } + + /** + * remember the result of the last occ command + * + * @param string[] $result associated array with "code", "stdOut", "stdErr" + * + * @return void + */ + public function setResultOfOccCommand(array $result): void { + Assert::assertIsArray($result); + Assert::assertArrayHasKey('code', $result); + Assert::assertArrayHasKey('stdOut', $result); + Assert::assertArrayHasKey('stdErr', $result); + $this->occLastCode = (int)$result['code']; + $this->lastStdOut = $result['stdOut']; + $this->lastStdErr = $result['stdErr']; + } + + /** + * @param string $sourceUser + * @param string $targetUser + * + * @return string|null + * @throws Exception + */ + public function findLastTransferFolderForUser(string $sourceUser, string $targetUser): ?string { + $foundPaths = []; + $responseXmlObject = $this->listFolderAndReturnResponseXml( + $targetUser, + '', + '1' + ); + $transferredElements = $responseXmlObject->xpath( + "//d:response/d:href[contains(., '/transferred%20from%20$sourceUser%20on%')]" + ); + foreach ($transferredElements as $transferredElement) { + // $transferredElement is an XML object. We want to work with the string in the XML element. + $path = \rawurldecode((string)$transferredElement); + $parts = \explode(' ', $path); + // store timestamp as key + $foundPaths[] = [ + 'date' => \strtotime(\trim($parts[4], '/')), + 'path' => $path, + ]; + } + if (empty($foundPaths)) { + return null; + } + + \usort( + $foundPaths, + function ($a, $b) { + return $a['date'] - $b['date']; + } + ); + + $davPath = \rtrim($this->getFullDavFilesPath($targetUser), '/'); + + $foundPath = \end($foundPaths)['path']; + // strip DAV path + return \substr($foundPath, \strlen($davPath) + 1); + } + + /** + * After Scenario. restore trusted servers + * + * @param string $server 'LOCAL'/'REMOTE' + * + * @return void + * @throws Exception + */ + public function restoreTrustedServers(string $server): void { + $currentTrustedServers = $this->getTrustedServers($server); + foreach (\array_diff($currentTrustedServers, $this->initialTrustedServer[$server]) as $url => $id) { + $this->appConfigurationContext->theAdministratorDeletesUrlFromTrustedServersUsingTheTestingApi($url); + } + foreach (\array_diff($this->initialTrustedServer[$server], $currentTrustedServers) as $url => $id) { + $this->appConfigurationContext->theAdministratorAddsUrlAsTrustedServerUsingTheTestingApi($url); + } + } + + /** + * + * @return void + * @throws Exception + */ + public function restoreParametersAfterScenario(): void { + if (!OcisHelper::isTestingOnOcisOrReva()) { + $this->authContext->deleteTokenAuthEnforcedAfterScenario(); + $user = $this->getCurrentUser(); + $this->setCurrentUser($this->getAdminUsername()); + $this->runFunctionOnEveryServer( + function ($server) { + $this->restoreParameters($server); + } + ); + $this->setCurrentUser($user); + } + } + + /** + * Get the array of trusted servers in format ["url" => "id"] + * + * @param string $server 'LOCAL'/'REMOTE' + * + * @return array + * @throws Exception + */ + public function getTrustedServers(string $server = 'LOCAL'): array { + if ($server === 'LOCAL') { + $url = $this->getLocalBaseUrl(); + } elseif ($server === 'REMOTE') { + $url = $this->getRemoteBaseUrl(); + } else { + throw new Exception(__METHOD__ . " Invalid value for server : $server"); + } + $adminUser = $this->getAdminUsername(); + $response = OcsApiHelper::sendRequest( + $url, + $adminUser, + $this->getAdminPassword(), + 'GET', + "/apps/testing/api/v1/trustedservers", + $this->getStepLineRef() + ); + if ($response->getStatusCode() !== 200) { + throw new Exception("Could not get the list of trusted servers" . $response->getBody()->getContents()); + } + $responseXml = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $serverData = \json_decode( + \json_encode( + $responseXml->data + ), + true + ); + if (!\array_key_exists('element', $serverData)) { + return []; + } else { + return isset($serverData['element'][0]) ? + \array_column($serverData['element'], 'id', 'url') : + \array_column($serverData, 'id', 'url'); + } + } + + /** + * @param string $method http request method + * @param string $property property in form d:getetag + * if property is `doesnotmatter` body is also set `doesnotmatter` + * + * @return string + */ + public function getBodyForOCSRequest(string $method, string $property): ?string { + $body = null; + if ($method === 'PROPFIND') { + $body = '<' . $property . '/>'; + } elseif ($method === 'LOCK') { + $body = " <" . $property . " />"; + } elseif ($method === 'PROPPATCH') { + if ($property === 'favorite') { + $property = '1'; + } + $body = '' . $property . ''; + } + if ($property === '') { + $body = ''; + } + return $body; + } + + /** + * @BeforeScenario + * + * @return void + * @throws Exception + */ + public function prepareParametersBeforeScenario(): void { + if (!OcisHelper::isTestingOnOcisOrReva()) { + $user = $this->getCurrentUser(); + $this->setCurrentUser($this->getAdminUsername()); + $previousServer = $this->getCurrentServer(); + foreach (['LOCAL', 'REMOTE'] as $server) { + if (($server === 'LOCAL') || $this->federatedServerExists()) { + $this->usingServer($server); + $this->resetAppConfigs(); + $result = SetupHelper::runOcc( + ['config:list', '--private'], + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getBaseUrl(), + $this->getOcPath() + ); + $this->savedConfigList[$server] = \json_decode($result['stdOut'], true); + } + } + $this->usingServer($previousServer); + $this->setCurrentUser($user); + } + } + + /** + * Before Scenario to Save trusted Servers + * + * @BeforeScenario @federation-app-required + * + * @return void + * @throws Exception + */ + public function setInitialTrustedServersBeforeScenario(): void { + $this->initialTrustedServer = [ + 'LOCAL' => $this->getTrustedServers(), + 'REMOTE' => $this->getTrustedServers('REMOTE') + ]; + } + + /** + * restore settings of the system and delete new settings that were created in the test runs + * + * @param string $server LOCAL|REMOTE + * + * @return void + * + * @throws Exception + * @throws GuzzleException + * + */ + private function restoreParameters(string $server): void { + $commands = []; + if ($this->isTestingWithLdap()) { + $this->resetOldLdapConfig(); + } + $result = SetupHelper::runOcc( + ['config:list'], + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getBaseUrl(), + $this->getOcPath() + ); + $currentConfigList = \json_decode($result['stdOut'], true); + foreach ($currentConfigList['system'] as $configKey => $configValue) { + if (!\array_key_exists( + $configKey, + $this->savedConfigList[$server]['system'] + ) + ) { + $commands[] = ["command" => ['config:system:delete', $configKey]]; + } + } + foreach ($this->savedConfigList[$server]['system'] as $configKey => $configValue) { + if (!\array_key_exists($configKey, $currentConfigList["system"]) + || $currentConfigList["system"][$configKey] !== $this->savedConfigList[$server]['system'][$configKey] + ) { + $commands[] = ["command" => ['config:system:set', "--type=json", "--value=" . \json_encode($configValue), $configKey]]; + } + } + foreach ($currentConfigList['apps'] as $appName => $appSettings) { + foreach ($appSettings as $configKey => $configValue) { + //only check if the app was there in the original configuration + if (\array_key_exists($appName, $this->savedConfigList[$server]['apps']) + && !\array_key_exists( + $configKey, + $this->savedConfigList[$server]['apps'][$appName] + ) + ) { + $commands[] = ["command" => ['config:app:delete', $appName, $configKey]]; + } elseif (\array_key_exists($appName, $this->savedConfigList[$server]['apps']) + && \array_key_exists($configKey, $this->savedConfigList[$server]['apps'][$appName]) + && $this->savedConfigList[$server]['apps'][$appName][$configKey] !== $configValue + ) { + // Do not accidentally disable apps here (perhaps too early) + // That is done in Provisioning.php restoreAppEnabledDisabledState() + if ($configKey !== "enabled") { + $commands[] = [ + "command" => [ + 'config:app:set', + $appName, + $configKey, + "--value=" . $this->savedConfigList[$server]['apps'][$appName][$configKey] + ] + ]; + } + } + } + } + SetupHelper::runBulkOcc( + $commands, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getBaseUrl() + ); + } +} diff --git a/tests/acceptance/features/bootstrap/FilesVersionsContext.php b/tests/acceptance/features/bootstrap/FilesVersionsContext.php new file mode 100644 index 000000000..7f26e3ed7 --- /dev/null +++ b/tests/acceptance/features/bootstrap/FilesVersionsContext.php @@ -0,0 +1,443 @@ + + * @copyright Copyright (c) 2018, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; +use TestHelpers\HttpRequestHelper; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * Steps that relate to files_versions app + */ +class FilesVersionsContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * @param string $fileId + * + * @return string + */ + private function getVersionsPathForFileId(string $fileId):string { + return "/meta/$fileId/v"; + } + + /** + * @When user :user tries to get versions of file :file from :fileOwner + * + * @param string $user + * @param string $file + * @param string $fileOwner + * + * @return void + * @throws Exception + */ + public function userTriesToGetFileVersions(string $user, string $file, string $fileOwner):void { + $user = $this->featureContext->getActualUsername($user); + $fileOwner = $this->featureContext->getActualUsername($fileOwner); + $fileId = $this->featureContext->getFileIdForPath($fileOwner, $file); + Assert::assertNotNull($fileId, __METHOD__ . " fileid of file $file user $fileOwner not found (the file may not exist)"); + $response = $this->featureContext->makeDavRequest( + $user, + "PROPFIND", + $this->getVersionsPathForFileId($fileId), + null, + null, + null, + '2' + ); + $this->featureContext->setResponse($response, $user); + } + + /** + * @When user :user gets the number of versions of file :file + * + * @param string $user + * @param string $file + * + * @return void + * @throws Exception + */ + public function userGetsFileVersions(string $user, string $file):void { + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $file); + Assert::assertNotNull($fileId, __METHOD__ . " fileid of file $file user $user not found (the file may not exist)"); + $response = $this->featureContext->makeDavRequest( + $user, + "PROPFIND", + $this->getVersionsPathForFileId($fileId), + null, + null, + null, + '2' + ); + $this->featureContext->setResponse($response, $user); + } + + /** + * @When user :user gets the version metadata of file :file + * + * @param string $user + * @param string $file + * + * @return void + * @throws Exception + */ + public function userGetsVersionMetadataOfFile(string $user, string $file):void { + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $file); + Assert::assertNotNull($fileId, __METHOD__ . " fileid of file $file user $user not found (the file may not exist)"); + $body = ' + + + + + + '; + $response = $this->featureContext->makeDavRequest( + $user, + "PROPFIND", + $this->getVersionsPathForFileId($fileId), + null, + $body, + null, + '2' + ); + $this->featureContext->setResponse($response, $user); + } + + /** + * @When user :user restores version index :versionIndex of file :path using the WebDAV API + * @Given user :user has restored version index :versionIndex of file :path + * + * @param string $user + * @param int $versionIndex + * @param string $path + * + * @return void + * @throws Exception + */ + public function userRestoresVersionIndexOfFile(string $user, int $versionIndex, string $path):void { + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $path); + Assert::assertNotNull($fileId, __METHOD__ . " fileid of file $path user $user not found (the file may not exist)"); + $responseXml = $this->listVersionFolder($user, $fileId, 1); + $xmlPart = $responseXml->xpath("//d:response/d:href"); + //restoring the version only works with DAV path v2 + $destinationUrl = $this->featureContext->getBaseUrl() . "/" . + WebDavHelper::getDavPath($user, 2) . \trim($path, "/"); + $fullUrl = $this->featureContext->getBaseUrlWithoutPath() . + $xmlPart[$versionIndex]; + $response = HttpRequestHelper::sendRequest( + $fullUrl, + $this->featureContext->getStepLineRef(), + 'COPY', + $user, + $this->featureContext->getPasswordForUser($user), + ['Destination' => $destinationUrl] + ); + $this->featureContext->setResponse($response, $user); + } + + /** + * @Then the version folder of file :path for user :user should contain :count element(s) + * + * @param string $path + * @param string $user + * @param int $count + * + * @return void + * @throws Exception + */ + public function theVersionFolderOfFileShouldContainElements( + string $path, + string $user, + int $count + ):void { + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $path); + Assert::assertNotNull($fileId, __METHOD__ . " file $path user $user not found (the file may not exist)"); + $this->theVersionFolderOfFileIdShouldContainElements($fileId, $user, $count); + } + + /** + * @Then the version folder of fileId :fileId for user :user should contain :count element(s) + * + * @param string $fileId + * @param string $user + * @param int $count + * + * @return void + * @throws Exception + */ + public function theVersionFolderOfFileIdShouldContainElements( + string $fileId, + string $user, + int $count + ):void { + $responseXml = $this->listVersionFolder($user, $fileId, 1); + $xmlPart = $responseXml->xpath("//d:prop/d:getetag"); + Assert::assertEquals( + $count, + \count($xmlPart) - 1, + "could not find $count version element(s) in \n" . $responseXml->asXML() + ); + } + + /** + * @Then the content length of file :path with version index :index for user :user in versions folder should be :length + * + * @param string $path + * @param int $index + * @param string $user + * @param int $length + * + * @return void + * @throws Exception + */ + public function theContentLengthOfFileForUserInVersionsFolderIs( + string $path, + int $index, + string $user, + int $length + ):void { + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $path); + Assert::assertNotNull($fileId, __METHOD__ . " fileid of file $path user $user not found (the file may not exist)"); + $responseXml = $this->listVersionFolder( + $user, + $fileId, + 1, + ['getcontentlength'] + ); + $xmlPart = $responseXml->xpath("//d:prop/d:getcontentlength"); + Assert::assertEquals( + $length, + (int) $xmlPart[$index], + "The content length of file {$path} with version {$index} for user {$user} was + expected to be {$length} but the actual content length is {$xmlPart[$index]}" + ); + } + + /** + * @Then /^as (?:users|user) "([^"]*)" the authors of the versions of file "([^"]*)" should be:$/ + * + * @param string $users comma-separated list of usernames + * @param string $filename + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function asUsersAuthorsOfVersionsOfFileShouldBe( + string $users, + string $filename, + TableNode $table + ): void { + $this->featureContext->verifyTableNodeColumns( + $table, + ['index', 'author'] + ); + $requiredVersionMetadata = $table->getHash(); + $usersArray = \explode(",", $users); + foreach ($usersArray as $username) { + $actualUsername = $this->featureContext->getActualUsername($username); + $this->userGetsVersionMetadataOfFile($actualUsername, $filename); + foreach ($requiredVersionMetadata as $versionMetadata) { + $this->featureContext->theAuthorOfEditedVersionFile( + $versionMetadata['index'], + $versionMetadata['author'] + ); + } + } + } + + /** + * @When user :user downloads the version of file :path with the index :index + * + * @param string $user + * @param string $path + * @param string $index + * + * @return void + * @throws Exception + */ + public function downloadVersion(string $user, string $path, string $index):void { + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $path); + Assert::assertNotNull($fileId, __METHOD__ . " fileid of file $path user $user not found (the file may not exist)"); + $index = (int)$index; + $responseXml = $this->listVersionFolder($user, $fileId, 1); + $xmlPart = $responseXml->xpath("//d:response/d:href"); + if (!isset($xmlPart[$index])) { + Assert::fail( + 'could not find version of path "' . $path . '" with index "' . $index . '"' + ); + } + // the href already contains the path + $url = WebDavHelper::sanitizeUrl( + $this->featureContext->getBaseUrlWithoutPath() . $xmlPart[$index] + ); + $response = HttpRequestHelper::get( + $url, + $this->featureContext->getStepLineRef(), + $user, + $this->featureContext->getPasswordForUser($user) + ); + $this->featureContext->setResponse($response, $user); + } + + /** + * @Then /^the content of version index "([^"]*)" of file "([^"]*)" for user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $index + * @param string $path + * @param string $user + * @param string $content + * + * @return void + * @throws Exception + */ + public function theContentOfVersionIndexOfFileForUserShouldBe( + string $index, + string $path, + string $user, + string $content + ): void { + $this->downloadVersion($user, $path, $index); + $this->featureContext->theHTTPStatusCodeShouldBe("200"); + $this->featureContext->downloadedContentShouldBe($content); + } + + /** + * @When /^user "([^"]*)" retrieves the meta information of (file|fileId) "([^"]*)" using the meta API$/ + * + * @param string $user + * @param string $fileOrFileId + * @param string $path + * + * @return void + */ + public function userGetMetaInfo(string $user, string $fileOrFileId, string $path):void { + $user = $this->featureContext->getActualUsername($user); + $baseUrl = $this->featureContext->getBaseUrl(); + $password = $this->featureContext->getPasswordForUser($user); + + if ($fileOrFileId === "file") { + $fileId = $this->featureContext->getFileIdForPath($user, $path); + $metaPath = "/meta/$fileId/"; + } else { + $metaPath = "/meta/$path/"; + } + + $body = ' + + + + + '; + + $response = WebDavHelper::makeDavRequest( + $baseUrl, + $user, + $password, + "PROPFIND", + $metaPath, + ['Content-Type' => 'text/xml','Depth' => '0'], + $this->featureContext->getStepLineRef(), + $body, + $this->featureContext->getDavPathVersion(), + null + ); + $this->featureContext->setResponse($response); + $responseXml = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $this->featureContext->setResponseXmlObject($responseXml); + } + + /** + * returns the result parsed into an SimpleXMLElement + * with an registered namespace with 'd' as prefix and 'DAV:' as namespace + * + * @param string $user + * @param string $fileId + * @param int $folderDepth + * @param string[]|null $properties + * + * @return SimpleXMLElement + * @throws Exception + */ + public function listVersionFolder( + string $user, + string $fileId, + int $folderDepth, + ?array $properties = null + ):SimpleXMLElement { + if (!$properties) { + $properties = [ + 'getetag' + ]; + } + $user = $this->featureContext->getActualUsername($user); + $password = $this->featureContext->getPasswordForUser($user); + $response = WebDavHelper::propfind( + $this->featureContext->getBaseUrl(), + $user, + $password, + $this->getVersionsPathForFileId($fileId), + $properties, + $this->featureContext->getStepLineRef(), + (string) $folderDepth, + "versions" + ); + return HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/OCSContext.php b/tests/acceptance/features/bootstrap/OCSContext.php new file mode 100644 index 000000000..75f7ea6a8 --- /dev/null +++ b/tests/acceptance/features/bootstrap/OCSContext.php @@ -0,0 +1,1011 @@ + + * @copyright Copyright (c) 2019, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode; +use Behat\Gherkin\Node\TableNode; +use Psr\Http\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use TestHelpers\OcsApiHelper; +use TestHelpers\TranslationHelper; + +require_once 'bootstrap.php'; + +/** + * steps needed to send requests to the OCS API + */ +class OCSContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * @When /^the user sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)"$/ + * + * @param string $verb + * @param string $url + * + * @return void + */ + public function theUserSendsToOcsApiEndpoint(string $verb, string $url):void { + $this->theUserSendsToOcsApiEndpointWithBody($verb, $url, null); + } + + /** + * @Given /^the user has sent HTTP method "([^"]*)" to OCS API endpoint "([^"]*)"$/ + * + * @param string $verb + * @param string $url + * + * @return void + */ + public function theUserHasSentToOcsApiEndpoint(string $verb, string $url):void { + $this->theUserSendsToOcsApiEndpointWithBody($verb, $url, null); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)"$/ + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" using password "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param string|null $password + * + * @return void + */ + public function userSendsToOcsApiEndpoint(string $user, string $verb, string $url, ?string $password = null):void { + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $verb, + $url, + null, + $password + ); + } + + /** + * @Given /^user "([^"]*)" has sent HTTP method "([^"]*)" to API endpoint "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param string|null $password + * + * @return void + */ + public function userHasSentToOcsApiEndpoint(string $user, string $verb, string $url, ?string $password = null):void { + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $verb, + $url, + null, + $password + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $user + * @param string $verb + * @param string $url + * @param TableNode|null $body + * @param string|null $password + * @param array|null $headers + * + * @return void + */ + public function userSendsHTTPMethodToOcsApiEndpointWithBody( + string $user, + string $verb, + string $url, + ?TableNode $body = null, + ?string $password = null, + ?array $headers = null + ):void { + /** + * array of the data to be sent in the body. + * contains $body data converted to an array + * + * @var array $bodyArray + */ + $bodyArray = []; + if ($body instanceof TableNode) { + $bodyArray = $body->getRowsHash(); + } elseif ($body !== null && \is_array($body)) { + $bodyArray = $body; + } + + if ($user !== 'UNAUTHORIZED_USER') { + if ($password === null) { + $password = $this->featureContext->getPasswordForUser($user); + } + $user = $this->featureContext->getActualUsername($user); + } else { + $user = null; + $password = null; + } + $response = OcsApiHelper::sendRequest( + $this->featureContext->getBaseUrl(), + $user, + $password, + $verb, + $url, + $this->featureContext->getStepLineRef(), + $bodyArray, + $this->featureContext->getOcsApiVersion(), + $headers + ); + $this->featureContext->setResponse($response); + } + + /** + * @param string $verb + * @param string $url + * @param TableNode|null $body + * + * @return void + */ + public function adminSendsHttpMethodToOcsApiEndpointWithBody( + string $verb, + string $url, + ?TableNode $body + ):void { + $admin = $this->featureContext->getAdminUsername(); + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $admin, + $verb, + $url, + $body + ); + } + + /** + * @param string $verb + * @param string $url + * @param TableNode|null $body + * + * @return void + */ + public function theUserSendsToOcsApiEndpointWithBody(string $verb, string $url, ?TableNode $body = null):void { + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $this->featureContext->getCurrentUser(), + $verb, + $url, + $body + ); + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with body$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param TableNode|null $body + * @param string|null $password + * + * @return void + */ + public function userSendHTTPMethodToOcsApiEndpointWithBody( + string $user, + string $verb, + string $url, + ?TableNode $body = null, + ?string $password = null + ):void { + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $verb, + $url, + $body, + $password + ); + } + + /** + * @Given /^user "([^"]*)" has sent HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with body$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param TableNode|null $body + * @param string $password + * + * @return void + */ + public function userHasSentHTTPMethodToOcsApiEndpointWithBody( + string $user, + string $verb, + string $url, + ?TableNode $body = null, + ?string $password = null + ):void { + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $verb, + $url, + $body, + $password + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the administrator sends HTTP method :verb to OCS API endpoint :url + * @When the administrator sends HTTP method :verb to OCS API endpoint :url using password :password + * + * @param string $verb + * @param string $url + * @param string|null $password + * + * @return void + */ + public function theAdministratorSendsHttpMethodToOcsApiEndpoint( + string $verb, + string $url, + ?string $password = null + ):void { + $admin = $this->featureContext->getAdminUsername(); + $this->userSendsToOcsApiEndpoint($admin, $verb, $url, $password); + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with headers$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param TableNode $headersTable + * + * @return void + * @throws Exception + */ + public function userSendsToOcsApiEndpointWithHeaders( + string $user, + string $verb, + string $url, + TableNode $headersTable + ):void { + $user = $this->featureContext->getActualUsername($user); + $password = $this->featureContext->getPasswordForUser($user); + $this->userSendsToOcsApiEndpointWithHeadersAndPassword( + $user, + $verb, + $url, + $password, + $headersTable + ); + } + + /** + * @When /^the administrator sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with headers$/ + * + * @param string $verb + * @param string $url + * @param TableNode $headersTable + * + * @return void + * @throws Exception + */ + public function administratorSendsToOcsApiEndpointWithHeaders( + string $verb, + string $url, + TableNode $headersTable + ):void { + $this->userSendsToOcsApiEndpointWithHeaders( + $this->featureContext->getAdminUsername(), + $verb, + $url, + $headersTable + ); + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with headers using password "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param string $password + * @param TableNode $headersTable + * + * @return void + * @throws Exception + */ + public function userSendsToOcsApiEndpointWithHeadersAndPassword( + string $user, + string $verb, + string $url, + string $password, + TableNode $headersTable + ):void { + $this->featureContext->verifyTableNodeColumns( + $headersTable, + ['header', 'value'] + ); + $user = $this->featureContext->getActualUsername($user); + $headers = []; + foreach ($headersTable as $row) { + $headers[$row['header']] = $row ['value']; + } + + $response = OcsApiHelper::sendRequest( + $this->featureContext->getBaseUrl(), + $user, + $password, + $verb, + $url, + $this->featureContext->getStepLineRef(), + [], + $this->featureContext->getOcsApiVersion(), + $headers + ); + $this->featureContext->setResponse($response); + } + + /** + * @When /^the administrator sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with headers using password "([^"]*)"$/ + * + * @param string $verb + * @param string $url + * @param string $password + * @param TableNode $headersTable + * + * @return void + * @throws Exception + */ + public function administratorSendsToOcsApiEndpointWithHeadersAndPassword( + string $verb, + string $url, + string $password, + TableNode $headersTable + ):void { + $this->userSendsToOcsApiEndpointWithHeadersAndPassword( + $this->featureContext->getAdminUsername(), + $verb, + $url, + $password, + $headersTable + ); + } + + /** + * @When the administrator sends HTTP method :verb to OCS API endpoint :url with body + * + * @param string $verb + * @param string $url + * @param TableNode|null $body + * + * @return void + */ + public function theAdministratorSendsHttpMethodToOcsApiEndpointWithBody( + string $verb, + string $url, + ?TableNode $body + ):void { + $this->adminSendsHttpMethodToOcsApiEndpointWithBody( + $verb, + $url, + $body + ); + } + + /** + * @Given the administrator has sent HTTP method :verb to OCS API endpoint :url with body + * + * @param string $verb + * @param string $url + * @param TableNode|null $body + * + * @return void + */ + public function theAdministratorHasSentHttpMethodToOcsApiEndpointWithBody( + string $verb, + string $url, + ?TableNode $body + ):void { + $this->adminSendsHttpMethodToOcsApiEndpointWithBody( + $verb, + $url, + $body + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When /^the user sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with body$/ + * + * @param string $verb + * @param string $url + * @param TableNode $body + * + * @return void + */ + public function theUserSendsHTTPMethodToOcsApiEndpointWithBody(string $verb, string $url, TableNode $body):void { + $this->theUserSendsHTTPMethodToOcsApiEndpointWithBody( + $verb, + $url, + $body + ); + } + + /** + * @Given /^the user has sent HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with body$/ + * + * @param string $verb + * @param string $url + * @param TableNode $body + * + * @return void + */ + public function theUserHasSentHTTPMethodToOcsApiEndpointWithBody(string $verb, string $url, TableNode $body):void { + $this->theUserSendsHTTPMethodToOcsApiEndpointWithBody( + $verb, + $url, + $body + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When the administrator sends HTTP method :verb to OCS API endpoint :url with body using password :password + * + * @param string $verb + * @param string $url + * @param string $password + * @param TableNode $body + * + * @return void + */ + public function theAdministratorSendsHttpMethodToOcsApiWithBodyAndPassword( + string $verb, + string $url, + string $password, + TableNode $body + ):void { + $admin = $this->featureContext->getAdminUsername(); + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $admin, + $verb, + $url, + $body, + $password + ); + } + + /** + * @When /^user "([^"]*)" sends HTTP method "([^"]*)" to OCS API endpoint "([^"]*)" with body using password "([^"]*)"$/ + * + * @param string $user + * @param string $verb + * @param string $url + * @param string $password + * @param TableNode $body + * + * @return void + */ + public function userSendsHTTPMethodToOcsApiEndpointWithBodyAndPassword( + string $user, + string $verb, + string $url, + string $password, + TableNode $body + ):void { + $this->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $verb, + $url, + $body, + $password + ); + } + + /** + * @When user :user requests these endpoints with :method using password :password about user :ofUser + * + * @param string $user + * @param string $method + * @param string $password + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userSendsRequestToTheseEndpointsWithOutBodyUsingPassword( + string $user, + string $method, + string $password, + string $ofUser, + TableNode $table + ):void { + $this->userSendsRequestToTheseEndpointsWithBodyUsingPassword( + $user, + $method, + null, + $password, + $ofUser, + $table + ); + } + + /** + * @When user :user requests these endpoints with :method including body :body using password :password about user :ofUser + * + * @param string|null $user + * @param string|null $method + * @param string|null $body + * @param string|null $password + * @param string|null $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userSendsRequestToTheseEndpointsWithBodyUsingPassword( + ?string $user, + ?string $method, + ?string $body, + ?string $password, + ?string $ofUser, + TableNode $table + ):void { + $user = $this->featureContext->getActualUsername($user); + $ofUser = $this->featureContext->getActualUsername($ofUser); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint'], ['destination']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $ofUser + ); + $header = []; + if (isset($row['destination'])) { + $header['Destination'] = $this->featureContext->substituteInLineCodes( + $this->featureContext->getBaseUrl() . $row['destination'], + $ofUser + ); + } + $this->featureContext->authContext->userRequestsURLWithUsingBasicAuth( + $user, + $row['endpoint'], + $method, + $password, + $body, + $header + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When user :user requests these endpoints with :method including body :body about user :ofUser + * + * @param string $user + * @param string $method + * @param string $body + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userSendsRequestToTheseEndpointsWithBody(string $user, string $method, string $body, string $ofUser, TableNode $table):void { + $header = []; + if ($method === 'MOVE' || $method === 'COPY') { + $header['Destination'] = '/path/to/destination'; + } + + $this->sendRequestToTheseEndpointsAsNormalUser( + $user, + $method, + $ofUser, + $table, + $body, + null, + $header, + ); + } + + /** + * @When user :user requests these endpoints with :method about user :ofUser + * + * @param string $user + * @param string $method + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userSendsRequestToTheseEndpointsWithOutBody(string $user, string $method, string $ofUser, TableNode $table):void { + $header = []; + if ($method === 'MOVE' || $method === 'COPY') { + $header['Destination'] = '/path/to/destination'; + } + + $this->sendRequestToTheseEndpointsAsNormalUser( + $user, + $method, + $ofUser, + $table, + null, + null, + $header, + ); + } + + /** + * @When /^user "([^"]*)" requests these endpoints with "([^"]*)" to (?:get|set) property "([^"]*)" about user "([^"]*)"$/ + * + * @param string $user + * @param string $method + * @param string $property + * @param string $ofUser + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userSendsRequestToTheseEndpointsWithProperty(string $user, string $method, string $property, string $ofUser, TableNode $table):void { + $this->sendRequestToTheseEndpointsAsNormalUser( + $user, + $method, + $ofUser, + $table, + null, + $property + ); + } + + /** + * @param string $user + * @param string $method + * @param string $ofUser + * @param TableNode $table + * @param string|null $body + * @param string|null $property + * @param Array|null $header + * + * @return void + * @throws Exception + */ + public function sendRequestToTheseEndpointsAsNormalUser( + string $user, + string $method, + string $ofUser, + TableNode $table, + ?string $body = null, + ?string $property = null, + ?array $header = null + ):void { + $user = $this->featureContext->getActualUsername($user); + $ofUser = $this->featureContext->getActualUsername($ofUser); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + if (!$body && $property) { + $body = $this->featureContext->getBodyForOCSRequest($method, $property); + } + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $ofUser + ); + $this->featureContext->authContext->userRequestsURLWithUsingBasicAuth( + $user, + $row['endpoint'], + $method, + $this->featureContext->getPasswordForUser($user), + $body, + $header + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When user :asUser requests these endpoints with :method including body :body using the password of user :user + * + * @param string $asUser + * @param string $method + * @param string|null $body + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsTheseEndpointsWithBodyUsingThePasswordOfUser(string $asUser, string $method, ?string $body, string $user, TableNode $table):void { + $asUser = $this->featureContext->getActualUsername($asUser); + $userRenamed = $this->featureContext->getActualUsername($user); + $this->featureContext->verifyTableNodeColumns($table, ['endpoint']); + $this->featureContext->emptyLastHTTPStatusCodesArray(); + $this->featureContext->emptyLastOCSStatusCodesArray(); + foreach ($table->getHash() as $row) { + $row['endpoint'] = $this->featureContext->substituteInLineCodes( + $row['endpoint'], + $userRenamed + ); + $this->featureContext->authContext->userRequestsURLWithUsingBasicAuth( + $asUser, + $row['endpoint'], + $method, + $this->featureContext->getPasswordForUser($user), + $body + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @When user :asUser requests these endpoints with :method using the password of user :user + * + * @param string $asUser + * @param string $method + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRequestsTheseEndpointsWithoutBodyUsingThePasswordOfUser(string $asUser, string $method, string $user, TableNode $table):void { + $this->userRequestsTheseEndpointsWithBodyUsingThePasswordOfUser( + $asUser, + $method, + null, + $user, + $table + ); + } + + /** + * @Then /^the OCS status code should be "([^"]*)"$/ + * + * @param string $statusCode + * @param string $message + * + * @return void + * @throws Exception + */ + public function theOCSStatusCodeShouldBe(string $statusCode, $message = ""):void { + $statusCodes = explode(",", $statusCode); + $responseStatusCode = $this->getOCSResponseStatusCode( + $this->featureContext->getResponse() + ); + if (\is_array($statusCodes)) { + if ($message === "") { + $message = "OCS status code is not any of the expected values " . \implode(",", $statusCodes) . " got " . $responseStatusCode; + } + Assert::assertContainsEquals( + $responseStatusCode, + $statusCodes, + $message + ); + $this->featureContext->emptyLastOCSStatusCodesArray(); + } else { + if ($message === "") { + $message = "OCS status code is not the expected value " . $statusCodes . " got " . $responseStatusCode; + } + + Assert::assertEquals( + $statusCodes, + $responseStatusCode, + $message + ); + } + } + + /** + * @Then /^the OCS status code should be "([^"]*)" or "([^"]*)"$/ + * + * @param string $statusCode1 + * @param string $statusCode2 + * + * @return void + * @throws Exception + */ + public function theOcsStatusCodeShouldBeOr(string $statusCode1, string $statusCode2):void { + $this->theOCSStatusCodeShouldBe( + $statusCode1 . "," . $statusCode2 + ); + } + + /** + * Check the text in an OCS status message + * + * @Then /^the OCS status message should be "([^"]*)"$/ + * @Then /^the OCS status message should be "([^"]*)" in language "([^"]*)"$/ + * + * @param string $statusMessage + * @param string|null $language + * + * @return void + */ + public function theOCSStatusMessageShouldBe(string $statusMessage, ?string $language = null):void { + $language = TranslationHelper::getLanguage($language); + $statusMessage = $this->getActualStatusMessage($statusMessage, $language); + + Assert::assertEquals( + $statusMessage, + $this->getOCSResponseStatusMessage( + $this->featureContext->getResponse() + ), + 'Unexpected OCS status message :"' . $this->getOCSResponseStatusMessage( + $this->featureContext->getResponse() + ) . '" in response' + ); + } + + /** + * Check the text in an OCS status message + * + * @Then /^the OCS status message about user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $user + * @param string $statusMessage + * + * @return void + */ + public function theOCSStatusMessageAboutUserShouldBe(string $user, string $statusMessage):void { + $user = \strtolower($this->featureContext->getActualUsername($user)); + $statusMessage = $this->featureContext->substituteInLineCodes( + $statusMessage, + $user + ); + Assert::assertEquals( + $statusMessage, + $this->getOCSResponseStatusMessage( + $this->featureContext->getResponse() + ), + 'Unexpected OCS status message :"' . $this->getOCSResponseStatusMessage( + $this->featureContext->getResponse() + ) . '" in response' + ); + } + + /** + * Check the text in an OCS status message. + * Use this step form if the expected text contains double quotes, + * single quotes and other content that theOCSStatusMessageShouldBe() + * cannot handle. + * + * After the step, write the expected text in PyString form like: + * + * """ + * File "abc.txt" can't be shared due to reason "xyz" + * """ + * + * @Then /^the OCS status message should be:$/ + * + * @param PyStringNode $statusMessage + * + * @return void + */ + public function theOCSStatusMessageShouldBePyString( + PyStringNode $statusMessage + ):void { + Assert::assertEquals( + $statusMessage->getRaw(), + $this->getOCSResponseStatusMessage( + $this->featureContext->getResponse() + ), + 'Unexpected OCS status message: "' . $this->getOCSResponseStatusMessage( + $this->featureContext->getResponse() + ) . '" in response' + ); + } + + /** + * Parses the xml answer to get ocs response which doesn't match with + * http one in v1 of the api. + * + * @param ResponseInterface $response + * + * @return string + * @throws Exception + */ + public function getOCSResponseStatusCode(ResponseInterface $response):string { + $responseXml = $this->featureContext->getResponseXml($response, __METHOD__); + if (isset($responseXml->meta[0], $responseXml->meta[0]->statuscode)) { + return (string) $responseXml->meta[0]->statuscode; + } + throw new Exception( + "No OCS status code found in responseXml" + ); + } + + /** + * Parses the xml answer to get ocs response message which doesn't match with + * http one in v1 of the api. + * + * @param ResponseInterface $response + * + * @return string + */ + public function getOCSResponseStatusMessage(ResponseInterface $response):string { + return (string) $this->featureContext->getResponseXml($response, __METHOD__)->meta[0]->message; + } + + /** + * convert status message in the desired language + * + * @param string $statusMessage + * @param string|null $language + * + * @return string + */ + public function getActualStatusMessage(string $statusMessage, ?string $language = null):string { + if ($language !== null) { + $multiLingualMessage = \json_decode( + \file_get_contents(__DIR__ . "/../../fixtures/multiLanguageErrors.json"), + true + ); + + if (isset($multiLingualMessage[$statusMessage][$language])) { + $statusMessage = $multiLingualMessage[$statusMessage][$language]; + } + } + return $statusMessage; + } + + /** + * check if the HTTP status code and the OCS status code indicate that the request was successful + * this function is aware of the currently used OCS version + * + * @param string|null $message + * + * @return void + * @throws Exception + */ + public function assertOCSResponseIndicatesSuccess(?string $message = ""):void { + $this->featureContext->theHTTPStatusCodeShouldBe('200', $message); + if ($this->featureContext->getOcsApiVersion() === 1) { + $this->theOCSStatusCodeShouldBe('100', $message); + } else { + $this->theOCSStatusCodeShouldBe('200', $message); + } + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/OccContext.php b/tests/acceptance/features/bootstrap/OccContext.php new file mode 100644 index 000000000..53d62081c --- /dev/null +++ b/tests/acceptance/features/bootstrap/OccContext.php @@ -0,0 +1,3748 @@ + + * @copyright Copyright (c) 2019, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use GuzzleHttp\Exception\GuzzleException; +use PHPUnit\Framework\Assert; +use TestHelpers\OcisHelper; +use TestHelpers\SetupHelper; +use Behat\Gherkin\Node\PyStringNode; + +require_once 'bootstrap.php'; + +/** + * Occ context for test steps that test occ commands + */ +class OccContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * + * @var ImportedCertificates + */ + private $importedCertificates = []; + + /** + * + * @var RemovedCertificates + */ + private $removedCertificates = []; + + /** + * @var string lastDeletedJobId + */ + private $lastDeletedJobId; + + /** + * The code to manage dav.enable.tech_preview was used in 10.4/10.3 + * The use of the steps to enable/disable it has been removed from the + * feature files. But the infrastructure has been left here, as a similar + * thing might likely happen in the future. + * + * @var boolean + */ + private $doTechPreview = false; + + /** + * @var boolean techPreviewEnabled + */ + private $techPreviewEnabled = false; + + /** + * @var string initialTechPreviewStatus + */ + private $initialTechPreviewStatus; + + /** + * @return boolean + */ + public function isTechPreviewEnabled():bool { + return $this->techPreviewEnabled; + } + + /** + * @return boolean + * @throws Exception + */ + public function enableDAVTechPreview():bool { + if ($this->doTechPreview) { + if (!$this->isTechPreviewEnabled()) { + $this->addSystemConfigKeyUsingTheOccCommand( + "dav.enable.tech_preview", + "true", + "boolean" + ); + $this->techPreviewEnabled = true; + return true; + } + } + return false; + } + + /** + * @return boolean true if delete-system-config-key was done + * @throws Exception + */ + public function disableDAVTechPreview():bool { + if ($this->doTechPreview) { + $this->deleteSystemConfigKeyUsingTheOccCommand( + "dav.enable.tech_preview" + ); + $this->techPreviewEnabled = false; + return true; + } + return false; + } + + /** + * @param string $cmd + * + * @return void + * @throws Exception + */ + public function invokingTheCommand(string $cmd):void { + $this->featureContext->setOccLastCode( + $this->featureContext->runOcc([$cmd]) + ); + } + + /** + * @param string $path + * + * @return void + * @throws Exception + */ + public function importSecurityCertificateFromFileInTemporaryStorage(string $path):void { + $this->invokingTheCommand("security:certificates:import " . TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/$path"); + if ($this->featureContext->getExitStatusCodeOfOccCommand() === 0) { + $pathComponents = \explode("/", $path); + $certificate = \end($pathComponents); + \array_push($this->importedCertificates, $certificate); + } + } + + /** + * @param string $cmd + * @param string $envVariableName + * @param string $envVariableValue + * + * @return void + * @throws Exception + */ + public function invokingTheCommandWithEnvVariable( + string $cmd, + string $envVariableName, + string $envVariableValue + ):int { + $args = [$cmd]; + return( + $this->featureContext->runOccWithEnvVariables( + $args, + [$envVariableName => $envVariableValue] + ) + ); + } + + /** + * @param string $mode + * + * @return void + * @throws Exception + */ + public function changeBackgroundJobsModeUsingTheOccCommand(string $mode):void { + $this->invokingTheCommand("background:$mode"); + } + + /** + * @param string $mountPoint + * @param boolean $setting + * + * @return void + * @throws Exception + */ + public function setExtStorageReadOnlyUsingTheOccCommand(string $mountPoint, bool $setting = true):void { + $command = "files_external:option"; + + $mountId = $this->featureContext->getStorageId($mountPoint); + + $key = "read_only"; + + if ($setting) { + $value = "1"; + } else { + $value = "0"; + } + + $this->invokingTheCommand( + "$command $mountId $key $value" + ); + } + + /** + * @param string $mountPoint + * @param boolean $enable + * + * @return void + * @throws Exception + */ + public function setExtStorageSharingUsingTheOccCommand(string $mountPoint, bool $enable = true):void { + $command = "files_external:option"; + + $mountId = $this->featureContext->getStorageId($mountPoint); + + $key = "enable_sharing"; + + $value = $enable ? "true" : "false"; + + $this->invokingTheCommand( + "$command $mountId $key $value" + ); + } + + /** + * @param string $mountPoint + * @param string $setting "never" (switch it off) otherwise "Once every direct access" + * + * @return void + * @throws Exception + */ + public function setExtStorageCheckChangesUsingTheOccCommand(string $mountPoint, string $setting):void { + $command = "files_external:option"; + + $mountId = $this->featureContext->getStorageId($mountPoint); + + $key = "filesystem_check_changes"; + + if ($setting === "never") { + $value = "0"; + } else { + $value = "1"; + } + + $this->invokingTheCommand( + "$command $mountId $key $value" + ); + } + + /** + * @return void + * @throws Exception + */ + public function scanFileSystemForAllUsersUsingTheOccCommand():void { + $this->invokingTheCommand( + "files:scan --all" + ); + } + + /** + * @param string $user + * + * @return void + * @throws Exception + */ + public function scanFileSystemForAUserUsingTheOccCommand(string $user):void { + $user = $this->featureContext->getActualUsername($user); + $this->invokingTheCommand( + "files:scan $user" + ); + } + + /** + * @param string $path + * @param string|null $user + * + * @return void + * @throws Exception + */ + public function scanFileSystemPathUsingTheOccCommand(string $path, ?string $user = null):void { + $path = $this->featureContext->substituteInLineCodes( + $path, + $user + ); + $this->invokingTheCommand( + "files:scan --path='$path'" + ); + } + + /** + * @param string $group + * + * @return void + * @throws Exception + */ + public function scanFileSystemForAGroupUsingTheOccCommand(string $group):void { + $this->invokingTheCommand( + "files:scan --group=$group" + ); + } + + /** + * @param string $groups + * + * @return void + * @throws Exception + */ + public function scanFileSystemForGroupsUsingTheOccCommand(string $groups):void { + $this->invokingTheCommand( + "files:scan --groups=$groups" + ); + } + + /** + * @param string $mount + * + * @return void + */ + public function createLocalStorageMountUsingTheOccCommand(string $mount):void { + $result = SetupHelper::createLocalStorageMount( + $mount, + $this->featureContext->getStepLineRef() + ); + $storageId = $result['storageId']; + if (!is_numeric($storageId)) { + throw new Exception( + __METHOD__ . " storageId '$storageId' is not numeric" + ); + } + $this->featureContext->setResultOfOccCommand($result); + $this->featureContext->addStorageId($mount, (int) $storageId); + } + + /** + * @param string $key + * @param string $value + * @param string $app + * + * @return void + * @throws Exception + */ + public function addConfigKeyWithValueInAppUsingTheOccCommand(string $key, string $value, string $app):void { + $this->invokingTheCommand( + "config:app:set --value ${value} ${app} ${key}" + ); + } + + /** + * @param string $key + * @param string $app + * + * @return void + * @throws Exception + */ + public function deleteConfigKeyOfAppUsingTheOccCommand(string $key, string $app):void { + $this->invokingTheCommand( + "config:app:delete ${app} ${key}" + ); + } + + /** + * @param string $key + * @param string $value + * @param string $type + * + * @return void + * @throws Exception + */ + public function addSystemConfigKeyUsingTheOccCommand( + string $key, + string $value, + string $type = "string" + ):void { + $this->invokingTheCommand( + "config:system:set --value '${value}' --type ${type} ${key}" + ); + } + + /** + * @param string $key + * + * @return void + * @throws Exception + */ + public function deleteSystemConfigKeyUsingTheOccCommand(string $key):void { + $this->invokingTheCommand( + "config:system:delete ${key}" + ); + } + + /** + * + * @return void + * @throws Exception + */ + public function cleanOrphanedRemoteStoragesUsingOccCommand():void { + $this->invokingTheCommand( + "sharing:cleanup-remote-storages" + ); + } + + /** + * @param string $user + * + * @return void + * @throws Exception + */ + public function emptyTrashBinOfUserUsingOccCommand(string $user):void { + $user = $this->featureContext->getActualUsername($user); + $this->invokingTheCommand( + "trashbin:cleanup $user" + ); + } + + /** + * Create a calendar for given user with given calendar name + * + * @param string $user + * @param string $calendarName + * + * @return void + * @throws Exception + */ + public function createCalendarForUserUsingOccCommand(string $user, string $calendarName):void { + $this->invokingTheCommand("dav:create-calendar $user $calendarName"); + } + + /** + * Create an address book for given user with given address book name + * + * @param string $user + * @param string $addressBookName + * + * @return void + * @throws Exception + */ + public function createAnAddressBookForUserUsingOccCommand(string $user, string $addressBookName):void { + $this->invokingTheCommand("dav:create-addressbook $user $addressBookName"); + } + + /** + * @return void + * @throws Exception + */ + public function getAllJobsInBackgroundQueueUsingOccCommand():void { + $this->invokingTheCommand( + "background:queue:status" + ); + } + + /** + * @param string $user + * + * @return void + * @throws Exception + */ + public function deleteAllVersionsForUserUsingOccCommand(string $user = ""): void { + $this->invokingTheCommand( + "versions:cleanup $user" + ); + } + + /** + * @param string $users space-separated usernames. E.g. "Alice Brian" (without ) + * + * @return void + * @throws Exception + */ + public function deleteAllVersionsForMultipleUsersUsingOccCommand(string $users): void { + $this->deleteAllVersionsForUserUsingOccCommand($users); + } + + /** + * @return void + * @throws Exception + */ + public function deleteAllVersionsForAllUsersUsingOccCommand(): void { + $this->deleteAllVersionsForUserUsingOccCommand(); + } + + /** + * @param string $user + * + * @return void + * @throws Exception + */ + public function deleteExpiredVersionsForUserUsingOccCommand(string $user = ""): void { + $this->invokingTheCommand( + "versions:expire $user" + ); + } + + /** + * @param string $users space-separated usernames. E.g. "Alice Brian" (without ) + * + * @return void + * @throws Exception + */ + public function deleteExpiredVersionsForMultipleUsersUsingOccCommand(string $users): void { + $this->deleteExpiredVersionsForUserUsingOccCommand($users); + } + + /** + * @return void + * @throws Exception + */ + public function deleteExpiredVersionsForAllUsersUsingOccCommand(): void { + $this->deleteExpiredVersionsForUserUsingOccCommand(); + } + + /** + * @param string $job + * + * @return void + * @throws Exception + */ + public function deleteLastBackgroundJobUsingTheOccCommand(string $job):void { + $match = $this->getLastJobIdForJob($job); + if ($match === false) { + throw new Exception("Couldn't find jobId for given job: $job"); + } + $this->invokingTheCommand( + "background:queue:delete $match" + ); + $this->lastDeletedJobId = $match; + } + + /** + * List created local storage mount + * + * @return void + * @throws Exception + */ + public function listLocalStorageMount():void { + $this->invokingTheCommand('files_external:list --output=json'); + } + + /** + * @When the administrator lists all local storage mount points using the occ command + * + * List created local storage mount with --short + * + * @return void + * @throws Exception + */ + public function listLocalStorageMountShort():void { + $this->invokingTheCommand('files_external:list --short --output=json'); + } + + /** + * List created local storage mount with --mount-options + * + * @return void + * @throws Exception + */ + public function listLocalStorageMountOptions():void { + $this->invokingTheCommand('files_external:list --mount-options --output=json'); + } + + /** + * List available backends + * + * @return void + * @throws Exception + */ + public function listAvailableBackends():void { + $this->invokingTheCommand('files_external:backends --output=json'); + } + + /** + * List available backends of type + * + * @param String $type + * + * @return void + * @throws Exception + */ + public function listAvailableBackendsOfType(string $type):void { + $this->invokingTheCommand('files_external:backends ' . $type . ' --output=json'); + } + + /** + * List backend of type + * + * @param String $type + * @param String $backend + * + * @return void + * @throws Exception + */ + public function listBackendOfType(string $type, string $backend):void { + $this->invokingTheCommand('files_external:backends ' . $type . ' ' . $backend . ' --output=json'); + } + + /** + * List created local storage mount with --show-password + * + * @return void + * @throws Exception + */ + public function listLocalStorageShowPassword():void { + $this->invokingTheCommand('files_external:list --show-password --output=json'); + } + + /** + * @When the administrator enables DAV tech_preview + * + * @return void + * @throws Exception + */ + public function theAdministratorEnablesDAVTechPreview():void { + $this->enableDAVTechPreview(); + } + + /** + * @Given the administrator has enabled DAV tech_preview + * + * @return void + * @throws Exception + */ + public function theAdministratorHasEnabledDAVTechPreview():void { + if ($this->enableDAVTechPreview()) { + $this->theCommandShouldHaveBeenSuccessful(); + } + } + + /** + * @When /^the administrator invokes occ command "([^"]*)"$/ + * + * @param string $cmd + * + * @return void + * @throws Exception + */ + public function theAdministratorInvokesOccCommand(string $cmd):void { + $this->invokingTheCommand($cmd); + } + + /** + * @When /^the administrator invokes occ command "([^"]*)" for user "([^"]*)"$/ + * + * @param string $cmd + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorInvokesOccCommandForUser(string $cmd, string $user):void { + $user = $this->featureContext->getActualUsername($user); + $cmd = $this->featureContext->substituteInLineCodes( + $cmd, + $user + ); + $this->invokingTheCommand($cmd); + } + + /** + * @Given /^the administrator has invoked occ command "([^"]*)"$/ + * + * @param string $cmd + * + * @return void + * @throws Exception + */ + public function theAdministratorHasInvokedOccCommand(string $cmd):void { + $this->invokingTheCommand($cmd); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @Given the administrator has selected master key encryption type using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSelectedMasterKeyEncryptionTypeUsingTheOccCommand():void { + $this->featureContext->runOcc(['encryption:select-encryption-type', "masterkey --yes"]); + } + + /** + * @When the administrator imports security certificate from file :filename in temporary storage on the system under test + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function theAdministratorImportsSecurityCertificateFromFile(string $filename):void { + $this->importSecurityCertificateFromFileInTemporaryStorage($filename); + } + + /** + * @Given the administrator has imported security certificate from file :filename in temporary storage on the system under test + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function theAdministratorHasImportedSecurityCertificateFromFile(string $filename):void { + $this->importSecurityCertificateFromFileInTemporaryStorage($filename); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator removes the security certificate :certificate + * + * @param string $certificate + * + * @return void + * @throws Exception + */ + public function theAdministratorRemovesTheSecurityCertificate(string $certificate):void { + $this->invokingTheCommand("security:certificates:remove " . $certificate); + \array_push($this->removedCertificates, $certificate); + } + + /** + * @When /^the administrator invokes occ command "([^"]*)" with environment variable "([^"]*)" set to "([^"]*)"$/ + * + * @param string $cmd + * @param string $envVariableName + * @param string $envVariableValue + * + * @return void + * @throws Exception + */ + public function theAdministratorInvokesOccCommandWithEnvironmentVariable( + string $cmd, + string $envVariableName, + string $envVariableValue + ):void { + $this->featureContext->setOccLastCode( + $this->invokingTheCommandWithEnvVariable( + $cmd, + $envVariableName, + $envVariableValue + ) + ); + } + + /** + * @Given /^the administrator has invoked occ command "([^"]*)" with environment variable "([^"]*)" set to "([^"]*)"$/ + * + * @param string $cmd + * @param string $envVariableName + * @param string $envVariableValue + * + * @return void + * @throws Exception + */ + public function theAdministratorHasInvokedOccCommandWithEnvironmentVariable( + string $cmd, + string $envVariableName, + string $envVariableValue + ):void { + $this->invokingTheCommandWithEnvVariable( + $cmd, + $envVariableName, + $envVariableValue + ); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator runs upgrade routines on local server using the occ command + * + * @return void + */ + public function theAdministratorRunsUpgradeRoutinesOnLocalServerUsingTheOccCommand():void { + \system("./occ upgrade", $status); + if ($status !== 0) { + // if the above command fails make sure to turn off maintenance mode + \system("./occ maintenance:mode --off"); + } + } + + /** + * @Given the administrator has decrypted everything + * + * @return void + * @throws Exception + */ + public function theAdministratorHasDecryptedEverything():void { + $this->theAdministratorRunsEncryptionDecryptAllUsingTheOccCommand(); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator disables encryption using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorDisablesEncryptionUsingTheOccCommand():void { + $this->invokingTheCommand("encryption:disable"); + } + + /** + * @When the administrator runs encryption decrypt all using the occ command + * + * @return void + */ + public function theAdministratorRunsEncryptionDecryptAllUsingTheOccCommand():void { + \system("./occ maintenance:singleuser --on"); + \system("./occ encryption:decrypt-all -c yes", $status); + + $this->featureContext->setResultOfOccCommand(["code" => $status, "stdOut" => "", "stdErr" => ""]); + \system("./occ maintenance:singleuser --off"); + } + + /** + * @return bool + */ + public function theOccCommandExitStatusWasSuccess():bool { + return ($this->featureContext->getExitStatusCodeOfOccCommand() === 0); + } + + /** + * @Then /^the command should have been successful$/ + * + * @return void + * @throws Exception + */ + public function theCommandShouldHaveBeenSuccessful():void { + $exceptions = $this->featureContext->findExceptions(); + $exitStatusCode = $this->featureContext->getExitStatusCodeOfOccCommand(); + if ($exitStatusCode !== 0) { + $msg = "The command was not successful, exit code was " . + $exitStatusCode . ".\n" . + "stdOut was: '" . + $this->featureContext->getStdOutOfOccCommand() . "'\n" . + "stdErr was: '" . + $this->featureContext->getStdErrOfOccCommand() . "'\n"; + if (!empty($exceptions)) { + $msg .= ' Exceptions: ' . \implode(', ', $exceptions); + } + if ($exitStatusCode === null) { + $msg = "The occ command did not run "; + } + throw new Exception($msg); + } elseif (!empty($exceptions)) { + $msg = 'The command was successful but triggered exceptions: ' + . \implode(', ', $exceptions); + throw new Exception($msg); + } + } + + /** + * @Then /^the command should have failed with exit code ([0-9]+)$/ + * + * @param int $exitCode + * + * @return void + * @throws Exception + */ + public function theCommandFailedWithExitCode(int $exitCode):void { + $exitStatusCode = $this->featureContext->getExitStatusCodeOfOccCommand(); + Assert::assertEquals( + (int) $exitCode, + $exitStatusCode, + "The command was expected to fail with exit code $exitCode but got {$exitStatusCode}" + ); + } + + /** + * @Then /^the command output should be the text ((?:'[^']*')|(?:"[^"]*"))$/ + * + * @param string $text + * + * @return void + * @throws Exception + */ + public function theCommandOutputShouldBeTheText(string $text):void { + $text = \trim($text, $text[0]); + $commandOutput = \trim($this->featureContext->getStdOutOfOccCommand()); + Assert::assertEquals( + $text, + $commandOutput, + "The command output did not match the expected text on stdout '$text'\n" . + "The command output on stdout was:\n" . + $commandOutput + ); + } + + /** + * @Then /^the command should have failed with exception text "([^"]*)"$/ + * + * @param string $exceptionText + * + * @return void + * @throws Exception + */ + public function theCommandFailedWithExceptionText(string $exceptionText):void { + $exceptions = $this->featureContext->findExceptions(); + Assert::assertNotEmpty( + $exceptions, + 'The command did not throw any exceptions' + ); + + Assert::assertContains( + $exceptionText, + $exceptions, + "The command did not throw any exception with the text '$exceptionText'" + ); + } + + /** + * @Then /^the command output should contain the text ((?:'[^']*')|(?:"[^"]*"))$/ + * + * @param string $text + * + * @return void + * @throws Exception + */ + public function theCommandOutputContainsTheText(string $text):void { + // The capturing group of the regex always includes the quotes at each + // end of the captured string, so trim them. + $text = \trim($text, $text[0]); + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $lines = SetupHelper::findLines( + $commandOutput, + $text + ); + Assert::assertGreaterThanOrEqual( + 1, + \count($lines), + "The command output did not contain the expected text on stdout '$text'\n" . + "The command output on stdout was:\n" . + $commandOutput + ); + } + + /** + * @Then /^the command output should contain the text ((?:'[^']*')|(?:"[^"]*")) about user "([^"]*)"$/ + * + * @param string $text + * @param string $user + * + * @return void + * @throws Exception + */ + public function theCommandOutputContainsTheTextAboutUser(string $text, string $user):void { + // The capturing group of the regex always includes the quotes at each + // end of the captured string, so trim them. + $text = \trim($text, $text[0]); + $text = $this->featureContext->substituteInLineCodes( + $text, + $user + ); + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $lines = SetupHelper::findLines( + $commandOutput, + $text + ); + Assert::assertGreaterThanOrEqual( + 1, + \count($lines), + "The command output did not contain the expected text on stdout '$text'\n" . + "The command output on stdout was:\n" . + $commandOutput + ); + } + + /** + * @Then /^the command error output should contain the text ((?:'[^']*')|(?:"[^"]*"))$/ + * + * @param string $text + * + * @return void + * @throws Exception + */ + public function theCommandErrorOutputContainsTheText(string $text):void { + // The capturing group of the regex always includes the quotes at each + // end of the captured string, so trim them. + $text = \trim($text, $text[0]); + $commandOutput = $this->featureContext->getStdErrOfOccCommand(); + $lines = SetupHelper::findLines( + $commandOutput, + $text + ); + Assert::assertGreaterThanOrEqual( + 1, + \count($lines), + "The command output did not contain the expected text on stderr '$text'\n" . + "The command output on stderr was:\n" . + $commandOutput + ); + } + + /** + * @Then the occ command JSON output should be empty + * + * @return void + */ + public function theOccCommandJsonOutputShouldNotReturnAnyData():void { + Assert::assertEquals( + \trim($this->featureContext->getStdOutOfOccCommand()), + "[]", + "Expected command output to be '[]' but got '" + . \trim($this->featureContext->getStdOutOfOccCommand()) + . "'" + ); + Assert::assertEmpty( + $this->featureContext->getStdErrOfOccCommand(), + "Expected occ command error to be empty but got '" + . $this->featureContext->getStdErrOfOccCommand() + . "'" + ); + } + + /** + * @Then :noOfOrphanedShare orphaned remote storage should have been cleared + * + * @param int $noOfOrphanedShare + * + * @return void + * @throws Exception + */ + public function theOrphanedRemoteStorageShouldBeCleared(int $noOfOrphanedShare):void { + $this->theCommandShouldHaveBeenSuccessful(); + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + // removing blank lines + $commandOutput = implode("\n", array_filter(explode("\n", $commandOutput))); + // splitting strings based on newline into an array + $outputLines = preg_split("/\r\n|\n|\r/", $commandOutput); + // first line of command output contains total remote storage scanned + $totalRemoteStorage = (int) filter_var($outputLines[0], FILTER_SANITIZE_NUMBER_INT); + echo "totalremotestorage: $totalRemoteStorage"; + // calculating total no of shares deleted from remote storage: first getting total length of the array + // then minus 2 for first two lines of scan info message + // then divide by 2 because each share delete has message of two line + $totalSharesDeleted = ((\count($outputLines) - 2) / 2) / $totalRemoteStorage; + Assert::assertEquals( + $noOfOrphanedShare, + $totalSharesDeleted, + "The command was expected to delete '$noOfOrphanedShare' orphaned shares but only deleted '$totalSharesDeleted' orphaned shares." + ); + } + + /** + * @Given the administrator has set the default folder for received shares to :folder + * + * @param string $folder + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetTheDefaultFolderForReceivedSharesTo(string $folder):void { + if (OcisHelper::isTestingOnOcisOrReva()) { + // The default folder for received shares is already "Shares" on OCIS and REVA. + // If the step is asking for a different folder, then fail. + // Otherwise just return - the setting is already done by default. + Assert::assertEquals( + "Shares", + \trim($folder, "/"), + __METHOD__ . " tried to set the default folder for received shares to $folder but that is not possible on OCIS" + ); + return; + } + $this->addSystemConfigKeyUsingTheOccCommand( + "share_folder", + $folder + ); + } + + /** + * @When the administrator has set depth_infinity_allowed to :depth_infinity_allowed + * + * @param int $depthInfinityAllowed + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetDepthInfinityAllowedTo($depthInfinityAllowed) { + $depthInfinityAllowedString = (string) $depthInfinityAllowed; + $this->addSystemConfigKeyUsingTheOccCommand( + "dav.propfind.depth_infinity", + $depthInfinityAllowedString + ); + if ($depthInfinityAllowedString === "0") { + $this->featureContext->davPropfindDepthInfinityDisabled(); + } else { + $this->featureContext->davPropfindDepthInfinityEnabled(); + } + } + + /** + * @Given the administrator has set the mail smtpmode to :smtpmode + * + * @param string $smtpmode + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetTheMailSmtpmodeTo(string $smtpmode):void { + $this->addSystemConfigKeyUsingTheOccCommand( + "mail_smtpmode", + $smtpmode + ); + } + + /** + * @When the administrator sets the log level to :level using the occ command + * + * @param string $level + * + * @return void + * @throws Exception + */ + public function theAdministratorSetsLogLevelUsingTheOccCommand(string $level):void { + $this->invokingTheCommand( + "log:manage --level $level" + ); + } + + /** + * @When the administrator sets the timezone to :timezone using the occ command + * + * @param string $timezone + * + * @return void + * @throws Exception + */ + public function theAdministratorSetsTimeZoneUsingTheOccCommand(string $timezone):void { + $this->invokingTheCommand( + "log:manage --timezone $timezone" + ); + } + + /** + * @When the administrator sets the backend to :backend using the occ command + * + * @param string $backend + * + * @return void + * @throws Exception + */ + public function theAdministratorSetsBackendUsingTheOccCommand(string $backend):void { + $this->invokingTheCommand( + "log:manage --backend $backend" + ); + } + + /** + * @When the administrator enables the ownCloud backend using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorEnablesOwnCloudBackendUsingTheOccCommand():void { + $this->invokingTheCommand( + "log:owncloud --enable" + ); + } + + /** + * @When the administrator sets the log file path to :path using the occ command + * + * @param string $path + * + * @return void + * @throws Exception + */ + public function theAdministratorSetsLogFilePathUsingTheOccCommand(string $path):void { + $this->invokingTheCommand( + "log:owncloud --file $path" + ); + } + + /** + * @When the administrator sets the log rotate file size to :size using the occ command + * + * @param string $size + * + * @return void + * @throws Exception + */ + public function theAdministratorSetsLogRotateFileSizeUsingTheOccCommand(string $size):void { + $this->invokingTheCommand( + "log:owncloud --rotate-size $size" + ); + } + + /** + * @Then the command output should be: + * + * @param PyStringNode $content + * + * @return void + * @throws Exception + */ + public function theCommandOutputShouldBe(PyStringNode $content):void { + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + // removing blank lines + $commandOutput = \implode("\n", \array_filter(\explode("\n", $commandOutput))); + $content = \implode("\n", \array_filter(\explode("\n", $content->getRaw()))); + Assert::assertEquals( + $content, + $commandOutput, + "The command output was expected to be '$content' but got '$commandOutput'" + ); + } + + /** + * @When the administrator changes the background jobs mode to :mode using the occ command + * + * @param string $mode + * + * @return void + * @throws Exception + */ + public function theAdministratorChangesTheBackgroundJobsModeTo(string $mode):void { + $this->changeBackgroundJobsModeUsingTheOccCommand($mode); + } + + /** + * @Given the administrator has changed the background jobs mode to :mode + * + * @param string $mode + * + * @return void + * @throws Exception + */ + public function theAdministratorHasChangedTheBackgroundJobsModeTo(string $mode):void { + $this->changeBackgroundJobsModeUsingTheOccCommand($mode); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator sets the external storage :mountPoint to read-only using the occ command + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdminSetsTheExtStorageToReadOnly(string $mountPoint):void { + $this->setExtStorageReadOnlyUsingTheOccCommand($mountPoint); + } + + /** + * @Given the administrator has set the external storage :mountPoint to read-only + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdminHasSetTheExtStorageToReadOnly(string $mountPoint):void { + $this->setExtStorageReadOnlyUsingTheOccCommand($mountPoint); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @Given the administrator has set the external storage :mountPoint to sharing + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdminHasSetTheExtStorageToSharing(string $mountPoint):void { + $this->setExtStorageSharingUsingTheOccCommand($mountPoint); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When /^the administrator (enables|disables) sharing for the external storage "([^"]*)" using the occ command$/ + * + * @param string $action + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdminDisablesSharingForTheExtStorage(string $action, string $mountPoint):void { + $this->setExtStorageSharingUsingTheOccCommand($mountPoint, $action === "enables"); + } + + /** + * @When the administrator sets the external storage :mountPoint to be never scanned automatically using the occ command + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdminSetsTheExtStorageToBeNeverScannedAutomatically(string $mountPoint):void { + $this->setExtStorageCheckChangesUsingTheOccCommand($mountPoint, "never"); + } + + /** + * @Given the administrator has set the external storage :mountPoint to be never scanned automatically + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function theAdminHasSetTheExtStorageToBeNeverScannedAutomatically(string $mountPoint):void { + $this->setExtStorageCheckChangesUsingTheOccCommand($mountPoint, "never"); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator scans the filesystem for all users using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorScansTheFilesystemForAllUsersUsingTheOccCommand():void { + $this->scanFileSystemForAllUsersUsingTheOccCommand(); + } + + /** + * @Given the administrator has scanned the filesystem for all users + * + * @return void + * @throws Exception + */ + public function theAdministratorHasScannedTheFilesystemForAllUsersUsingTheOccCommand():void { + $this->scanFileSystemForAllUsersUsingTheOccCommand(); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator scans the filesystem for user :user using the occ command + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorScansTheFilesystemForUserUsingTheOccCommand(string $user):void { + $this->scanFileSystemForAUserUsingTheOccCommand($user); + } + + /** + * @Given the administrator has scanned the filesystem for user :user + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorHasScannedTheFilesystemForUserUsingTheOccCommand(string $user):void { + $this->scanFileSystemForAUserUsingTheOccCommand($user); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator scans the filesystem in path :path of user :user using the occ command + * + * @param string $path + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorScansTheFilesystemInPathUsingTheOccCommand(string $path, string $user):void { + $user = $this->featureContext->getActualUsername($user); + $this->scanFileSystemPathUsingTheOccCommand($path, $user); + } + + /** + * @Given the administrator scans the filesystem in path :path + * + * @param string $path + * + * @return void + * @throws Exception + */ + public function theAdministratorHasScannedTheFilesystemInPathUsingTheOccCommand(string $path):void { + $this->scanFileSystemPathUsingTheOccCommand($path); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator scans the filesystem for group :group using the occ command + * + * Used to test the --group option of the files:scan command + * + * @param string $group a single group name + * + * @return void + * @throws Exception + */ + public function theAdministratorScansTheFilesystemForGroupUsingTheOccCommand(string $group):void { + $this->scanFileSystemForAGroupUsingTheOccCommand($group); + } + + /** + * @Given the administrator has scanned the filesystem for group :group + * + * Used to test the --group option of the files:scan command + * + * @param string $group a single group name + * + * @return void + * @throws Exception + */ + public function theAdministratorHasScannedTheFilesystemForGroupUsingTheOccCommand(string $group):void { + $this->scanFileSystemForAGroupUsingTheOccCommand($group); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator scans the filesystem for groups list :groups using the occ command + * + * Used to test the --groups option of the files:scan command + * + * @param string $groups a comma-separated list of group names + * + * @return void + * @throws Exception + */ + public function theAdministratorScansTheFilesystemForGroupsUsingTheOccCommand(string $groups):void { + $this->scanFileSystemForGroupsUsingTheOccCommand($groups); + } + + /** + * @Given the administrator has scanned the filesystem for groups list :groups + * + * Used to test the --groups option of the files:scan command + * + * @param string $groups a comma-separated list of group names + * + * @return void + * @throws Exception + */ + public function theAdministratorHasScannedTheFilesystemForGroupsUsingTheOccCommand(string $groups):void { + $this->scanFileSystemForGroupsUsingTheOccCommand($groups); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator cleanups the filesystem for all users using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorCleanupsTheFilesystemForAllUsersUsingTheOccCommand():void { + $this->invokingTheCommand( + "files:cleanup" + ); + } + + /** + * @When the administrator creates the local storage mount :mount using the occ command + * + * @param string $mount + * + * @return void + */ + public function theAdministratorCreatesTheLocalStorageMountUsingTheOccCommand(string $mount):void { + $this->createLocalStorageMountUsingTheOccCommand($mount); + } + + /** + * @Given the administrator has created the local storage mount :mount + * + * @param string $mount + * + * @return void + * @throws Exception + */ + public function theAdministratorHasCreatedTheLocalStorageMountUsingTheOccCommand(string $mount):void { + $this->createLocalStorageMountUsingTheOccCommand($mount); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @param string $action + * @param string $userOrGroup + * @param string $userOrGroupName + * @param string $mountName + * + * @return void + * @throws Exception + */ + public function addRemoveUserOrGroupToOrFromMount( + string $action, + string $userOrGroup, + string $userOrGroupName, + string $mountName + ):void { + if ($action === "adds" || $action === "added") { + $action = "--add"; + } else { + $action = "--remove"; + } + if ($userOrGroup === "user") { + $action = "$action-user"; + $userOrGroupName = $this->featureContext->getActualUsername($userOrGroupName); + } else { + $action = "$action-group"; + } + $mountId = $this->featureContext->getStorageId($mountName); + $this->featureContext->setOccLastCode( + $this->featureContext->runOcc( + [ + 'files_external:applicable', + $mountId, + "$action ", + "$userOrGroupName" + ] + ) + ); + } + + /** + * @param string $mount + * @param string $userOrGroup + * + * @return array + * @throws Exception + */ + public function getListOfApplicableUserOrGroupForMount(string $mount, string $userOrGroup):array { + $validArgs = ["users", "groups"]; + if (!\in_array($userOrGroup, $validArgs)) { + throw new Exception( + "Invalid key provided. Expected:" . + \implode(", ", $validArgs) . + "Found: " . $userOrGroup + ); + } + $mountId = $this->getMountIdForLocalStorage($mount); + $this->featureContext->runOcc( + [ + 'files_external:applicable', + $mountId, + '--output=json' + ] + ); + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + return ($userOrGroup === "users") ? $commandOutput->users : $commandOutput->groups; + } + + /** + * @param string $action + * @param string $userOrGroup + * @param string $userOrGroupName + * + * @return void + * @throws Exception + */ + public function addRemoveUserOrGroupToOrFromLastLocalMount( + string $action, + string $userOrGroup, + string $userOrGroupName + ):void { + $storageIds = $this->featureContext->getStorageIds(); + Assert::assertGreaterThan( + 0, + \count($storageIds), + "addRemoveAsApplicableUserLastLocalMount no local mounts exist" + ); + $lastMountName = \end($storageIds); + $this->addRemoveUserOrGroupToOrFromMount( + $action, + $userOrGroup, + $userOrGroupName, + $lastMountName + ); + } + + /** + * @When /^the administrator (adds|removes) (user|group) "([^"]*)" (?:as|from) the applicable (?:user|group) for the last local storage mount using the occ command$/ + * + * @param string $action + * @param string $userOrGroup + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdminAddsRemovesAsTheApplicableUserLastLocalMountUsingTheOccCommand( + string $action, + string $userOrGroup, + string $user + ):void { + $this->addRemoveUserOrGroupToOrFromLastLocalMount( + $action, + $userOrGroup, + $user + ); + } + + /** + * @Given /^the administrator has (added|removed) (user|group) "([^"]*)" (?:as|from) the applicable (?:user|group) for the last local storage mount$/ + * + * @param string $action + * @param string $userOrGroup + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdminHasAddedRemovedAsTheApplicableUserLastLocalMountUsingTheOccCommand( + string $action, + string $userOrGroup, + string $user + ):void { + $this->addRemoveUserOrGroupToOrFromLastLocalMount( + $action, + $userOrGroup, + $user + ); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When /^the administrator (adds|removes) (user|group) "([^"]*)" (?:as|from) the applicable (?:user|group) for local storage mount "([^"]*)" using the occ command$/ + * + * @param string $action + * @param string $userOrGroup + * @param string $user + * @param string $mount + * + * @return void + * @throws Exception + */ + public function theAdminAddsRemovesAsTheApplicableUserForMountUsingTheOccCommand( + string $action, + string $userOrGroup, + string $user, + string $mount + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->addRemoveUserOrGroupToOrFromMount( + $action, + $userOrGroup, + $user, + $mount + ); + } + + /** + * @Then /^the following (users|groups) should be listed as applicable for local storage mount "([^"]*)"$/ + * + * @param string $usersOrGroups comma separated lists eg: Alice, Brian + * @param string $localStorage + * @param TableNode $applicable + * + * @return void + * @throws Exception + */ + public function theFollowingUsersOrGroupsShouldBeListedAsApplicable(string $usersOrGroups, string $localStorage, TableNode $applicable): void { + $this->featureContext->verifyTableNodeRows( + $applicable, + [], + ["users", "groups"] + ); + $expectedApplicableList = $applicable->getRowsHash(); + $actualApplicableList = $this->getListOfApplicableUserOrGroupForMount($localStorage, $usersOrGroups); + foreach ($expectedApplicableList as $expectedApplicable) { + Assert::assertContains( + $expectedApplicable, + $actualApplicableList, + __METHOD__ + . $usersOrGroups + . " not found!\nexpected: " + . $expectedApplicable + . " to be in the list [" + . \implode(", ", $actualApplicableList) + . "]." + ); + } + } + + /** + * @Then /^the applicable (users|groups) list should be empty for local storage mount "([^"]*)"$/ + * + * @param string $usersOrGroups + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function theApplicableUsersOrGroupsListShouldBeEmptyForLocalStorageMount(string $usersOrGroups, string $localStorage): void { + $actualApplicableList = $this->getListOfApplicableUserOrGroupForMount($localStorage, $usersOrGroups); + Assert::assertEquals( + 0, + \count($actualApplicableList), + __METHOD__ + . "Expected empty list for applicable " + . $usersOrGroups + . " but found: [" + . \implode(", ", $actualApplicableList) + . "]." + ); + } + + /** + * @When the administrator removes all from the applicable users and groups for local storage mount :localStorage using the occ command + * + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function theAdminRemovesAllForTheMountUsingOccCommand(string $localStorage):void { + $mountId = $this->featureContext->getStorageId($localStorage); + $this->featureContext->runOcc( + [ + 'files_external:applicable', + $mountId, + "--remove-all" + ] + ); + } + + /** + * @Given /^the administrator has (added|removed) (user|group) "([^"]*)" (?:as|from) the applicable (?:user|group) for local storage mount "([^"]*)"$/ + * + * @param string $action + * @param string $userOrGroup + * @param string $user + * @param string $mount + * + * @return void + * @throws Exception + */ + public function theAdminHasAddedRemovedTheApplicableUserForMountUsingTheOccCommand( + string $action, + string $userOrGroup, + string $user, + string $mount + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->addRemoveUserOrGroupToOrFromMount( + $action, + $userOrGroup, + $user, + $mount + ); + $this->theCommandShouldHaveBeenSuccessful(); + // making plural "users" or "groups" + $userOrGroup = $userOrGroup . "s"; + $actualApplicableList = $this->getListOfApplicableUserOrGroupForMount($mount, $userOrGroup); + + if ($action === "added") { + Assert::assertContains( + $user, + $actualApplicableList, + __METHOD__ + . " The expected applicable " + . $userOrGroup + . " is not present in the actual list of applicable " + . $userOrGroup + . " for mount point: " + . $mount . ".\n" + ); + } else { + Assert::assertNotContains( + $user, + $actualApplicableList, + __METHOD__ + . " The applicable " + . $userOrGroup + . " is present in the actual list of applicable " + . $userOrGroup + . " for mount point: " + . $mount . ".\n" + ); + } + } + + /** + * @When the administrator configures the key :key with value :value for the local storage mount :localStorage + * + * @param string $key + * @param string $value + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function adminConfiguresLocalStorageMountUsingTheOccCommand(string $key, string $value, string $localStorage):void { + $mountId = $this->featureContext->getStorageId($localStorage); + $this->featureContext->runOcc( + [ + "files_external:config", + $mountId, + $key, + $value + ] + ); + } + + /** + * @When the administrator lists the available backends using the occ command + * + * @return void + * @throws Exception + */ + public function adminListsAvailableBackendsUsingTheOccCommand():void { + $this->listAvailableBackends(); + } + + /** + * @When the administrator lists the available backends of type :type using the occ command + * + * @param String $type + * + * @return void + * @throws Exception + */ + public function adminListsAvailableBackendsOfTypeUsingTheOccCommand(string $type):void { + $this->listAvailableBackendsOfType($type); + } + + /** + * @When the administrator lists the :backend backend of type :backendType using the occ command + * + * @param String $backend + * @param String $backendType + * + * @return void + * @throws Exception + */ + public function adminListsBackendOfTypeUsingTheOccCommand(string $backend, string $backendType):void { + $this->listBackendOfType($backendType, $backend); + } + + /** + * @When the administrator lists configurations with the existing key :key for the local storage mount :localStorage + * + * @param string $key + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function adminListsConfigurationsWithExistingKeyForLocalStorageMountUsingTheOccCommand(string $key, string $localStorage):void { + $mountId = $this->featureContext->getStorageId($localStorage); + $this->featureContext->runOcc( + [ + "files_external:config", + $mountId, + $key, + ] + ); + } + + /** + * @Given the administrator has configured the key :key with value :value for the local storage mount :localStorage + * + * @param string $key + * @param string $value + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function adminConfiguredLocalStorageMountUsingTheOccCommand(string $key, string $value, string $localStorage):void { + $mountId = $this->featureContext->getStorageId($localStorage); + $this->featureContext->runOcc( + [ + "files_external:config", + $mountId, + $key, + $value + ] + ); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator lists the local storage using the occ command + * + * @return void + * @throws Exception + */ + public function adminListsLocalStorageMountUsingTheOccCommand():void { + $this->listLocalStorageMount(); + } + + /** + * @When the administrator lists the local storage with --short using the occ command + * + * @return void + * @throws Exception + */ + public function adminListsLocalStorageMountShortUsingTheOccCommand():void { + $this->listLocalStorageMountShort(); + } + + /** + * @When the administrator lists the local storage with --mount-options using the occ command + * + * @return void + * @throws Exception + */ + public function adminListsLocalStorageMountOptionsUsingTheOccCommand():void { + $this->listLocalStorageMountOptions(); + } + + /** + * @When the administrator lists the local storage with --show-password using the occ command + * + * @return void + * @throws Exception + */ + public function adminListsLocalStorageShowPasswordUsingTheOccCommand():void { + $this->listLocalStorageShowPassword(); + } + + /** + * @Then the following local storage should exist + * + * @param TableNode $mountPoints + * + * @return void + */ + public function theFollowingLocalStoragesShouldExist(TableNode $mountPoints):void { + $createdLocalStorage = []; + $expectedLocalStorages = $mountPoints->getColumnsHash(); + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($commandOutput as $storageEntry) { + $createdLocalStorage[$storageEntry->mount_id] = \ltrim($storageEntry->mount_point, '/'); + } + foreach ($expectedLocalStorages as $expectedStorageEntry) { + Assert::assertContains( + $expectedStorageEntry['localStorage'], + $createdLocalStorage, + "'" + . \implode(', ', $createdLocalStorage) + . "' does not contain '${expectedStorageEntry['localStorage']}' " + . __METHOD__ + ); + } + } + + /** + * @Then the following local storage should not exist + * + * @param TableNode $mountPoints + * + * @return void + * @throws Exception + */ + public function theFollowingLocalStoragesShouldNotExist(TableNode $mountPoints):void { + $createdLocalStorage = []; + $this->listLocalStorageMount(); + $expectedLocalStorages = $mountPoints->getColumnsHash(); + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($commandOutput as $i) { + $createdLocalStorage[$i->mount_id] = \ltrim($i->mount_point, '/'); + } + foreach ($expectedLocalStorages as $i) { + Assert::assertNotContains( + $i['localStorage'], + $createdLocalStorage, + "{$i['localStorage']} exists but was not expected to exist" + ); + } + } + + /** + * @Then the following backend types should be listed: + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingBackendTypesShouldBeListed(TableNode $table):void { + $expectedBackendTypes = $table->getColumnsHash(); + foreach ($expectedBackendTypes as $expectedBackendTypeEntry) { + Assert::assertArrayHasKey( + 'backend-type', + $expectedBackendTypeEntry, + __METHOD__ + . " The provided expected backend type entry '" + . \implode(', ', $expectedBackendTypeEntry) + . "' do not have key 'backend-type'" + ); + } + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + $keys = \array_keys((array) $commandOutput); + foreach ($expectedBackendTypes as $backendTypesEntry) { + Assert::assertContains( + $backendTypesEntry['backend-type'], + $keys, + __METHOD__ + . " ${backendTypesEntry['backend-type']} is not contained in '" + . \implode(', ', $keys) + . "' but was expected to be." + ); + } + } + + /** + * @Then the following authentication/storage backends should be listed: + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingBackendsShouldBeListed(TableNode $table):void { + $expectedBackends = $table->getColumnsHash(); + foreach ($expectedBackends as $expectedBackendEntry) { + Assert::assertArrayHasKey( + 'backends', + $expectedBackendEntry, + __METHOD__ + . " The provided expected backend entry '" + . \implode(', ', $expectedBackendEntry) + . "' do not have key 'backends'" + ); + } + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + $keys = \array_keys((array) $commandOutput); + foreach ($expectedBackends as $backendsEntry) { + Assert::assertContains( + $backendsEntry['backends'], + $keys, + __METHOD__ + . " ${backendsEntry['backends']} is not contained in '" + . \implode(', ', $keys) + . "' but was expected to be." + ); + } + } + + /** + * @Then the following authentication/storage backend keys should be listed: + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingBackendKeysOfTypeShouldBeListed(TableNode $table):void { + $expectedBackendKeys = $table->getColumnsHash(); + foreach ($expectedBackendKeys as $expectedBackendKeyEntry) { + Assert::assertArrayHasKey( + 'backend-keys', + $expectedBackendKeyEntry, + __METHOD__ + . " The provided expected backend key entry '" + . \implode(', ', $expectedBackendKeyEntry) + . "' do not have key 'backend-keys'" + ); + } + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + $keys = \array_keys((array) $commandOutput); + foreach ($expectedBackendKeys as $backendKeysEntry) { + Assert::assertContains( + $backendKeysEntry['backend-keys'], + $keys, + __METHOD__ + . " ${backendKeysEntry['backend-keys']} is not contained in '" + . \implode(', ', $keys) + . "' but was expected to be." + ); + } + } + + /** + * @Then the following local storage should be listed: + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingLocalStorageShouldBeListed(TableNode $table):void { + $this->featureContext->verifyTableNodeColumns( + $table, + ['MountPoint', 'ApplicableUsers', 'ApplicableGroups'], + ['Storage', 'AuthenticationType', 'Configuration', 'Options', 'Auth', 'Type'] + ); + $expectedLocalStorages = $table->getColumnsHash(); + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($expectedLocalStorages as $expectedStorageEntry) { + $isStorageEntryListed = false; + foreach ($commandOutput as $listedStorageEntry) { + if ($expectedStorageEntry["MountPoint"] === $listedStorageEntry->mount_point) { + if (isset($expectedStorageEntry['Storage'])) { + Assert::assertEquals( + $expectedStorageEntry['Storage'], + $listedStorageEntry->storage, + "Storage column does not have the expected value for storage " + . $expectedStorageEntry['MountPoint'] + ); + } + if (isset($expectedStorageEntry['AuthenticationType'])) { + Assert::assertEquals( + $expectedStorageEntry['AuthenticationType'], + $listedStorageEntry->authentication_type, + "AuthenticationType column does not have the expected value for storage " + . $expectedStorageEntry['MountPoint'] + ); + } + if (isset($expectedStorageEntry['Auth'])) { + Assert::assertEquals( + $expectedStorageEntry['Auth'], + $listedStorageEntry->auth, + "Auth column does not have the expected value for storage " + . $expectedStorageEntry['MountPoint'] + ); + } + if (isset($expectedStorageEntry['Configuration'])) { + if ($expectedStorageEntry['Configuration'] === '') { + Assert::assertEquals( + '', + $listedStorageEntry->configuration, + 'Configuration column should be empty but is ' + . $listedStorageEntry->configuration + ); + } else { + if (\is_string($listedStorageEntry->configuration)) { + Assert::assertStringStartsWith( + $expectedStorageEntry['Configuration'], + $listedStorageEntry->configuration, + "Configuration column does not start with the expected value for storage " + . $expectedStorageEntry['Configuration'] + ); + } else { + $item = \strtok($expectedStorageEntry['Configuration'], ':'); + Assert::assertTrue( + \property_exists($listedStorageEntry->configuration, $item), + "$item was not found in the Configuration column" + ); + } + } + } + if (isset($expectedStorageEntry['Options'])) { + Assert::assertEquals( + $expectedStorageEntry['Options'], + $listedStorageEntry->options, + "Options column does not have the expected value for storage " + . $expectedStorageEntry['MountPoint'] + ); + } + if (isset($expectedStorageEntry['ApplicableUsers'])) { + if (\is_string($listedStorageEntry->applicable_users)) { + if ($listedStorageEntry->applicable_users === '') { + $listedApplicableUsers = []; + } else { + $listedApplicableUsers = \explode(', ', $listedStorageEntry->applicable_users); + } + } else { + $listedApplicableUsers = $listedStorageEntry->applicable_users; + } + if ($expectedStorageEntry['ApplicableUsers'] === '') { + Assert::assertEquals( + [], + $listedApplicableUsers, + "ApplicableUsers was expected to be an empty array but was not empty" + ); + } else { + $expectedApplicableUsers = \explode(', ', $expectedStorageEntry['ApplicableUsers']); + foreach ($expectedApplicableUsers as $expectedApplicableUserEntry) { + $expectedApplicableUserEntry = $this->featureContext->getActualUsername($expectedApplicableUserEntry); + Assert::assertContains( + $expectedApplicableUserEntry, + $listedApplicableUsers, + __METHOD__ + . " '$expectedApplicableUserEntry' is not listed in '" + . \implode(', ', $listedApplicableUsers) + . "'" + ); + } + } + } + if (isset($expectedStorageEntry['ApplicableGroups'])) { + if (\is_string($listedStorageEntry->applicable_groups)) { + if ($listedStorageEntry->applicable_groups === '') { + $listedApplicableGroups = []; + } else { + $listedApplicableGroups = \explode(', ', $listedStorageEntry->applicable_groups); + } + } else { + $listedApplicableGroups = $listedStorageEntry->applicable_groups; + } + if ($expectedStorageEntry['ApplicableGroups'] === '') { + Assert::assertEquals( + [], + $listedApplicableGroups, + "ApplicableGroups was expected to be an empty array but was not empty" + ); + Assert::assertEquals([], $listedApplicableGroups); + } else { + $expectedApplicableGroups = \explode(', ', $expectedStorageEntry['ApplicableGroups']); + foreach ($expectedApplicableGroups as $expectedApplicableGroupEntry) { + Assert::assertContains( + $expectedApplicableGroupEntry, + $listedApplicableGroups, + __METHOD__ + . " '$expectedApplicableGroupEntry' is not listed in '" + . \implode(', ', $listedApplicableGroups) + . "'" + ); + } + } + } + if (isset($expectedStorageEntry['Type'])) { + Assert::assertEquals( + $expectedStorageEntry['Type'], + $listedStorageEntry->type, + "Type column does not have the expected value for storage " + . $expectedStorageEntry['MountPoint'] + ); + } + $isStorageEntryListed = true; + break; + } + } + Assert::assertTrue($isStorageEntryListed, __METHOD__ . " Expected local storage {$expectedStorageEntry['MountPoint']} not found"); + } + } + + /** + * @Then the configuration output should be :expectedOutput + * + * @param string $expectedOutput + * + * @return void + * @throws Exception + */ + public function theConfigurationOutputShouldBe(string $expectedOutput):void { + $actualOutput = $this->featureContext->getStdOutOfOccCommand(); + $trimmedOutput = \trim($actualOutput); + Assert::assertEquals( + $expectedOutput, + $trimmedOutput, + __METHOD__ + . " The expected configuration output was '$expectedOutput', but got '$actualOutput' instead." + ); + } + + /** + * @Then the following should be included in the configuration of local storage :localStorage: + * + * @param string $localStorage + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingShouldBeIncludedInTheConfigurationOfLocalStorage(string $localStorage, TableNode $table):void { + $expectedConfigurations = $table->getColumnsHash(); + foreach ($expectedConfigurations as $expectedConfigurationEntry) { + Assert::assertArrayHasKey( + 'configuration', + $expectedConfigurationEntry, + __METHOD__ + . " The provided expected configuration entry '" + . \implode(', ', $expectedConfigurationEntry) + . "' do not have key 'configuration'" + ); + } + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + $isStorageEntryListed = false; + foreach ($commandOutput as $listedStorageEntry) { + if ($listedStorageEntry->mount_point === $localStorage) { + $isStorageEntryListed = true; + $configurations = $listedStorageEntry->configuration; + $configurationsSplitted = \explode(', ', $configurations); + foreach ($expectedConfigurations as $expectedConfigArray) { + foreach ($expectedConfigArray as $expectedConfigEntry) { + Assert::assertContains( + $expectedConfigEntry, + $configurationsSplitted, + __METHOD__ + . " $expectedConfigEntry is not contained in '" + . \implode(', ', $configurationsSplitted) + . "' but was expected to be." + ); + } + } + break; + } + } + Assert::assertTrue($isStorageEntryListed, "Expected local storage '$localStorage' not found "); + } + + /** + * @When the administrator adds an option with key :key and value :value for the local storage mount :localStorage + * + * @param string $key + * @param string $value + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function adminAddsOptionForLocalStorageMountUsingTheOccCommand(string $key, string $value, string $localStorage):void { + $mountId = $this->featureContext->getStorageId($localStorage); + $this->featureContext->runOcc( + [ + "files_external:option", + $mountId, + $key, + $value + ] + ); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @Then the following should be included in the options of local storage :localStorage: + * + * @param string $localStorage + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingShouldBeIncludedInTheOptionsOfLocalStorage(string $localStorage, TableNode $table):void { + $expectedOptions = $table->getColumnsHash(); + foreach ($expectedOptions as $expectedOptionEntry) { + Assert::assertArrayHasKey( + 'options', + $expectedOptionEntry, + __METHOD__ + . " The provided expected option '" + . \implode(', ', $expectedOptionEntry) + . "' do not have key 'option'" + ); + } + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + $isStorageEntryListed = false; + foreach ($commandOutput as $listedStorageEntry) { + if ($listedStorageEntry->mount_point === $localStorage) { + $isStorageEntryListed = true; + $options = $listedStorageEntry->options; + $optionsSplitted = \explode(', ', $options); + foreach ($expectedOptions as $expectedOptionArray) { + foreach ($expectedOptionArray as $expectedOptionEntry) { + Assert::assertContains( + $expectedOptionEntry, + $optionsSplitted, + __METHOD__ + . " $expectedOptionEntry is not contained in '" + . \implode(', ', $optionsSplitted) + . "' , but was expected to be." + ); + } + } + break; + } + } + Assert::assertTrue($isStorageEntryListed, "Expected local storage '$localStorage' not found "); + } + + /** + * @Then the following should not be included in the options of local storage :localStorage: + * + * @param string $localStorage + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingShouldNotBeIncludedInTheOptionsOfLocalStorage(string $localStorage, TableNode $table):void { + $expectedOptions = $table->getColumnsHash(); + foreach ($expectedOptions as $expectedOptionEntry) { + Assert::assertArrayHasKey( + 'options', + $expectedOptionEntry, + __METHOD__ + . " The provided expected option '" + . \implode(', ', $expectedOptionEntry) + . "' do not have key 'options'" + ); + } + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + $isStorageEntryListed = false; + foreach ($commandOutput as $listedStorageEntry) { + if ($listedStorageEntry->mount_point === $localStorage) { + $isStorageEntryListed = true; + $options = $listedStorageEntry->options; + $optionsSplitted = \explode(', ', $options); + foreach ($expectedOptions as $expectedOptionArray) { + foreach ($expectedOptionArray as $expectedOptionEntry) { + Assert::assertNotContains( + $expectedOptionEntry, + $optionsSplitted, + __METHOD__ + . " $expectedOptionEntry is contained in '" + . \implode(', ', $optionsSplitted) + . "' , but was not expected to be." + ); + } + } + break; + } + } + Assert::assertTrue($isStorageEntryListed, "Expected local storage '$localStorage' not found "); + } + + /** + * @When the administrator deletes local storage :folder using the occ command + * + * @param string $folder + * + * @return integer|boolean + * @throws Exception + */ + public function administratorDeletesFolder(string $folder) { + return $this->deleteLocalStorageFolderUsingTheOccCommand($folder); + } + + /** + * @Given the administrator has deleted local storage :folder using the occ command + * + * @param string $folder + * + * @return integer + * @throws Exception + */ + public function administratorHasDeletedLocalStorageFolderUsingTheOccCommand(string $folder):int { + $mount_id = $this->deleteLocalStorageFolderUsingTheOccCommand($folder); + $this->theCommandShouldHaveBeenSuccessful(); + return $mount_id; + } + + /** + * @param string $folder + * + * @return integer|null + * @throws Exception + */ + public function getMountIdForLocalStorage(string $folder): ?int { + $createdLocalStorage = []; + $mount_id = null; + $this->listLocalStorageMount(); + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($commandOutput as $i) { + $createdLocalStorage[$i->mount_id] = \ltrim($i->mount_point, '/'); + } + foreach ($createdLocalStorage as $key => $value) { + if ($value === $folder) { + $mount_id = $key; + } + } + + return (int) $mount_id; + } + + /** + * @param string $folder + * @param bool $mustExist + * + * @return integer|bool + * @throws Exception + */ + public function deleteLocalStorageFolderUsingTheOccCommand(string $folder, bool $mustExist = true) { + $mount_id = $this->getMountIdForLocalStorage($folder); + + if (!isset($mount_id)) { + if ($mustExist) { + throw new Exception("Id not found for folder to be deleted"); + } + return false; + } + $this->invokingTheCommand('files_external:delete --yes ' . $mount_id); + return $mount_id; + } + + /** + * @When the administrator exports the local storage mounts using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorExportsTheMountsUsingTheOccCommand():void { + $this->invokingTheCommand('files_external:export'); + } + + /** + * @When the administrator imports the local storage mount from file :file using the occ command + * + * @param string $file + * + * @return void + * @throws Exception + */ + public function theAdministratorImportsTheMountFromFileUsingTheOccCommand(string $file):void { + $this->invokingTheCommand( + 'files_external:import ' . TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/$file" + ); + } + + /** + * @Given /^the administrator has exported the (local|external) storage mounts using the occ command$/ + * + * @return void + * @throws Exception + */ + public function theAdministratorHasExportedTheMountsUsingTheOccCommand():void { + $this->invokingTheCommand('files_external:export'); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @Then the command should output configuration for local storage mount :mount + * + * @param string $mount + * + * @return void + * @throws Exception + */ + public function theOutputShouldContainConfigurationForMount(string $mount):void { + $actualConfig = null; + + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($commandOutput as $i) { + if ($mount === \ltrim($i->mount_point, '/')) { + $actualConfig = $i; + break; + } + } + + Assert::assertNotNull($actualConfig, 'Configuration for local storage mount ' . $mount . ' not found.'); + } + + /** + * @When the administrator verifies the mount configuration for local storage :localStorage using the occ command + * + * @param string $localStorage + * + * @return void + * @throws Exception + */ + public function theAdministratorVerifiesTheMountConfigurationForLocalStorageUsingTheOccCommand(string $localStorage):void { + $this->listLocalStorageMount(); + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($commandOutput as $entry) { + if (\ltrim($entry->mount_point, '/') == $localStorage) { + $mountId = $entry->mount_id; + } + } + if (!isset($mountId)) { + throw new Exception("Id not found for local storage $localStorage to be verified"); + } + $this->invokingTheCommand('files_external:verify ' . $mountId); + } + + /** + * @Then the following mount configuration information should be listed: + * + * @param TableNode $info + * + * @return void + */ + public function theFollowingInformationShouldBeListed(TableNode $info):void { + $ResultArray = []; + $expectedInfo = $info->getColumnsHash(); + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $commandOutputSplitted = \preg_split("/[-]/", $commandOutput); + $filteredArray = \array_filter(\array_map("trim", $commandOutputSplitted)); + foreach ($filteredArray as $entry) { + $keyValue = \preg_split("/[:]/", $entry); + if (isset($keyValue[1])) { + $ResultArray[$keyValue[0]] = $keyValue[1]; + } else { + $ResultArray[$keyValue[0]] = ""; + } + } + foreach ($expectedInfo as $element) { + Assert::assertEquals( + $element, + \array_map('trim', $ResultArray), + __METHOD__ + . " '" . \implode(', ', $element) + . "' was expected to be listed, but is not listed in the mount configuration information" + ); + } + } + + /** + * @When the administrator list the repair steps using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorListTheRepairStepsUsingTheOccCommand():void { + $this->invokingTheCommand('maintenance:repair --list'); + } + + /** + * @Then the background jobs mode should be :mode + * + * @param string $mode + * + * @return void + * @throws Exception + */ + public function theBackgroundJobsModeShouldBe(string $mode):void { + $this->invokingTheCommand( + "config:app:get core backgroundjobs_mode" + ); + $lastOutput = $this->featureContext->getStdOutOfOccCommand(); + Assert::assertEquals( + $mode, + \trim($lastOutput), + "The background jobs mode was expected to be {$mode} but got '" + . \trim($lastOutput) + . "'" + ); + } + + /** + * @Then the update channel should be :value + * + * @param string $value + * + * @return void + * @throws Exception + */ + public function theUpdateChannelShouldBe(string $value):void { + $this->invokingTheCommand( + "config:app:get core OC_Channel" + ); + $lastOutput = $this->featureContext->getStdOutOfOccCommand(); + Assert::assertEquals( + $value, + \trim($lastOutput), + "The update channel was expected to be '$value' but got '" + . \trim($lastOutput) + . "'" + ); + } + + /** + * @Then the log level should be :logLevel + * + * @param string $logLevel + * + * @return void + * @throws Exception + */ + public function theLogLevelShouldBe(string $logLevel):void { + $this->invokingTheCommand( + "config:system:get loglevel" + ); + $lastOutput = $this->featureContext->getStdOutOfOccCommand(); + Assert::assertEquals( + $logLevel, + \trim($lastOutput), + "The log level was expected to be '$logLevel' but got '" + . \trim($lastOutput) + . "'" + ); + } + + /** + * @When the administrator adds/updates config key :key with value :value in app :app using the occ command + * + * @param string $key + * @param string $value + * @param string $app + * + * @return void + * @throws Exception + */ + public function theAdministratorAddsConfigKeyWithValueInAppUsingTheOccCommand(string $key, string $value, string $app):void { + $this->addConfigKeyWithValueInAppUsingTheOccCommand( + $key, + $value, + $app + ); + } + + /** + * @Given the administrator has added config key :key with value :value in app :app + * + * @param string $key + * @param string $value + * @param string $app + * + * @return void + * @throws Exception + */ + public function theAdministratorHasAddedConfigKeyWithValueInAppUsingTheOccCommand(string $key, string $value, string $app):void { + $this->addConfigKeyWithValueInAppUsingTheOccCommand( + $key, + $value, + $app + ); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator deletes config key :key of app :app using the occ command + * + * @param string $key + * @param string $app + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesConfigKeyOfAppUsingTheOccCommand(string $key, string $app):void { + $this->deleteConfigKeyOfAppUsingTheOccCommand($key, $app); + } + + /** + * @When the administrator adds/updates system config key :key with value :value using the occ command + * @When the administrator adds/updates system config key :key with value :value and type :type using the occ command + * + * @param string $key + * @param string $value + * @param string $type + * + * @return void + * @throws Exception + */ + public function theAdministratorAddsSystemConfigKeyWithValueUsingTheOccCommand( + string $key, + string $value, + string $type = "string" + ):void { + $this->addSystemConfigKeyUsingTheOccCommand( + $key, + $value, + $type + ); + } + + /** + * @Given the administrator has added/updated system config key :key with value :value + * @Given the administrator has added/updated system config key :key with value :value and type :type + * + * @param string $key + * @param string $value + * @param string $type + * + * @return void + * @throws Exception + */ + public function theAdministratorHasAddedSystemConfigKeyWithValueUsingTheOccCommand( + string $key, + string $value, + string $type = "string" + ):void { + $this->addSystemConfigKeyUsingTheOccCommand( + $key, + $value, + $type + ); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator deletes system config key :key using the occ command + * + * @param string $key + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesSystemConfigKeyUsingTheOccCommand(string $key):void { + $this->deleteSystemConfigKeyUsingTheOccCommand($key); + } + + /** + * @When the administrator empties the trashbin of user :user using the occ command + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorEmptiesTheTrashbinOfUserUsingTheOccCommand(string $user):void { + $this->emptyTrashBinOfUserUsingOccCommand($user); + } + + /** + * @When the administrator deletes all the versions for user :user + * @When the administrator tries to delete all the versions for user :user + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesAllTheVersionsForUser(string $user):void { + $user = $this->featureContext->getActualUsername($user); + $this->deleteAllVersionsForUserUsingOccCommand($user); + } + + /** + * @When the administrator cleanups all the orphaned remote storages of shares using the occ command + * + * @return void + * @throws Exception + */ + public function theAdminCleanupsOrphanedRemoteStoragesOfSharesUsingOccCommand():void { + $this->cleanOrphanedRemoteStoragesUsingOccCommand(); + } + + /** + * @When the administrator deletes all the versions for the following users: + * + * @param TableNode $usersTable + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesAllTheVersionsForSpecificUsers(TableNode $usersTable):void { + $this->featureContext->verifyTableNodeColumns($usersTable, ["username"]); + $usernames = $usersTable->getHash(); + $usernamesArray = []; + foreach ($usernames as $username) { + array_push($usernamesArray, $username["username"]); + } + $users = implode(" ", $usernamesArray); + $this->deleteAllVersionsForMultipleUsersUsingOccCommand($users); + } + + /** + * @When the administrator deletes the file versions for all users + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesVersionsForAllUsers(): void { + $this->deleteAllVersionsForAllUsersUsingOccCommand(); + } + + /** + * @When the administrator deletes the expired versions for user :user + * @When the administrator tries to delete the expired versions for user :user + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesExpiredVersionsForUser(string $user): void { + $user = $this->featureContext->getActualUsername($user); + $this->deleteExpiredVersionsForUserUsingOccCommand($user); + } + + /** + * @When the administrator deletes the expired versions for the following users: + * + * @param TableNode $usersTable + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesExpiredVersionsForSpecificUsers(TableNode $usersTable): void { + $this->featureContext->verifyTableNodeColumns($usersTable, ["username"]); + $usernames = $usersTable->getHash(); + $usernamesArray = []; + foreach ($usernames as $username) { + array_push($usernamesArray, $username["username"]); + } + $users = implode(" ", $usernamesArray); + $this->deleteExpiredVersionsForMultipleUsersUsingOccCommand($users); + } + + /** + * @When the administrator deletes the expired versions for all users + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesExpiredVersionsForAllUsers(): void { + $this->deleteExpiredVersionsForAllUsersUsingOccCommand(); + } + + /** + * @When the administrator empties the trashbin of all users using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorEmptiesTheTrashbinOfAllUsersUsingTheOccCommand():void { + $this->emptyTrashBinOfUserUsingOccCommand(''); + } + + /** + * @When the administrator creates a calendar for user :user with name :calendarName using the occ command + * + * @param string $user + * @param string $calendarName + * + * @return void + * @throws Exception + */ + public function theAdminCreatesACalendarForUserUsingTheOccCommand(string $user, string $calendarName):void { + $user = $this->featureContext->getActualUsername($user); + $this->createCalendarForUserUsingOccCommand($user, $calendarName); + } + + /** + * @When the administrator creates an address book for user :user with name :addressBookName using the occ command + * + * @param string $user + * @param string $addressBookName + * + * @return void + * @throws Exception + */ + public function theAdminCreatesAnAddressBookForUserUsingTheOccCommand(string $user, string $addressBookName):void { + $user = $this->featureContext->getActualUsername($user); + $this->createAnAddressBookForUserUsingOccCommand($user, $addressBookName); + } + + /** + * @When the administrator gets all the jobs in the background queue using the occ command + * + * @return void + * @throws Exception + */ + public function theAdministratorGetsAllTheJobsInTheBackgroundQueueUsingTheOccCommand():void { + $this->getAllJobsInBackgroundQueueUsingOccCommand(); + } + + /** + * @When the administrator deletes the last background job :job using the occ command + * + * @param string $job + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesLastBackgroundJobUsingTheOccCommand(string $job):void { + $this->deleteLastBackgroundJobUsingTheOccCommand($job); + } + + /** + * @Then the last deleted background job :job should not be listed in the background jobs queue + * + * @param string $job + * + * @return void + * @throws Exception + */ + public function theLastDeletedJobShouldNotBeListedInTheJobsQueue(string $job):void { + $jobId = $this->lastDeletedJobId; + $match = $this->getLastJobIdForJob($job); + if ($match) { + Assert::assertNotEquals( + $jobId, + $match, + "job $job with jobId $jobId" . + " was not expected to be listed in background queue, but was" + ); + } + } + + /** + * @Then system config key :key should have value :value + * + * @param string $key + * @param string $value + * + * @return void + * @throws Exception + */ + public function systemConfigKeyShouldHaveValue(string $key, string $value):void { + $config = \trim( + SetupHelper::getSystemConfigValue( + $key, + $this->featureContext->getStepLineRef() + ) + ); + Assert::assertSame( + $value, + $config, + "The system config key '$key' was expected to have value '$value', but got '$config'" + ); + } + + /** + * @Then the command output table should contain the following text: + * + * @param TableNode $table table of patterns to find with table title as 'table_column' + * + * @return void + * @throws Exception + */ + public function theCommandOutputTableShouldContainTheFollowingText(TableNode $table):void { + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $this->featureContext->verifyTableNodeColumns($table, ['table_column']); + foreach ($table as $row) { + $lines = SetupHelper::findLines( + $commandOutput, + $row['table_column'] + ); + Assert::assertNotEmpty( + $lines, + "Value: " . $row['table_column'] . " not found" + ); + } + } + + /** + * @Then system config key :key should not exist + * + * @param string $key + * + * @return void + * @throws Exception + */ + public function systemConfigKeyShouldNotExist(string $key):void { + Assert::assertEmpty( + SetupHelper::getSystemConfig( + $key, + $this->featureContext->getStepLineRef() + )['stdOut'], + "The system config key '$key' was not expected to exist" + ); + } + + /** + * @When the administrator lists the config keys + * + * @return void + * @throws Exception + */ + public function theAdministratorListsTheConfigKeys():void { + $this->invokingTheCommand( + "config:list" + ); + } + + /** + * @Then the command output should contain the apps configs + * + * @return void + */ + public function theCommandOutputShouldContainTheAppsConfigs():void { + $config_list = \json_decode($this->featureContext->getStdOutOfOccCommand(), true); + Assert::assertArrayHasKey( + 'apps', + $config_list, + "The occ output does not contain apps configs" + ); + Assert::assertNotEmpty( + $config_list['apps'], + "The occ output does not contain apps configs" + ); + } + + /** + * @Then the command output should contain the system configs + * + * @return void + */ + public function theCommandOutputShouldContainTheSystemConfigs():void { + $config_list = \json_decode($this->featureContext->getStdOutOfOccCommand(), true); + Assert::assertArrayHasKey( + 'system', + $config_list, + "The occ output does not contain system configs" + ); + Assert::assertNotEmpty( + $config_list['system'], + "The occ output does not contain system configs" + ); + } + + /** + * @Given the administrator has cleared the versions for user :user + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorHasClearedTheVersionsForUser(string $user):void { + $user = $this->featureContext->getActualUsername($user); + $this->deleteAllVersionsForUserUsingOccCommand($user); + Assert::assertSame( + "Delete versions of $user", + \trim($this->featureContext->getStdOutOfOccCommand()) + ); + } + + /** + * @Given the administrator has cleared the versions for all users + * + * @return void + * @throws Exception + */ + public function theAdministratorHasClearedTheVersionsForAllUsers():void { + $this->deleteAllVersionsForAllUsersUsingOccCommand(); + Assert::assertStringContainsString( + "Delete all versions", + \trim($this->featureContext->getStdOutOfOccCommand()), + "Expected 'Delete all versions' to be contained in the output of occ command: " + . \trim($this->featureContext->getStdOutOfOccCommand()) + ); + } + + /** + * get jobId of the latest job found of given job class + * + * @param string $job + * + * @return string|bool + * @throws Exception + */ + public function getLastJobIdForJob(string $job) { + $this->getAllJobsInBackgroundQueueUsingOccCommand(); + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $lines = SetupHelper::findLines( + $commandOutput, + $job + ); + if (!$lines) { + return false; + } + // find the jobId of the newest job among the jobs with given class + $success = \preg_match("/\d+/", \end($lines), $match); + if ($success) { + return $match[0]; + } + return false; + } + + /** + * @Then the system config key :key from the last command output should match value :value of type :type + * + * @param string $key + * @param string $value + * @param string $type + * + * @return void + */ + public function theSystemConfigKeyFromLastCommandOutputShouldContainValue( + string $key, + string $value, + string $type + ):void { + $configList = \json_decode( + $this->featureContext->getStdOutOfOccCommand(), + true + ); + $systemConfig = $configList['system']; + + // convert the value to it's respective type based on type given in the type column + if ($type === 'boolean') { + $value = $value === 'true' ? true : false; + } elseif ($type === 'integer') { + $value = (int) $value; + } elseif ($type === 'json') { + // if the expected value of the key is a json + // match the value with the regular expression + $actualKeyValuePair = \json_encode( + $systemConfig[$key], + JSON_UNESCAPED_SLASHES + ); + + Assert::assertThat( + $actualKeyValuePair, + Assert::matchesRegularExpression($value) + ); + return; + } + + if (!\array_key_exists($key, $systemConfig)) { + Assert::fail( + "system config doesn't contain key: " . $key + ); + } + + Assert::assertEquals( + $value, + $systemConfig[$key], + "config: $key doesn't contain value: $value" + ); + } + + /** + * @Given the administrator has enabled the external storage + * + * @return void + * @throws Exception + */ + public function enableExternalStorageUsingOccAsAdmin():void { + SetupHelper::runOcc( + [ + 'config:app:set', + 'core', + 'enable_external_storage', + '--value=yes' + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $response = SetupHelper::runOcc( + [ + 'config:app:get', + 'core', + 'enable_external_storage', + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $status = \trim($response['stdOut']); + Assert::assertEquals( + 'yes', + $status, + "The external storage was expected to be enabled but got '$status'" + ); + } + + /** + * @Given the administrator has added group :group to the exclude group from sharing list + * + * @param string $groups + * multiple groups can be passed as comma separated string + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function theAdministratorHasAddedGroupToTheExcludeGroupFromSharingList(string $groups):void { + $groups = \explode(',', \trim($groups)); + $groups = \array_map('trim', $groups); //removing whitespaces around group names + $groups = '"' . \implode('","', $groups) . '"'; + SetupHelper::runOcc( + [ + 'config:app:set', + 'core', + 'shareapi_exclude_groups_list', + "--value='[$groups]'" + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $response = SetupHelper::runOcc( + [ + 'config:app:get', + 'core', + 'shareapi_exclude_groups_list' + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $excludedGroupsFromResponse = (\trim($response['stdOut'])); + $excludedGroupsFromResponse = \trim($excludedGroupsFromResponse, '[]'); + Assert::assertEquals( + $groups, + $excludedGroupsFromResponse, + "'$groups' is not added to exclude groups from sharing list: '" + . $excludedGroupsFromResponse + . "' but was expected to be" + ); + } + + /** + * @Given the administrator has enabled exclude groups from sharing + * + * @return void + * @throws Exception + */ + public function theAdministratorHasEnabledExcludeGroupsFromSharingUsingTheWebui():void { + SetupHelper::runOcc( + [ + "config:app:set", + "core", + "shareapi_exclude_groups", + "--value=yes" + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $response = SetupHelper::runOcc( + [ + "config:app:get", + "core", + "shareapi_exclude_groups" + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $status = \trim($response['stdOut']); + Assert::assertEquals( + "yes", + $status, + "Exclude groups from sharing was expected to be 'yes'(enabled) but got '$status'" + ); + } + + /** + * @Given /^the administrator has (enabled|disabled) the webUI lock file action$/ + * + * @param string $enabledOrDisabled + * + * @return void + * @throws Exception + */ + public function theAdministratorHasEnabledTheWebUILockFileAction(string $enabledOrDisabled):void { + $switch = ($enabledOrDisabled !== "disabled"); + if ($switch) { + $value = 'yes'; + } else { + $value = 'no'; + } + SetupHelper::runOcc( + [ + "config:app:set", + "files", + "enable_lock_file_action", + "--value=$value" + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $response = SetupHelper::runOcc( + [ + "config:app:get", + "files", + "enable_lock_file_action" + ], + $this->featureContext->getStepLineRef(), + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $status = \trim($response['stdOut']); + Assert::assertEquals( + $value, + $status, + "enable_lock_file_action was expected to be '$value'($enabledOrDisabled) but got '$status'" + ); + } + + /** + * @When the administrator creates an external mount point with the following configuration about user :user using the occ command + * + * @param string $user + * @param TableNode $settings + * + * necessary attributes inside $settings table: + * 1. host - remote server url + * 2. root - remote folder name -> mount path + * 3. secure - true/false (http or https) + * 4. user - remote server user username + * 5. password - remote server user password + * 6. mount_point - external storage name + * 7. storage_backend - options: [local, owncloud, smb, googledrive, sftp, dav] + * 8. authentication_backend - options: [null::null, password::password, password::sessioncredentials] + * + * @see [`php occ files_external:backends`] to view + * detailed information of parameters used above + * + * @return void + * @throws Exception + */ + public function createExternalMountPointUsingTheOccCommand(string $user, TableNode $settings):void { + $userRenamed = $this->featureContext->getActualUsername($user); + $this->featureContext->verifyTableNodeRows( + $settings, + ["host", "root", "storage_backend", + "authentication_backend", "mount_point", + "user", "password", "secure"] + ); + $extMntSettings = $settings->getRowsHash(); + $extMntSettings['user'] = $this->featureContext->substituteInLineCodes( + $extMntSettings['user'], + $userRenamed + ); + $password = $this->featureContext->substituteInLineCodes( + $extMntSettings['password'], + $user + ); + $args = [ + "files_external:create", + "-c host=" . + $this->featureContext->substituteInLineCodes($extMntSettings['host']), + "-c root=" . $extMntSettings['root'], + "-c secure=" . $extMntSettings['secure'], + "-c user=" . $extMntSettings['user'], + "-c password=" . $password, + $extMntSettings['mount_point'], + $extMntSettings['storage_backend'], + $extMntSettings['authentication_backend'] + ]; + $this->featureContext->setOccLastCode( + $this->featureContext->runOcc($args) + ); + // add to array of created storageIds + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $mountId = \preg_replace('/\D/', '', $commandOutput); + $this->featureContext->addStorageId($extMntSettings["mount_point"], (int) $mountId); + } + + /** + * @Given the administrator has created an external mount point with the following configuration about user :user using the occ command + * + * @param string $user + * @param TableNode $settings + * + * @return void + * @throws Exception + */ + public function adminHasCreatedAnExternalMountPointWithFollowingConfigUsingTheOccCommand(string $user, TableNode $settings):void { + $this->createExternalMountPointUsingTheOccCommand($user, $settings); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + private function deleteExternalMountPointUsingTheAdmin(string $mountPoint):void { + $mount_id = $this->administratorDeletesFolder($mountPoint); + $this->featureContext->popStorageId($mount_id); + } + + /** + * @Given the administrator has deleted external storage with mount point :mountPoint + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function adminHasDeletedExternalMountPoint(string $mountPoint):void { + $this->deleteExternalMountPointUsingTheAdmin($mountPoint); + $this->theCommandShouldHaveBeenSuccessful(); + } + + /** + * @When the administrator deletes external storage with mount point :mountPoint + * + * @param string $mountPoint + * + * @return void + * @throws Exception + */ + public function adminDeletesExternalMountPoint(string $mountPoint):void { + $this->deleteExternalMountPointUsingTheAdmin($mountPoint); + } + + /** + * @Then mount point :mountPoint should not be listed as an external storage + * + * @param string $mountPoint + * + * @return void + */ + public function mountPointShouldNotBeListedAsAnExternalStorage(string $mountPoint):void { + $commandOutput = \json_decode($this->featureContext->getStdOutOfOccCommand()); + foreach ($commandOutput as $entry) { + Assert::assertNotEquals($mountPoint, $entry->mount_point); + } + } + + /** + * @Given the administrator has changed the database type to :dbType + * + * @param string $dbType + * + * @return void + * @throws Exception + */ + public function theAdministratorHasChangedTheDatabaseType(string $dbType): void { + $this->theAdministratorChangesTheDatabaseType($dbType); + $exitStatusCode = $this->featureContext->getExitStatusCodeOfOccCommand(); + + if ($exitStatusCode !== 0) { + $exceptions = $this->featureContext->findExceptions(); + $commandErr = $this->featureContext->getStdErrOfOccCommand(); + $sameTypeError = "Can not convert from $dbType to $dbType."; + $lines = SetupHelper::findLines( + $commandErr, + $sameTypeError + ); + // pass if the same type error is found + if (\count($lines) === 0) { + $msg = "The command was not successful, exit code was " . + $exitStatusCode . ".\n" . + "stdOut was: '" . + $this->featureContext->getStdOutOfOccCommand() . "'\n" . + "stdErr was: '$commandErr'\n"; + if (!empty($exceptions)) { + $msg .= ' Exceptions: ' . \implode(', ', $exceptions); + } + throw new Exception($msg); + } + } + } + + /** + * @When the administrator changes the database type to :dbType + * @When the administrator tries to change the database type to :dbType + * + * @param string $dbType + * + * @return void + * @throws Exception + */ + public function theAdministratorChangesTheDatabaseType(string $dbType): void { + $dbUser = "owncloud"; + $dbHost = $dbType; + $dbName = "owncloud"; + $dbPass = "owncloud"; + + if ($dbType === "postgres") { + $dbType = "pgsql"; + } + if ($dbType === "oracle") { + $dbUser = "autotest"; + $dbType = "oci"; + } + + $this->invokingTheCommand("db:convert-type --password=$dbPass $dbType $dbUser $dbHost $dbName"); + $this->featureContext->setDbConversionState(true); + } + + /** + * This will run after EVERY scenario. + * It will set the properties for this object. + * + * @AfterScenario + * + * @return void + * @throws Exception + */ + public function removeImportedCertificates():void { + $remainingCertificates = \array_diff($this->importedCertificates, $this->removedCertificates); + foreach ($remainingCertificates as $certificate) { + $this->invokingTheCommand("security:certificates:remove " . $certificate); + $this->theCommandShouldHaveBeenSuccessful(); + } + } + + /** + * This will run after EVERY scenario. + * It will set the properties for this object. + * + * @AfterScenario + * + * @return void + * @throws Exception + */ + public function resetDAVTechPreview():void { + if ($this->doTechPreview) { + if ($this->initialTechPreviewStatus === "") { + SetupHelper::deleteSystemConfig( + 'dav.enable.tech_preview', + $this->featureContext->getStepLineRef() + ); + } elseif ($this->initialTechPreviewStatus === 'true' && !$this->techPreviewEnabled) { + $this->enableDAVTechPreview(); + } elseif ($this->initialTechPreviewStatus === 'false' && $this->techPreviewEnabled) { + $this->disableDAVTechPreview(); + } + } + } + + /** + * This will run after EVERY scenario. + * Some local_storage tests import storage from an export file. In that case + * we have not explicitly created the storage, and so we do not explicitly + * know to delete it. So delete the local storage that is known to be used + * in tests. + * + * @AfterScenario @local_storage + * + * @return void + * @throws Exception + */ + public function removeLocalStorageIfExists():void { + $this->deleteLocalStorageFolderUsingTheOccCommand('local_storage', false); + $this->deleteLocalStorageFolderUsingTheOccCommand('local_storage2', false); + $this->deleteLocalStorageFolderUsingTheOccCommand('local_storage3', false); + $this->deleteLocalStorageFolderUsingTheOccCommand('TestMountPoint', false); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + * @throws Exception + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + SetupHelper::init( + $this->featureContext->getAdminUsername(), + $this->featureContext->getAdminPassword(), + $this->featureContext->getBaseUrl(), + $this->featureContext->getOcPath() + ); + $ocVersion = SetupHelper::getSystemConfigValue( + 'version', + $this->featureContext->getStepLineRef() + ); + // dav.enable.tech_preview was used in some ownCloud versions before 10.4.0 + // only set it on those versions of ownCloud + if (\version_compare($ocVersion, '10.4.0') === -1) { + $this->doTechPreview = true; + $techPreviewEnabled = \trim( + SetupHelper::getSystemConfigValue( + 'dav.enable.tech_preview', + $this->featureContext->getStepLineRef() + ) + ); + $this->initialTechPreviewStatus = $techPreviewEnabled; + $this->techPreviewEnabled = $techPreviewEnabled === 'true'; + } + } + + /** + * @Then /^the system config should have dbtype set as "([^"]*)"$/ + * + * @param string $value + * + * @return void + * @throws GuzzleException + */ + public function theSystemConfigKeyShouldBeSetAs(string $value):void { + $actual_value = SetupHelper::getSystemConfigValue( + "dbtype", + $this->featureContext->getStepLineRef() + ); + $actual_value = \str_replace("\n", "", $actual_value); + Assert::assertEquals( + $value, + $actual_value, + "System config mismatched.\n + Expected dbType to be: " . $actual_value . "\n + Found: " . $value + ); + } + + /** + * @When the administrator lists migration status of app :app + * + * @param string $app + * + * @return void + * @throws Exception + */ + public function theAdministratorListsMigrationStatusOfApp(string $app):void { + $this->featureContext->setStdOutOfOccCommand(""); + $this->featureContext->setOccLastCode( + $this->featureContext->runOcc(['migrations:status', $app]) + ); + } + + /** + * @Then the following migration status should have been listed + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingMigrationStatusShouldHaveBeenListed(TableNode $table): void { + $actualOuput = $this->getMigrationStatusInfo(); + $expectedOutput = $table->getRowsHash(); + foreach ($expectedOutput as $key => $value) { + try { + $actualValue = $actualOuput[$key]; + } catch (Exception $e) { + Assert:: fail("Expected '$key' but not found!\nActual Migration status: " . \print_r($actualOuput, true)); + } + if ($this->isRegex($value)) { + $match = preg_match($value, $actualValue); + Assert:: assertEquals(1, $match, "Pattern '$value' is not matchable with value '$actualValue'"); + } else { + Assert:: assertEquals($value, $actualValue, "Expected '$key' to have value '$value' but got '$actualValue'"); + } + } + } + + /** + * @Then the Executed Migrations should equal the Available Migrations + * + * @return void + * @throws Exception + */ + public function theExecutedMigrationsShouldEqualTheAvailableMigrations(): void { + $migrationStatus = $this->getMigrationStatusInfo(); + Assert:: assertEquals($migrationStatus["Executed Migrations"], $migrationStatus["Available Migrations"], "The 'Executed Migration' is not same as 'Available Migration'"); + } + + /** + * @param string $value + * + * @return int + */ + public function isRegex($value) { + $regex = "/^\/[\s\S]+\/$/"; + return preg_match($regex, $value); + } + + /** + * @return void + */ + public function getMigrationStatusInfo() { + $commandOutput = $this->featureContext->getStdOutOfOccCommand(); + $migrationStatus = []; + if (!empty($commandOutput)) { + $infoArr = explode("\n", $commandOutput); + foreach ($infoArr as $info) { + if (!empty($info)) { + $row = \trim(\str_replace('>>', '', $info)); + $rowCol = explode(":", $row); + $migrationStatus[\trim($rowCol[0])] = \trim($rowCol[1]); + } + } + return $migrationStatus; + } else { + throwException("Migration status information is empty!"); + } + } +} diff --git a/tests/acceptance/features/bootstrap/Provisioning.php b/tests/acceptance/features/bootstrap/Provisioning.php new file mode 100644 index 000000000..64ae80f98 --- /dev/null +++ b/tests/acceptance/features/bootstrap/Provisioning.php @@ -0,0 +1,6141 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +use Behat\Gherkin\Node\TableNode; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use TestHelpers\OcsApiHelper; +use TestHelpers\SetupHelper; +use TestHelpers\UserHelper; +use TestHelpers\HttpRequestHelper; +use TestHelpers\OcisHelper; +use TestHelpers\WebDavHelper; +use Laminas\Ldap\Exception\LdapException; +use Laminas\Ldap\Ldap; + +/** + * Functions for provisioning of users and groups + */ +trait Provisioning { + /** + * list of users that were created on the local server during test runs + * key is the lowercase username, value is an array of user attributes + * + * @var array + */ + private $createdUsers = []; + + /** + * list of users that were created on the remote server during test runs + * key is the lowercase username, value is an array of user attributes + * + * @var array + */ + private $createdRemoteUsers = []; + + /** + * @var array + */ + private $enabledApps = []; + + /** + * @var array + */ + private $disabledApps = []; + + /** + * @var array + */ + private $startingGroups = []; + + /** + * @var array + */ + private $createdRemoteGroups = []; + + /** + * @var array + */ + private $createdGroups = []; + + /** + * @var array + */ + private $userResponseFields = [ + "enabled", "quota", "email", "displayname", "home", "two_factor_auth_enabled", + "quota definition", "quota free", "quota user", "quota total", "quota relative" + ]; + + /** + * Check if this is the admin group. That group is always a local group in + * ownCloud10, even if other groups come from LDAP. + * + * @param string $groupname + * + * @return boolean + */ + public function isLocalAdminGroup(string $groupname):bool { + return ($groupname === "admin"); + } + + /** + * Usernames are not case-sensitive, and can generally be specified with any + * mix of upper and lower case. For remembering usernames use the normalized + * form so that "alice" and "Alice" are remembered as the same user. + * + * @param string|null $username + * + * @return string + */ + public function normalizeUsername(?string $username):string { + return \strtolower((string)$username); + } + + /** + * @return array + */ + public function getCreatedUsers():array { + return $this->createdUsers; + } + + /** + * @return boolean + */ + public function someUsersHaveBeenCreated():bool { + return (\count($this->createdUsers) > 0); + } + + /** + * @return array + */ + public function getCreatedGroups():array { + return $this->createdGroups; + } + + /** + * returns the display name of the current user + * if no "Display Name" is set the user-name is returned instead + * + * @return string + */ + public function getCurrentUserDisplayName():string { + return $this->getUserDisplayName($this->getCurrentUser()); + } + + /** + * returns the display name of a user + * if no "Display Name" is set the username is returned instead + * + * @param string $username + * + * @return string + */ + public function getUserDisplayName(string $username):string { + $normalizedUsername = $this->normalizeUsername($username); + if (isset($this->createdUsers[$normalizedUsername]['displayname'])) { + $displayName = (string) $this->createdUsers[$normalizedUsername]['displayname']; + if ($displayName !== '') { + return $displayName; + } + } + return $username; + } + + /** + * @param string $user + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function rememberUserDisplayName(string $user, string $displayName):void { + $normalizedUsername = $this->normalizeUsername($user); + if ($this->isAdminUsername($normalizedUsername)) { + $this->adminDisplayName = $displayName; + } else { + if ($this->currentServer === 'LOCAL') { + if (\array_key_exists($normalizedUsername, $this->createdUsers)) { + $this->createdUsers[$normalizedUsername]['displayname'] = $displayName; + } else { + throw new Exception( + __METHOD__ . " tried to remember display name '$displayName' for nonexistent local user '$user'" + ); + } + } elseif ($this->currentServer === 'REMOTE') { + if (\array_key_exists($normalizedUsername, $this->createdRemoteUsers)) { + $this->createdRemoteUsers[$normalizedUsername]['displayname'] = $displayName; + } else { + throw new Exception( + __METHOD__ . " tried to remember display name '$displayName' for nonexistent federated user '$user'" + ); + } + } + } + } + + /** + * @param string $user + * @param string $emailAddress + * + * @return void + * @throws Exception + */ + public function rememberUserEmailAddress(string $user, string $emailAddress):void { + $normalizedUsername = $this->normalizeUsername($user); + if ($this->isAdminUsername($normalizedUsername)) { + $this->adminEmailAddress = $emailAddress; + } else { + if ($this->currentServer === 'LOCAL') { + if (\array_key_exists($normalizedUsername, $this->createdUsers)) { + $this->createdUsers[$normalizedUsername]['email'] = $emailAddress; + } else { + throw new Exception( + __METHOD__ . " tried to remember email address '$emailAddress' for nonexistent local user '$user'" + ); + } + } elseif ($this->currentServer === 'REMOTE') { + if (\array_key_exists($normalizedUsername, $this->createdRemoteUsers)) { + $this->createdRemoteUsers[$normalizedUsername]['email'] = $emailAddress; + } else { + throw new Exception( + __METHOD__ . " tried to remember email address '$emailAddress' for nonexistent federated user '$user'" + ); + } + } + } + } + + /** + * returns an array of the user display names, keyed by normalized username + * if no "Display Name" is set the user-name is returned instead + * + * @return array + */ + public function getCreatedUserDisplayNames():array { + $result = []; + foreach ($this->getCreatedUsers() as $normalizedUsername => $user) { + $result[$normalizedUsername] = $this->getUserDisplayName($normalizedUsername); + } + return $result; + } + + /** + * @param string $user + * @param string $attribute + * + * @return mixed + * @throws Exception + */ + public function getAttributeOfCreatedUser(string $user, string $attribute) { + $usersList = $this->getCreatedUsers(); + $normalizedUsername = $this->normalizeUsername($user); + if (\array_key_exists($normalizedUsername, $usersList)) { + // provide attributes only if the user exists + if ($usersList[$normalizedUsername]["shouldExist"]) { + if (\array_key_exists($attribute, $usersList[$normalizedUsername])) { + return $usersList[$normalizedUsername][$attribute]; + } else { + throw new Exception( + __METHOD__ . ": User '$user' has no attribute with name '$attribute'." + ); + } + } else { + throw new Exception( + __METHOD__ . ": User '$user' has been deleted." + ); + } + } else { + throw new Exception( + __METHOD__ . ": User '$user' does not exist in the created list." + ); + } + } + + /** + * @param string $group + * @param string $attribute + * + * @return mixed + * @throws Exception + */ + public function getAttributeOfCreatedGroup(string $group, string $attribute) { + $groupsList = $this->getCreatedGroups(); + if (\array_key_exists($group, $groupsList)) { + // provide attributes only if the group exists + if ($groupsList[$group]["shouldExist"]) { + if (\array_key_exists($attribute, $groupsList[$group])) { + return $groupsList[$group][$attribute]; + } else { + throw new Exception( + __METHOD__ . ": Group '$group' has no attribute with name '$attribute'." + ); + } + } else { + throw new Exception( + __METHOD__ . ": Group '$group' has been deleted." + ); + } + } else { + throw new Exception( + __METHOD__ . ": Group '$group' does not exist in the created list." + ); + } + } + + /** + * returns an array of the group display names, keyed by group name + * currently group name and display name are always the same, so this + * function is a convenience for getting the group names in a similar + * format to what getCreatedUserDisplayNames() returns + * + * @return array + */ + public function getCreatedGroupDisplayNames():array { + $result = []; + foreach ($this->getCreatedGroups() as $groupName => $groupData) { + $result[$groupName] = $groupName; + } + return $result; + } + + /** + * + * @param string $username + * + * @return string password + * @throws Exception + */ + public function getUserPassword(string $username):string { + $normalizedUsername = $this->normalizeUsername($username); + if ($normalizedUsername === $this->getAdminUsername()) { + $password = $this->getAdminPassword(); + } elseif (\array_key_exists($normalizedUsername, $this->createdUsers)) { + $password = $this->createdUsers[$normalizedUsername]['password']; + } elseif (\array_key_exists($normalizedUsername, $this->createdRemoteUsers)) { + $password = $this->createdRemoteUsers[$normalizedUsername]['password']; + } else { + throw new Exception( + "user '$username' was not created by this test run" + ); + } + + //make sure the function always returns a string + return (string) $password; + } + + /** + * + * @param string $username + * + * @return boolean + * @throws Exception + */ + public function theUserShouldExist(string $username):bool { + $normalizedUsername = $this->normalizeUsername($username); + if (\array_key_exists($normalizedUsername, $this->createdUsers)) { + return $this->createdUsers[$normalizedUsername]['shouldExist']; + } + + if (\array_key_exists($normalizedUsername, $this->createdRemoteUsers)) { + return $this->createdRemoteUsers[$normalizedUsername]['shouldExist']; + } + + throw new Exception( + __METHOD__ + . " user '$username' was not created by this test run" + ); + } + + /** + * + * @param string $groupname + * + * @return boolean + * @throws Exception + */ + public function theGroupShouldExist(string $groupname):bool { + if (\array_key_exists($groupname, $this->createdGroups)) { + if (\array_key_exists('shouldExist', $this->createdGroups[$groupname])) { + return $this->createdGroups[$groupname]['shouldExist']; + } + return false; + } + + if (\array_key_exists($groupname, $this->createdRemoteGroups)) { + if (\array_key_exists('shouldExist', $this->createdRemoteGroups[$groupname])) { + return $this->createdRemoteGroups[$groupname]['shouldExist']; + } + return false; + } + + throw new Exception( + __METHOD__ + . " group '$groupname' was not created by this test run" + ); + } + + /** + * + * @param string $groupname + * + * @return boolean + * @throws Exception + */ + public function theGroupShouldBeAbleToBeDeleted(string $groupname):bool { + if (\array_key_exists($groupname, $this->createdGroups)) { + return $this->createdGroups[$groupname]['possibleToDelete'] ?? true; + } + + if (\array_key_exists($groupname, $this->createdRemoteGroups)) { + return $this->createdRemoteGroups[$groupname]['possibleToDelete'] ?? true; + } + + throw new Exception( + __METHOD__ + . " group '$groupname' was not created by this test run" + ); + } + + /** + * @When /^the administrator creates user "([^"]*)" using the provisioning API$/ + * + * @param string|null $user + * + * @return void + * @throws Exception + */ + public function adminCreatesUserUsingTheProvisioningApi(?string $user):void { + $this->createUser( + $user, + null, + null, + null, + true, + 'api' + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has been created with default attributes in the database user backend$/ + * + * @param string|null $user + * + * @return void + * @throws Exception + */ + public function userHasBeenCreatedOnDatabaseBackend(?string $user):void { + $this->adminCreatesUserUsingTheProvisioningApi($user); + $this->userShouldExist($user); + } + + /** + * @Given /^user "([^"]*)" has been created with default attributes and (tiny|small|large)\s?skeleton files$/ + * + * @param string $user + * @param string $skeletonType + * @param boolean $skeleton + * + * @return void + * @throws Exception + */ + public function userHasBeenCreatedWithDefaultAttributes( + string $user, + string $skeletonType = "", + bool $skeleton = true + ):void { + if ($skeletonType === "") { + $skeletonType = $this->getSmallestSkeletonDirName(); + } + + $originalSkeletonPath = $this->setSkeletonDirByType($skeletonType); + + try { + $this->createUser( + $user, + null, + null, + null, + true, + null, + true, + $skeleton + ); + $this->userShouldExist($user); + } finally { + $this->setSkeletonDir($originalSkeletonPath); + } + } + + /** + * @Given /^user "([^"]*)" has been created with default attributes and without skeleton files$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userHasBeenCreatedWithDefaultAttributesAndWithoutSkeletonFiles(string $user):void { + $this->userHasBeenCreatedWithDefaultAttributes($user); + $this->resetOccLastCode(); + } + + /** + * @Given these users have been created with default attributes and without skeleton files: + * expects a table of users with the heading + * "|username|" + * + * @param TableNode $table + * + * @return void + * @throws Exception|GuzzleException + */ + public function theseUsersHaveBeenCreatedWithDefaultAttributesAndWithoutSkeletonFiles(TableNode $table):void { + $originalSkeletonPath = $this->setSkeletonDirByType($this->getSmallestSkeletonDirName()); + try { + $this->createTheseUsers(true, true, true, $table); + } finally { + // restore skeleton directory even if user creation failed + $this->setSkeletonDir($originalSkeletonPath); + } + } + + /** + * @Given /^these users have been created without skeleton files ?(and not initialized|):$/ + * expects a table of users with the heading + * "|username|password|displayname|email|" + * password, displayname & email are optional + * + * @param TableNode $table + * @param string $doNotInitialize + * + * @return void + * @throws Exception + */ + public function theseUsersHaveBeenCreatedWithoutSkeletonFiles(TableNode $table, string $doNotInitialize):void { + $this->theseUsersHaveBeenCreated("", "", $doNotInitialize, $table); + } + + /** + * @Given the administrator has set the system language to :defaultLanguage + * + * @param string $defaultLanguage + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetTheSystemLanguageTo(string $defaultLanguage):void { + $this->runOcc( + ["config:system:set default_language --value $defaultLanguage"] + ); + } + + /** + * + * @param string $path + * + * @return void + */ + public function importLdifFile(string $path):void { + $ldifData = \file_get_contents($path); + $this->importLdifData($ldifData); + } + + /** + * imports an ldif string + * + * @param string $ldifData + * + * @return void + */ + public function importLdifData(string $ldifData):void { + $items = Laminas\Ldap\Ldif\Encoder::decode($ldifData); + if (isset($items['dn'])) { + //only one item in the ldif data + $this->ldap->add($items['dn'], $items); + } else { + foreach ($items as $item) { + if (isset($item["objectclass"])) { + if (\in_array("posixGroup", $item["objectclass"])) { + \array_push($this->ldapCreatedGroups, $item["cn"][0]); + $this->addGroupToCreatedGroupsList($item["cn"][0]); + } elseif (\in_array("inetOrgPerson", $item["objectclass"])) { + \array_push($this->ldapCreatedUsers, $item["uid"][0]); + $this->addUserToCreatedUsersList($item["uid"][0], $item["userpassword"][0]); + } + } + $this->ldap->add($item['dn'], $item); + } + } + } + + /** + * @param array $suiteParameters + * + * @return void + * @throws Exception + * @throws \LdapException + */ + public function connectToLdap(array $suiteParameters):void { + $useSsl = false; + + $this->ldapBaseDN = OcisHelper::getBaseDN(); + $this->ldapUsersOU = OcisHelper::getUsersOU(); + $this->ldapGroupsOU = OcisHelper::getGroupsOU(); + $this->ldapGroupSchema = OcisHelper::getGroupSchema(); + $this->ldapHost = OcisHelper::getHostname(); + $this->ldapPort = OcisHelper::getLdapPort(); + $useSsl = OcisHelper::useSsl(); + $this->ldapAdminUser = OcisHelper::getBindDN(); + $this->ldapAdminPassword = OcisHelper::getBindPassword(); + $this->skipImportLdif = (\getenv("REVA_LDAP_SKIP_LDIF_IMPORT") === "true"); + if ($useSsl === true) { + \putenv('LDAPTLS_REQCERT=never'); + } + + if ($this->ldapAdminPassword === "") { + $this->ldapAdminPassword = (string)$suiteParameters['ldapAdminPassword']; + } + $options = [ + 'host' => $this->ldapHost, + 'port' => $this->ldapPort, + 'password' => $this->ldapAdminPassword, + 'bindRequiresDn' => true, + 'useSsl' => $useSsl, + 'baseDn' => $this->ldapBaseDN, + 'username' => $this->ldapAdminUser + ]; + $this->ldap = new Ldap($options); + $this->ldap->bind(); + + $ldifFile = __DIR__ . (string)$suiteParameters['ldapInitialUserFilePath']; + if (OcisHelper::isTestingParallelDeployment()) { + $behatYml = \getenv("BEHAT_YML"); + if ($behatYml) { + $configPath = \dirname($behatYml); + $ldifFile = $configPath . "/" . \basename($ldifFile); + } + } + if (!$this->skipImportLdif) { + $this->importLdifFile($ldifFile); + } + $this->theLdapUsersHaveBeenResynced(); + } + + /** + * @Given the LDAP users have been resynced + * + * @return void + * @throws Exception + */ + public function theLdapUsersHaveBeenReSynced():void { + // we need to sync ldap users when testing for parallel deployment + if (!OcisHelper::isTestingOnOcisOrReva() || OcisHelper::isTestingParallelDeployment()) { + $occResult = SetupHelper::runOcc( + ['user:sync', 'OCA\User_LDAP\User_Proxy', '-m', 'remove'], + $this->getStepLineRef() + ); + if ($occResult['code'] !== "0") { + throw new Exception(__METHOD__ . " could not sync LDAP users " . $occResult['stdErr']); + } + } + } + + /** + * prepares suitable nested array with user-attributes for multiple users to be created + * + * @param boolean $setDefaultAttributes + * @param array $table + * + * @return array + * @throws JsonException + */ + public function buildUsersAttributesArray(bool $setDefaultAttributes, array $table):array { + $usersAttributes = []; + foreach ($table as $row) { + $userAttribute['userid'] = $this->getActualUsername($row['username']); + + if (isset($row['displayname'])) { + $userAttribute['displayName'] = $row['displayname']; + } elseif ($setDefaultAttributes) { + $userAttribute['displayName'] = $this->getDisplayNameForUser($row['username']); + if ($userAttribute['displayName'] === null) { + $userAttribute['displayName'] = $this->getDisplayNameForUser('regularuser'); + } + } else { + $userAttribute['displayName'] = null; + } + if (isset($row['email'])) { + $userAttribute['email'] = $row['email']; + } elseif ($setDefaultAttributes) { + $userAttribute['email'] = $this->getEmailAddressForUser($row['username']); + if ($userAttribute['email'] === null) { + $userAttribute['email'] = $row['username'] . '@owncloud.com'; + } + } else { + $userAttribute['email'] = null; + } + + if (isset($row['password'])) { + $userAttribute['password'] = $this->getActualPassword($row['password']); + } else { + $userAttribute['password'] = $this->getPasswordForUser($row['username']); + } + // Add request body to the bodies array. we will use that later to loop through created users. + $usersAttributes[] = $userAttribute; + } + return $usersAttributes; + } + + /** + * creates a user in the ldap server + * the created user is added to `createdUsersList` + * ldap users are re-synced after creating a new user + * + * @param array $setting + * + * @return void + * @throws Exception + */ + public function createLdapUser(array $setting):void { + $ou = $this->ldapUsersOU ; + // Some special characters need to be escaped in LDAP DN and attributes + // The special characters allowed in a username (UID) are +_.@- + // Of these, only + has to be escaped. + $userId = \str_replace('+', '\+', $setting["userid"]); + $newDN = 'uid=' . $userId . ',ou=' . $ou . ',' . $this->ldapBaseDN; + + //pick a high number as uidnumber to make sure there are no conflicts with existing uidnumbers + $uidNumber = \count($this->ldapCreatedUsers) + 30000; + $entry = []; + $entry['cn'] = $userId; + $entry['sn'] = $userId; + $entry['uid'] = $setting["userid"]; + $entry['homeDirectory'] = '/home/openldap/' . $setting["userid"]; + $entry['objectclass'][] = 'posixAccount'; + $entry['objectclass'][] = 'inetOrgPerson'; + $entry['objectclass'][] = 'organizationalPerson'; + $entry['objectclass'][] = 'person'; + $entry['objectclass'][] = 'top'; + + $entry['userPassword'] = $setting["password"]; + if (isset($setting["displayName"])) { + $entry['displayName'] = $setting["displayName"]; + } + if (isset($setting["email"])) { + $entry['mail'] = $setting["email"]; + } elseif (OcisHelper::isTestingOnOcis()) { + $entry['mail'] = $userId . '@owncloud.com'; + } + $entry['gidNumber'] = 5000; + $entry['uidNumber'] = $uidNumber; + + if (OcisHelper::isTestingOnOcis()) { + $entry['objectclass'][] = 'ownCloud'; + $entry['ownCloudUUID'] = WebDavHelper::generateUUIDv4(); + } + if (OcisHelper::isTestingParallelDeployment()) { + $entry['ownCloudSelector'] = $this->getOCSelector(); + } + + if ($this->federatedServerExists()) { + if (!\in_array($setting['userid'], $this->ldapCreatedUsers)) { + $this->ldap->add($newDN, $entry); + } + } else { + $this->ldap->add($newDN, $entry); + } + $this->ldapCreatedUsers[] = $setting["userid"]; + $this->theLdapUsersHaveBeenReSynced(); + } + + /** + * @param string $group group name + * + * @return void + * @throws Exception + * @throws LdapException + */ + public function createLdapGroup(string $group):void { + $baseDN = $this->getLdapBaseDN(); + $newDN = 'cn=' . $group . ',ou=' . $this->ldapGroupsOU . ',' . $baseDN; + $entry = []; + $entry['cn'] = $group; + $entry['objectclass'][] = 'top'; + + if ($this->ldapGroupSchema == "rfc2307") { + $entry['objectclass'][] = 'posixGroup'; + $entry['gidNumber'] = 5000; + } else { + $entry['objectclass'][] = 'groupOfNames'; + $entry['member'] = ""; + } + if (OcisHelper::isTestingOnOcis()) { + $entry['objectclass'][] = 'ownCloud'; + $entry['ownCloudUUID'] = WebDavHelper::generateUUIDv4(); + } + $this->ldap->add($newDN, $entry); + $this->ldapCreatedGroups[] = $group; + } + + /** + * + * @param string $configId + * @param string $configKey + * @param string $configValue + * + * @return void + * @throws Exception + */ + public function setLdapSetting(string $configId, string $configKey, string $configValue):void { + if ($configValue === "") { + $configValue = "''"; + } + $substitutions = [ + [ + "code" => "%ldap_host_without_scheme%", + "function" => [ + $this, + "getLdapHostWithoutScheme" + ], + "parameter" => [] + ], + [ + "code" => "%ldap_host%", + "function" => [ + $this, + "getLdapHost" + ], + "parameter" => [] + ], + [ + "code" => "%ldap_port%", + "function" => [ + $this, + "getLdapPort" + ], + "parameter" => [] + ] + ]; + $configValue = $this->substituteInLineCodes( + $configValue, + null, + [], + $substitutions + ); + $occResult = SetupHelper::runOcc( + ['ldap:set-config', $configId, $configKey, $configValue], + $this->getStepLineRef() + ); + if ($occResult['code'] !== "0") { + throw new Exception( + __METHOD__ . " could not set LDAP setting " . $occResult['stdErr'] + ); + } + } + + /** + * deletes LDAP users|groups created during test + * + * @return void + * @throws Exception + */ + public function deleteLdapUsersAndGroups():void { + $isOcisOrReva = OcisHelper::isTestingOnOcisOrReva(); + foreach ($this->ldapCreatedUsers as $user) { + if ($isOcisOrReva) { + $this->ldap->delete( + "uid=" . ldap_escape($user, "", LDAP_ESCAPE_DN) . ",ou=" . $this->ldapUsersOU . "," . $this->ldapBaseDN, + ); + } + $this->rememberThatUserIsNotExpectedToExist($user); + } + foreach ($this->ldapCreatedGroups as $group) { + if ($isOcisOrReva) { + $this->ldap->delete( + "cn=" . ldap_escape($group, "", LDAP_ESCAPE_DN) . ",ou=" . $this->ldapGroupsOU . "," . $this->ldapBaseDN, + ); + } + $this->rememberThatGroupIsNotExpectedToExist($group); + } + if (!$isOcisOrReva || !$this->skipImportLdif) { + //delete ou from LDIF import + $this->ldap->delete( + "ou=" . $this->ldapUsersOU . "," . $this->ldapBaseDN, + true + ); + //delete all created ldap groups + $this->ldap->delete( + "ou=" . $this->ldapGroupsOU . "," . $this->ldapBaseDN, + true + ); + } + $this->theLdapUsersHaveBeenResynced(); + } + + /** + * Sets back old settings + * + * @return void + * @throws Exception + */ + public function resetOldLdapConfig():void { + $toDeleteLdapConfig = $this->getToDeleteLdapConfigs(); + foreach ($toDeleteLdapConfig as $configId) { + SetupHelper::runOcc( + ['ldap:delete-config', $configId], + $this->getStepLineRef() + ); + } + foreach ($this->oldLdapConfig as $configId => $settings) { + foreach ($settings as $configKey => $configValue) { + $this->setLdapSetting($configId, $configKey, $configValue); + } + } + foreach ($this->toDeleteDNs as $dn) { + $this->getLdap()->delete($dn, true); + } + } + + /** + * Manually add skeleton files for a single user on OCIS and reva systems + * + * @param string $user + * @param string $password + * + * @return void + * @throws Exception + */ + public function manuallyAddSkeletonFilesForUser(string $user, string $password):void { + $settings = []; + $setting["userid"] = $user; + $setting["password"] = $password; + $settings[] = $setting; + $this->manuallyAddSkeletonFiles($settings); + } + + /** + * Manually add skeleton files on OCIS and reva systems + * + * @param array $usersAttributes + * + * @return void + * @throws Exception + */ + public function manuallyAddSkeletonFiles(array $usersAttributes):void { + if ($this->isEmptySkeleton()) { + // The empty skeleton has no files. There is nothing to do so return early. + return; + } + $skeletonDir = \getenv("SKELETON_DIR"); + $revaRoot = \getenv("OCIS_REVA_DATA_ROOT"); + $skeletonStrategy = \getenv("OCIS_SKELETON_STRATEGY"); + if (!$skeletonStrategy) { + $skeletonStrategy = 'upload'; //slower, but safer, so make it the default + } + if ($skeletonStrategy !== 'upload' && $skeletonStrategy !== 'copy') { + throw new Exception( + 'Wrong OCIS_SKELETON_STRATEGY environment variable. ' . + 'OCIS_SKELETON_STRATEGY has to be set to "upload" or "copy"' + ); + } + if (!$skeletonDir) { + throw new Exception('Missing SKELETON_DIR environment variable, cannot copy skeleton files for OCIS'); + } + if ($skeletonStrategy === 'copy' && !$revaRoot) { + throw new Exception( + 'OCIS_SKELETON_STRATEGY is set to "copy" ' . + 'but no "OCIS_REVA_DATA_ROOT" given' + ); + } + if ($skeletonStrategy === 'upload') { + foreach ($usersAttributes as $userAttributes) { + OcisHelper::recurseUpload( + $this->getBaseUrl(), + $skeletonDir, + $userAttributes['userid'], + $userAttributes['password'], + $this->getStepLineRef() + ); + } + } + if ($skeletonStrategy === 'copy') { + foreach ($usersAttributes as $userAttributes) { + $user = $userAttributes['userid']; + $dataDir = $revaRoot . "$user/files"; + if (!\file_exists($dataDir)) { + \mkdir($dataDir, 0777, true); + } + OcisHelper::recurseCopy($skeletonDir, $dataDir); + } + } + } + + /** + * This function will allow us to send user creation requests in parallel. + * This will be faster in comparison to waiting for each request to complete before sending another request. + * + * @param boolean $initialize + * @param array|null $usersAttributes + * @param string|null $method create the user with "ldap" or "api" + * @param boolean $skeleton + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function usersHaveBeenCreated( + bool $initialize, + ?array $usersAttributes, + ?string $method = null, + ?bool $skeleton = true + ) { + $requests = []; + $client = HttpRequestHelper::createClient( + $this->getAdminUsername(), + $this->getAdminPassword() + ); + + $useLdap = false; + $useGraph = false; + if ($method === null) { + $useLdap = $this->isTestingWithLdap(); + $useGraph = OcisHelper::isTestingWithGraphApi(); + } elseif ($method === "ldap") { + $useLdap = true; + } elseif ($method === "graph") { + $useGraph = true; + } + + foreach ($usersAttributes as $i => $userAttributes) { + if ($useLdap) { + $this->createLdapUser($userAttributes); + } else { + $attributesToCreateUser['userid'] = $userAttributes['userid']; + $attributesToCreateUser['password'] = $userAttributes['password']; + $attributesToCreateUser['displayname'] = $userAttributes['displayName']; + if (OcisHelper::isTestingOnOcisOrReva()) { + $attributesToCreateUser['username'] = $userAttributes['userid']; + if ($userAttributes['email'] === null) { + Assert::assertArrayHasKey( + 'userid', + $userAttributes, + __METHOD__ . " userAttributes array does not have key 'userid'" + ); + $attributesToCreateUser['email'] = $userAttributes['userid'] . '@owncloud.com'; + } else { + $attributesToCreateUser['email'] = $userAttributes['email']; + } + } + if ($useGraph) { + $body = \TestHelpers\GraphHelper::prepareCreateUserPayload( + $attributesToCreateUser['userid'], + $attributesToCreateUser['password'], + $attributesToCreateUser['email'], + $attributesToCreateUser['displayname'] + ); + $request = \TestHelpers\GraphHelper::createRequest( + $this->getBaseUrl(), + $this->getStepLineRef(), + "POST", + 'users', + $body, + ); + } else { + // Create a OCS request for creating the user. The request is not sent to the server yet. + $request = OcsApiHelper::createOcsRequest( + $this->getBaseUrl(), + 'POST', + "/cloud/users", + $this->stepLineRef, + $attributesToCreateUser + ); + } + // Add the request to the $requests array so that they can be sent in parallel. + $requests[] = $request; + } + } + + $exceptionToThrow = null; + if (!$useLdap) { + $results = HttpRequestHelper::sendBatchRequest($requests, $client); + // Check all requests to inspect failures. + foreach ($results as $key => $e) { + if ($e instanceof ClientException) { + if ($useGraph) { + $responseBody = $this->getJsonDecodedResponse($e->getResponse()); + $httpStatusCode = $e->getResponse()->getStatusCode(); + $graphStatusCode = $responseBody['error']['code']; + $messageText = $responseBody['error']['message']; + $exceptionToThrow = new Exception( + __METHOD__ . + " Unexpected failure when creating the user '" . + $usersAttributes[$key]['userid'] . "'" . + "\nHTTP status $httpStatusCode " . + "\nGraph status $graphStatusCode " . + "\nError message $messageText" + ); + } else { + $responseXml = $this->getResponseXml($e->getResponse(), __METHOD__); + $messageText = (string) $responseXml->xpath("/ocs/meta/message")[0]; + $ocsStatusCode = (string) $responseXml->xpath("/ocs/meta/statuscode")[0]; + $httpStatusCode = $e->getResponse()->getStatusCode(); + $reasonPhrase = $e->getResponse()->getReasonPhrase(); + $exceptionToThrow = new Exception( + __METHOD__ . " Unexpected failure when creating the user '" . + $usersAttributes[$key]['userid'] . "': HTTP status $httpStatusCode " . + "HTTP reason $reasonPhrase OCS status $ocsStatusCode " . + "OCS message $messageText" + ); + } + } + } + } + + // Create requests for setting displayname and email for the newly created users. + // These values cannot be set while creating the user, so we have to edit the newly created user to set these values. + $users = []; + $editData = []; + foreach ($usersAttributes as $userAttributes) { + $users[] = $userAttributes['userid']; + if ($useGraph) { + // for graph api, we need to save the user id to be able to add it in some group + // can be fetched with the "onPremisesSamAccountName" i.e. userid + $this->graphContext->adminHasRetrievedUserUsingTheGraphApi($userAttributes['userid']); + $userAttributes['id'] = $this->getJsonDecodedResponse()['id']; + } else { + $userAttributes['id'] = null; + } + $this->addUserToCreatedUsersList( + $userAttributes['userid'], + $userAttributes['password'], + $userAttributes['displayName'], + $userAttributes['email'], + $userAttributes['id'] + ); + + if (OcisHelper::isTestingOnOcisOrReva()) { + OcisHelper::createEOSStorageHome( + $this->getBaseUrl(), + $userAttributes['userid'], + $userAttributes['password'], + $this->getStepLineRef() + ); + // We don't need to set displayName and email while running in oCIS + // As they are set when creating the user + continue; + } + if (isset($userAttributes['displayName'])) { + $editData[] = ['user' => $userAttributes['userid'], 'key' => 'displayname', 'value' => $userAttributes['displayName']]; + } + if (isset($userAttributes['email'])) { + $editData[] = ['user' => $userAttributes['userid'], 'key' => 'email', 'value' => $userAttributes['email']]; + } + } + // Edit the users in parallel to make the process faster. + if (!OcisHelper::isTestingOnOcisOrReva() && !$useLdap && \count($editData) > 0) { + UserHelper::editUserBatch( + $this->getBaseUrl(), + $editData, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->stepLineRef + ); + } + + if (isset($exceptionToThrow)) { + throw $exceptionToThrow; + } + + // If the user should have skeleton files, and we are testing on OCIS + // then do some work to "manually" put the skeleton files in place. + // When testing on ownCloud 10 the user is already getting whatever + // skeleton dir is defined in the server-under-test. + if ($skeleton && OcisHelper::isTestingOnOcisOrReva()) { + $this->manuallyAddSkeletonFiles($usersAttributes); + } + + if ($initialize && ($this->isEmptySkeleton() || !OcisHelper::isTestingOnOcis())) { + // We need to initialize each user using the individual authentication of each user. + // That is not possible in Guzzle6 batch mode. So we do it with normal requests in serial. + $this->initializeUsers($users); + } + } + + /** + * @When /^the administrator creates these users with ?(default attributes and|) skeleton files ?(but not initialized|):$/ + * + * expects a table of users with the heading + * "|username|password|displayname|email|" + * password, displayname & email are optional + * + * @param string $setDefaultAttributes + * @param string $doNotInitialize + * @param TableNode $table + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function theAdministratorCreatesTheseUsers( + string $setDefaultAttributes, + string $doNotInitialize, + TableNode $table + ): void { + $this->verifyTableNodeColumns($table, ['username'], ['displayname', 'email', 'password']); + $table = $table->getColumnsHash(); + $setDefaultAttributes = $setDefaultAttributes !== ""; + $initialize = $doNotInitialize === ""; + $usersAttributes = $this->buildUsersAttributesArray($setDefaultAttributes, $table); + $this->usersHaveBeenCreated( + $initialize, + $usersAttributes + ); + } + + /** + * expects a table of users with the heading + * "|username|password|displayname|email|" + * password, displayname & email are optional + * + * @param boolean $setDefaultAttributes + * @param boolean $initialize + * @param boolean $skeleton + * @param TableNode $table + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function createTheseUsers(bool $setDefaultAttributes, bool $initialize, bool $skeleton, TableNode $table):void { + $this->verifyTableNodeColumns($table, ['username'], ['displayname', 'email', 'password']); + $table = $table->getColumnsHash(); + $usersAttributes = $this->buildUsersAttributesArray($setDefaultAttributes, $table); + $this->usersHaveBeenCreated( + $initialize, + $usersAttributes, + null, + $skeleton + ); + foreach ($usersAttributes as $expectedUser) { + $this->userShouldExist($expectedUser["userid"]); + } + } + + /** + * @Given /^these users have been created with ?(default attributes and|) (tiny|small|large)\s?skeleton files ?(but not initialized|):$/ + * + * expects a table of users with the heading + * "|username|password|displayname|email|" + * password, displayname & email are optional + * + * @param string $defaultAttributesText + * @param string $skeletonType + * @param string $doNotInitialize + * @param TableNode $table + * + * @return void + * @throws Exception|GuzzleException + */ + public function theseUsersHaveBeenCreated( + string $defaultAttributesText, + string $skeletonType, + string $doNotInitialize, + TableNode $table + ):void { + if ($skeletonType === "") { + $skeletonType = $this->getSmallestSkeletonDirName(); + } + + $originalSkeletonPath = $this->setSkeletonDirByType($skeletonType); + $setDefaultAttributes = $defaultAttributesText !== ""; + $initialize = $doNotInitialize === ""; + try { + $this->createTheseUsers($setDefaultAttributes, $initialize, true, $table); + } finally { + // The effective skeleton directory is the one when the user is initialized + // If we did not initialize the user on creation, then we need to leave + // the skeleton directory in effect so that it applies when some action + // happens later in the scenario that causes the user to be initialized. + if ($initialize) { + $this->setSkeletonDir($originalSkeletonPath); + } + } + } + + /** + * @When the administrator changes the password of user :user to :password using the provisioning API + * + * @param string $user + * @param string $password + * + * @return void + * @throws Exception + */ + public function adminChangesPasswordOfUserToUsingTheProvisioningApi( + string $user, + string $password + ):void { + $this->response = UserHelper::editUser( + $this->getBaseUrl(), + $user, + 'password', + $password, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef() + ); + } + + /** + * @Given the administrator has changed the password of user :user to :password + * + * @param string $user + * @param string $password + * + * @return void + * @throws Exception + */ + public function adminHasChangedPasswordOfUserTo( + string $user, + string $password + ):void { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminChangesPasswordOfUserToUsingTheGraphApi( + $user, + $password + ); + } else { + $this->adminChangesPasswordOfUserToUsingTheProvisioningApi( + $user, + $password + ); + } + $this->theHTTPStatusCodeShouldBe( + 200, + "could not change password of user $user" + ); + } + + /** + * @When /^user "([^"]*)" (enables|disables) app "([^"]*)"$/ + * + * @param string $user + * @param string $action enables or disables + * @param string $app + * + * @return void + */ + public function userEnablesOrDisablesApp(string $user, string $action, string $app):void { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/apps/$app"; + if ($action === 'enables') { + $this->response = HttpRequestHelper::post( + $fullUrl, + $this->getStepLineRef(), + $user, + $this->getPasswordForUser($user) + ); + } else { + $this->response = HttpRequestHelper::delete( + $fullUrl, + $this->getStepLineRef(), + $user, + $this->getPasswordForUser($user) + ); + } + } + + /** + * @When /^the administrator (enables|disables) app "([^"]*)"$/ + * + * @param string $action enables or disables + * @param string $app + * + * @return void + */ + public function adminEnablesOrDisablesApp(string $action, string $app):void { + $this->userEnablesOrDisablesApp( + $this->getAdminUsername(), + $action, + $app + ); + } + + /** + * @Given /^app "([^"]*)" has been (enabled|disabled)$/ + * + * @param string $app + * @param string $action enabled or disabled + * + * @return void + */ + public function appHasBeenDisabled(string $app, string $action):void { + if ($action === 'enabled') { + $action = 'enables'; + } else { + $action = 'disables'; + } + $this->userEnablesOrDisablesApp( + $this->getAdminUsername(), + $action, + $app + ); + } + + /** + * @When the administrator gets the info of app :app + * + * @param string $app + * + * @return void + */ + public function theAdministratorGetsTheInfoOfApp(string $app):void { + $this->ocsContext->userSendsToOcsApiEndpoint( + $this->getAdminUsername(), + "GET", + "/cloud/apps/$app" + ); + } + + /** + * @When the administrator gets all apps using the provisioning API + * + * @return void + */ + public function theAdministratorGetsAllAppsUsingTheProvisioningApi():void { + $this->getAllApps(); + } + + /** + * @When the administrator gets all enabled apps using the provisioning API + * + * @return void + */ + public function theAdministratorGetsAllEnabledAppsUsingTheProvisioningApi():void { + $this->getEnabledApps(); + } + + /** + * @When the administrator gets all disabled apps using the provisioning API + * + * @return void + */ + public function theAdministratorGetsAllDisabledAppsUsingTheProvisioningApi():void { + $this->getDisabledApps(); + } + + /** + * @When the administrator sends a user creation request with the following attributes using the provisioning API: + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function adminSendsUserCreationRequestWithFollowingAttributesUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeRows($table, ["username", "password"], ["email", "displayname"]); + $table = $table->getRowsHash(); + $username = $this->getActualUsername($table["username"]); + $password = $this->getActualPassword($table["password"]); + $displayname = \array_key_exists("displayname", $table) ? $table["displayname"] : null; + $email = \array_key_exists("email", $table) ? $table["email"] : null; + + if (OcisHelper::isTestingOnOcisOrReva()) { + if ($email === null) { + $email = $username . '@owncloud.com'; + } + } + + $userAttributes = [ + ["userid", $username], + ["password", $password], + ]; + + if ($displayname !== null) { + $userAttributes[] = ["displayname", $displayname]; + } + + if ($email !== null) { + $userAttributes[] = ["email", $email]; + } + + if (OcisHelper::isTestingOnOcisOrReva()) { + $userAttributes[] = ["username", $username]; + } + + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $this->getAdminUsername(), + "POST", + "/cloud/users", + new TableNode($userAttributes) + ); + $this->addUserToCreatedUsersList( + $username, + $password, + $displayname, + $email, + null, + $this->theHTTPStatusCodeWasSuccess() + ); + if (OcisHelper::isTestingOnOcisOrReva()) { + $this->manuallyAddSkeletonFilesForUser($username, $password); + } + } + + /** + * @When /^the administrator sends a user creation request for user "([^"]*)" password "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $password + * + * @return void + * @throws Exception + */ + public function adminSendsUserCreationRequestUsingTheProvisioningApi(string $user, string $password):void { + $user = $this->getActualUsername($user); + $password = $this->getActualPassword($password); + if (OcisHelper::isTestingOnOcisOrReva()) { + $email = $user . '@owncloud.com'; + $bodyTable = new TableNode( + [ + ['userid', $user], + ['password', $password], + ['username', $user], + ['email', $email] + ] + ); + } else { + $email = null; + $bodyTable = new TableNode([['userid', $user], ['password', $password]]); + } + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $this->getAdminUsername(), + "POST", + "/cloud/users", + $bodyTable + ); + $this->pushToLastStatusCodesArrays(); + $success = $this->theHTTPStatusCodeWasSuccess(); + $this->addUserToCreatedUsersList( + $user, + $password, + null, + $email, + null, + $success + ); + if (OcisHelper::isTestingOnOcisOrReva() && $success) { + OcisHelper::createEOSStorageHome( + $this->getBaseUrl(), + $user, + $password, + $this->getStepLineRef() + ); + $this->manuallyAddSkeletonFilesForUser($user, $password); + } + } + + /** + * @When /^the administrator sends a user creation request for the following users with password using the provisioning API$/ + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorSendsAUserCreationRequestForTheFollowingUsersWithPasswordUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username", "password"], ["comment"]); + $users = $table->getHash(); + foreach ($users as $user) { + $this->adminSendsUserCreationRequestUsingTheProvisioningApi($user["username"], $user["password"]); + } + } + + /** + * @When /^unauthorized user "([^"]*)" tries to create new user "([^"]*)" with password "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $userToCreate + * @param string $password + * + * @return void + * @throws Exception + */ + public function userSendsUserCreationRequestUsingTheProvisioningApi(string $user, string $userToCreate, string $password):void { + $userToCreate = $this->getActualUsername($userToCreate); + $password = $this->getActualPassword($password); + if (OcisHelper::isTestingOnOcisOrReva()) { + $email = $userToCreate . '@owncloud.com'; + $bodyTable = new TableNode( + [ + ['userid', $userToCreate], + ['password', $password], + ['username', $userToCreate], + ['email', $email] + ] + ); + } else { + $email = null; + $bodyTable = new TableNode([['userid', $userToCreate], ['password', $password]]); + } + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "POST", + "/cloud/users", + $bodyTable + ); + $this->addUserToCreatedUsersList( + $userToCreate, + $password, + null, + $email, + null, + $this->theHTTPStatusCodeWasSuccess() + ); + } + + /** + * @When /^the administrator sends a user creation request for user "([^"]*)" password "([^"]*)" group "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $password + * @param string $group + * + * @return void + * @throws Exception + */ + public function theAdministratorCreatesUserPasswordGroupUsingTheProvisioningApi( + string $user, + string $password, + string $group + ):void { + $user = $this->getActualUsername($user); + $password = $this->getActualPassword($password); + if (OcisHelper::isTestingOnOcisOrReva()) { + $email = $user . '@owncloud.com'; + $bodyTable = new TableNode( + [ + ['userid', $user], + ['password', $password], + ['username', $user], + ['email', $email], + ['groups[]', $group], + ] + ); + } else { + $email = null; + $bodyTable = new TableNode( + [['userid', $user], ['password', $password], ['groups[]', $group]] + ); + } + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $this->getAdminUsername(), + "POST", + "/cloud/users", + $bodyTable + ); + $this->addUserToCreatedUsersList( + $user, + $password, + null, + $email, + null, + $this->theHTTPStatusCodeWasSuccess() + ); + if (OcisHelper::isTestingOnOcisOrReva()) { + $this->manuallyAddSkeletonFilesForUser($user, $password); + } + } + + /** + * @When /^the groupadmin "([^"]*)" sends a user creation request for user "([^"]*)" password "([^"]*)" group "([^"]*)" using the provisioning API$/ + * + * @param string $groupadmin + * @param string $userToCreate + * @param string $password + * @param string $group + * + * @return void + * @throws Exception + */ + public function theGroupAdminCreatesUserPasswordGroupUsingTheProvisioningApi( + string $groupadmin, + string $userToCreate, + string $password, + string $group + ):void { + $userToCreate = $this->getActualUsername($userToCreate); + $password = $this->getActualPassword($password); + if (OcisHelper::isTestingOnOcisOrReva()) { + $email = $userToCreate . '@owncloud.com'; + $bodyTable = new TableNode( + [ + ['userid', $userToCreate], + ['password', $userToCreate], + ['username', $userToCreate], + ['email', $email], + ['groups[]', $group], + ] + ); + } else { + $email = null; + $bodyTable = new TableNode( + [['userid', $userToCreate], ['password', $password], ['groups[]', $group]] + ); + } + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $groupadmin, + "POST", + "/cloud/users", + $bodyTable + ); + $this->addUserToCreatedUsersList( + $userToCreate, + $password, + null, + $email, + null, + $this->theHTTPStatusCodeWasSuccess() + ); + if (OcisHelper::isTestingOnOcisOrReva()) { + $this->manuallyAddSkeletonFilesForUser($userToCreate, $password); + } + } + + /** + * @When /^the groupadmin "([^"]*)" tries to create new user "([^"]*)" password "([^"]*)" in other group "([^"]*)" using the provisioning API$/ + * + * @param string $groupadmin + * @param string $userToCreate + * @param string|null $password + * @param string $group + * + * @return void + */ + public function theGroupAdminCreatesUserPasswordInOtherGroupUsingTheProvisioningApi( + string $groupadmin, + string $userToCreate, + ?string $password, + string $group + ):void { + $userToCreate = $this->getActualUsername($userToCreate); + $password = $this->getActualPassword($password); + if (OcisHelper::isTestingOnOcisOrReva()) { + $email = $userToCreate . '@owncloud.com'; + $bodyTable = new TableNode( + [ + ['userid', $userToCreate], + ['password', $userToCreate], + ['username', $userToCreate], + ['email', $email], + ['groups[]', $group], + ] + ); + } else { + $email = null; + $bodyTable = new TableNode( + [['userid', $userToCreate], ['password', $password], ['groups[]', $group]] + ); + } + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $groupadmin, + "POST", + "/cloud/users", + $bodyTable + ); + $this->addUserToCreatedUsersList( + $userToCreate, + $password, + null, + $email, + null, + $this->theHTTPStatusCodeWasSuccess() + ); + } + + /** + * @param string $username + * @param string|null $password + * + * @return void + */ + public function resetUserPasswordAsAdminUsingTheProvisioningApi(string $username, ?string $password):void { + $this->userResetUserPasswordUsingProvisioningApi( + $this->getAdminUsername(), + $username, + $password + ); + } + + /** + * @When the administrator resets the password of user :username to :password using the provisioning API + * + * @param string $username of the user whose password is reset + * @param string|null $password + * + * @return void + */ + public function adminResetsPasswordOfUserUsingTheProvisioningApi(string $username, ?string $password):void { + $this->resetUserPasswordAsAdminUsingTheProvisioningApi( + $username, + $password + ); + } + + /** + * @Given the administrator has reset the password of user :username to :password + * + * @param string $username of the user whose password is reset + * @param string $password + * + * @return void + */ + public function adminHasResetPasswordOfUserUsingTheProvisioningApi(string $username, ?string $password):void { + $this->resetUserPasswordAsAdminUsingTheProvisioningApi( + $username, + $password + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string|null $user + * @param string|null $username + * @param string|null $password + * + * @return void + */ + public function userResetUserPasswordUsingProvisioningApi(?string $user, ?string $username, ?string $password):void { + $targetUsername = $this->getActualUsername($username); + $password = $this->getActualPassword($password); + $this->userTriesToResetUserPasswordUsingTheProvisioningApi( + $user, + $targetUsername, + $password + ); + $this->rememberUserPassword($targetUsername, $password); + } + + /** + * @When user :user resets the password of user :username to :password using the provisioning API + * + * @param string|null $user that does the password reset + * @param string|null $username of the user whose password is reset + * @param string|null $password + * + * @return void + */ + public function userResetsPasswordOfUserUsingTheProvisioningApi(?string $user, ?string $username, ?string $password):void { + $this->userResetUserPasswordUsingProvisioningApi( + $user, + $username, + $password + ); + } + + /** + * @Given user :user has reset the password of user :username to :password + * + * @param string|null $user that does the password reset + * @param string|null $username of the user whose password is reset + * @param string|null $password + * + * @return void + */ + public function userHasResetPasswordOfUserUsingTheProvisioningApi(?string $user, ?string $username, ?string $password):void { + $this->userResetUserPasswordUsingProvisioningApi( + $user, + $username, + $password + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string|null $user + * @param string|null $username + * @param string|null $password + * + * @return void + */ + public function userTriesToResetUserPasswordUsingTheProvisioningApi(?string $user, ?string $username, ?string $password):void { + $password = $this->getActualPassword($password); + $bodyTable = new TableNode([['key', 'password'], ['value', $password]]); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "PUT", + "/cloud/users/$username", + $bodyTable + ); + } + + /** + * @When user :user tries to reset the password of user :username to :password using the provisioning API + * + * @param string|null $user that does the password reset + * @param string|null $username of the user whose password is reset + * @param string|null $password + * + * @return void + */ + public function userTriesToResetPasswordOfUserUsingTheProvisioningApi(?string $user, ?string $username, ?string $password):void { + $this->userTriesToResetUserPasswordUsingTheProvisioningApi( + $user, + $username, + $password + ); + } + + /** + * @Given user :user has tried to reset the password of user :username to :password + * + * @param string|null $user that does the password reset + * @param string|null $username of the user whose password is reset + * @param string|null $password + * + * @return void + */ + public function userHasTriedToResetPasswordOfUserUsingTheProvisioningApi(?string $user, ?string $username, ?string $password):void { + $this->userTriesToResetUserPasswordUsingTheProvisioningApi( + $user, + $username, + $password + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Given /^the administrator has deleted user "([^"]*)" using the provisioning API$/ + * + * @param string|null $user + * + * @return void + * @throws Exception + */ + public function theAdministratorHasDeletedUserUsingTheProvisioningApi(?string $user):void { + $user = $this->getActualUsername($user); + $this->deleteTheUserUsingTheProvisioningApi($user); + WebDavHelper::removeSpaceIdReferenceForUser($user); + $this->userShouldNotExist($user); + } + + /** + * @When /^the administrator deletes user "([^"]*)" using the provisioning API$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdminDeletesUserUsingTheProvisioningApi(string $user):void { + $user = $this->getActualUsername($user); + $this->deleteTheUserUsingTheProvisioningApi($user); + $this->rememberThatUserIsNotExpectedToExist($user); + } + + /** + * @When the administrator deletes the following users using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesTheFollowingUsersUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->theAdminDeletesUserUsingTheProvisioningApi($username["username"]); + } + } + + /** + * @When user :user deletes user :otherUser using the provisioning API + * + * @param string $user + * @param string $otherUser + * + * @return void + * @throws Exception + */ + public function userDeletesUserUsingTheProvisioningApi( + string $user, + string $otherUser + ):void { + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $actualOtherUser = $this->getActualUsername($otherUser); + + $this->response = UserHelper::deleteUser( + $this->getBaseUrl(), + $actualOtherUser, + $actualUser, + $actualPassword, + $this->getStepLineRef(), + $this->ocsApiVersion + ); + } + + /** + * @When /^the administrator changes the email of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $email + * + * @return void + * @throws Exception + */ + public function adminChangesTheEmailOfUserToUsingTheProvisioningApi( + string $user, + string $email + ):void { + $user = $this->getActualUsername($user); + $this->response = UserHelper::editUser( + $this->getBaseUrl(), + $user, + 'email', + $email, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->rememberUserEmailAddress($user, $email); + } + + /** + * @Given /^the administrator has changed the email of user "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $email + * + * @return void + * @throws Exception + */ + public function adminHasChangedTheEmailOfUserTo(string $user, string $email):void { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userHasBeenEditedUsingTheGraphApi( + $user, + null, + null, + $email + ); + $updatedUserData = $this->getJsonDecodedResponse(); + Assert::assertEquals( + $email, + $updatedUserData['mail'] + ); + } else { + $this->adminChangesTheEmailOfUserToUsingTheProvisioningApi( + $user, + $email + ); + $this->theHTTPStatusCodeShouldBe( + 200, + "could not change email of user $user" + ); + } + } + + /** + * @Given the administrator has changed their own email address to :email + * + * @param string|null $email + * + * @return void + * @throws Exception + */ + public function theAdministratorHasChangedTheirOwnEmailAddressTo(?string $email):void { + $admin = $this->getAdminUsername(); + $this->adminHasChangedTheEmailOfUserTo($admin, $email); + } + + /** + * @param string $requestingUser + * @param string $targetUser + * @param string $email + * + * @return void + * @throws JsonException + */ + public function userChangesUserEmailUsingProvisioningApi( + string $requestingUser, + string $targetUser, + string $email + ):void { + $this->response = UserHelper::editUser( + $this->getBaseUrl(), + $this->getActualUsername($targetUser), + 'email', + $email, + $this->getActualUsername($requestingUser), + $this->getPasswordForUser($requestingUser), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + } + + /** + * @When /^user "([^"]*)" changes the email of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $email + * + * @return void + * @throws Exception + */ + public function userChangesTheEmailOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $email + ):void { + $this->userTriesToChangeTheEmailOfUserUsingTheProvisioningApi( + $requestingUser, + $targetUser, + $email + ); + $targetUser = $this->getActualUsername($targetUser); + $this->rememberUserEmailAddress($targetUser, $email); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" tries to change the email of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $email + * + * @return void + */ + public function userTriesToChangeTheEmailOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $email + ):void { + $requestingUser = $this->getActualUsername($requestingUser); + $targetUser = $this->getActualUsername($targetUser); + $this->userChangesUserEmailUsingProvisioningApi( + $requestingUser, + $targetUser, + $email + ); + } + + /** + * @Given /^user "([^"]*)" has changed the email of user "([^"]*)" to "([^"]*)"$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $email + * + * @return void + * @throws Exception + */ + public function userHasChangedTheEmailOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $email + ):void { + $requestingUser = $this->getActualUsername($requestingUser); + $targetUser = $this->getActualUsername($targetUser); + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userHasBeenEditedUsingTheGraphApi( + $targetUser, + null, + null, + $email, + null, + $requestingUser, + $this->getPasswordForUser($requestingUser) + ); + $updatedUserData = $this->getJsonDecodedResponse(); + Assert::assertEquals( + $email, + $updatedUserData['mail'], + ); + } else { + $this->userChangesUserEmailUsingProvisioningApi( + $requestingUser, + $targetUser, + $email + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + $this->rememberUserEmailAddress($targetUser, $email); + } + + /** + * Edit the "display name" of a user by sending the key "displayname" to the API end point. + * + * This is the newer and consistent key name. + * + * @see https://github.com/owncloud/core/pull/33040 + * + * @When /^the administrator changes the display name of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function adminChangesTheDisplayNameOfUserUsingTheProvisioningApi( + string $user, + string $displayName + ):void { + $user = $this->getActualUsername($user); + $this->adminChangesTheDisplayNameOfUserUsingKey( + $user, + 'displayname', + $displayName + ); + $this->rememberUserDisplayName($user, $displayName); + } + + /** + * @Given /^the administrator has changed the display name of user "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function adminHasChangedTheDisplayNameOfUser( + string $user, + string $displayName + ):void { + $user = $this->getActualUsername($user); + if ($this->isTestingWithLdap()) { + $this->editLdapUserDisplayName( + $user, + $displayName + ); + } elseif (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userHasBeenEditedUsingTheGraphApi( + $user, + null, + null, + null, + $displayName + ); + $updatedUserData = $this->getJsonDecodedResponse(); + Assert::assertEquals( + $displayName, + $updatedUserData['displayName'] + ); + } else { + $this->adminChangesTheDisplayNameOfUserUsingKey( + $user, + 'displayname', + $displayName + ); + $response = UserHelper::getUser( + $this->getBaseUrl(), + $user, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef() + ); + $this->setResponse($response); + $this->theDisplayNameReturnedByTheApiShouldBe($displayName); + } + $this->rememberUserDisplayName($user, $displayName); + } + + /** + * As the administrator, edit the "display name" of a user by sending the key "display" to the API end point. + * + * This is the older and inconsistent key name, which remains for backward-compatibility. + * + * @see https://github.com/owncloud/core/pull/33040 + * + * @When /^the administrator changes the display of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function adminChangesTheDisplayOfUserUsingTheProvisioningApi( + string $user, + string $displayName + ):void { + $user = $this->getActualUsername($user); + $this->adminChangesTheDisplayNameOfUserUsingKey( + $user, + 'display', + $displayName + ); + $this->rememberUserDisplayName($user, $displayName); + } + + /** + * + * @param string $user + * @param string $key + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function adminChangesTheDisplayNameOfUserUsingKey( + string $user, + string $key, + string $displayName + ):void { + $result = UserHelper::editUser( + $this->getBaseUrl(), + $this->getActualUsername($user), + $key, + $displayName, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + if ($result->getStatusCode() !== 200) { + throw new Exception( + __METHOD__ . " could not change display name of user using key $key " + . $result->getStatusCode() . " " . $result->getBody() + ); + } + } + + /** + * As a user, edit the "display name" of a user by sending the key "displayname" to the API end point. + * + * This is the newer and consistent key name. + * + * @see https://github.com/owncloud/core/pull/33040 + * + * @When /^user "([^"]*)" changes the display name of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function userChangesTheDisplayNameOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $displayName + ):void { + $this->userTriesToChangeTheDisplayNameOfUserUsingTheProvisioningApi( + $requestingUser, + $targetUser, + $displayName + ); + $targetUser = $this->getActualUsername($targetUser); + $this->rememberUserDisplayName($targetUser, $displayName); + } + + /** + * As a user, try to edit the "display name" of a user by sending the key "displayname" to the API end point. + * + * This is the newer and consistent key name. + * + * @see https://github.com/owncloud/core/pull/33040 + * + * @When /^user "([^"]*)" tries to change the display name of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $displayName + * + * @return void + */ + public function userTriesToChangeTheDisplayNameOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $displayName + ):void { + $requestingUser = $this->getActualUsername($requestingUser); + $targetUser = $this->getActualUsername($targetUser); + $this->userChangesTheDisplayNameOfUserUsingKey( + $requestingUser, + $targetUser, + 'displayname', + $displayName + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * As a user, edit the "display name" of a user by sending the key "display" to the API end point. + * + * This is the older and inconsistent key name. + * + * @see https://github.com/owncloud/core/pull/33040 + * + * @When /^user "([^"]*)" changes the display of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function userChangesTheDisplayOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $displayName + ):void { + $requestingUser = $this->getActualUsername($requestingUser); + $targetUser = $this->getActualUsername($targetUser); + $this->userChangesTheDisplayNameOfUserUsingKey( + $requestingUser, + $targetUser, + 'display', + $displayName + ); + $this->rememberUserDisplayName($targetUser, $displayName); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has changed the display name of user "([^"]*)" to "([^"]*)"$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $displayName + * + * @return void + * @throws Exception + */ + public function userHasChangedTheDisplayNameOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $displayName + ):void { + $requestingUser = $this->getActualUsername($requestingUser); + $targetUser = $this->getActualUsername($targetUser); + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userHasBeenEditedUsingTheGraphApi( + $targetUser, + null, + null, + null, + $displayName, + $requestingUser, + $this->getPasswordForUser($requestingUser) + ); + $updatedUserData = $this->getJsonDecodedResponse(); + Assert::assertEquals( + $displayName, + $updatedUserData['displayName'] + ); + } else { + $this->userChangesTheDisplayNameOfUserUsingKey( + $requestingUser, + $targetUser, + 'displayname', + $displayName + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + $this->rememberUserDisplayName($targetUser, $displayName); + } + /** + * + * @param string $requestingUser + * @param string $targetUser + * @param string $key + * @param string $displayName + * + * @return void + */ + public function userChangesTheDisplayNameOfUserUsingKey( + string $requestingUser, + string $targetUser, + string $key, + string $displayName + ):void { + $result = UserHelper::editUser( + $this->getBaseUrl(), + $this->getActualUsername($targetUser), + $key, + $displayName, + $this->getActualUsername($requestingUser), + $this->getPasswordForUser($requestingUser), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + } + + /** + * @When /^the administrator changes the quota of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $quota + * + * @return void + */ + public function adminChangesTheQuotaOfUserUsingTheProvisioningApi( + string $user, + string $quota + ):void { + $result = UserHelper::editUser( + $this->getBaseUrl(), + $this->getActualUsername($user), + 'quota', + $quota, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + } + + /** + * @Given /^the administrator has (?:changed|set) the quota of user "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $quota + * + * @return void + */ + public function adminHasChangedTheQuotaOfUserTo( + string $user, + string $quota + ):void { + $user = $this->getActualUsername($user); + $this->adminChangesTheQuotaOfUserUsingTheProvisioningApi( + $user, + $quota + ); + $this->theHTTPStatusCodeShouldBe( + 200, + "could not change quota of user $user" + ); + } + + /** + * @param string $requestingUser + * @param string $targetUser + * @param string $quota + * + * @return void + */ + public function userChangeQuotaOfUserUsingProvisioningApi( + string $requestingUser, + string $targetUser, + string $quota + ):void { + $result = UserHelper::editUser( + $this->getBaseUrl(), + $this->getActualUsername($targetUser), + 'quota', + $quota, + $this->getActualUsername($requestingUser), + $this->getPasswordForUser($requestingUser), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + } + + /** + * @When /^user "([^"]*)" changes the quota of user "([^"]*)" to "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $quota + * + * @return void + */ + public function userChangesTheQuotaOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $quota + ):void { + $this->userChangeQuotaOfUserUsingProvisioningApi( + $requestingUser, + $targetUser, + $quota + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has changed the quota of user "([^"]*)" to "([^"]*)"$/ + * + * @param string $requestingUser + * @param string $targetUser + * @param string $quota + * + * @return void + */ + public function userHasChangedTheQuotaOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser, + string $quota + ):void { + $this->userChangeQuotaOfUserUsingProvisioningApi( + $requestingUser, + $targetUser, + $quota + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $user + * + * @return void + * @throws JsonException + */ + public function retrieveUserInformationAsAdminUsingProvisioningApi( + string $user + ):void { + $result = UserHelper::getUser( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + } + + /** + * @When /^the administrator retrieves the information of user "([^"]*)" using the provisioning API$/ + * + * @param string $user + * + * @return void + * @throws JsonException + */ + public function adminRetrievesTheInformationOfUserUsingTheProvisioningApi( + string $user + ):void { + $user = $this->getActualUsername($user); + $this->retrieveUserInformationAsAdminUsingProvisioningApi( + $user + ); + } + + /** + * @Given /^the administrator has retrieved the information of user "([^"]*)"$/ + * + * @param string $user + * + * @return void + * @throws JsonException + */ + public function adminHasRetrievedTheInformationOfUserUsingTheProvisioningApi( + string $user + ):void { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminHasRetrievedUserUsingTheGraphApi($user); + } else { + $this->retrieveUserInformationAsAdminUsingProvisioningApi($user); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + } + + /** + * @param string $requestingUser + * @param string $targetUser + * + * @return void + * @throws JsonException + */ + public function userRetrieveUserInformationUsingProvisioningApi( + string $requestingUser, + string $targetUser + ):void { + $result = UserHelper::getUser( + $this->getBaseUrl(), + $this->getActualUsername($targetUser), + $this->getActualUsername($requestingUser), + $this->getPasswordForUser($requestingUser), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + } + + /** + * @When /^user "([^"]*)" retrieves the information of user "([^"]*)" using the provisioning API$/ + * + * @param string $requestingUser + * @param string $targetUser + * + * @return void + * @throws JsonException + */ + public function userRetrievesTheInformationOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser + ):void { + $this->userRetrieveUserInformationUsingProvisioningApi( + $requestingUser, + $targetUser + ); + } + + /** + * @Given /^user "([^"]*)" has retrieved the information of user "([^"]*)"$/ + * + * @param string $requestingUser + * @param string $targetUser + * + * @return void + * @throws JsonException + */ + public function userHasRetrievedTheInformationOfUserUsingTheProvisioningApi( + string $requestingUser, + string $targetUser + ):void { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userHasRetrievedUserUsingTheGraphApi( + $requestingUser, + $targetUser + ); + } else { + $this->userRetrieveUserInformationUsingProvisioningApi( + $requestingUser, + $targetUser + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + } + + /** + * @Then /^user "([^"]*)" should exist$/ + * + * @param string $user + * + * @return void + * @throws JsonException + */ + public function userShouldExist(string $user):void { + $user = $this->getActualUsername($user); + Assert::assertTrue( + $this->userExists($user), + "User '$user' should exist but does not exist" + ); + } + + /** + * @Then the following users should exist + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUsersShouldExist(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->userShouldExist($username["username"]); + } + } + + /** + * @Then /^user "([^"]*)" should not exist$/ + * + * @param string $user + * + * @return void + * @throws JsonException + */ + public function userShouldNotExist(string $user):void { + $user = $this->getActualUsername($user); + Assert::assertFalse( + $this->userExists($user), + "User '$user' should not exist but does exist" + ); + $this->rememberThatUserIsNotExpectedToExist($user); + } + + /** + * @Then the following users should not exist + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUsersShouldNotExist(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->userShouldNotExist($username["username"]); + } + } + + /** + * @Then /^group "([^"]*)" should exist$/ + * + * @param string $group + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function groupShouldExist(string $group):void { + Assert::assertTrue( + $this->groupExists($group), + "Group '$group' should exist but does not exist" + ); + } + + /** + * @Then /^group "([^"]*)" should not exist$/ + * + * @param string $group + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function groupShouldNotExist(string $group):void { + Assert::assertFalse( + $this->groupExists($group), + "Group '$group' should not exist but does exist" + ); + } + + /** + * @Then the following groups should not exist + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingGroupsShouldNotExist(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["groupname"]); + $groups = $table->getHash(); + foreach ($groups as $group) { + $this->groupShouldNotExist($group["groupname"]); + } + } + + /** + * @Then /^these groups should (not|)\s?exist:$/ + * expects a table of groups with the heading "groupname" + * + * @param string $shouldOrNot (not|) + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theseGroupsShouldNotExist(string $shouldOrNot, TableNode $table):void { + $should = ($shouldOrNot !== "not"); + $this->verifyTableNodeColumns($table, ['groupname']); + $useGraph = OcisHelper::isTestingWithGraphApi(); + if ($useGraph) { + $this->graphContext->theseGroupsShouldNotExist($shouldOrNot, $table); + } else { + $groups = $this->getArrayOfGroupsResponded($this->getAllGroups()); + foreach ($table as $row) { + if (\in_array($row['groupname'], $groups, true) !== $should) { + throw new Exception( + "group '" . $row['groupname'] . + "' does" . ($should ? " not" : "") . + " exist but should" . ($should ? "" : " not") + ); + } + } + } + } + + /** + * @Given /^user "([^"]*)" has been deleted$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userHasBeenDeleted(string $user):void { + $user = $this->getActualUsername($user); + if ($this->userExists($user)) { + if ($this->isTestingWithLdap() && \in_array($user, $this->ldapCreatedUsers)) { + $this->deleteLdapUser($user); + } else { + $this->deleteTheUserUsingTheProvisioningApi($user); + } + } + $this->userShouldNotExist($user); + } + + /** + * @Given the following users have been deleted + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUsersHaveBeenDeleted(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->userHasBeenDeleted($username["username"]); + } + } + + /** + * @Given these users have been initialized: + * expects a table of users with the heading + * "|username|password|" + * + * @param TableNode $table + * + * @return void + */ + public function theseUsersHaveBeenInitialized(TableNode $table):void { + foreach ($table as $row) { + if (!isset($row ['password'])) { + $password = $this->getPasswordForUser($row ['username']); + } else { + $password = $row ['password']; + } + $this->initializeUser( + $row ['username'], + $password + ); + } + } + + /** + * @When the administrator gets all the members of group :group using the provisioning API + * + * @param string $group + * + * @return void + */ + public function theAdministratorGetsAllTheMembersOfGroupUsingTheProvisioningApi(string $group):void { + $this->userGetsAllTheMembersOfGroupUsingTheProvisioningApi( + $this->getAdminUsername(), + $group + ); + } + + /** + * @When /^user "([^"]*)" gets all the members of group "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $group + * + * @return void + */ + public function userGetsAllTheMembersOfGroupUsingTheProvisioningApi(string $user, string $group):void { + $fullUrl = $this->getBaseUrl() . "/ocs/v{$this->ocsApiVersion}.php/cloud/groups/$group"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getActualUsername($user), + $this->getPasswordForUser($user) + ); + } + + /** + * get all the existing groups + * + * @return ResponseInterface + */ + public function getAllGroups():ResponseInterface { + $fullUrl = $this->getBaseUrl() . "/ocs/v{$this->ocsApiVersion}.php/cloud/groups"; + return HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + } + + /** + * @When the administrator gets all the groups using the provisioning API + * + * @return void + */ + public function theAdministratorGetsAllTheGroupsUsingTheProvisioningApi():void { + $this->response = $this->getAllGroups(); + } + + /** + * @When /^user "([^"]*)" tries to get all the groups using the provisioning API$/ + * @When /^user "([^"]*)" gets all the groups using the provisioning API$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userTriesToGetAllTheGroupsUsingTheProvisioningApi(string $user):void { + $fullUrl = $this->getBaseUrl() . "/ocs/v{$this->ocsApiVersion}.php/cloud/groups"; + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword + ); + } + + /** + * @When the administrator gets all the groups of user :user using the provisioning API + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theAdministratorGetsAllTheGroupsOfUser(string $user):void { + $this->userGetsAllTheGroupsOfUser($this->getAdminUsername(), $user); + } + + /** + * @When user :user gets all the groups of user :otherUser using the provisioning API + * + * @param string $user + * @param string $otherUser + * + * @return void + * @throws Exception + */ + public function userGetsAllTheGroupsOfUser(string $user, string $otherUser):void { + $actualOtherUser = $this->getActualUsername($otherUser); + $fullUrl = $this->getBaseUrl() . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$actualOtherUser/groups"; + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword + ); + } + + /** + * @When the administrator gets the list of all users using the provisioning API + * + * @return void + */ + public function theAdministratorGetsTheListOfAllUsersUsingTheProvisioningApi():void { + $this->userGetsTheListOfAllUsersUsingTheProvisioningApi($this->getAdminUsername()); + } + + /** + * @When user :user gets the list of all users using the provisioning API + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userGetsTheListOfAllUsersUsingTheProvisioningApi(string $user):void { + $fullUrl = $this->getBaseUrl() . "/ocs/v{$this->ocsApiVersion}.php/cloud/users"; + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword + ); + } + + /** + * Make a request about the user. That will force the server to fully + * initialize the user, including their skeleton files. + * + * @param string $user + * @param string $password + * + * @return void + */ + public function initializeUser(string $user, string $password):void { + $url = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$user"; + HttpRequestHelper::get( + $url, + $this->getStepLineRef(), + $user, + $password + ); + $this->lastUploadTime = \time(); + } + + /** + * Touch an API end-point for each user so that their file-system gets setup + * + * @param array $users + * + * @return void + * @throws Exception + */ + public function initializeUsers(array $users):void { + $url = "/cloud/users/%s"; + foreach ($users as $user) { + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + 'GET', + \sprintf($url, $user), + $this->getStepLineRef() + ); + $this->setResponse($response); + $this->theHTTPStatusCodeShouldBe(200); + } + } + + /** + * adds a user to the list of users that were created during test runs + * makes it possible to use this list in other test steps + * or to delete them at the end of the test + * + * @param string|null $user + * @param string|null $password + * @param string|null $displayName + * @param string|null $email + * @param string|null $userId only set for the users created using the Graph API + * @param bool $shouldExist + * + * @return void + * @throws JsonException + */ + public function addUserToCreatedUsersList( + ?string $user, + ?string $password, + ?string $displayName = null, + ?string $email = null, + ?string $userId = null, + bool $shouldExist = true + ):void { + $user = $this->getActualUsername($user); + $normalizedUsername = $this->normalizeUsername($user); + $userData = [ + "password" => $password, + "displayname" => $displayName, + "email" => $email, + "shouldExist" => $shouldExist, + "actualUsername" => $user, + "id" => $userId + ]; + + if ($this->currentServer === 'LOCAL') { + // Only remember this user creation if it was expected to have been successful + // or the user has not been processed before. Some tests create a user the + // first time (successfully) and then purposely try to create the user again. + // The 2nd user creation is expected to fail, and in that case we want to + // still remember the details of the first user creation. + if ($shouldExist || !\array_key_exists($normalizedUsername, $this->createdUsers)) { + $this->createdUsers[$normalizedUsername] = $userData; + } + } elseif ($this->currentServer === 'REMOTE') { + // See comment above about the LOCAL case. The logic is the same for the remote case. + if ($shouldExist || !\array_key_exists($normalizedUsername, $this->createdRemoteUsers)) { + $this->createdRemoteUsers[$normalizedUsername] = $userData; + } + } + } + + /** + * remember the password of a user that already exists so that you can use + * ordinary test steps after changing their password. + * + * @param string $user + * @param string $password + * + * @return void + */ + public function rememberUserPassword( + string $user, + string $password + ):void { + $normalizedUsername = $this->normalizeUsername($user); + if ($this->currentServer === 'LOCAL') { + if (\array_key_exists($normalizedUsername, $this->createdUsers)) { + $this->createdUsers[$normalizedUsername]['password'] = $password; + } + } elseif ($this->currentServer === 'REMOTE') { + if (\array_key_exists($normalizedUsername, $this->createdRemoteUsers)) { + $this->createdRemoteUsers[$user]['password'] = $password; + } + } + } + + /** + * Remembers that a user from the list of users that were created during + * test runs is no longer expected to exist. Useful if a user was created + * during the setup phase but was deleted in a test run. We don't expect + * this user to exist in the tear-down phase, so remember that fact. + * + * @param string $user + * + * @return void + */ + public function rememberThatUserIsNotExpectedToExist(string $user):void { + $user = $this->getActualUsername($user); + $normalizedUsername = $this->normalizeUsername($user); + if (\array_key_exists($normalizedUsername, $this->createdUsers)) { + $this->createdUsers[$normalizedUsername]['shouldExist'] = false; + } + } + + /** + * creates a single user + * + * @param string|null $user + * @param string|null $password if null, then select a password + * @param string|null $displayName + * @param string|null $email + * @param bool $initialize initialize the user skeleton files etc + * @param string|null $method how to create the user api|occ, default api + * @param bool $setDefault sets the missing values to some default + * @param bool $skeleton + * + * @return void + * @throws Exception + */ + public function createUser( + ?string $user, + ?string $password = null, + ?string $displayName = null, + ?string $email = null, + bool $initialize = true, + ?string $method = null, + bool $setDefault = true, + bool $skeleton = true + ):void { + $userId = null; + if ($password === null) { + $password = $this->getPasswordForUser($user); + } + + if ($displayName === null && $setDefault === true) { + $displayName = $this->getDisplayNameForUser($user); + if ($displayName === null) { + $displayName = $this->getDisplayNameForUser('regularuser'); + } + } + + if ($email === null && $setDefault === true) { + $email = $this->getEmailAddressForUser($user); + + if ($email === null) { + // escape @ & space if present in userId + $email = \str_replace(["@", " "], "", $user) . '@owncloud.com'; + } + } + + $user = $this->getActualUsername($user); + + if ($method === null && $this->isTestingWithLdap()) { + //guess yourself + $method = "ldap"; + } elseif (OcisHelper::isTestingWithGraphApi()) { + $method = "graph"; + } elseif ($method === null) { + $method = "api"; + } + $user = \trim($user); + $method = \trim(\strtolower($method)); + switch ($method) { + case "occ": + $result = SetupHelper::createUser( + $user, + $password, + $this->getStepLineRef(), + $displayName, + $email + ); + if ($result["code"] !== "0") { + throw new Exception( + __METHOD__ . " could not create user. {$result['stdOut']} {$result['stdErr']}" + ); + } + break; + case "api": + case "ldap": + $settings = []; + $setting["userid"] = $user; + $setting["displayName"] = $displayName; + $setting["password"] = $password; + $setting["email"] = $email; + \array_push($settings, $setting); + try { + $this->usersHaveBeenCreated( + $initialize, + $settings, + $method, + $skeleton + ); + } catch (LdapException $exception) { + throw new Exception( + __METHOD__ . " cannot create a LDAP user with provided data. Error: {$exception}" + ); + } + break; + case "graph": + $this->graphContext->theAdminHasCreatedUser( + $user, + $password, + $email, + $displayName, + ); + $newUser = $this->getJsonDecodedResponse(); + $userId = $newUser['id']; + break; + default: + throw new InvalidArgumentException( + __METHOD__ . " Invalid method to create a user" + ); + } + + $this->addUserToCreatedUsersList($user, $password, $displayName, $email, $userId); + if ($initialize) { + $this->initializeUser($user, $password); + } + } + + /** + * @param string $user + * + * @return void + * @throws Exception + */ + public function deleteUser(string $user):void { + $this->deleteTheUserUsingTheProvisioningApi($user); + $this->userShouldNotExist($user); + } + + /** + * Try to delete the group, catching anything bad that might happen. + * Use this method only in places where you want to try as best you + * can to delete the group, but do not want to error if there is a problem. + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function cleanupGroup(string $group):void { + try { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminHasDeletedGroupUsingTheGraphApi($group); + } else { + $this->deleteTheGroupUsingTheProvisioningApi($group); + } + } catch (Exception $e) { + \error_log( + "INFORMATION: There was an unexpected problem trying to delete group " . + "'$group' message '" . $e->getMessage() . "'" + ); + } + + if ($this->theGroupShouldBeAbleToBeDeleted($group) + && $this->groupExists($group) + ) { + \error_log( + "INFORMATION: tried to delete group '$group'" . + " at the end of the scenario but it seems to still exist. " . + "There might be problems with later scenarios." + ); + } + } + + /** + * @param string|null $user + * + * @return bool + * @throws JsonException + */ + public function userExists(?string $user):bool { + // in OCIS there is no admin user and in oC10 there are issues when + // sending the username in lowercase in the auth but in uppercase in + // the URL see https://github.com/owncloud/core/issues/36822 + $user = $this->getActualUsername($user); + if (OcisHelper::isTestingOnOcisOrReva()) { + // In OCIS an intermittent issue restricts users to list their own account + // So use admin account to list the user + // https://github.com/owncloud/ocis/issues/820 + // The special code can be reverted once the issue is fixed + if (OcisHelper::isTestingParallelDeployment()) { + $requestingUser = $this->getActualUsername($user); + $requestingPassword = $this->getPasswordForUser($user); + } elseif (OcisHelper::isTestingWithGraphApi()) { + $requestingUser = $this->getAdminUsername(); + $requestingPassword = $this->getAdminPassword(); + } elseif (OcisHelper::isTestingOnOcis()) { + $requestingUser = 'moss'; + $requestingPassword = 'vista'; + } else { + $requestingUser = $this->getActualUsername($user); + $requestingPassword = $this->getPasswordForUser($requestingUser); + } + } else { + $requestingUser = $this->getAdminUsername(); + $requestingPassword = $this->getAdminPassword(); + } + $path = (OcisHelper::isTestingWithGraphApi()) + ? "/graph/v1.0" + : "/ocs/v2.php/cloud"; + $fullUrl = $this->getBaseUrl() . $path . "/users/$user"; + + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $requestingUser, + $requestingPassword + ); + if ($this->response->getStatusCode() >= 400) { + return false; + } + return true; + } + + /** + * @Then /^user "([^"]*)" should belong to group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userShouldBelongToGroup(string $user, string $group):void { + $user = $this->getActualUsername($user); + $respondedArray = []; + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userShouldBeMemberInGroupUsingTheGraphApi( + $user, + $group + ); + } else { + $this->theAdministratorGetsAllTheGroupsOfUser($user); + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + \sort($respondedArray); + Assert::assertContains( + $group, + $respondedArray, + __METHOD__ . " Group '$group' does not exist in '" + . \implode(', ', $respondedArray) + . "'" + ); + Assert::assertEquals( + 200, + $this->response->getStatusCode(), + __METHOD__ + . " Expected status code is '200' but got '" + . $this->response->getStatusCode() + . "'" + ); + } + } + + /** + * @Then the following users should belong to the following groups + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theTheFollowingUserShouldBelongToTheFollowingGroup(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username", "groupname"]); + $rows = $table->getHash(); + foreach ($rows as $row) { + $this->userShouldBelongToGroup($row["username"], $row["groupname"]); + } + } + + /** + * @param string $group + * + * @return array + */ + public function getUsersOfLdapGroup(string $group):array { + $ou = $this->getLdapGroupsOU(); + $entry = 'cn=' . $group . ',ou=' . $ou . ',' . $this->ldapBaseDN; + $ldapResponse = $this->ldap->getEntry($entry); + return $ldapResponse["memberuid"]; + } + + /** + * @Then /^user "([^"]*)" should not belong to group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws JsonException + */ + public function userShouldNotBelongToGroup(string $user, string $group):void { + $user = $this->getActualUsername($user); + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->userShouldNotBeMemberInGroupUsingTheGraphApi($user, $group); + } else { + $fullUrl = $this->getBaseUrl() . "/ocs/v2.php/cloud/users/$user/groups"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + \sort($respondedArray); + Assert::assertNotContains($group, $respondedArray); + Assert::assertEquals( + 200, + $this->response->getStatusCode() + ); + } + } + + /** + * @Then the following users should not belong to the following groups + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theTheFollowingUserShouldNotBelongToTheFollowingGroup(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username", "groupname"]); + $rows = $table->getHash(); + foreach ($rows as $row) { + $this->userShouldNotBelongToGroup($row["username"], $row["groupname"]); + } + } + + /** + * @Then group :group should not contain user :username + * + * @param string $group + * @param string $username + * + * @return void + */ + public function groupShouldNotContainUser(string $group, string $username):void { + $username = $this->getActualUsername($username); + $fullUrl = $this->getBaseUrl() . "/ocs/v2.php/cloud/groups/$group"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + $this->theUsersReturnedByTheApiShouldNotInclude($username); + } + + /** + * @param string $user + * @param string $group + * + * @return bool + */ + public function userBelongsToGroup(string $user, string $group):bool { + $fullUrl = $this->getBaseUrl() . "/ocs/v2.php/cloud/users/$user/groups"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + + if (\in_array($group, $respondedArray)) { + return true; + } else { + return false; + } + } + + /** + * @When /^the administrator adds user "([^"]*)" to group "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function adminAddsUserToGroupUsingTheProvisioningApi(string $user, string $group):void { + $this->addUserToGroup($user, $group, "api"); + } + + /** + * @When the administrator adds the following users to the following groups using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorAddsUserToTheFollowingGroupsUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username", "groupname"], ["comment"]); + $rows = $table->getHash(); + foreach ($rows as $row) { + $this->adminAddsUserToGroupUsingTheProvisioningApi($row["username"], $row["groupname"]); + } + } + + /** + * @When user :user tries to add user :otherUser to group :group using the provisioning API + * + * @param string $user + * @param string $otherUser + * @param string $group + * + * @return void + * @throws Exception + */ + public function userTriesToAddUserToGroupUsingTheProvisioningApi(string $user, string $otherUser, string $group):void { + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $actualOtherUser = $this->getActualUsername($otherUser); + $result = UserHelper::addUserToGroup( + $this->getBaseUrl(), + $actualOtherUser, + $group, + $actualUser, + $actualPassword, + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->response = $result; + } + + /** + * @When user :user tries to add himself to group :group using the provisioning API + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userTriesToAddHimselfToGroupUsingTheProvisioningApi(string $user, string $group):void { + $this->userTriesToAddUserToGroupUsingTheProvisioningApi($user, $user, $group); + } + + /** + * @When the administrator tries to add user :user to group :group using the provisioning API + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function theAdministratorTriesToAddUserToGroupUsingTheProvisioningApi( + string $user, + string $group + ):void { + $this->userTriesToAddUserToGroupUsingTheProvisioningApi( + $this->getAdminUsername(), + $user, + $group + ); + } + + /** + * @Given /^user "([^"]*)" has been added to group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userHasBeenAddedToGroup(string $user, string $group):void { + $user = $this->getActualUsername($user); + $this->addUserToGroup($user, $group, null, true); + } + + /** + * @Given the following users have been added to the following groups + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUserHaveBeenAddedToTheFollowingGroup(TableNode $table):void { + $this->verifyTableNodeColumns($table, ['username', 'groupname']); + foreach ($table as $row) { + $this->userHasBeenAddedToGroup($row['username'], $row['groupname']); + } + } + + /** + * @Given /^user "([^"]*)" has been added to database backend group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userHasBeenAddedToDatabaseBackendGroup(string $user, string $group):void { + $this->addUserToGroup($user, $group, 'api', true); + } + + /** + * @param string $user + * @param string $group + * @param string|null $method how to add the user to the group api|occ + * @param bool $checkResult if true, then check the status of the operation. default false. + * for given step checkResult is expected to be set as true + * for when step checkResult is expected to be set as false + * + * @return void + * @throws Exception + */ + public function addUserToGroup(string $user, string $group, ?string $method = null, bool $checkResult = false):void { + $user = $this->getActualUsername($user); + if ($method === null + && $this->isTestingWithLdap() + && !$this->isLocalAdminGroup($group) + ) { + //guess yourself + $method = "ldap"; + } elseif ($method === null && OcisHelper::isTestingWithGraphApi()) { + $method = "graph"; + } elseif ($method === null) { + $method = "api"; + } + $method = \trim(\strtolower($method)); + switch ($method) { + case "api": + $result = UserHelper::addUserToGroup( + $this->getBaseUrl(), + $user, + $group, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + if ($checkResult && ($result->getStatusCode() !== 200)) { + throw new Exception( + "could not add user to group. " + . $result->getStatusCode() . " " . $result->getBody() + ); + } + $this->response = $result; + if (!$checkResult) { + // for when step only + $this->pushToLastStatusCodesArrays(); + } + break; + case "occ": + $result = SetupHelper::addUserToGroup( + $group, + $user, + $this->getStepLineRef() + ); + if ($checkResult && ($result["code"] !== "0")) { + throw new Exception( + "could not add user to group. {$result['stdOut']} {$result['stdErr']}" + ); + } + break; + case "ldap": + try { + $this->addUserToLdapGroup( + $user, + $group + ); + } catch (LdapException $exception) { + throw new Exception( + "User " . $user . " cannot be added to " . $group . " . Error: {$exception}" + ); + }; + break; + case "graph": + $this->graphContext->adminHasAddedUserToGroupUsingTheGraphApi( + $user, + $group + ); + break; + default: + throw new InvalidArgumentException( + "Invalid method to add a user to a group" + ); + } + } + + /** + * @Given the administrator has been added to group :group + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function theAdministratorHasBeenAddedToGroup(string $group):void { + $admin = $this->getAdminUsername(); + $this->addUserToGroup($admin, $group, null, true); + } + + /** + * @param string $group + * @param bool $shouldExist - true if the group should exist + * @param bool $possibleToDelete - true if it is possible to delete the group + * @param string|null $id - id of the group, only required for the groups created using the Graph API + * + * @return void + */ + public function addGroupToCreatedGroupsList( + string $group, + bool $shouldExist = true, + bool $possibleToDelete = true, + ?string $id = null + ):void { + $groupData = [ + "shouldExist" => $shouldExist, + "possibleToDelete" => $possibleToDelete + ]; + if ($id !== null) { + $groupData["id"] = $id; + } + + if ($this->currentServer === 'LOCAL') { + $this->createdGroups[$group] = $groupData; + } elseif ($this->currentServer === 'REMOTE') { + $this->createdRemoteGroups[$group] = $groupData; + } + } + + /** + * Remembers that a group from the list of groups that were created during + * test runs is no longer expected to exist. Useful if a group was created + * during the setup phase but was deleted in a test run. We don't expect + * this group to exist in the tear-down phase, so remember that fact. + * + * @param string $group + * + * @return void + */ + public function rememberThatGroupIsNotExpectedToExist(string $group):void { + if (\array_key_exists($group, $this->createdGroups)) { + $this->createdGroups[$group]['shouldExist'] = false; + } + } + + /** + * @When /^the administrator creates group "([^"]*)" using the provisioning API$/ + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function adminCreatesGroupUsingTheProvisioningApi(string $group):void { + if (!$this->groupExists($group)) { + $this->createTheGroup($group, 'api'); + } + $this->groupShouldExist($group); + } + + /** + * @Given /^group "([^"]*)" has been created$/ + * + * @param string $group + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function groupHasBeenCreated(string $group):void { + $this->createTheGroup($group); + $this->groupShouldExist($group); + } + + /** + * @Given /^group "([^"]*)" has been created in the database user backend$/ + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function groupHasBeenCreatedOnDatabaseBackend(string $group):void { + $this->adminCreatesGroupUsingTheProvisioningApi($group); + } + + /** + * @Given these groups have been created: + * expects a table of groups with the heading "groupname" + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theseGroupsHaveBeenCreated(TableNode $table):void { + $this->verifyTableNodeColumns($table, ['groupname'], ['comment']); + foreach ($table as $row) { + $this->createTheGroup($row['groupname']); + } + } + + /** + * @When /^the administrator sends a group creation request for group "([^"]*)" using the provisioning API$/ + * + * @param string $group + * @param string|null $user + * + * @return void + */ + public function adminSendsGroupCreationRequestUsingTheProvisioningApi( + string $group, + ?string $user = null + ):void { + $bodyTable = new TableNode([['groupid', $group]]); + $user = $user === null ? $this->getAdminUsername() : $user; + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "POST", + "/cloud/groups", + $bodyTable + ); + $this->pushToLastStatusCodesArrays(); + $this->addGroupToCreatedGroupsList($group); + } + + /** + * @When the administrator sends a group creation request for the following groups using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorSendsAGroupCreationRequestForTheFollowingGroupUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["groupname"], ["comment"]); + $groups = $table->getHash(); + foreach ($groups as $group) { + $this->adminSendsGroupCreationRequestUsingTheProvisioningApi($group["groupname"]); + } + } + + /** + * @When /^the administrator tries to send a group creation request for group "([^"]*)" using the provisioning API$/ + * + * @param string $group + * + * @return void + */ + public function adminTriesToSendGroupCreationRequestUsingTheAPI(string $group):void { + $this->adminSendsGroupCreationRequestUsingTheProvisioningApi($group); + $this->rememberThatGroupIsNotExpectedToExist($group); + } + + /** + * @When /^user "([^"]*)" tries to send a group creation request for group "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $group + * + * @return void + */ + public function userTriesToSendGroupCreationRequestUsingTheAPI(string $user, string $group):void { + $this->adminSendsGroupCreationRequestUsingTheProvisioningApi($group, $user); + $this->rememberThatGroupIsNotExpectedToExist($group); + } + + /** + * creates a single group + * + * @param string $group + * @param string|null $method how to create the group api|occ + * + * @return void + * @throws Exception + */ + public function createTheGroup(string $group, ?string $method = null):void { + //guess yourself + if ($method === null) { + if ($this->isTestingWithLdap()) { + $method = "ldap"; + } elseif (OcisHelper::isTestingWithGraphApi()) { + $method = "graph"; + } else { + $method = "api"; + } + } + $group = \trim($group); + $method = \trim(\strtolower($method)); + $groupCanBeDeleted = false; + $groupId = null; + switch ($method) { + case "api": + $result = UserHelper::createGroup( + $this->getBaseUrl(), + $group, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef() + ); + if ($result->getStatusCode() === 200) { + $groupCanBeDeleted = true; + } else { + throw new Exception( + "could not create group '$group'. " + . $result->getStatusCode() . " " . $result->getBody() + ); + } + break; + case "occ": + $result = SetupHelper::createGroup( + $group, + $this->getStepLineRef() + ); + if ($result["code"] == 0) { + $groupCanBeDeleted = true; + } else { + throw new Exception( + "could not create group '$group'. {$result['stdOut']} {$result['stdErr']}" + ); + } + break; + case "ldap": + try { + $this->createLdapGroup($group); + } catch (LdapException $e) { + throw new Exception( + "could not create group '$group'. Error: {$e}" + ); + } + break; + case "graph": + $newGroup = $this->graphContext->adminHasCreatedGroupUsingTheGraphApi($group); + $groupCanBeDeleted = true; + $groupId = $newGroup["id"]; + break; + default: + throw new InvalidArgumentException( + "Invalid method to create group '$group'" + ); + } + + $this->addGroupToCreatedGroupsList($group, true, $groupCanBeDeleted, $groupId); + } + + /** + * @param string $attribute + * @param string $entry + * @param string $value + * @param bool $append + * + * @return void + * @throws Exception + */ + public function setTheLdapAttributeOfTheEntryTo( + string $attribute, + string $entry, + string $value, + bool $append = false + ):void { + $ldapEntry = $this->ldap->getEntry($entry . "," . $this->ldapBaseDN); + Laminas\Ldap\Attribute::setAttribute($ldapEntry, $attribute, $value, $append); + $this->ldap->update($entry . "," . $this->ldapBaseDN, $ldapEntry); + $this->theLdapUsersHaveBeenReSynced(); + } + + /** + * @param string $user + * @param string $group + * @param string|null $ou + * + * @return void + * @throws Exception + */ + public function addUserToLdapGroup(string $user, string $group, ?string $ou = null):void { + if ($ou === null) { + $ou = $this->getLdapGroupsOU(); + } + $memberAttr = ""; + $memberValue = ""; + if ($this->ldapGroupSchema == "rfc2307") { + $memberAttr = "memberUID"; + $memberValue = "$user"; + } else { + $memberAttr = "member"; + $userbase = "ou=" . $this->getLdapUsersOU() . "," . $this->ldapBaseDN; + $memberValue = "uid=$user" . "," . "$userbase"; + } + $this->setTheLdapAttributeOfTheEntryTo( + $memberAttr, + "cn=$group,ou=$ou", + $memberValue, + true + ); + } + + /** + * @param string $value + * @param string $attribute + * @param string $entry + * + * @return void + */ + public function deleteValueFromLdapAttribute(string $value, string $attribute, string $entry):void { + $this->ldap->deleteAttributes( + $entry . "," . $this->ldapBaseDN, + [$attribute => [$value]] + ); + } + + /** + * @param string $user + * @param string $group + * @param string|null $ou + * + * @return void + * @throws Exception + */ + public function removeUserFromLdapGroup(string $user, string $group, ?string $ou = null):void { + if ($ou === null) { + $ou = $this->getLdapGroupsOU(); + } + $memberAttr = ""; + $memberValue = ""; + if ($this->ldapGroupSchema == "rfc2307") { + $memberAttr = "memberUID"; + $memberValue = "$user"; + } else { + $memberAttr = "member"; + $userbase = "ou=" . $this->getLdapUsersOU() . "," . $this->ldapBaseDN; + $memberValue = "uid=$user" . "," . "$userbase"; + } + $this->deleteValueFromLdapAttribute( + $memberValue, + $memberAttr, + "cn=$group,ou=$ou" + ); + $this->theLdapUsersHaveBeenReSynced(); + } + + /** + * @param string $entry + * + * @return void + * @throws Exception + */ + public function deleteTheLdapEntry(string $entry):void { + $this->ldap->delete($entry . "," . $this->ldapBaseDN); + $this->theLdapUsersHaveBeenReSynced(); + } + + /** + * @param string $group + * @param string|null $ou + * + * @return void + * @throws LdapException + * @throws Exception + */ + public function deleteLdapGroup(string $group, ?string $ou = null):void { + if ($ou === null) { + $ou = $this->getLdapGroupsOU(); + } + $this->deleteTheLdapEntry("cn=$group,ou=$ou"); + $this->theLdapUsersHaveBeenReSynced(); + $key = \array_search($group, $this->ldapCreatedGroups); + if ($key !== false) { + unset($this->ldapCreatedGroups[$key]); + } + $this->rememberThatGroupIsNotExpectedToExist($group); + } + + /** + * @param string|null $username + * @param string|null $ou + * + * @return void + * @throws Exception + */ + public function deleteLdapUser(?string $username, ?string $ou = null):void { + if (!\in_array($username, $this->ldapCreatedUsers)) { + throw new Error( + "User " . $username . " was not created using Ldap and does not exist as an Ldap User" + ); + } + if ($ou === null) { + $ou = $this->getLdapUsersOU(); + } + $entry = "uid=$username,ou=$ou"; + $this->deleteTheLdapEntry($entry); + $key = \array_search($username, $this->ldapCreatedUsers); + if ($key !== false) { + unset($this->ldapCreatedUsers[$key]); + } + $this->rememberThatUserIsNotExpectedToExist($username); + } + + /** + * @param string|null $user + * @param string|null $displayName + * + * @return void + * @throws Exception + */ + public function editLdapUserDisplayName(?string $user, ?string $displayName):void { + $entry = "uid=" . $user . ",ou=" . $this->getLdapUsersOU(); + $this->setTheLdapAttributeOfTheEntryTo( + 'displayname', + $entry, + $displayName + ); + $this->theLdapUsersHaveBeenReSynced(); + } + + /** + * @When /^the administrator disables user "([^"]*)" using the provisioning API$/ + * + * @param string|null $user + * + * @return void + */ + public function adminDisablesUserUsingTheProvisioningApi(?string $user):void { + $user = $this->getActualUsername($user); + $this->disableOrEnableUser($this->getAdminUsername(), $user, 'disable'); + } + + /** + * @When the administrator disables the following users using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorDisablesTheFollowingUsersUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->adminDisablesUserUsingTheProvisioningApi($username["username"]); + } + } + + /** + * @Given /^user "([^"]*)" has been disabled$/ + * + * @param string|null $user + * + * @return void + * @throws Exception + */ + public function adminHasDisabledUserUsingTheProvisioningApi(?string $user):void { + $user = $this->getActualUsername($user); + $this->disableOrEnableUser($this->getAdminUsername(), $user, 'disable'); + $this->theHTTPStatusCodeShouldBeSuccess(); + $this->ocsContext->assertOCSResponseIndicatesSuccess(); + } + + /** + * @Given the following users have been disabled + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUsersHaveBeenDisabled(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->adminHasDisabledUserUsingTheProvisioningApi($username["username"]); + } + } + + /** + * @When user :user disables user :otherUser using the provisioning API + * + * @param string $user + * @param string $otherUser + * + * @return void + */ + public function userDisablesUserUsingTheProvisioningApi(string $user, string $otherUser):void { + $user = $this->getActualUsername($user); + $actualOtherUser = $this->getActualUsername($otherUser); + $this->disableOrEnableUser($user, $actualOtherUser, 'disable'); + } + + /** + * @When the administrator enables user :user using the provisioning API + * + * @param string $user + * + * @return void + */ + public function theAdministratorEnablesUserUsingTheProvisioningApi(string $user):void { + $this->disableOrEnableUser($this->getAdminUsername(), $user, 'enable'); + } + + /** + * @When the administrator enables the following users using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorEnablesTheFollowingUsersUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->theAdministratorEnablesUserUsingTheProvisioningApi($username["username"]); + } + } + + /** + * @When /^user "([^"]*)" enables user "([^"]*)" using the provisioning API$/ + * @When /^user "([^"]*)" tries to enable user "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $otherUser + * + * @return void + */ + public function userTriesToEnableUserUsingTheProvisioningApi( + string $user, + string $otherUser + ):void { + $this->disableOrEnableUser($user, $otherUser, 'enable'); + } + + /** + * @param string $user + * + * @return void + * @throws Exception + */ + public function deleteTheUserUsingTheProvisioningApi(string $user):void { + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + // Always try to delete the user + if (OcisHelper::isTestingWithGraphApi()) { + // users can be deleted using the username in the GraphApi too + $this->graphContext->adminDeletesUserUsingTheGraphApi($user); + } else { + $this->response = UserHelper::deleteUser( + $this->getBaseUrl(), + $user, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + } + $this->pushToLastStatusCodesArrays(); + + // Only log a message if the test really expected the user to have been + // successfully created (i.e. the delete is expected to work) and + // there was a problem deleting the user. Because in this case there + // might be an effect on later tests. + if ($this->theUserShouldExist($user) + && (!\in_array($this->response->getStatusCode(), [200, 204])) + ) { + \error_log( + "INFORMATION: could not delete user '$user' " + . $this->response->getStatusCode() . " " . $this->response->getBody() + ); + } + + $this->rememberThatUserIsNotExpectedToExist($user); + } + + /** + * @param string $group group name + * + * @return void + * @throws Exception + * @throws LdapException + */ + public function deleteGroup(string $group):void { + if ($this->groupExists($group)) { + if ($this->isTestingWithLdap() && \in_array($group, $this->ldapCreatedGroups)) { + $this->deleteLdapGroup($group); + } else { + $this->deleteTheGroupUsingTheProvisioningApi($group); + } + } + } + + /** + * @Given /^group "([^"]*)" has been deleted$/ + * + * @param string $group + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function groupHasBeenDeleted(string $group):void { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminHasDeletedGroupUsingTheGraphApi($group); + } else { + $this->deleteGroup($group); + } + $this->groupShouldNotExist($group); + } + + /** + * @When /^the administrator deletes group "([^"]*)" from the default user backend$/ + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function adminDeletesGroup(string $group):void { + $this->deleteGroup($group); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^the administrator deletes group "([^"]*)" using the provisioning API$/ + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function deleteTheGroupUsingTheProvisioningApi(string $group):void { + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + $this->response = UserHelper::deleteGroup( + $this->getBaseUrl(), + $group, + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + $this->pushToLastStatusCodesArrays(); + if ($this->theGroupShouldExist($group) + && $this->theGroupShouldBeAbleToBeDeleted($group) + && ($this->response->getStatusCode() !== 200) + ) { + \error_log( + "INFORMATION: could not delete group '$group'" + . $this->response->getStatusCode() . " " . $this->response->getBody() + ); + } + + $this->rememberThatGroupIsNotExpectedToExist($group); + } + + /** + * @When the administrator deletes the following groups using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorDeletesTheFollowingGroupsUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["groupname"]); + $groups = $table->getHash(); + foreach ($groups as $group) { + $this->deleteTheGroupUsingTheProvisioningApi($group["groupname"]); + } + } + + /** + * @When user :user tries to delete group :group using the provisioning API + * + * @param string $user + * @param string $group + * + * @return void + * @throws JsonException + */ + public function userTriesToDeleteGroupUsingTheProvisioningApi(string $user, string $group):void { + $this->response = UserHelper::deleteGroup( + $this->getBaseUrl(), + $group, + $this->getActualUsername($user), + $this->getActualPassword($user), + $this->getStepLineRef(), + $this->ocsApiVersion + ); + } + + /** + * @param string $group + * + * @return bool + * @throws Exception + * @throws GuzzleException + */ + public function groupExists(string $group):bool { + if ($this->isTestingWithLdap() && OcisHelper::isTestingOnOcisOrReva()) { + $baseDN = $this->getLdapBaseDN(); + $newDN = 'cn=' . $group . ',ou=' . $this->ldapGroupsOU . ',' . $baseDN; + if ($this->ldap->getEntry($newDN) !== null) { + return true; + } + return false; + } + if (OcisHelper::isTestingWithGraphApi()) { + $base = '/graph/v1.0'; + } else { + $base = '/ocs/v2.php/cloud'; + } + $group = \rawurlencode($group); + $fullUrl = $this->getBaseUrl() . "$base/groups/$group"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + if ($this->response->getStatusCode() >= 400) { + return false; + } + return true; + } + + /** + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function removeUserFromGroupAsAdminUsingTheProvisioningApi(string $user, string $group):void { + $this->userRemovesUserFromGroupUsingTheProvisioningApi( + $this->getAdminUsername(), + $user, + $group + ); + } + + /** + * @When the administrator removes user :user from group :group using the provisioning API + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function adminRemovesUserFromGroupUsingTheProvisioningApi(string $user, string $group):void { + $user = $this->getActualUsername($user); + $this->removeUserFromGroupAsAdminUsingTheProvisioningApi( + $user, + $group + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When the administrator removes the following users from the following groups using the provisioning API + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorRemovesTheFollowingUserFromTheFollowingGroupUsingTheProvisioningApi(TableNode $table):void { + $this->verifyTableNodeColumns($table, ['username', 'groupname']); + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + foreach ($table as $row) { + $this->adminRemovesUserFromGroupUsingTheProvisioningApi($row['username'], $row['groupname']); + $this->pushToLastStatusCodesArrays(); + } + } + + /** + * @Given user :user has been removed from group :group + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function adminHasRemovedUserFromGroup(string $user, string $group):void { + if ($this->isTestingWithLdap() + && !$this->isLocalAdminGroup($group) + && \in_array($group, $this->ldapCreatedGroups) + ) { + $this->removeUserFromLdapGroup($user, $group); + } elseif (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminHasRemovedUserFromGroupUsingTheGraphApi($user, $group); + } else { + $this->removeUserFromGroupAsAdminUsingTheProvisioningApi( + $user, + $group + ); + } + $this->userShouldNotBelongToGroup($user, $group); + } + + /** + * @When user :user removes user :otherUser from group :group using the provisioning API + * + * @param string $user + * @param string $otherUser + * @param string $group + * + * @return void + * @throws Exception + */ + public function userRemovesUserFromGroupUsingTheProvisioningApi( + string $user, + string $otherUser, + string $group + ):void { + $this->userTriesToRemoveUserFromGroupUsingTheProvisioningApi( + $user, + $otherUser, + $group + ); + + if ($this->response->getStatusCode() !== 200) { + \error_log( + "INFORMATION: could not remove user '$user' from group '$group'" + . $this->response->getStatusCode() . " " . $this->response->getBody() + ); + } + } + + /** + * @When user :user tries to remove user :otherUser from group :group using the provisioning API + * + * @param string $user + * @param string $otherUser + * @param string $group + * + * @return void + * @throws Exception + */ + public function userTriesToRemoveUserFromGroupUsingTheProvisioningApi( + string $user, + string $otherUser, + string $group + ):void { + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $actualOtherUser = $this->getActualUsername($otherUser); + $this->response = UserHelper::removeUserFromGroup( + $this->getBaseUrl(), + $actualOtherUser, + $group, + $actualUser, + $actualPassword, + $this->getStepLineRef(), + $this->ocsApiVersion + ); + } + + /** + * @When /^the administrator makes user "([^"]*)" a subadmin of group "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $group + * + * @return void + */ + public function adminMakesUserSubadminOfGroupUsingTheProvisioningApi( + string $user, + string $group + ):void { + $user = $this->getActualUsername($user); + $this->userMakesUserASubadminOfGroupUsingTheProvisioningApi( + $this->getAdminUsername(), + $user, + $group + ); + } + + /** + * @When user :user makes user :otherUser a subadmin of group :group using the provisioning API + * + * @param string $user + * @param string $otherUser + * @param string $group + * + * @return void + * @throws Exception + */ + public function userMakesUserASubadminOfGroupUsingTheProvisioningApi( + string $user, + string $otherUser, + string $group + ):void { + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $actualSubadminUsername = $this->getActualUsername($otherUser); + + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$actualSubadminUsername/subadmins"; + $body = ['groupid' => $group]; + $this->response = HttpRequestHelper::post( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword, + null, + $body + ); + } + + /** + * @When the administrator gets all the groups where user :user is subadmin using the provisioning API + * + * @param string $user + * + * @return void + */ + public function theAdministratorGetsAllTheGroupsWhereUserIsSubadminUsingTheProvisioningApi(string $user):void { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$user/subadmins"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + } + + /** + * @When /^user "([^"]*)" gets all the groups where user "([^"]*)" is subadmin using the provisioning API$/ + * @When /^user "([^"]*)" tries to get all the groups where user "([^"]*)" is subadmin using the provisioning API$/ + * + * @param string $user + * @param string $otherUser + * + * @return void + * @throws Exception + */ + public function userTriesToGetAllTheGroupsWhereUserIsSubadminUsingTheProvisioningApi(string $user, string $otherUser):void { + $actualOtherUser = $this->getActualUsername($otherUser); + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$actualOtherUser/subadmins"; + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword + ); + } + + /** + * @Given /^user "([^"]*)" has been made a subadmin of group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + */ + public function userHasBeenMadeSubadminOfGroup( + string $user, + string $group + ):void { + $this->adminMakesUserSubadminOfGroupUsingTheProvisioningApi( + $user, + $group + ); + Assert::assertEquals( + 200, + $this->response->getStatusCode() + ); + } + + /** + * @When the administrator gets all the subadmins of group :group using the provisioning API + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function theAdministratorGetsAllTheSubadminsOfGroupUsingTheProvisioningApi(string $group):void { + $this->userGetsAllTheSubadminsOfGroupUsingTheProvisioningApi( + $this->getAdminUsername(), + $group + ); + } + + /** + * @When user :user gets all the subadmins of group :group using the provisioning API + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userGetsAllTheSubadminsOfGroupUsingTheProvisioningApi(string $user, string $group):void { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/groups/$group/subadmins"; + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword + ); + } + + /** + * @When the administrator removes user :user from being a subadmin of group :group using the provisioning API + * + * @param string $user + * @param string $group + * + * @return void + */ + public function theAdministratorRemovesUserFromBeingASubadminOfGroupUsingTheProvisioningApi( + string $user, + string $group + ):void { + $this->userRemovesUserFromBeingASubadminOfGroupUsingTheProvisioningApi( + $this->getAdminUsername(), + $user, + $group + ); + } + + /** + * @When user :user removes user :otherUser from being a subadmin of group :group using the provisioning API + * + * @param string $user + * @param string $otherUser + * @param string $group + * + * @return void + * @throws Exception + */ + public function userRemovesUserFromBeingASubadminOfGroupUsingTheProvisioningApi( + string $user, + string $otherUser, + string $group + ):void { + $actualOtherUser = $this->getActualUsername($otherUser); + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$actualOtherUser/subadmins"; + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getUserPassword($actualUser); + $this->response = HttpRequestHelper::delete( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword, + null, + ['groupid' => $group] + ); + } + + /** + * @Then /^the users returned by the API should be$/ + * + * @param TableNode $usersList + * + * @return void + * @throws Exception + */ + public function theUsersShouldBe(TableNode $usersList):void { + $this->verifyTableNodeColumnsCount($usersList, 1); + $users = $usersList->getRows(); + $usersSimplified = \array_map( + function ($user) { + return $this->getActualUsername($user); + }, + $this->simplifyArray($users) + ); + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->theseUsersShouldBeInTheResponse($usersSimplified); + } else { + $respondedArray = $this->getArrayOfUsersResponded($this->response); + Assert::assertEqualsCanonicalizing( + $usersSimplified, + $respondedArray, + __METHOD__ + . " Provided users do not match the users returned in the response." + ); + } + } + + /** + * @Then /^the users returned by the API should include$/ + * + * @param TableNode $usersList + * + * @return void + * @throws Exception + */ + public function theUsersShouldInclude(TableNode $usersList):void { + $this->verifyTableNodeColumnsCount($usersList, 1); + $users = $usersList->getRows(); + $usersSimplified = \array_map( + function ($user) { + return $this->getActualUsername($user); + }, + $this->simplifyArray($users) + ); + $respondedArray = $this->getArrayOfUsersResponded($this->response); + foreach ($usersSimplified as $userElement) { + Assert::assertContains( + $userElement, + $respondedArray, + __METHOD__ + . " user $userElement is not present in the users list: \n" + . \join("\n", $respondedArray) + ); + } + } + + /** + * @Then /^the groups returned by the API should be$/ + * + * @param TableNode $groupsList + * + * @return void + * @throws Exception + */ + public function theGroupsShouldBe(TableNode $groupsList):void { + $this->verifyTableNodeColumnsCount($groupsList, 1); + $groups = $groupsList->getRows(); + $groupsSimplified = $this->simplifyArray($groups); + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->theseGroupsShouldBeInTheResponse($groupsSimplified); + } else { + Assert::assertEqualsCanonicalizing( + $groupsSimplified, + $respondedArray, + __METHOD__ + . " Provided groups do not match the groups returned in the response." + ); + } + } + + /** + * @Then /^the extra groups returned by the API should be$/ + * + * @param TableNode $groupsList + * + * @return void + * @throws Exception + */ + public function theExtraGroupsShouldBe(TableNode $groupsList):void { + $this->verifyTableNodeColumnsCount($groupsList, 1); + $groups = $groupsList->getRows(); + $groupsSimplified = $this->simplifyArray($groups); + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->theseGroupsShouldBeInTheResponse($groupsSimplified); + } else { + $expectedGroups = \array_merge($this->startingGroups, $groupsSimplified); + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + Assert::assertEqualsCanonicalizing( + $expectedGroups, + $respondedArray, + __METHOD__ + . " Provided groups do not match the groups returned in the response." + ); + } + } + + /** + * @Then /^the groups returned by the API should include "([^"]*)"$/ + * + * @param string $group + * + * @return void + */ + public function theGroupsReturnedByTheApiShouldInclude(string $group):void { + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + Assert::assertContains($group, $respondedArray); + } + + /** + * @Then /^the groups returned by the API should not include "([^"]*)"$/ + * + * @param string $group + * + * @return void + */ + public function theGroupsReturnedByTheApiShouldNotInclude(string $group):void { + $respondedArray = $this->getArrayOfGroupsResponded($this->response); + Assert::assertNotContains($group, $respondedArray); + } + + /** + * @Then /^the users returned by the API should not include "([^"]*)"$/ + * + * @param string $user + * + * @return void + */ + public function theUsersReturnedByTheApiShouldNotInclude(string $user):void { + $respondedArray = $this->getArrayOfUsersResponded($this->response); + Assert::assertNotContains($user, $respondedArray); + } + + /** + * @param TableNode|null $groupsOrUsersList + * + * @return void + * @throws Exception + */ + public function checkSubadminGroupsOrUsersTable(?TableNode $groupsOrUsersList):void { + $this->verifyTableNodeColumnsCount($groupsOrUsersList, 1); + $tableRows = $groupsOrUsersList->getRows(); + $simplifiedTableRows = $this->simplifyArray($tableRows); + $respondedArray = $this->getArrayOfSubadminsResponded($this->response); + Assert::assertEqualsCanonicalizing( + $simplifiedTableRows, + $respondedArray + ); + } + + /** + * @Then /^the subadmin groups returned by the API should be$/ + * + * @param TableNode|null $groupsList + * + * @return void + * @throws Exception + */ + public function theSubadminGroupsShouldBe(?TableNode $groupsList):void { + $this->checkSubadminGroupsOrUsersTable($groupsList); + } + + /** + * @Then /^the subadmin users returned by the API should be$/ + * + * @param TableNode|null $usersList + * + * @return void + * @throws Exception + */ + public function theSubadminUsersShouldBe(?TableNode $usersList):void { + $this->checkSubadminGroupsOrUsersTable($usersList); + } + + /** + * @Then /^the apps returned by the API should include$/ + * + * @param TableNode|null $appList + * + * @return void + * @throws Exception + */ + public function theAppsShouldInclude(?TableNode $appList):void { + $this->verifyTableNodeColumnsCount($appList, 1); + $apps = $appList->getRows(); + $appsSimplified = $this->simplifyArray($apps); + $respondedArray = $this->getArrayOfAppsResponded($this->response); + foreach ($appsSimplified as $app) { + Assert::assertContains( + $app, + $respondedArray, + "The apps returned by the API should include $app but $app was not included" + ); + } + } + + /** + * @Then /^the apps returned by the API should not include$/ + * + * @param TableNode|null $appList + * + * @return void + * @throws Exception + */ + public function theAppsShouldNotInclude(?TableNode $appList):void { + $this->verifyTableNodeColumnsCount($appList, 1); + $apps = $appList->getRows(); + $appsSimplified = $this->simplifyArray($apps); + $respondedArray = $this->getArrayOfAppsResponded($this->response); + foreach ($appsSimplified as $app) { + Assert::assertNotContains( + $app, + $respondedArray, + "The apps returned by the API should not include $app but $app was included" + ); + } + } + + /** + * @Then /^app "([^"]*)" should not be in the apps list$/ + * + * @param string $appName + * + * @return void + */ + public function appShouldNotBeInTheAppsList(string $appName):void { + $fullUrl = $this->getBaseUrl() . "/ocs/v2.php/cloud/apps"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + $respondedArray = $this->getArrayOfAppsResponded($this->response); + Assert::assertNotContains($appName, $respondedArray); + } + + /** + * @Then /^user "([^"]*)" should be a subadmin of group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userShouldBeASubadminOfGroup(string $user, string $group):void { + $this->theAdministratorGetsAllTheSubadminsOfGroupUsingTheProvisioningApi($group); + Assert::assertEquals( + 200, + $this->response->getStatusCode() + ); + $listOfSubadmins = $this->getArrayOfSubadminsResponded($this->response); + Assert::assertContains( + $user, + $listOfSubadmins + ); + } + + /** + * @Then /^user "([^"]*)" should not be a subadmin of group "([^"]*)"$/ + * + * @param string $user + * @param string $group + * + * @return void + * @throws Exception + */ + public function userShouldNotBeASubadminOfGroup(string $user, string $group):void { + $this->theAdministratorGetsAllTheSubadminsOfGroupUsingTheProvisioningApi($group); + $listOfSubadmins = $this->getArrayOfSubadminsResponded($this->response); + Assert::assertNotContains( + $user, + $listOfSubadmins + ); + } + + /** + * @Then /^the display name returned by the API should be "([^"]*)"$/ + * + * @param string $expectedDisplayName + * + * @return void + * @throws Exception + */ + public function theDisplayNameReturnedByTheApiShouldBe(string $expectedDisplayName):void { + $responseDisplayName = (string) $this->getResponseXml(null, __METHOD__)->data[0]->displayname; + Assert::assertEquals( + $expectedDisplayName, + $responseDisplayName + ); + } + + /** + * @Then /^the display name of user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $user + * @param string $displayname + * + * @return void + * @throws Exception + */ + public function theDisplayNameOfUserShouldBe(string $user, string $displayname):void { + $actualUser = $this->getActualUsername($user); + $this->retrieveUserInformationAsAdminUsingProvisioningApi($actualUser); + $this->theDisplayNameReturnedByTheApiShouldBe($displayname); + } + + /** + * @Then the display name of the following users should be + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theDisplayNameOfTheFollowingUsersShouldBe(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["name", "display-name"]); + $users = $table->getHash(); + foreach ($users as $user) { + $this->theDisplayNameOfUserShouldBe($user["name"], $user["display-name"]); + } + } + + /** + * @Then /^the display name of user "([^"]*)" should not have changed$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theDisplayNameOfUserShouldNotHaveChanged(string $user):void { + $actualUser = $this->getActualUsername($user); + $expectedDisplayName = $this->getDisplayNameForUser($user); + $this->retrieveUserInformationAsAdminUsingProvisioningApi($actualUser); + $this->theDisplayNameReturnedByTheApiShouldBe($expectedDisplayName); + } + + /** + * @Then /^the email address returned by the API should be "([^"]*)"$/ + * + * @param string $expectedEmailAddress + * + * @return void + * @throws Exception + */ + public function theEmailAddressReturnedByTheApiShouldBe(string $expectedEmailAddress):void { + $responseEmailAddress = (string) $this->getResponseXml(null, __METHOD__)->data[0]->email; + Assert::assertEquals( + $expectedEmailAddress, + $responseEmailAddress + ); + } + + /** + * @Then /^the email address of user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $user + * @param string $expectedEmailAddress + * + * @return void + * @throws Exception + */ + public function theEmailAddressOfUserShouldBe(string $user, string $expectedEmailAddress):void { + $user = $this->getActualUsername($user); + $this->retrieveUserInformationAsAdminUsingProvisioningApi($user); + $this->theEmailAddressReturnedByTheApiShouldBe($expectedEmailAddress); + } + + /** + * @Then /^the email address of user "([^"]*)" should not have changed$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function theEmailAddressOfUserShouldNotHaveChanged(string $user):void { + $user = $this->getActualUsername($user); + $expectedEmailAddress = $this->getEmailAddressForUser($user); + $this->retrieveUserInformationAsAdminUsingProvisioningApi($user); + $this->theEmailAddressReturnedByTheApiShouldBe($expectedEmailAddress); + } + + /** + * @Then /^the free, used, total and relative quota returned by the API should exist and be valid numbers$/ + * + * @return void + * @throws Exception + */ + public function theQuotaFieldsReturnedByTheApiShouldBValid():void { + $quotaData = $this->getResponseXml(null, __METHOD__)->data[0]->quota; + $missingQuotaDataString = ""; + if (!isset($quotaData->free)) { + $missingQuotaDataString .= "free "; + } + if (!isset($quotaData->used)) { + $missingQuotaDataString .= "used "; + } + if (!isset($quotaData->total)) { + $missingQuotaDataString .= "total "; + } + if (!isset($quotaData->relative)) { + $missingQuotaDataString .= "relative "; + } + Assert::assertSame( + "", + $missingQuotaDataString, + "These quota data items are missing: $missingQuotaDataString" + ); + $freeQuota = (string) $quotaData->free; + Assert::assertIsNumeric($freeQuota, "free quota '$freeQuota' is not numeric"); + $usedQuota = (string) $quotaData->used; + Assert::assertIsNumeric($usedQuota, "used quota '$usedQuota' is not numeric"); + $totalQuota = (string) $quotaData->total; + Assert::assertIsNumeric($totalQuota, "total quota '$totalQuota' is not numeric"); + $relativeQuota = (string) $quotaData->relative; + Assert::assertIsNumeric($relativeQuota, "free quota '$relativeQuota' is not numeric"); + Assert::assertSame( + (int) $freeQuota + (int) $usedQuota, + (int) $totalQuota, + "free $freeQuota plus used $usedQuota quota is not equal to total quota $totalQuota" + ); + } + + /** + * @Then /^the quota definition returned by the API should be "([^"]*)"$/ + * + * @param string $expectedQuotaDefinition a string that describes the quota + * + * @return void + * @throws Exception + */ + public function theQuotaDefinitionReturnedByTheApiShouldBe(string $expectedQuotaDefinition):void { + $responseQuotaDefinition = (string) $this->getResponseXml(null, __METHOD__)->data[0]->quota->definition; + Assert::assertEquals( + $expectedQuotaDefinition, + $responseQuotaDefinition + ); + } + + /** + * @Then /^the quota definition of user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $user + * @param string $expectedQuotaDefinition + * + * @return void + * @throws Exception + */ + public function theQuotaDefinitionOfUserShouldBe(string $user, string $expectedQuotaDefinition):void { + $this->retrieveUserInformationAsAdminUsingProvisioningApi($user); + $this->theQuotaDefinitionReturnedByTheApiShouldBe($expectedQuotaDefinition); + } + + /** + * @Then /^the last login returned by the API should be a current Unix timestamp$/ + * + * @return void + * @throws Exception + */ + public function theLastLoginReturnedByTheApiShouldBe():void { + $responseLastLogin = (string) $this->getResponseXml(null, __METHOD__)->data[0]->last_login; + Assert::assertIsNumeric($responseLastLogin); + Assert::assertGreaterThan($this->scenarioStartTime, (int) $responseLastLogin); + } + + /** + * Parses the xml answer to get the array of users returned. + * + * @param ResponseInterface $resp + * + * @return array + * @throws Exception + */ + public function getArrayOfUsersResponded(ResponseInterface $resp):array { + $listCheckedElements + = $this->getResponseXml($resp, __METHOD__)->data[0]->users[0]->element; + $extractedElementsArray + = \json_decode(\json_encode($listCheckedElements), true); + return $extractedElementsArray; + } + + /** + * Parses the xml answer to get the array of groups returned. + * + * @param ResponseInterface $resp + * + * @return array + * @throws Exception + */ + public function getArrayOfGroupsResponded(ResponseInterface $resp):array { + $listCheckedElements + = $this->getResponseXml($resp, __METHOD__)->data[0]->groups[0]->element; + $extractedElementsArray + = \json_decode(\json_encode($listCheckedElements), true); + return $extractedElementsArray; + } + + /** + * Parses the xml answer to get the array of apps returned. + * + * @param ResponseInterface $resp + * + * @return array + * @throws Exception + */ + public function getArrayOfAppsResponded(ResponseInterface $resp):array { + $listCheckedElements + = $this->getResponseXml($resp, __METHOD__)->data[0]->apps[0]->element; + $extractedElementsArray + = \json_decode(\json_encode($listCheckedElements), true); + return $extractedElementsArray; + } + + /** + * Parses the xml answer to get the array of subadmins returned. + * + * @param ResponseInterface $resp + * + * @return array + * @throws Exception + */ + public function getArrayOfSubadminsResponded(ResponseInterface $resp):array { + $listCheckedElements + = $this->getResponseXml($resp, __METHOD__)->data[0]->element; + $extractedElementsArray + = \json_decode(\json_encode($listCheckedElements), true); + return $extractedElementsArray; + } + + /** + * Parses the xml answer to get the array of app info returned for an app. + * + * @param ResponseInterface $resp + * + * @return array + * @throws Exception + */ + public function getArrayOfAppInfoResponded(ResponseInterface $resp):array { + $listCheckedElements + = $this->getResponseXml($resp, __METHOD__)->data[0]; + $extractedElementsArray + = \json_decode(\json_encode($listCheckedElements), true); + return $extractedElementsArray; + } + + /** + * @Then /^app "([^"]*)" should be disabled$/ + * + * @param string $app + * + * @return void + * @throws Exception + */ + public function appShouldBeDisabled(string $app):void { + $fullUrl = $this->getBaseUrl() + . "/ocs/v2.php/cloud/apps?filter=disabled"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + $respondedArray = $this->getArrayOfAppsResponded($this->response); + Assert::assertContains($app, $respondedArray); + Assert::assertEquals( + 200, + $this->response->getStatusCode() + ); + } + + /** + * @Then /^app "([^"]*)" should be enabled$/ + * + * @param string $app + * + * @return void + * @throws Exception + */ + public function appShouldBeEnabled(string $app):void { + $fullUrl = $this->getBaseUrl() . "/ocs/v2.php/cloud/apps?filter=enabled"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + $respondedArray = $this->getArrayOfAppsResponded($this->response); + Assert::assertContains($app, $respondedArray); + Assert::assertEquals( + 200, + $this->response->getStatusCode() + ); + } + + /** + * @Then /^the information for app "([^"]*)" should have a valid version$/ + * + * @param string $app + * + * @return void + * @throws Exception + */ + public function theInformationForAppShouldHaveAValidVersion(string $app):void { + $fullUrl = $this->getBaseUrl() . "/ocs/v2.php/cloud/apps/$app"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + Assert::assertEquals( + 200, + $this->response->getStatusCode() + ); + $respondedArray = $this->getArrayOfAppInfoResponded($this->response); + Assert::assertArrayHasKey( + 'version', + $respondedArray, + "app info returned for $app app does not have a version" + ); + $appVersion = $respondedArray['version']; + Assert::assertTrue( + \substr_count($appVersion, '.') > 1, + "app version '$appVersion' returned in app info is not a valid version string" + ); + } + + /** + * @Then /^user "([^"]*)" should be disabled$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userShouldBeDisabled(string $user):void { + $user = $this->getActualUsername($user); + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$user"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + Assert::assertEquals( + "false", + $this->getResponseXml(null, __METHOD__)->data[0]->enabled + ); + } + + /** + * @Then the following users should be disabled + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUsersShouldBeDisabled(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->userShouldBeDisabled($username["username"]); + } + } + + /** + * @Then /^user "([^"]*)" should be enabled$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userShouldBeEnabled(string $user):void { + $user = $this->getActualUsername($user); + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$user"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + Assert::assertEquals( + "true", + $this->getResponseXml(null, __METHOD__)->data[0]->enabled + ); + } + + /** + * @Then the following users should be enabled + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingUsersShouldBeEnabled(TableNode $table):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $this->userShouldBeEnabled($username["username"]); + } + } + + /** + * @When the administrator sets the quota of user :user to :quota using the provisioning API + * + * @param string $user + * @param string $quota + * + * @return void + */ + public function adminSetsUserQuotaToUsingTheProvisioningApi(string $user, string $quota):void { + $user = $this->getActualUsername($user); + $body + = [ + 'key' => 'quota', + 'value' => $quota, + ]; + + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + "PUT", + "/cloud/users/$user", + $this->getStepLineRef(), + $body, + 2 + ); + } + + /** + * @Given the quota of user :user has been set to :quota + * + * @param string $user + * @param string $quota + * + * @return void + */ + public function theQuotaOfUserHasBeenSetTo(string $user, string $quota):void { + $this->adminSetsUserQuotaToUsingTheProvisioningApi($user, $quota); + $this->theHTTPStatusCodeShouldBe(200); + } + + /** + * @When the administrator gives unlimited quota to user :user using the provisioning API + * + * @param string $user + * + * @return void + */ + public function adminGivesUnlimitedQuotaToUserUsingTheProvisioningApi(string $user):void { + $this->adminSetsUserQuotaToUsingTheProvisioningApi($user, 'none'); + } + + /** + * @Given user :user has been given unlimited quota + * + * @param string $user + * + * @return void + */ + public function userHasBeenGivenUnlimitedQuota(string $user):void { + $this->theQuotaOfUserHasBeenSetTo($user, 'none'); + } + + /** + * Returns home path of the given user + * + * @param string $user + * + * @return string + * @throws Exception + */ + public function getUserHome(string $user):string { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$user"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + return $this->getResponseXml(null, __METHOD__)->data[0]->home; + } + + /** + * @Then /^the user attributes returned by the API should include$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function checkUserAttributes(?TableNode $body):void { + $this->verifyTableNodeRows($body, [], $this->userResponseFields); + $bodyRows = $body->getRowsHash(); + foreach ($bodyRows as $field => $value) { + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + $field_array = \explode(' ', $field); + foreach ($field_array as $field_name) { + $data = $data->$field_name; + } + if ($data != $value) { + Assert::fail( + "$field has value $data" + ); + } + } + } + + /** + * @Then the attributes of user :user returned by the API should include + * + * @param string $user + * @param TableNode $body + * + * @return void + * @throws Exception + */ + public function checkAttributesForUser(string $user, TableNode $body):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumnsCount($body, 2); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $this->getAdminUsername(), + "GET", + "/cloud/users/$user", + null + ); + $this->checkUserAttributes($body); + } + + /** + * @Then /^the API should not return any data$/ + * + * @return void + * @throws Exception + */ + public function theApiShouldNotReturnAnyData():void { + $responseData = $this->getResponseXml(null, __METHOD__)->data[0]; + Assert::assertEmpty( + $responseData, + "Response data is not empty but it should be empty" + ); + } + + /** + * @Then /^the list of users returned by the API should be empty$/ + * + * @return void + * @throws Exception + */ + public function theListOfUsersReturnedByTheApiShouldBeEmpty():void { + $usersList = $this->getResponseXml(null, __METHOD__)->data[0]->users[0]; + Assert::assertEmpty( + $usersList, + "Users list is not empty but it should be empty" + ); + } + + /** + * @Then /^the list of groups returned by the API should be empty$/ + * + * @return void + * @throws Exception + */ + public function theListOfGroupsReturnedByTheApiShouldBeEmpty():void { + $groupsList = $this->getResponseXml(null, __METHOD__)->data[0]->groups[0]; + Assert::assertEmpty( + $groupsList, + "Groups list is not empty but it should be empty" + ); + } + + /** + * @AfterScenario + * + * @return void + * @throws Exception + */ + public function afterScenario():void { + $this->waitForDavRequestsToFinish(); + $this->restoreParametersAfterScenario(); + + if (OcisHelper::isTestingOnOcisOrReva() && $this->someUsersHaveBeenCreated()) { + foreach ($this->getCreatedUsers() as $user) { + OcisHelper::deleteRevaUserData($user["actualUsername"]); + } + } elseif (OcisHelper::isTestingOnOc10()) { + $this->resetAdminUserAttributes(); + } + if ($this->isTestingWithLdap()) { + $this->deleteLdapUsersAndGroups(); + } + $this->cleanupDatabaseUsers(); + $this->cleanupDatabaseGroups(); + } + + /** + * + * @return void + * @throws Exception + */ + public function resetAdminUserAttributes():void { + if ($this->adminDisplayName !== '') { + $this->adminChangesTheDisplayNameOfUserUsingTheProvisioningApi( + $this->getAdminUsername(), + '' + ); + } + if ($this->adminEmailAddress !== '') { + $this->adminChangesTheEmailOfUserToUsingTheProvisioningApi( + $this->getAdminUsername(), + '' + ); + } + } + + /** + * + * @return void + * @throws Exception + */ + public function cleanupDatabaseUsers():void { + $this->authContext->deleteTokenAuthEnforcedAfterScenario(); + $previousServer = $this->currentServer; + $this->usingServer('LOCAL'); + foreach ($this->createdUsers as $user => $userData) { + $this->deleteUser($userData['actualUsername']); + } + $this->usingServer('REMOTE'); + foreach ($this->createdRemoteUsers as $remoteUser => $userData) { + $this->deleteUser($userData['actualUsername']); + } + $this->usingServer($previousServer); + } + + /** + * + * @return void + * @throws Exception + */ + public function cleanupDatabaseGroups():void { + $this->authContext->deleteTokenAuthEnforcedAfterScenario(); + $previousServer = $this->currentServer; + $this->usingServer('LOCAL'); + foreach ($this->createdGroups as $group => $groupData) { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminDeletesGroupWithGroupId( + $groupData['id'] + ); + } else { + $this->cleanupGroup((string)$group); + } + } + $this->usingServer('REMOTE'); + foreach ($this->createdRemoteGroups as $remoteGroup => $groupData) { + if (OcisHelper::isTestingWithGraphApi()) { + $this->graphContext->adminDeletesGroupWithGroupId( + $groupData['id'] + ); + } else { + $this->cleanupGroup((string)$remoteGroup); + } + } + $this->usingServer($previousServer); + } + + /** + * @BeforeScenario + * + * @return void + * @throws Exception + */ + public function rememberAppEnabledDisabledState():void { + if (!OcisHelper::isTestingOnOcisOrReva()) { + SetupHelper::init( + $this->getAdminUsername(), + $this->getAdminPassword(), + $this->getBaseUrl(), + $this->getOcPath() + ); + $this->runOcc(['app:list', '--output json']); + $apps = \json_decode($this->getStdOutOfOccCommand(), true); + $this->enabledApps = \array_keys($apps["enabled"]); + $this->disabledApps = \array_keys($apps["disabled"]); + } + } + + /** + * @BeforeScenario @rememberGroupsThatExist + * + * @return void + * @throws Exception + */ + public function rememberGroupsThatExistAtTheStartOfTheScenario():void { + $this->startingGroups = $this->getArrayOfGroupsResponded($this->getAllGroups()); + } + + /** + * @AfterScenario + * + * @return void + * @throws Exception + */ + public function restoreAppEnabledDisabledState():void { + if (!OcisHelper::isTestingOnOcisOrReva() && !$this->isRunningForDbConversion()) { + $this->runOcc(['app:list', '--output json']); + + $apps = \json_decode($this->getStdOutOfOccCommand(), true); + $currentlyEnabledApps = \array_keys($apps["enabled"]); + $currentlyDisabledApps = \array_keys($apps["disabled"]); + + foreach ($currentlyDisabledApps as $disabledApp) { + if (\in_array($disabledApp, $this->enabledApps)) { + $this->adminEnablesOrDisablesApp('enables', $disabledApp); + } + } + + foreach ($currentlyEnabledApps as $enabledApp) { + if (\in_array($enabledApp, $this->disabledApps)) { + $this->adminEnablesOrDisablesApp('disables', $enabledApp); + } + } + } + } + + /** + * disable or enable user + * + * @param string $user + * @param string $otherUser + * @param string $action + * + * @return void + */ + public function disableOrEnableUser(string $user, string $otherUser, string $action):void { + $actualUser = $this->getActualUsername($user); + $actualPassword = $this->getPasswordForUser($actualUser); + $actualOtherUser = $this->getActualUsername($otherUser); + + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/users/$actualOtherUser/$action"; + $this->response = HttpRequestHelper::put( + $fullUrl, + $this->getStepLineRef(), + $actualUser, + $actualPassword + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * Returns an array of all apps reported by the cloud/apps endpoint + * + * @return array + * @throws Exception + */ + public function getAllApps():array { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/apps"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + return ($this->getArrayOfAppsResponded($this->response)); + } + + /** + * Returns array of enabled apps reported by the cloud/apps endpoint + * + * @return array + * @throws Exception + */ + public function getEnabledApps():array { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/apps?filter=enabled"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + return ($this->getArrayOfAppsResponded($this->response)); + } + + /** + * Returns array of disabled apps reported by the cloud/apps endpoint + * + * @return array + * @throws Exception + */ + public function getDisabledApps():array { + $fullUrl = $this->getBaseUrl() + . "/ocs/v{$this->ocsApiVersion}.php/cloud/apps?filter=disabled"; + $this->response = HttpRequestHelper::get( + $fullUrl, + $this->getStepLineRef(), + $this->getAdminUsername(), + $this->getAdminPassword() + ); + return ($this->getArrayOfAppsResponded($this->response)); + } + + /** + * Removes skeleton directory config from config.php and returns the config value + * + * @param string|null $baseUrl + * + * @return string + * @throws Exception + */ + public function popSkeletonDirectoryConfig(?string $baseUrl = null):string { + $path = $this->getSkeletonDirectory($baseUrl); + $this->runOcc( + ["config:system:delete skeletondirectory"], + null, + null, + $baseUrl + ); + return $path; + } + + /** + * @param string|null $baseUrl + * + * @return string + * @throws Exception + */ + private function getSkeletonDirectory(?string $baseUrl = null):string { + $this->runOcc( + ["config:system:get skeletondirectory"], + null, + null, + $baseUrl + ); + return \trim($this->getStdOutOfOccCommand()); + } + + /** + * Get the name of the smallest available skeleton, to "simulate" without skeleton. + * + * In ownCloud 10 there is always a skeleton directory. If none is specified + * then whatever is in core/skeleton is used. That contains different files + * and folders depending on the build that is being tested. So for testing + * we have "empty" skeleton that is created on-the-fly by the testing app. + * That provides a consistent skeleton for test scenarios that specify + * "without skeleton files" + * + * @return string name of the smallest skeleton folder + */ + private function getSmallestSkeletonDirName(): string { + return "empty"; + } + + /** + * @return bool + */ + private function isEmptySkeleton(): bool { + $skeletonDir = \getenv("SKELETON_DIR"); + if (($skeletonDir !== false) && (\basename($skeletonDir) === $this->getSmallestSkeletonDirName() . "Skeleton")) { + return true; + } + return false; + } + + /** + * sets the skeletondirectory according to the type + * + * @param string $skeletonType can be "tiny", "small", "large" OR empty. + * If an empty string is given, the current + * setting will not be changed + * + * @return string skeleton folder before the change + * @throws Exception + */ + private function setSkeletonDirByType(string $skeletonType): string { + if (OcisHelper::isTestingOnOcisOrReva()) { + $originalSkeletonPath = \getenv("SKELETON_DIR"); + if ($originalSkeletonPath === false) { + $originalSkeletonPath = ''; + } + if ($skeletonType !== '') { + $skeletonDirName = $skeletonType . "Skeleton"; + $newSkeletonPath = \dirname($originalSkeletonPath) . '/' . $skeletonDirName; + \putenv( + "SKELETON_DIR=" . $newSkeletonPath + ); + } + } else { + $baseUrl = $this->getBaseUrl(); + $originalSkeletonPath = $this->getSkeletonDirectory($baseUrl); + + if ($skeletonType !== '') { + OcsApiHelper::sendRequest( + $baseUrl, + $this->getAdminUsername(), + $this->getAdminPassword(), + 'POST', + "/apps/testing/api/v1/testingskeletondirectory", + $this->getStepLineRef(), + [ + 'directory' => $skeletonType . "Skeleton" + ], + $this->getOcsApiVersion() + ); + } + } + return $originalSkeletonPath; + } + + /** + * sets the skeletondirectory + * + * @param string $skeletonDir Full path of the skeleton directory + * If an empty string is given, the current + * setting will not be changed + * + * @return string skeleton folder before the change + * @throws Exception + */ + private function setSkeletonDir(string $skeletonDir): string { + if (OcisHelper::isTestingOnOcisOrReva()) { + $originalSkeletonPath = \getenv("SKELETON_DIR"); + if ($skeletonDir !== '') { + \putenv("SKELETON_DIR=" . $skeletonDir); + } + } else { + $baseUrl = $this->getBaseUrl(); + $originalSkeletonPath = $this->getSkeletonDirectory($baseUrl); + if ($skeletonDir !== '') { + $this->runOcc( + ["config:system:set skeletondirectory --value $skeletonDir"], + null, + null, + $baseUrl + ); + } + } + return $originalSkeletonPath; + } +} diff --git a/tests/acceptance/features/bootstrap/PublicWebDavContext.php b/tests/acceptance/features/bootstrap/PublicWebDavContext.php new file mode 100644 index 000000000..bdcb48f67 --- /dev/null +++ b/tests/acceptance/features/bootstrap/PublicWebDavContext.php @@ -0,0 +1,1661 @@ + + * @copyright Copyright (c) 2018, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use PHPUnit\Framework\Assert; +use TestHelpers\HttpRequestHelper; +use TestHelpers\OcisHelper; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * context file for steps that execute actions as "the public". + */ +class PublicWebDavContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * + * @var OccContext + */ + private $occContext; + + /** + * @When /^the public downloads the last public link shared file with range "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $range ignore if empty + * @param string $publicWebDAVAPIVersion + * @param string|null $password + * + * @return void + */ + public function downloadPublicFileWithRange(string $range, string $publicWebDAVAPIVersion, ?string $password = ""):void { + if ($publicWebDAVAPIVersion === "new") { + // In this case a single file has been shared as a public link. + // Even if that file is somewhere down inside a folder(s), when + // accessing it as a public link using the "new" public webDAV API + // the client needs to provide the public link share token followed + // by just the name of the file - not the full path. + $fullPath = $this->featureContext->getLastPublicSharePath(); + $fullPathParts = \explode("/", $fullPath); + $path = \end($fullPathParts); + } else { + $path = ""; + } + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + $password, + $range, + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public downloads the last public link shared file with range "([^"]*)" and password "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $range ignore if empty + * @param string $password + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function downloadPublicFileWithRangeAndPassword(string $range, string $password, string $publicWebDAVAPIVersion):void { + if ($publicWebDAVAPIVersion === "new") { + $path = $this->featureContext->getLastPublicShareData()->data->file_target; + } else { + $path = ""; + } + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + $password, + $range, + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public downloads the last public link shared file using the (old|new) public WebDAV API$/ + * + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function downloadPublicFile(string $publicWebDAVAPIVersion):void { + $this->downloadPublicFileWithRange("", $publicWebDAVAPIVersion); + } + + /** + * @When /^the public downloads the last public link shared file with password "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $password + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function downloadPublicFileWithPassword(string $password, string $publicWebDAVAPIVersion):void { + $this->downloadPublicFileWithRange("", $publicWebDAVAPIVersion, $password); + } + + /** + * @When /^the public deletes (?:file|folder|entry) "([^"]*)" from the last public link share using the (old|new) public WebDAV API$/ + * + * @param string $fileName + * @param string $publicWebDAVAPIVersion + * @param string $password + * + * @return void + */ + public function deleteFileFromPublicShare(string $fileName, string $publicWebDAVAPIVersion, string $password = ""):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } + $token = $this->featureContext->getLastPublicShareToken(); + $davPath = WebDavHelper::getDavPath( + $token, + 0, + "public-files-$publicWebDAVAPIVersion" + ); + $fullUrl = $this->featureContext->getBaseUrl() . "/$davPath$fileName"; + $userName = $this->getUsernameForPublicWebdavApi( + $token, + $password, + $publicWebDAVAPIVersion + ); + $headers = [ + 'X-Requested-With' => 'XMLHttpRequest' + ]; + $this->featureContext->setResponse( + HttpRequestHelper::delete( + $fullUrl, + $this->featureContext->getStepLineRef(), + $userName, + $password, + $headers + ) + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + + /** + * @When /^the public deletes file "([^"]*)" from the last public link share using the password "([^"]*)" and (old|new) public WebDAV API$/ + * + * @param string $file + * @param string $password + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicDeletesFileFromTheLastPublicShareUsingThePasswordPasswordAndOldPublicWebdavApi(string $file, string $password, string $publicWebDAVAPIVersion):void { + $this->deleteFileFromPublicShare($file, $publicWebDAVAPIVersion, $password); + } + + /** + * @When /^the public renames (?:file|folder|entry) "([^"]*)" to "([^"]*)" from the last public link share using the (old|new) public WebDAV API$/ + * + * @param string $fileName + * @param string $toFileName + * @param string $publicWebDAVAPIVersion + * @param string|null $password + * + * @return void + */ + public function renameFileFromPublicShare(string $fileName, string $toFileName, string $publicWebDAVAPIVersion, ?string $password = ""):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } + $token = $this->featureContext->getLastPublicShareToken(); + $davPath = WebDavHelper::getDavPath( + $token, + 0, + "public-files-$publicWebDAVAPIVersion" + ); + $fullUrl = $this->featureContext->getBaseUrl() . "/$davPath$fileName"; + $destination = $this->featureContext->getBaseUrl() . "/$davPath$toFileName"; + $userName = $this->getUsernameForPublicWebdavApi( + $token, + $password, + $publicWebDAVAPIVersion + ); + $headers = [ + 'X-Requested-With' => 'XMLHttpRequest', + 'Destination' => $destination + ]; + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $fullUrl, + $this->featureContext->getStepLineRef(), + "MOVE", + $userName, + $password, + $headers + ) + ); + } + + /** + * @When /^the public renames file "([^"]*)" to "([^"]*)" from the last public link share using the password "([^"]*)" and (old|new) public WebDAV API$/ + * + * @param string $fileName + * @param string $toName + * @param string $password + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicRenamesFileFromTheLastPublicShareUsingThePasswordPasswordAndOldPublicWebdavApi(string $fileName, string $toName, string $password, string $publicWebDAVAPIVersion):void { + $this->renameFileFromPublicShare($fileName, $toName, $publicWebDAVAPIVersion, $password); + } + + /** + * @When /^the public downloads file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function downloadPublicFileInsideAFolder(string $path, string $publicWebDAVAPIVersion = "old"):void { + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + "", + "", + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public downloads file "([^"]*)" from inside the last public link shared folder with password "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $path + * @param string|null $password + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publicDownloadsTheFileInsideThePublicSharedFolderWithPassword( + string $path, + ?string $password = "", + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + $password, + "", + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public downloads file "([^"]*)" from inside the last public link shared folder with range "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $path + * @param string $range + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function downloadPublicFileInsideAFolderWithRange(string $path, string $range, string $publicWebDAVAPIVersion):void { + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + "", + $range, + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public downloads file "([^"]*)" from inside the last public link shared folder with password "([^"]*)" with range "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $path + * @param string $password + * @param string $range ignored when empty + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + string $path, + string $password, + string $range, + string $publicWebDAVAPIVersion = "old" + ):void { + $path = \ltrim($path, "/"); + $password = $this->featureContext->getActualPassword($password); + $token = $this->featureContext->getLastPublicShareToken(); + $davPath = WebDavHelper::getDavPath( + $token, + 0, + "public-files-$publicWebDAVAPIVersion" + ); + $fullUrl = $this->featureContext->getBaseUrl() . "/$davPath$path"; + $userName = $this->getUsernameForPublicWebdavApi( + $token, + $password, + $publicWebDAVAPIVersion + ); + + $headers = [ + 'X-Requested-With' => 'XMLHttpRequest' + ]; + if ($range !== "") { + $headers['Range'] = $range; + } + $response = HttpRequestHelper::get( + $fullUrl, + $this->featureContext->getStepLineRef(), + $userName, + $password, + $headers + ); + $this->featureContext->setResponse($response); + } + + /** + * @param string $source target file name + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publiclyUploadingFile(string $source, string $publicWebDAVAPIVersion):void { + $this->publicUploadContent( + \basename($source), + '', + \file_get_contents($source), + false, + [], + $publicWebDAVAPIVersion + ); + } + + /** + * @When the public uploads file :filename using the :publicWebDAVAPIVersion WebDAV API + * + * @param string $source target file name + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicUploadsFileUsingTheWebDAVApi(string $source, string $publicWebDAVAPIVersion):void { + $this->publiclyUploadingFile( + $source, + $publicWebDAVAPIVersion + ); + } + + /** + * + * @param string $baseUrl URL of owncloud + * e.g. http://localhost:8080 + * should include the subfolder + * if owncloud runs in a subfolder + * e.g. http://localhost:8080/owncloud-core + * @param string $source + * @param string $destination + * + * @return void + */ + public function publiclyCopyingFile( + string $baseUrl, + string $source, + string $destination + ):void { + $fullSourceUrl = $baseUrl . $source; + $fullDestUrl = WebDavHelper::sanitizeUrl( + $baseUrl . $destination + ); + + $headers["Destination"] = $fullDestUrl; + $response = HttpRequestHelper::sendRequest( + $fullSourceUrl, + $this->featureContext->getStepLineRef(), + "COPY", + null, + null, + $headers, + null, + null, + null, + false, + 0, + null + ); + $this->featureContext->setResponse($response); + } + + /** + * @When /^the public copies (?:file|folder) "([^"]*)" to "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $source + * @param string $destination + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicCopiesFileUsingTheWebDAVApi(string $source, string $destination, string $publicWebDAVAPIVersion):void { + $token = $this->featureContext->getLastPublicShareToken(); + $davPath = WebDavHelper::getDavPath( + $token, + 0, + "public-files-$publicWebDAVAPIVersion" + ); + $baseUrl = $this->featureContext->getLocalBaseUrl() . '/' . $davPath; + + $this->publiclyCopyingFile( + $baseUrl, + $source, + $destination + ); + } + + /** + * @Given the public has uploaded file :filename + * + * @param string $source target file name + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicHasUploadedFileUsingTheWebDAVApi(string $source, string $publicWebDAVAPIVersion):void { + $this->publiclyUploadingFile( + $source, + $publicWebDAVAPIVersion + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * This only works with the old API, auto-rename is not supported in the new API + * auto renaming is handled on files drop folders implicitly + * + * @param string $filename target file name + * @param string $body content to upload + * + * @return void + */ + public function publiclyUploadingContentAutoRename(string $filename, string $body = 'test'):void { + $this->publicUploadContent($filename, '', $body, true); + } + + /** + * @When the public uploads file :filename with content :body with auto-rename mode using the old public WebDAV API + * + * @param string $filename target file name + * @param string $body content to upload + * + * @return void + */ + public function thePublicUploadsFileWithContentWithAutoRenameMode(string $filename, string $body = 'test'):void { + $this->publiclyUploadingContentAutoRename($filename, $body); + } + + /** + * @Given the public has uploaded file :filename with content :body with auto-rename mode + * + * @param string $filename target file name + * @param string $body content to upload + * + * @return void + */ + public function thePublicHasUploadedFileWithContentWithAutoRenameMode(string $filename, string $body = 'test'):void { + $this->publiclyUploadingContentAutoRename($filename, $body); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $filename target file name + * @param string $password + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publiclyUploadingContentWithPassword( + string $filename, + string $password = '', + string $body = 'test', + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publicUploadContent( + $filename, + $password, + $body, + false, + [], + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public uploads file "([^"]*)" with password "([^"]*)" and content "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $filename target file name + * @param string|null $password + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicUploadsFileWithPasswordAndContentUsingPublicWebDAVApi( + string $filename, + ?string $password = '', + string $body = 'test', + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publiclyUploadingContentWithPassword( + $filename, + $password, + $body, + $publicWebDAVAPIVersion + ); + } + + /** + * @Given the public has uploaded file :filename" with password :password and content :body + * + * @param string $filename target file name + * @param string|null $password + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicHasUploadedFileWithPasswordAndContentUsingPublicWebDAVApi( + string $filename, + ?string $password = '', + string $body = 'test', + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publiclyUploadingContentWithPassword( + $filename, + $password, + $body, + $publicWebDAVAPIVersion + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $filename target file name + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publiclyOverwritingContent(string $filename, string $body = 'test', string $publicWebDAVAPIVersion = 'old'):void { + $this->publicUploadContent($filename, '', $body, false, [], $publicWebDAVAPIVersion); + } + + /** + * @When the public overwrites file :filename with content :body using the :type WebDAV API + * + * @param string $filename target file name + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicOverwritesFileWithContentUsingWebDavApi(string $filename, string $body = 'test', string $publicWebDAVAPIVersion = 'old'):void { + $this->publiclyOverwritingContent( + $filename, + $body, + $publicWebDAVAPIVersion + ); + } + + /** + * @Given the public has overwritten file :filename with content :body + * + * @param string $filename target file name + * @param string $body content to upload + * + * @return void + */ + public function thePublicHasOverwrittenFileWithContentUsingOldWebDavApi(string $filename, string $body = 'test'):void { + $this->publiclyOverwritingContent( + $filename, + $body + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $filename target file name + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publiclyUploadingContent( + string $filename, + string $body = 'test', + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publicUploadContent( + $filename, + '', + $body, + false, + [], + $publicWebDAVAPIVersion + ); + } + + /** + * @When /^the public uploads file "([^"]*)" with content "([^"]*)" using the (old|new) public WebDAV API$/ + * + * @param string $filename target file name + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicUploadsFileWithContentUsingThePublicWebDavApi( + string $filename, + string $body = 'test', + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publiclyUploadingContent( + $filename, + $body, + $publicWebDAVAPIVersion + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + + /** + * @Given the public has uploaded file :filename with content :body + * + * @param string $filename target file name + * @param string $body content to upload + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function thePublicHasUploadedFileWithContentUsingThePublicWebDavApi( + string $filename, + string $body = 'test', + string $publicWebDAVAPIVersion = "old" + ):void { + $this->publiclyUploadingContent( + $filename, + $body, + $publicWebDAVAPIVersion + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public should be able to download the last publicly shared file using the (old|new) public WebDAV API without a password and the content should be "([^"]*)"$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $expectedContent + * + * @return void + * @throws Exception + */ + public function checkLastPublicSharedFileDownload( + string $publicWebDAVAPIVersion, + string $expectedContent + ):void { + $this->checkLastPublicSharedFileWithPasswordDownload( + $publicWebDAVAPIVersion, + "", + $expectedContent + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public should be able to download the last publicly shared file using the (old|new) public WebDAV API without a password and the content should be "([^"]*)" plus end-of-line$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $expectedContent + * + * @return void + * @throws Exception + */ + public function checkLastPublicSharedFileDownloadPlusEndOfLine( + string $publicWebDAVAPIVersion, + string $expectedContent + ):void { + $this->checkLastPublicSharedFileWithPasswordDownload( + $publicWebDAVAPIVersion, + "", + "$expectedContent\n" + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public should be able to download the last publicly shared file using the (old|new) public WebDAV API with password "([^"]*)" and the content should be "([^"]*)"$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $expectedContent + * + * @return void + * @throws Exception + */ + public function checkLastPublicSharedFileWithPasswordDownload( + string $publicWebDAVAPIVersion, + string $password, + string $expectedContent + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->downloadPublicFileWithRange( + "", + $publicWebDAVAPIVersion, + $password + ); + + $this->featureContext->checkDownloadedContentMatches( + $expectedContent, + "Checking the content of the last public shared file after downloading with the $publicWebDAVAPIVersion public WebDAV API" + ); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public download of the last publicly shared file using the (old|new) public WebDAV API with password "([^"]*)" should fail with HTTP status code "([^"]*)"$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $expectedHttpCode + * + * @return void + */ + public function theLastPublicSharedFileShouldNotBeAbleToBeDownloadedWithPassword( + string $publicWebDAVAPIVersion, + string $password, + string $expectedHttpCode + ):void { + $this->downloadPublicFileWithRange( + "", + $publicWebDAVAPIVersion, + $password + ); + $responseContent = $this->featureContext->getResponse()->getBody()->getContents(); + \libxml_use_internal_errors(true); + Assert::assertNotFalse( + \simplexml_load_string($responseContent), + "response body is not valid XML, maybe download did work\n" . + "response body: \n$responseContent\n" + ); + $this->featureContext->theHTTPStatusCodeShouldBe($expectedHttpCode); + } + + /** + * @Then /^the public download of the last publicly shared file using the (old|new) public WebDAV API without a password should fail with HTTP status code "([^"]*)"$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $expectedHttpCode + * + * @return void + */ + public function theLastPublicSharedFileShouldNotBeAbleToBeDownloadedWithoutAPassword( + string $publicWebDAVAPIVersion, + string $expectedHttpCode + ):void { + $this->theLastPublicSharedFileShouldNotBeAbleToBeDownloadedWithPassword( + $publicWebDAVAPIVersion, + "", + $expectedHttpCode + ); + } + + /** + * @Then /^the public should be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function shouldBeAbleToDownloadFileInsidePublicSharedFolder( + string $path, + string $publicWebDAVAPIVersion + ):void { + $this->shouldBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + "", + $path, + $publicWebDAVAPIVersion, + "" + ); + } + + /** + * @Then /^the public should not be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API without a password$/ + * @Then /^the public download of file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API should fail with HTTP status code "([^"]*)"$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $expectedHttpCode + * + * @return void + */ + public function shouldNotBeAbleToDownloadFileInsidePublicSharedFolder( + string $path, + string $publicWebDAVAPIVersion, + string $expectedHttpCode = "401" + ):void { + $this->shouldNotBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + "", + $path, + $publicWebDAVAPIVersion, + "", + $expectedHttpCode + ); + } + + /** + * @Then /^the public should be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)"$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $password + * + * @return void + */ + public function shouldBeAbleToDownloadFileInsidePublicSharedFolderWithPassword( + string $path, + string $publicWebDAVAPIVersion, + string $password + ):void { + $this->shouldBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + "", + $path, + $publicWebDAVAPIVersion, + $password + ); + } + + /** + * @Then /^the public should be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)" and the content should be "([^"]*)" plus end-of-line$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $content + * + * @return void + * @throws Exception + */ + public function shouldBeAbleToDownloadFileInsidePublicSharedFolderWithPasswordAndEOL( + string $path, + string $publicWebDAVAPIVersion, + string $password, + string $content + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPassword( + $path, + $password, + $publicWebDAVAPIVersion + ); + + $this->featureContext->downloadedContentShouldBePlusEndOfLine($content); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public should be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)" and the content should be "([^"]*)"$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $content + * + * @return void + * @throws Exception + */ + public function shouldBeAbleToDownloadFileInsidePublicSharedFolderWithPasswordAndContentShouldBe( + string $path, + string $publicWebDAVAPIVersion, + string $password, + string $content + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPassword( + $path, + $password, + $publicWebDAVAPIVersion + ); + + $this->featureContext->downloadedContentShouldBe($content); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public should be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API without password and the content should be "([^"]*)"$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $content + * + * @return void + * @throws Exception + */ + public function shouldBeAbleToDownloadFileInsidePublicSharedFolderWithoutPassword( + string $path, + string $publicWebDAVAPIVersion, + string $content + ):void { + $this->shouldBeAbleToDownloadFileInsidePublicSharedFolderWithPasswordAndContentShouldBe($path, $publicWebDAVAPIVersion, "", $content); + } + + /** + * @Then /^the public should not be able to download file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)"$/ + * @Then /^the public download of file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)" should fail with HTTP status code "([^"]*)"$/ + * + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $expectedHttpCode + * + * @return void + */ + public function shouldNotBeAbleToDownloadFileInsidePublicSharedFolderWithPassword( + string $path, + string $publicWebDAVAPIVersion, + string $password, + string $expectedHttpCode = "401" + ):void { + $this->shouldNotBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + "", + $path, + $publicWebDAVAPIVersion, + $password, + $expectedHttpCode + ); + } + + /** + * @Then /^the public should be able to download the range "([^"]*)" of file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)""$/ + * + * @param string $range + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $password + * + * @return void + * @throws Exception + */ + public function shouldBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + string $range, + string $path, + string $publicWebDAVAPIVersion, + string $password + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + $password, + $range, + $publicWebDAVAPIVersion + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public should not be able to download the range "([^"]*)" of file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)"$/ + * + * @param string $range + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $expectedHttpCode + * + * @return void + * @throws Exception + */ + public function shouldNotBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + string $range, + string $path, + string $publicWebDAVAPIVersion, + string $password, + string $expectedHttpCode = "401" + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicDownloadsTheFileInsideThePublicSharedFolderWithPasswordAndRange( + $path, + $password, + $range, + $publicWebDAVAPIVersion + ); + + $responseContent = $this->featureContext->getResponse()->getBody()->getContents(); + \libxml_use_internal_errors(true); + Assert::assertNotFalse( + \simplexml_load_string($responseContent), + "response body is not valid XML, maybe download did work\n" . + "response body: \n$responseContent\n" + ); + $this->featureContext->theHTTPStatusCodeShouldBe($expectedHttpCode); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + } + + /** + * @Then /^the public should be able to download the range "([^"]*)" of file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API and the content should be "([^"]*)"$/ + * + * @param string $range + * @param string $path + * @param string $publicWebDAVAPIVersion + * @param string $content + * + * @return void + * @throws Exception + */ + public function shouldBeAbleToDownloadRangeOfFileInsidePublicSharedFolder( + string $range, + string $path, + string $publicWebDAVAPIVersion, + string $content + ):void { + $this->shouldBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + $range, + $path, + $publicWebDAVAPIVersion, + "", + $content + ); + } + + /** + * @Then /^the public should not be able to download the range "([^"]*)" of file "([^"]*)" from inside the last public link shared folder using the (old|new) public WebDAV API without a password$/ + * + * @param string $range + * @param string $path + * @param string $publicWebDAVAPIVersion + * + * @return void + * @throws Exception + */ + public function shouldNotBeAbleToDownloadRangeOfFileInsidePublicSharedFolder( + string $range, + string $path, + string $publicWebDAVAPIVersion + ):void { + $this->shouldNotBeAbleToDownloadRangeOfFileInsidePublicSharedFolderWithPassword( + $range, + $path, + $publicWebDAVAPIVersion, + "" + ); + } + + /** + * @Then /^the public upload to the last publicly shared file using the (old|new) public WebDAV API should (?:fail|pass) with HTTP status code "([^"]*)"$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $expectedHttpCode + * + * @return void + * @throws Exception + */ + public function publiclyUploadingShouldToSharedFileShouldFail( + string $publicWebDAVAPIVersion, + string $expectedHttpCode + ):void { + $filename = ""; + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $filename = (string)$this->featureContext->getLastPublicShareData()->data[0]->file_target; + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicUploadContent( + $filename, + '', + 'test', + false, + [], + $publicWebDAVAPIVersion + ); + + $this->featureContext->theHTTPStatusCodeShouldBe($expectedHttpCode); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + } + + /** + * @Then /^uploading a file should not work using the (old|new) public WebDAV API$/ + * @Then /^the public upload to the last publicly shared folder using the (old|new) public WebDAV API should fail with HTTP status code "([^"]*)"$/ + * + * @param string $publicWebDAVAPIVersion + * @param string $expectedHttpCode + * + * @return void + * @throws Exception + */ + public function publiclyUploadingShouldNotWork( + string $publicWebDAVAPIVersion, + string $expectedHttpCode = null + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicUploadContent( + 'whateverfilefortesting.txt', + '', + 'test', + false, + [], + $publicWebDAVAPIVersion + ); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + + $response = $this->featureContext->getResponse(); + if ($expectedHttpCode === null) { + $expectedHttpCode = [507, 400, 401, 403, 404, 423]; + } + $this->featureContext->theHTTPStatusCodeShouldBe( + $expectedHttpCode, + "upload should have failed but passed with code " . + $response->getStatusCode() + ); + } + + /** + * @Then /^the public should be able to upload file "([^"]*)" into the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)"$/ + * + * @param string $filename + * @param string $publicWebDAVAPIVersion + * @param string $password + * + * @return void + */ + public function publiclyUploadingIntoFolderWithPasswordShouldWork( + string $filename, + string $publicWebDAVAPIVersion, + string $password + ):void { + $this->publicUploadContent( + $filename, + $password, + 'test', + false, + [], + $publicWebDAVAPIVersion + ); + + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public upload of file "([^"]*)" into the last public link shared folder using the (old|new) public WebDAV API with password "([^"]*)" should fail with HTTP status code "([^"]*)"$/ + * + * @param string $filename + * @param string $publicWebDAVAPIVersion + * @param string $password + * @param string $expectedHttpCode + * + * @return void + */ + public function publiclyUploadingIntoFolderWithPasswordShouldFail( + string $filename, + string $publicWebDAVAPIVersion, + string $password, + string $expectedHttpCode + ):void { + $this->publicUploadContent( + $filename, + $password, + 'test', + false, + [], + $publicWebDAVAPIVersion + ); + + $response = $this->featureContext->getResponse(); + $this->featureContext->theHTTPStatusCodeShouldBe( + $expectedHttpCode, + "upload of $filename into the last publicly shared folder should have failed with code " . + $expectedHttpCode . " but the code was " . $response->getStatusCode() + ); + } + + /** + * @Then /^uploading a file should work using the (old|new) public WebDAV API$/ + * + * @param string $publicWebDAVAPIVersion + * + * @return void + * @throws Exception + */ + public function publiclyUploadingShouldWork(string $publicWebDAVAPIVersion):void { + $path = "whateverfilefortesting-$publicWebDAVAPIVersion-publicWebDAVAPI.txt"; + $content = "test $publicWebDAVAPIVersion"; + + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + } else { + $techPreviewHadToBeEnabled = false; + } + + $this->publicUploadContent( + $path, + '', + $content, + false, + [], + $publicWebDAVAPIVersion + ); + $response = $this->featureContext->getResponse(); + Assert::assertTrue( + ($response->getStatusCode() == 201), + "upload should have passed but failed with code " . + $response->getStatusCode() + ); + $this->shouldBeAbleToDownloadFileInsidePublicSharedFolder( + $path, + $publicWebDAVAPIVersion + ); + $this->featureContext->checkDownloadedContentMatches($content); + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + } + + /** + * @Then /^uploading content to a public link shared file should (not|)\s?work using the (old|new) public WebDAV API$/ + * + * @param string $shouldOrNot (not|) + * @param string $publicWebDAVAPIVersion + * + * @return void + * @throws Exception + */ + public function publiclyUploadingToPublicLinkSharedFileShouldWork( + string $shouldOrNot, + string $publicWebDAVAPIVersion + ):void { + $content = "test $publicWebDAVAPIVersion"; + $should = ($shouldOrNot !== "not"); + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } elseif ($publicWebDAVAPIVersion === "new") { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + $path = $this->featureContext->getLastPublicSharePath(); + } else { + $techPreviewHadToBeEnabled = false; + $path = ""; + } + + $this->publicUploadContent( + $path, + '', + $content, + false, + [], + $publicWebDAVAPIVersion + ); + $response = $this->featureContext->getResponse(); + if ($should) { + Assert::assertTrue( + ($response->getStatusCode() == 204), + "upload should have passed but failed with code " . + $response->getStatusCode() + ); + + $this->downloadPublicFileWithRange( + "", + $publicWebDAVAPIVersion, + "" + ); + + $this->featureContext->checkDownloadedContentMatches( + $content, + "Checking the content of the last public shared file after downloading with the $publicWebDAVAPIVersion public WebDAV API" + ); + } else { + $expectedCode = 403; + Assert::assertTrue( + ($response->getStatusCode() == $expectedCode), + "upload should have failed with HTTP status $expectedCode but passed with code " . + $response->getStatusCode() + ); + } + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + } + + /** + * @When the public uploads file :fileName to the last public link shared folder with mtime :mtime using the :davVersion public WebDAV API + * + * @param String $fileName + * @param String $mtime + * @param String $davVersion + * + * @return void + * @throws Exception + */ + public function thePublicUploadsFileToLastSharedFolderWithMtimeUsingTheWebdavApi( + string $fileName, + string $mtime, + string $davVersion = "old" + ):void { + $mtime = new DateTime($mtime); + $mtime = $mtime->format('U'); + + $this->publicUploadContent( + $fileName, + '', + 'test', + false, + ["X-OC-Mtime" => $mtime], + $davVersion + ); + } + + /** + * @param string $destination + * @param string $password + * + * @return void + */ + public function publicCreatesFolderUsingPassword( + string $destination, + string $password + ):void { + $token = $this->featureContext->getLastPublicShareToken(); + $davPath = WebDavHelper::getDavPath( + $token, + 0, + "public-files-new" + ); + $url = $this->featureContext->getBaseUrl() . "/$davPath"; + $password = $this->featureContext->getActualPassword($password); + $userName = $this->getUsernameForPublicWebdavApi( + $token, + $password, + "new" + ); + $foldername = \implode( + '/', + \array_map('rawurlencode', \explode('/', $destination)) + ); + $url .= \ltrim($foldername, '/'); + + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $url, + $this->featureContext->getStepLineRef(), + 'MKCOL', + $userName, + $password + ) + ); + } + + /** + * @When the public creates folder :destination using the new public WebDAV API + * + * @param String $destination + * + * @return void + */ + public function publicCreatesFolder(string $destination):void { + $this->publicCreatesFolderUsingPassword($destination, ''); + } + + /** + * @Then /^the public should be able to create folder "([^"]*)" in the last public link shared folder using the new public WebDAV API with password "([^"]*)"$/ + * + * @param string $foldername + * @param string $password + * + * @return void + */ + public function publicShouldBeAbleToCreateFolderWithPassword( + string $foldername, + string $password + ):void { + $this->publicCreatesFolderUsingPassword($foldername, $password); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the public creation of folder "([^"]*)" in the last public link shared folder using the new public WebDAV API with password "([^"]*)" should fail with HTTP status code "([^"]*)"$/ + * + * @param string $foldername + * @param string $password + * @param string $expectedHttpCode + * + * @return void + */ + public function publicCreationOfFolderWithPasswordShouldFail( + string $foldername, + string $password, + string $expectedHttpCode + ):void { + $this->publicCreatesFolderUsingPassword($foldername, $password); + $this->featureContext->theHTTPStatusCodeShouldBe( + $expectedHttpCode, + "creation of $foldername in the last publicly shared folder should have failed with code " . + $expectedHttpCode + ); + } + + /** + * @Then the mtime of file :fileName in the last shared public link using the WebDAV API should be :mtime + * + * @param string $fileName + * @param string $mtime + * + * @return void + * @throws Exception + */ + public function theMtimeOfFileInTheLastSharedPublicLinkUsingTheWebdavApiShouldBe( + string $fileName, + string $mtime + ):void { + $token = $this->featureContext->getLastPublicShareToken(); + $baseUrl = $this->featureContext->getBaseUrl(); + if (\TestHelpers\OcisHelper::isTestingOnOcisOrReva()) { + $mtime = \explode(" ", $mtime); + \array_pop($mtime); + $mtime = \implode(" ", $mtime); + Assert::assertStringContainsString( + $mtime, + WebDavHelper::getMtimeOfFileinPublicLinkShare( + $baseUrl, + $fileName, + $token, + $this->featureContext->getStepLineRef() + ) + ); + } else { + Assert::assertEquals( + $mtime, + WebDavHelper::getMtimeOfFileinPublicLinkShare( + $baseUrl, + $fileName, + $token, + $this->featureContext->getStepLineRef() + ) + ); + } + } + + /** + * @Then the mtime of file :fileName in the last shared public link using the WebDAV API should not be :mtime + * + * @param string $fileName + * @param string $mtime + * + * @return void + * @throws Exception + */ + public function theMtimeOfFileInTheLastSharedPublicLinkUsingTheWebdavApiShouldNotBe( + string $fileName, + string $mtime + ):void { + $token = $this->featureContext->getLastPublicShareToken(); + $baseUrl = $this->featureContext->getBaseUrl(); + Assert::assertNotEquals( + $mtime, + WebDavHelper::getMtimeOfFileinPublicLinkShare( + $baseUrl, + $fileName, + $token, + $this->featureContext->getStepLineRef() + ) + ); + } + + /** + * Uploads a file through the public WebDAV API and sets the response in FeatureContext + * + * @param string $filename + * @param string|null $password + * @param string $body + * @param bool $autoRename + * @param array $additionalHeaders + * @param string $publicWebDAVAPIVersion + * + * @return void + */ + public function publicUploadContent( + string $filename, + ?string $password = '', + string $body = 'test', + bool $autoRename = false, + array $additionalHeaders = [], + string $publicWebDAVAPIVersion = "old" + ):void { + if (OcisHelper::isTestingOnOcisOrReva() && $publicWebDAVAPIVersion === "old") { + return; + } + $password = $this->featureContext->getActualPassword($password); + $token = $this->featureContext->getLastPublicShareToken(); + $davPath = WebDavHelper::getDavPath( + $token, + 0, + "public-files-$publicWebDAVAPIVersion" + ); + $url = $this->featureContext->getBaseUrl() . "/$davPath"; + $userName = $this->getUsernameForPublicWebdavApi( + $token, + $password, + $publicWebDAVAPIVersion + ); + + $filename = \implode( + '/', + \array_map('rawurlencode', \explode('/', $filename)) + ); + $url .= \ltrim($filename, '/'); + // Trim any "/" from the end. For example, if we are putting content to a + // single file that has been shared with a link, then the URL should end + // with the link token and no "/" at the end. + $url = \rtrim($url, "/"); + $headers = ['X-Requested-With' => 'XMLHttpRequest']; + + if ($autoRename) { + $headers['OC-Autorename'] = 1; + } + $headers = \array_merge($headers, $additionalHeaders); + $response = HttpRequestHelper::put( + $url, + $this->featureContext->getStepLineRef(), + $userName, + $password, + $headers, + $body + ); + $this->featureContext->setResponse($response); + } + + /** + * @param string $token + * @param string $password + * @param string $publicWebDAVAPIVersion + * + * @return string|null + */ + private function getUsernameForPublicWebdavApi( + string $token, + string $password, + string $publicWebDAVAPIVersion + ):?string { + if ($publicWebDAVAPIVersion === "old") { + $userName = $token; + } else { + if ($password !== '') { + $userName = 'public'; + } else { + $userName = null; + } + } + return $userName; + } + + /** + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function setUpScenario(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + $this->occContext = $environment->getContext('OccContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/SearchContext.php b/tests/acceptance/features/bootstrap/SearchContext.php new file mode 100644 index 000000000..173c91758 --- /dev/null +++ b/tests/acceptance/features/bootstrap/SearchContext.php @@ -0,0 +1,192 @@ + + * @copyright Copyright (c) 2018 Artur Neumann artur@jankaritech.com + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; +use TestHelpers\OcisHelper; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * context containing search related API steps + */ +class SearchContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * @When user :user searches for :pattern using the WebDAV API + * @When user :user searches for :pattern and limits the results to :limit items using the WebDAV API + * @When user :user searches for :pattern using the WebDAV API requesting these properties: + * @When user :user searches for :pattern and limits the results to :limit items using the WebDAV API requesting these properties: + * + * @param string $user + * @param string $pattern + * @param string|null $limit + * @param TableNode|null $properties + * + * @return void + */ + public function userSearchesUsingWebDavAPI( + string $user, + string $pattern, + ?string $limit = null, + TableNode $properties = null + ):void { + // Because indexing of newly uploaded files or directories with ocis is decoupled and occurs asynchronously, a short wait is necessary before searching files or folders. + if (OcisHelper::isTestingOnOcis()) { + sleep(4); + } + $user = $this->featureContext->getActualUsername($user); + $baseUrl = $this->featureContext->getBaseUrl(); + $password = $this->featureContext->getPasswordForUser($user); + $body + = "\n" . + " \n" . + " \n" . + " $pattern\n"; + if ($limit !== null) { + $body .= " $limit\n"; + } + + $body .= " \n"; + if ($properties !== null) { + $propertiesRows = $properties->getRows(); + $body .= " "; + foreach ($propertiesRows as $property) { + $body .= "<$property[0]/>"; + } + $body .= " "; + } + $body .= " "; + $response = WebDavHelper::makeDavRequest( + $baseUrl, + $user, + $password, + "REPORT", + "/", + null, + $this->featureContext->getStepLineRef(), + $body, + $this->featureContext->getDavPathVersion() + ); + $this->featureContext->setResponse($response); + } + + /** + * @Then file/folder :path in the search result of user :user should contain these properties: + * + * @param string $path + * @param string $user + * @param TableNode $properties + * + * @return void + * @throws Exception + */ + public function fileOrFolderInTheSearchResultShouldContainProperties( + string $path, + string $user, + TableNode $properties + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->featureContext->verifyTableNodeColumns($properties, ['name', 'value']); + $properties = $properties->getHash(); + $fileResult = $this->featureContext->findEntryFromPropfindResponse( + $path, + $user, + "REPORT", + ); + Assert::assertNotFalse( + $fileResult, + "could not find file/folder '$path'" + ); + $fileProperties = $fileResult['value'][1]['value'][0]['value']; + foreach ($properties as $property) { + $foundProperty = false; + $property['value'] = $this->featureContext->substituteInLineCodes( + $property['value'], + $user + ); + foreach ($fileProperties as $fileProperty) { + if ($fileProperty['name'] === $property['name']) { + Assert::assertMatchesRegularExpression( + "/" . $property['value'] . "/", + $fileProperty['value'] + ); + $foundProperty = true; + break; + } + } + Assert::assertTrue( + $foundProperty, + "could not find property '" . $property['name'] . "'" + ); + } + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } + + /** + * @Then the search result by tags for user :user should contain these entries: + * + * @param string|null $user + * @param TableNode $expectedEntries + * + * @return void + * @throws Exception + */ + public function theSearchResultByTagsForUserShouldContainTheseEntries( + ?string $user, + TableNode $expectedEntries + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->featureContext->verifyTableNodeColumnsCount($expectedEntries, 1); + $expectedEntries = $expectedEntries->getRows(); + $expectedEntriesArray = []; + $responseResourcesArray = $this->featureContext->findEntryFromReportResponse($user); + foreach ($expectedEntries as $item) { + \array_push($expectedEntriesArray, $item[0]); + } + Assert::assertEqualsCanonicalizing($expectedEntriesArray, $responseResourcesArray); + } +} diff --git a/tests/acceptance/features/bootstrap/Sharing.php b/tests/acceptance/features/bootstrap/Sharing.php new file mode 100644 index 000000000..918976782 --- /dev/null +++ b/tests/acceptance/features/bootstrap/Sharing.php @@ -0,0 +1,4391 @@ + + * @author Sergio Bertolin + * @author Phillip Davis + * @copyright Copyright (c) 2018, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Gherkin\Node\PyStringNode; +use Behat\Gherkin\Node\TableNode; +use Psr\Http\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use TestHelpers\OcsApiHelper; +use TestHelpers\OcisHelper; +use TestHelpers\SharingHelper; +use TestHelpers\HttpRequestHelper; +use TestHelpers\SetupHelper; +use TestHelpers\TranslationHelper; + +/** + * Sharing trait + */ +trait Sharing { + /** + * @var int + */ + private $sharingApiVersion = 1; + + /** + * Contains the API response to the last share that was created by each user + * using the Sharing API. Shares created on the webUI do not have an entry. + * + * @var SimpleXMLElement[] + */ + private $lastShareDataByUser = []; + + /** + * Contains the share id of the last share that was created by each user, + * either using the Sharing API or on the web UI. + * + * @var string[] + */ + private $lastShareIdByUser = []; + + /** + * @var string + */ + private $userWhoCreatedLastShare = null; + + /** + * @var string + */ + private $userWhoCreatedLastPublicShare = null; + + /** + * Contains the API response to the last public link share that was created + * by the test-runner using the Sharing API. + * Shares created on the webUI do not have an entry. + * + * @var SimpleXMLElement + */ + private $lastPublicShareData = null; + + /** + * Contains the share id of the last public link share that was created by + * the test-runner, either using the Sharing API or on the web UI. + * + * @var string + */ + private $lastPublicShareId = null; + + /** + * @var int + */ + private $savedShareId = null; + + /** + * @var int + */ + private $localLastShareTime = null; + + /** + * Defines the fields that can be provided in a share request. + * + * @var array + */ + private $shareFields = [ + 'path', 'name', 'publicUpload', 'password', 'expireDate', + 'expireDateAsString', 'permissions', 'shareWith', 'shareType' + ]; + + /** + * Defines the fields that are known and can be tested in a share response. + * Note that ownCloud10 also provides file_parent in responses. + * file_parent is not provided by OCIS/reva. + * There are no known clients that use file_parent. + * The acceptance tests do not test for file_parent. + * + * @var array fields that are possible in a share response + */ + private $shareResponseFields = [ + 'id', 'share_type', 'uid_owner', 'displayname_owner', 'stime', 'parent', + 'expiration', 'token', 'uid_file_owner', 'displayname_file_owner', 'path', + 'item_type', 'mimetype', 'storage_id', 'storage', 'item_source', + 'file_source', 'file_target', 'name', 'url', 'mail_send', + 'attributes', 'permissions', 'share_with', 'share_with_displayname', 'share_with_additional_info' + ]; + + /* + * Contains information about the public links that have been created with the webUI. + * Each entry in the array has a "name", "url" and "path". + */ + private $createdPublicLinks = []; + + /** + * @return array + */ + public function getCreatedPublicLinks():array { + return $this->createdPublicLinks; + } + + /** + * The end (last) entry will itself be an array with keys "name", "url" and "path" + * + * @return array + */ + public function getLastCreatedPublicLink():array { + return \end($this->createdPublicLinks); + } + + /** + * @return string + */ + public function getLastCreatedPublicLinkUrl():string { + $lastCreatedLink = $this->getLastCreatedPublicLink(); + return $lastCreatedLink["url"]; + } + + /** + * @return string + */ + public function getLastCreatedPublicLinkPath():string { + $lastCreatedLink = $this->getLastCreatedPublicLink(); + return $lastCreatedLink["path"]; + } + + /** + * @return string + */ + public function getLastCreatedPublicLinkToken():string { + $lastCreatedLinkUrl = $this->getLastCreatedPublicLinkUrl(); + // The token is the last part of the URL, delimited by "/" + $urlParts = \explode("/", $lastCreatedLinkUrl); + return \end($urlParts); + } + + /** + * @return SimpleXMLElement|null + */ + public function getLastPublicShareData():?SimpleXMLElement { + return $this->lastPublicShareData; + } + + /** + * @param SimpleXMLElement $responseXml + * + * @return void + */ + public function setLastPublicShareData(SimpleXMLElement $responseXml): void { + $this->lastPublicShareData = $responseXml; + } + + /** + * @return SimpleXMLElement + * @throws Exception + */ + public function getLastShareData():SimpleXMLElement { + return $this->getLastShareDataForUser($this->userWhoCreatedLastShare); + } + + /** + * @param string|null $user + * + * @return SimpleXMLElement + * @throws Exception + */ + public function getLastShareDataForUser(?string $user):SimpleXMLElement { + if ($user === null) { + throw new Exception( + __METHOD__ . " user not specified. Probably no user or group shares have been created yet in the test scenario." + ); + } + if (isset($this->lastShareDataByUser[$user])) { + return $this->lastShareDataByUser[$user]; + } else { + throw new Exception(__METHOD__ . " last share data for user '$user' was not found"); + } + } + + /** + * @return int|null + */ + public function getSavedShareId():?int { + return $this->savedShareId; + } + + /** + * @return void + */ + public function resetLastPublicShareData():void { + $this->lastPublicShareData = null; + $this->lastPublicShareId = null; + $this->userWhoCreatedLastPublicShare = null; + } + + /** + * @param string $user + * + * @return void + */ + public function resetLastShareInfoForUser(string $user):void { + if (isset($this->lastShareDataByUser[$user])) { + unset($this->lastShareDataByUser[$user]); + } + if (isset($this->lastShareIdByUser[$user])) { + unset($this->lastShareIdByUser[$user]); + } + } + + /** + * @return int + */ + public function getLocalLastShareTime():int { + return $this->localLastShareTime; + } + + /** + * @return int + */ + public function getServerLastShareTime():int { + return (int) $this->getLastShareData()->data->stime; + } + + /** + * @param string|null $postfix string to append to the end of the path + * + * @return string + */ + public function getSharesEndpointPath(?string $postfix = ''):string { + return "/apps/files_sharing/api/v{$this->sharingApiVersion}/shares$postfix"; + } + + /** + * Split given permissions string each separated with "," into array of strings + * + * @param string $str + * + * @return string[] + */ + private function splitPermissionsString(string $str):array { + $str = \trim($str); + $permissions = \array_map('trim', \explode(',', $str)); + + /* We use 'all', 'uploadwriteonly' and 'change' in feature files + for readability. Parse into appropriate permissions and return them + without any duplications.*/ + if (\in_array('all', $permissions, true)) { + $permissions = \array_keys(SharingHelper::PERMISSION_TYPES); + } + if (\in_array('uploadwriteonly', $permissions, true)) { + // remove 'uploadwriteonly' from $permissions + $permissions = \array_diff($permissions, ['uploadwriteonly']); + $permissions = \array_merge($permissions, ['create']); + } + if (\in_array('change', $permissions, true)) { + // remove 'change' from $permissions + $permissions = \array_diff($permissions, ['change']); + $permissions = \array_merge( + $permissions, + ['create', 'delete', 'read', 'update'] + ); + } + + return \array_unique($permissions); + } + + /** + * + * @return int + * + * @throws Exception + */ + public function getServerShareTimeFromLastResponse():int { + $stime = $this->getResponseXml(null, __METHOD__)->xpath("//stime"); + if ((bool) $stime) { + return (int) $stime[0]; + } + throw new Exception("Last share time (i.e. 'stime') could not be found in the response."); + } + + /** + * @return void + */ + private function waitToCreateShare():void { + if (($this->localLastShareTime !== null) + && ((\microtime(true) - $this->localLastShareTime) < 1) + ) { + // prevent creating two shares with the same "stime" which is + // based on seconds, this affects share merging order and could + // affect expected test result order + \sleep(1); + } + } + + /** + * @param string $user + * @param TableNode|null $body + * TableNode $body should not have any heading and can have following rows | + * | path | The folder or file path to be shared | + * | name | A (human-readable) name for the share, | + * | | which can be up to 64 characters in length. | + * | publicUpload | Whether to allow public upload to a public | + * | | shared folder. Write true for allowing. | + * | password | The password to protect the public link share with. | + * | expireDate | An expire date for public link shares. | + * | | This argument takes a date string in any format | + * | | that can be passed to strtotime(), for example: | + * | | 'YYYY-MM-DD' or '+ x days'. It will be converted to | + * | | 'YYYY-MM-DD' format before sending | + * | expireDateAsString | An expire date string for public link shares. | + * | | Whatever string is provided will be sent as the | + * | | expire date. For example, use this to test sending | + * | | invalid date strings. | + * | permissions | The permissions to set on the share. | + * | | 1 = read; 2 = update; 4 = create; | + * | | 8 = delete; 16 = share; 31 = all | + * | | 15 = change | + * | | 4 = uploadwriteonly | + * | | (default: 31, for public shares: 1) | + * | | Pass either the (total) number, | + * | | or the keyword, | + * | | or an comma separated list of keywords | + * | shareWith | The user or group id with which the file should | + * | | be shared. | + * | shareType | The type of the share. This can be one of: | + * | | 0 = user, 1 = group, 3 = public_link, | + * | | 6 = federated (cloud share). | + * | | Pass either the number or the keyword. | + * + * @return void + * @throws Exception + */ + public function createShareWithSettings(string $user, ?TableNode $body):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeRows( + $body, + ['path'], + $this->shareFields + ); + $bodyRows = $body->getRowsHash(); + $bodyRows['name'] = \array_key_exists('name', $bodyRows) ? $bodyRows['name'] : null; + $bodyRows['shareWith'] = \array_key_exists('shareWith', $bodyRows) ? $bodyRows['shareWith'] : null; + $bodyRows['shareWith'] = $this->getActualUsername($bodyRows['shareWith']); + $bodyRows['publicUpload'] = \array_key_exists('publicUpload', $bodyRows) ? $bodyRows['publicUpload'] === 'true' : null; + $bodyRows['password'] = \array_key_exists('password', $bodyRows) ? $this->getActualPassword($bodyRows['password']) : null; + + if (\array_key_exists('permissions', $bodyRows)) { + if (\is_numeric($bodyRows['permissions'])) { + $bodyRows['permissions'] = (int) $bodyRows['permissions']; + } else { + $bodyRows['permissions'] = $this->splitPermissionsString($bodyRows['permissions']); + } + } else { + $bodyRows['permissions'] = null; + } + if (\array_key_exists('shareType', $bodyRows)) { + if (\is_numeric($bodyRows['shareType'])) { + $bodyRows['shareType'] = (int) $bodyRows['shareType']; + } + } else { + $bodyRows['shareType'] = null; + } + + Assert::assertFalse( + isset($bodyRows['expireDate'], $bodyRows['expireDateAsString']), + 'expireDate and expireDateAsString cannot be set at the same time.' + ); + $needToParse = \array_key_exists('expireDate', $bodyRows); + $expireDate = $bodyRows['expireDate'] ?? $bodyRows['expireDateAsString'] ?? null; + $bodyRows['expireDate'] = $needToParse ? \date('Y-m-d', \strtotime($expireDate)) : $expireDate; + $this->createShare( + $user, + $bodyRows['path'], + $bodyRows['shareType'], + $bodyRows['shareWith'], + $bodyRows['publicUpload'], + $bodyRows['password'], + $bodyRows['permissions'], + $bodyRows['name'], + $bodyRows['expireDate'] + ); + } + + /** + * @Given auto-accept shares has been disabled + * + * @return void + */ + public function autoAcceptSharesHasBeenDisabled():void { + if (OcisHelper::isTestingOnOcisOrReva()) { + // auto-accept shares is disabled by default on OCIS. + // so there is nothing to do, just return + return; + } + + $this->appConfigurationContext->serverParameterHasBeenSetTo( + "shareapi_auto_accept_share", + "core", + "no" + ); + } + + /** + * @When /^user "([^"]*)" creates a share using the sharing API with settings$/ + * + * @param string $user + * @param TableNode|null $body {@link createShareWithSettings} + * + * @return void + * @throws Exception + */ + public function userCreatesAShareWithSettings(string $user, ?TableNode $body):void { + $user = $this->getActualUsername($user); + $this->createShareWithSettings( + $user, + $body + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has created a share with settings$/ + * + * @param string $user + * @param TableNode|null $body {@link createShareWithSettings} + * + * @return void + * @throws Exception + */ + public function userHasCreatedAShareWithSettings(string $user, ?TableNode $body) { + $this->createShareWithSettings( + $user, + $body + ); + $this->theHTTPStatusCodeShouldBe( + 200, + "Failed HTTP status code for last share for user $user" . ", Reason: " . $this->getResponse()->getReasonPhrase() + ); + } + + /** + * @When /^the user creates a share using the sharing API with settings$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theUserCreatesAShareWithSettings(?TableNode $body):void { + $this->createShareWithSettings($this->currentUser, $body); + } + + /** + * @When /^user "([^"]*)" creates a public link share using the sharing API with settings$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userCreatesAPublicLinkShareWithSettings(string $user, ?TableNode $body):void { + $rows = $body->getRows(); + // A public link share is shareType 3 + $rows[] = ['shareType', 'public_link']; + $newBody = new TableNode($rows); + $this->createShareWithSettings($user, $newBody); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has created a public link share with settings$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userHasCreatedAPublicLinkShareWithSettings(string $user, ?TableNode $body):void { + $this->userCreatesAPublicLinkShareWithSettings($user, $body); + $this->ocsContext->theOCSStatusCodeShouldBe("100,200"); + $this->theHTTPStatusCodeShouldBe(200); + $this->clearStatusCodeArrays(); + } + + /** + * @When /^the user creates a public link share using the sharing API with settings$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theUserCreatesAPublicLinkShareWithSettings(?TableNode $body):void { + $this->userCreatesAPublicLinkShareWithSettings($this->currentUser, $body); + } + + /** + * @Given /^the user has created a share with settings$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theUserHasCreatedAShareWithSettings(?TableNode $body):void { + $this->createShareWithSettings($this->currentUser, $body); + $this->ocsContext->theOCSStatusCodeShouldBe("100,200"); + $this->theHTTPStatusCodeShouldBe(200); + } + + /** + * @Given /^the user has created a public link share with settings$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theUserHasCreatedAPublicLinkShareWithSettings(?TableNode $body):void { + $this->theUserCreatesAPublicLinkShareWithSettings($body); + $this->ocsContext->theOCSStatusCodeShouldBe("100,200"); + $this->theHTTPStatusCodeShouldBe(200); + } + + /** + * @param string $user + * @param string $path + * @param boolean $publicUpload + * @param string|null $sharePassword + * @param string|int|string[]|int[]|null $permissions + * @param string|null $linkName + * @param string|null $expireDate + * + * @return void + */ + public function createAPublicShare( + string $user, + string $path, + bool $publicUpload = false, + string $sharePassword = null, + $permissions = null, + ?string $linkName = null, + ?string $expireDate = null + ):void { + $user = $this->getActualUsername($user); + $this->createShare( + $user, + $path, + 'public_link', + null, // shareWith + $publicUpload, + $sharePassword, + $permissions, + $linkName, + $expireDate + ); + } + + /** + * @When /^user "([^"]*)" creates a public link share of (?:file|folder) "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userCreatesAPublicLinkShareOf(string $user, string $path):void { + $this->createAPublicShare($user, $path); + } + + /** + * @Given /^user "([^"]*)" has created a public link share of (?:file|folder) "([^"]*)"$/ + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userHasCreatedAPublicLinkShareOf(string $user, string $path):void { + $this->createAPublicShare($user, $path); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $path + * + * @return void + */ + public function createPublicLinkShareOfResourceAsCurrentUser(string $path):void { + $this->createAPublicShare($this->currentUser, $path); + } + + /** + * @When /^the user creates a public link share of (?:file|folder) "([^"]*)" using the sharing API$/ + * + * @param string $path + * + * @return void + */ + public function aPublicLinkShareOfIsCreated(string $path):void { + $this->createPublicLinkShareOfResourceAsCurrentUser($path); + } + + /** + * @Given /^the user has created a public link share of (?:file|folder) "([^"]*)"$/ + * + * @param string $path + * + * @return void + */ + public function aPublicLinkShareOfHasCreated(string $path):void { + $this->createPublicLinkShareOfResourceAsCurrentUser($path); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $user + * @param string $path + * @param string|int|string[]|int[]|null $permissions + * + * @return void + */ + public function createPublicLinkShareOfResourceWithPermission( + string $user, + string $path, + $permissions + ):void { + $this->createAPublicShare($user, $path, true, null, $permissions); + } + + /** + * @When /^user "([^"]*)" creates a public link share of (?:file|folder) "([^"]*)" using the sharing API with (read|update|create|delete|change|uploadwriteonly|share|all) permission(?:s|)$/ + * + * @param string $user + * @param string $path + * @param string|int|string[]|int[]|null $permissions + * + * @return void + */ + public function userCreatesAPublicLinkShareOfWithPermission( + string $user, + string $path, + $permissions + ):void { + $this->createPublicLinkShareOfResourceWithPermission( + $user, + $path, + $permissions + ); + } + + /** + * @Given /^user "([^"]*)" has created a public link share of (?:file|folder) "([^"]*)" with (read|update|create|delete|change|uploadwriteonly|share|all) permission(?:s|)$/ + * + * @param string $user + * @param string $path + * @param string|int|string[]|int[]|null $permissions + * + * @return void + */ + public function userHasCreatedAPublicLinkShareOfWithPermission( + string $user, + string $path, + $permissions + ):void { + $this->createPublicLinkShareOfResourceWithPermission( + $user, + $path, + $permissions + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $path + * @param string|int|string[]|int[]|null $permissions + * + * @return void + */ + public function createPublicLinkShareWithPermissionByCurrentUser(string $path, $permissions):void { + $this->createAPublicShare( + $this->currentUser, + $path, + true, + null, + $permissions + ); + } + + /** + * @When /^the user creates a public link share of (?:file|folder) "([^"]*)" using the sharing API with (read|update|create|delete|change|uploadwriteonly|share|all) permission(?:s|)$/ + * + * @param string $path + * @param string|int|string[]|int[]|null $permissions + * + * @return void + */ + public function aPublicLinkShareOfIsCreatedWithPermission(string $path, $permissions):void { + $this->createPublicLinkShareWithPermissionByCurrentUser($path, $permissions); + } + + /** + * @Given /^the user has created a public link share of (?:file|folder) "([^"]*)" with (read|update|create|delete|change|uploadwriteonly|share|all) permission(?:s|)$/ + * + * @param string $path + * @param string|int|string[]|int[]|null $permissions + * + * @return void + */ + public function aPublicLinkShareOfHasCreatedWithPermission(string $path, $permissions):void { + $this->createPublicLinkShareWithPermissionByCurrentUser($path, $permissions); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $user + * @param string $path + * @param string $expiryDate in a valid date format, e.g. "+30 days" + * + * @return void + */ + public function createPublicLinkShareOfResourceWithExpiry( + string $user, + string $path, + string $expiryDate + ):void { + $this->createAPublicShare( + $user, + $path, + true, + null, + null, + null, + $expiryDate + ); + } + + /** + * @When /^user "([^"]*)" creates a public link share of (?:file|folder) "([^"]*)" using the sharing API with expiry "([^"]*)"$/ + * + * @param string $user + * @param string $path + * @param string $expiryDate in a valid date format, e.g. "+30 days" + * + * @return void + */ + public function userCreatesAPublicLinkShareOfWithExpiry( + string $user, + string $path, + string $expiryDate + ):void { + $this->createPublicLinkShareOfResourceWithExpiry( + $user, + $path, + $expiryDate + ); + } + + /** + * @Given /^user "([^"]*)" has created a public link share of (?:file|folder) "([^"]*)" with expiry "([^"]*)"$/ + * + * @param string $user + * @param string $path + * @param string $expiryDate in a valid date format, e.g. "+30 days" + * + * @return void + */ + public function userHasCreatedAPublicLinkShareOfWithExpiry( + string $user, + string $path, + string $expiryDate + ):void { + $this->createPublicLinkShareOfResourceWithExpiry( + $user, + $path, + $expiryDate + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $path + * @param string $expiryDate in a valid date format, e.g. "+30 days" + * + * @return void + */ + public function createPublicLinkShareOfResourceWithExpiryByCurrentUser( + string $path, + string $expiryDate + ):void { + $this->createAPublicShare( + $this->currentUser, + $path, + true, + null, + null, + null, + $expiryDate + ); + } + + /** + * @When /^the user creates a public link share of (?:file|folder) "([^"]*)" using the sharing API with expiry "([^"]*)$"/ + * + * @param string $path + * @param string $expiryDate in a valid date format, e.g. "+30 days" + * + * @return void + */ + public function aPublicLinkShareOfIsCreatedWithExpiry( + string $path, + string $expiryDate + ):void { + $this->createPublicLinkShareOfResourceWithExpiryByCurrentUser( + $path, + $expiryDate + ); + } + + /** + * @Given /^the user has created a public link share of (?:file|folder) "([^"]*)" with expiry "([^"]*)$/ + * + * @param string $path + * @param string $expiryDate in a valid date format, e.g. "+30 days" + * + * @return void + */ + public function aPublicLinkShareOfHasCreatedWithExpiry( + string $path, + string $expiryDate + ):void { + $this->createPublicLinkShareOfResourceWithExpiryByCurrentUser( + $path, + $expiryDate + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * Give the mimetype of the last shared file + * + * @return string + */ + public function getMimeTypeOfLastSharedFile():string { + return \json_decode(\json_encode($this->getLastShareData()->data->mimetype), true)[0]; + } + + /** + * @param string $url + * @param string|null $user + * @param string|null $password + * @param string|null $mimeType + * + * @return void + */ + private function checkDownload( + string $url, + ?string $user = null, + ?string $password = null, + ?string $mimeType = null + ) { + $password = $this->getActualPassword($password); + $headers = ['X-Requested-With' => 'XMLHttpRequest']; + $this->response = HttpRequestHelper::get( + $url, + $this->getStepLineRef(), + $user, + $password, + $headers + ); + Assert::assertEquals( + 200, + $this->response->getStatusCode(), + __METHOD__ + . " Expected status code is '200' but got '" + . $this->response->getStatusCode() + . "'" + ); + + $buf = ''; + $body = $this->response->getBody(); + while (!$body->eof()) { + // read everything + $buf .= $body->read(8192); + } + $body->close(); + + if ($mimeType !== null) { + $finfo = new finfo; + Assert::assertEquals( + $mimeType, + $finfo->buffer($buf, FILEINFO_MIME_TYPE), + __METHOD__ + . " Expected mimeType '$mimeType' but got '" + . $finfo->buffer($buf, FILEINFO_MIME_TYPE) + ); + } + } + + /** + * @Then /^user "([^"]*)" should not be able to create a public link share of (file|folder) "([^"]*)" using the sharing API$/ + * + * @param string $sharer + * @param string $entry + * @param string $filepath + * + * @return void + * @throws Exception + */ + public function shouldNotBeAbleToCreatePublicLinkShare(string $sharer, string $entry, string $filepath):void { + $this->asFileOrFolderShouldExist( + $this->getActualUsername($sharer), + $entry, + $filepath + ); + $this->createAPublicShare($sharer, $filepath); + Assert::assertEquals( + 404, + $this->ocsContext->getOCSResponseStatusCode($this->response), + __METHOD__ + . " Expected response status code is '404' but got '" + . $this->ocsContext->getOCSResponseStatusCode($this->response) + . "'" + ); + } + + /** + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function updateLastShareByCurrentUser(?TableNode $body):void { + $this->updateLastShareWithSettings($this->currentUser, $body); + } + + /** + * @When /^the user updates the last share using the sharing API with$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theUserUpdatesTheLastShareWith(?TableNode $body):void { + $this->updateLastShareByCurrentUser($body); + } + + /** + * @Given /^the user has updated the last share with$/ + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theUserHasUpdatedTheLastShareWith(?TableNode $body):void { + $this->updateLastShareByCurrentUser($body); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $user + * @param TableNode|null $body + * @param string|null $shareOwner + * @param bool $updateLastPublicLink + * + * @return void + * @throws Exception + */ + public function updateLastShareWithSettings( + string $user, + ?TableNode $body, + ?string $shareOwner = null, + ?bool $updateLastPublicLink = false + ):void { + $user = $this->getActualUsername($user); + + if ($updateLastPublicLink) { + $share_id = $this->getLastPublicLinkShareId(); + } else { + if ($shareOwner === null) { + $share_id = $this->getLastShareId(); + } else { + $share_id = $this->getLastShareIdForUser($shareOwner); + } + } + + $this->verifyTableNodeRows( + $body, + [], + $this->shareFields + ); + $bodyRows = $body->getRowsHash(); + if (\array_key_exists('expireDate', $bodyRows)) { + $dateModification = $bodyRows['expireDate']; + if (!empty($bodyRows['expireDate'])) { + $bodyRows['expireDate'] = \date('Y-m-d', \strtotime($dateModification)); + } else { + $bodyRows['expireDate'] = ''; + } + } + if (\array_key_exists('password', $bodyRows)) { + $bodyRows['password'] = $this->getActualPassword($bodyRows['password']); + } + if (\array_key_exists('permissions', $bodyRows)) { + if (\is_numeric($bodyRows['permissions'])) { + $bodyRows['permissions'] = (int) $bodyRows['permissions']; + } else { + $bodyRows['permissions'] = $this->splitPermissionsString($bodyRows['permissions']); + $bodyRows['permissions'] = SharingHelper::getPermissionSum($bodyRows['permissions']); + } + } + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "PUT", + $this->getSharesEndpointPath("/$share_id"), + $this->getStepLineRef(), + $bodyRows, + $this->ocsApiVersion + ); + } + + /** + * @When /^user "([^"]*)" updates the last share using the sharing API with$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userUpdatesTheLastShareWith(string $user, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" updates the last public link share using the sharing API with$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userUpdatesTheLastPublicLinkShareWith(string $user, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body, null, true); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has updated the last share with$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userHasUpdatedTheLastShareWith(string $user, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Given /^user "([^"]*)" has updated the last public link share with$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userHasUpdatedTheLastPublicLinkShareWith(string $user, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body, null, true); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Given /^user "([^"]*)" has updated the last share of "([^"]*)" with$/ + * + * @param string $user + * @param string $shareOwner + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userHasUpdatedTheLastShareOfWith(string $user, string $shareOwner, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body, $shareOwner); + $this->theHTTPStatusCodeShouldBeSuccess(); + if ($this->ocsApiVersion == 1) { + $this->ocsContext->theOCSStatusCodeShouldBe("100"); + } elseif ($this->ocsApiVersion === 2) { + $this->ocsContext->theOCSStatusCodeShouldBe("200"); + } else { + throw new Exception('Invalid ocs api version used'); + } + } + + /** + * @param string $name + * @param string $url + * @param string $path + * + * @return void + */ + public function addToListOfCreatedPublicLinks(string $name, string $url, string $path = ""):void { + $this->createdPublicLinks[] = ["name" => $name, "url" => $url, "path" => $path]; + } + + /** + * @param string $user + * @param string|null $path + * @param string|null $shareType + * @param string|null $shareWith + * @param bool|null $publicUpload + * @param string|null $sharePassword + * @param string|int|string[]|int[]|null $permissions + * @param string|null $linkName + * @param string|null $expireDate + * @param string $sharingApp + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function createShare( + string $user, + ?string $path = null, + ?string $shareType = null, + ?string $shareWith = null, + ?bool $publicUpload = null, + ?string $sharePassword = null, + $permissions = null, + ?string $linkName = null, + ?string $expireDate = null, + string $sharingApp = 'files_sharing' + ):void { + $userActual = $this->getActualUsername($user); + if (\is_string($permissions) && !\is_numeric($permissions)) { + $permissions = $this->splitPermissionsString($permissions); + } + $this->waitToCreateShare(); + $this->response = SharingHelper::createShare( + $this->getBaseUrl(), + $userActual, + $this->getPasswordForUser($user), + $path, + $shareType, + $this->getStepLineRef(), + $shareWith, + $publicUpload, + $sharePassword, + $permissions, + $linkName, + $expireDate, + $this->ocsApiVersion, + $this->sharingApiVersion, + $sharingApp + ); + $httpStatusCode = $this->response->getStatusCode(); + // In case of HTTP status code 204 "no content", or a failure code like 4xx + // in the HTTP or OCS status there is no useful content in response payload body. + // Clear the test-runner's memory of "last share data" to avoid later steps + // accidentally using some previous share data. + if (($httpStatusCode === 204) + || !$this->theHTTPStatusCodeWasSuccess() + || (($httpStatusCode === 200) && ($this->ocsContext->getOCSResponseStatusCode($this->response) > 299)) + ) { + if ($shareType === 'public_link') { + $this->resetLastPublicShareData(); + } else { + $this->resetLastShareInfoForUser($user); + } + } else { + if ($shareType === 'public_link') { + $this->setLastPublicShareData($this->getResponseXml(null, __METHOD__)); + $this->setLastPublicLinkShareId((string) $this->lastPublicShareData->data[0]->id); + $this->setUserWhoCreatedLastPublicShare($user); + if (isset($this->lastPublicShareData->data)) { + $linkName = (string) $this->lastPublicShareData->data[0]->name; + $linkUrl = (string) $this->lastPublicShareData->data[0]->url; + $this->addToListOfCreatedPublicLinks($linkName, $linkUrl, $path); + } + } else { + $shareData = $this->getResponseXml(null, __METHOD__); + $this->lastShareDataByUser[$user] = $shareData; + $shareId = (string) $shareData->data[0]->id; + $this->setLastShareIdOf($user, $shareId); + } + } + $this->localLastShareTime = \microtime(true); + } + + /** + * @param string $field + * @param string $value + * @param string $contentExpected + * @param bool $expectSuccess if true then the caller expects that the field + * has the expected content + * emit debugging information if the field is not as expected + * + * @return bool + */ + public function doesFieldValueMatchExpectedContent( + string $field, + string $value, + string $contentExpected, + bool $expectSuccess = true + ):bool { + if (($contentExpected === "ANY_VALUE") + || (($contentExpected === "A_TOKEN") && (\strlen($value) === 15)) + || (($contentExpected === "A_NUMBER") && \is_numeric($value)) + || (($contentExpected === "A_STRING") && \is_string($value) && $value !== "") + || (($contentExpected === "AN_URL") && $this->isAPublicLinkUrl($value)) + || (($field === 'remote') && (\rtrim($value, "/") === $contentExpected)) + || ($contentExpected === $value) + ) { + if (!$expectSuccess) { + echo $field . " is unexpectedly set with value '" . $value . "'\n"; + } + return true; + } + return false; + } + + /** + * @param string $field + * @param string|null $contentExpected + * @param bool $expectSuccess if true then the caller expects that the field + * is in the response with the expected content + * so emit debugging information if the field is not correct + * @param SimpleXMLElement|null $data + * + * @return bool + * @throws Exception + */ + public function isFieldInResponse(string $field, ?string $contentExpected, bool $expectSuccess = true, ?SimpleXMLElement $data = null):bool { + if ($data === null) { + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + } + Assert::assertIsObject($data, __METHOD__ . " data not found in response XML"); + + $dateFieldsArrayToConvert = ['expiration', 'original_date', 'new_date']; + //do not try to convert empty date + if ((string) \in_array($field, \array_merge($dateFieldsArrayToConvert)) && !empty($contentExpected)) { + $timestamp = \strtotime($contentExpected, $this->getServerShareTimeFromLastResponse()); + // strtotime returns false if it failed to parse, just leave it as it is in that condition + if ($timestamp !== false) { + $contentExpected + = \date( + 'Y-m-d', + $timestamp + ) . " 00:00:00"; + } + } + $contentExpected = (string) $contentExpected; + + if (\count($data->element) > 0) { + $fieldIsSet = false; + $value = ""; + foreach ($data as $element) { + if (isset($element->$field)) { + $fieldIsSet = true; + $value = (string) $element->$field; + if ($this->doesFieldValueMatchExpectedContent( + $field, + $value, + $contentExpected, + $expectSuccess + ) + ) { + return true; + } + } + } + } else { + $fieldIsSet = isset($data->$field); + if ($fieldIsSet) { + $value = (string) $data->$field; + if ($this->doesFieldValueMatchExpectedContent( + $field, + $value, + $contentExpected, + $expectSuccess + ) + ) { + return true; + } + } + } + if ($expectSuccess) { + if ($fieldIsSet) { + echo $field . " has unexpected value '" . $value . "'\n"; + } else { + echo $field . " is not set in response\n"; + } + } + return false; + } + + /** + * @Then no files or folders should be included in the response + * + * @return void + */ + public function checkNoFilesFoldersInResponse():void { + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + Assert::assertIsObject($data, __METHOD__ . " data not found in response XML"); + Assert::assertCount(0, $data); + } + + /** + * @Then exactly :count file/files or folder/folders should be included in the response + * + * @param string $count + * + * @return void + */ + public function checkCountFilesFoldersInResponse(string $count):void { + $count = (int) $count; + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + Assert::assertIsObject($data, __METHOD__ . " data not found in response XML"); + Assert::assertCount($count, $data, __METHOD__ . " the response does not contain $count entries"); + } + + /** + * @Then /^(?:file|folder|entry) "([^"]*)" should be included in the response$/ + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function checkSharedFileInResponse(string $filename):void { + $filename = "/" . \ltrim($filename, '/'); + Assert::assertTrue( + $this->isFieldInResponse('file_target', "$filename"), + "'file_target' value '$filename' was not found in response" + ); + } + + /** + * @Then /^(?:file|folder|entry) "([^"]*)" should not be included in the response$/ + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function checkSharedFileNotInResponse(string $filename):void { + $filename = "/" . \ltrim($filename, '/'); + Assert::assertFalse( + $this->isFieldInResponse('file_target', "$filename", false), + "'file_target' value '$filename' was unexpectedly found in response" + ); + } + + /** + * @Then /^(?:file|folder|entry) "([^"]*)" should be included as path in the response$/ + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function checkSharedFileAsPathInResponse(string $filename):void { + $filename = "/" . \ltrim($filename, '/'); + Assert::assertTrue( + $this->isFieldInResponse('path', "$filename"), + "'path' value '$filename' was not found in response" + ); + } + + /** + * @Then /^(?:file|folder|entry) "([^"]*)" should not be included as path in the response$/ + * + * @param string $filename + * + * @return void + * @throws Exception + */ + public function checkSharedFileAsPathNotInResponse(string $filename):void { + $filename = "/" . \ltrim($filename, '/'); + Assert::assertFalse( + $this->isFieldInResponse('path', "$filename", false), + "'path' value '$filename' was unexpectedly found in response" + ); + } + + /** + * @Then /^(user|group) "([^"]*)" should be included in the response$/ + * + * @param string $type + * @param string $user + * + * @return void + * @throws Exception + */ + public function checkSharedUserOrGroupInResponse(string $type, string $user):void { + if ($type === 'user') { + $user = $this->getActualUsername($user); + } + Assert::assertTrue( + $this->isFieldInResponse('share_with', "$user"), + "'share_with' value '$user' was not found in response" + ); + } + + /** + * @Then /^user "([^"]*)" should not be included in the response$/ + * @Then /^group "([^"]*)" should not be included in the response$/ + * + * @param string $userOrGroup + * + * @return void + * @throws Exception + */ + public function checkSharedUserOrGroupNotInResponse(string $userOrGroup):void { + Assert::assertFalse( + $this->isFieldInResponse('share_with', "$userOrGroup", false), + "'share_with' value '$userOrGroup' was unexpectedly found in response" + ); + } + + /** + * @param string $userOrGroupId + * @param string|int $shareType 0 or "user" for user, 1 or "group" for group + * @param string|int|string[]|int[]|null $permissions + * + * @return bool + */ + public function isUserOrGroupInSharedData(string $userOrGroupId, $shareType, $permissions = null):bool { + $shareType = SharingHelper::getShareType($shareType); + + if ($permissions !== null) { + if (\is_string($permissions) && !\is_numeric($permissions)) { + $permissions = $this->splitPermissionsString($permissions); + } + $permissionSum = SharingHelper::getPermissionSum($permissions); + } + + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + if (\is_iterable($data)) { + foreach ($data as $element) { + if (($element->share_type->__toString() === (string) $shareType) + && ($element->share_with->__toString() === $userOrGroupId) + && ($permissions === null || $permissionSum === (int) $element->permissions->__toString()) + ) { + return true; + } + } + return false; + } + \error_log( + "INFORMATION: isUserOrGroupInSharedData response XML data is " . + \gettype($data) . + " and therefore does not contain share_with information." + ); + return false; + } + + /** + * + * @param string $user1 + * @param string $filepath + * @param string $user2 + * @param string|int|string[]|int[] $permissions + * @param bool|null $getShareData If true then only create the share if it is not + * already existing, and at the end request the + * share information and leave that in $this->response + * Typically used in a "Given" step which verifies + * that the share did get created successfully. + * + * @return void + */ + public function shareFileWithUserUsingTheSharingApi( + string $user1, + string $filepath, + string $user2, + $permissions = null, + ?bool $getShareData = false + ):void { + $user1Actual = $this->getActualUsername($user1); + $user2Actual = $this->getActualUsername($user2); + + $path = $this->getSharesEndpointPath("?path=" . \urlencode($filepath)); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user1Actual, + $this->getPasswordForUser($user1), + "GET", + $path, + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + if ($getShareData && $this->isUserOrGroupInSharedData($user2Actual, "user", $permissions)) { + return; + } else { + $this->createShare( + $user1, + $filepath, + '0', + $user2Actual, + null, + null, + $permissions + ); + } + if ($getShareData) { + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user1Actual, + $this->getPasswordForUser($user1), + "GET", + $path, + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + } + + /** + * @When /^user "([^"]*)" shares (?:file|folder|entry) "([^"]*)" with user "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @When /^user "([^"]*)" shares (?:file|folder|entry) "([^"]*)" with user "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $user1 + * @param string $filepath + * @param string $user2 + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function userSharesFileWithUserUsingTheSharingApi( + string $user1, + string $filepath, + string $user2, + $permissions = null + ):void { + $this->shareFileWithUserUsingTheSharingApi( + $user1, + $filepath, + $user2, + $permissions + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" shares the following (?:files|folders|entries) with user "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @When /^user "([^"]*)" shares the following (?:files|folders|entries) with user "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $sharer + * @param string $sharee + * @param TableNode $table + * @param string|int|string[]|int[] $permissions + * + * @return void + * @throws Exception + */ + public function userSharesTheFollowingFilesWithUserUsingTheSharingApi( + string $sharer, + string $sharee, + TableNode $table, + $permissions = null + ):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $filepath) { + $this->userSharesFileWithUserUsingTheSharingApi( + $sharer, + $filepath["path"], + $sharee, + $permissions + ); + } + } + + /** + * @Given /^user "([^"]*)" has shared (?:file|folder|entry) "([^"]*)" with user "([^"]*)"(?: with permissions (\d+))?$/ + * @Given /^user "([^"]*)" has shared (?:file|folder|entry) "([^"]*)" with user "([^"]*)" with permissions "([^"]*)"$/ + * + * @param string $user1 + * @param string $filepath + * @param string $user2 + * @param string|int|string[]|int[] $permissions + * + * @return void + * @throws Exception + */ + public function userHasSharedFileWithUserUsingTheSharingApi( + string $user1, + string $filepath, + string $user2, + $permissions = null + ):void { + $user1 = $this->getActualUsername($user1); + $user2 = $this->getActualUsername($user2); + $this->shareFileWithUserUsingTheSharingApi( + $user1, + $filepath, + $user2, + $permissions, + true + ); + $this->ocsContext->assertOCSResponseIndicatesSuccess( + 'The ocs share response does not indicate success.', + ); + // this is expected to fail if a file is shared with create and delete permissions, which is not possible + Assert::assertTrue( + $this->isUserOrGroupInSharedData($user2, "user", $permissions), + __METHOD__ . " User $user1 failed to share $filepath with user $user2" + ); + } + + /** + * @Given /^user "([^"]*)" has shared (?:file|folder|entry) "([^"]*)" with the administrator(?: with permissions (\d+))?$/ + * @Given /^user "([^"]*)" has shared (?:file|folder|entry) "([^"]*)" with the administrator with permissions "([^"]*)"$/ + * + * @param string $sharer + * @param string $filepath + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function userHasSharedFileWithTheAdministrator( + string $sharer, + string $filepath, + $permissions = null + ):void { + $admin = $this->getAdminUsername(); + $this->userHasSharedFileWithUserUsingTheSharingApi( + $sharer, + $filepath, + $admin, + $permissions + ); + } + + /** + * @When /^the user shares (?:file|folder|entry) "([^"]*)" with user "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @When /^the user shares (?:file|folder|entry) "([^"]*)" with user "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $filepath + * @param string $user2 + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function theUserSharesFileWithUserUsingTheSharingApi( + string $filepath, + string $user2, + $permissions = null + ) { + $this->userSharesFileWithUserUsingTheSharingApi( + $this->getCurrentUser(), + $filepath, + $user2, + $permissions + ); + } + + /** + * @Given /^the user has shared (?:file|folder|entry) "([^"]*)" with user "([^"]*)"(?: with permissions (\d+))?$/ + * @Given /^the user has shared (?:file|folder|entry) "([^"]*)" with user "([^"]*)" with permissions "([^"]*)"$/ + * + * @param string $filepath + * @param string $user2 + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function theUserHasSharedFileWithUserUsingTheSharingApi( + string $filepath, + string $user2, + $permissions = null + ):void { + $user2 = $this->getActualUsername($user2); + $this->userHasSharedFileWithUserUsingTheSharingApi( + $this->getCurrentUser(), + $filepath, + $user2, + $permissions + ); + } + + /** + * @When /^the user shares (?:file|folder|entry) "([^"]*)" with group "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @When /^the user shares (?:file|folder|entry) "([^"]*)" with group "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $filepath + * @param string $group + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function theUserSharesFileWithGroupUsingTheSharingApi( + string $filepath, + string $group, + $permissions = null + ):void { + $this->userSharesFileWithGroupUsingTheSharingApi( + $this->currentUser, + $filepath, + $group, + $permissions + ); + } + + /** + * @Given /^the user has shared (?:file|folder|entry) "([^"]*)" with group "([^"]*)"(?: with permissions (\d+))?$/ + * @Given /^the user has shared (?:file|folder|entry) "([^"]*)" with group "([^"]*)" with permissions "([^"]*)"$/ + * + * @param string $filepath + * @param string $group + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function theUserHasSharedFileWithGroupUsingTheSharingApi( + string $filepath, + string $group, + $permissions = null + ):void { + $this->userHasSharedFileWithGroupUsingTheSharingApi( + $this->currentUser, + $filepath, + $group, + $permissions + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * + * @param string $user + * @param string $filepath + * @param string $group + * @param string|int|string[]|int[] $permissions + * @param bool $getShareData If true then only create the share if it is not + * already existing, and at the end request the + * share information and leave that in $this->response + * Typically used in a "Given" step which verifies + * that the share did get created successfully. + * + * @return void + */ + public function shareFileWithGroupUsingTheSharingApi( + string $user, + string $filepath, + string$group, + $permissions = null, + bool $getShareData = false + ):void { + $userActual = $this->getActualUsername($user); + $path = $this->getSharesEndpointPath("?path=$filepath"); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $userActual, + $this->getPasswordForUser($user), + "GET", + $path, + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + if ($getShareData && $this->isUserOrGroupInSharedData($group, "group", $permissions)) { + return; + } else { + $this->createShare( + $user, + $filepath, + '1', + $group, + null, + null, + $permissions + ); + } + if ($getShareData) { + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $userActual, + $this->getPasswordForUser($user), + "GET", + $path, + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + } + + /** + * @When /^user "([^"]*)" shares (?:file|folder|entry) "([^"]*)" with group "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * @When /^user "([^"]*)" shares (?:file|folder|entry) "([^"]*)" with group "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * + * @param string $user + * @param string $filepath + * @param string $group + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function userSharesFileWithGroupUsingTheSharingApi( + string $user, + string $filepath, + string $group, + $permissions = null + ) { + $this->shareFileWithGroupUsingTheSharingApi( + $user, + $filepath, + $group, + $permissions + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" shares the following (?:files|folders|entries) with group "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @When /^user "([^"]*)" shares the following (?:files|folders|entries) with group "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $group + * @param TableNode $table + * @param string|int|string[]|int[] $permissions + * + * @return void + * @throws Exception + */ + public function userSharesTheFollowingFilesWithGroupUsingTheSharingApi( + string $user, + string $group, + TableNode $table, + $permissions = null + ) { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $filepath) { + $this->userSharesFileWithGroupUsingTheSharingApi( + $user, + $filepath["path"], + $group, + $permissions + ); + } + } + + /** + * @Given /^user "([^"]*)" has shared (?:file|folder|entry) "([^"]*)" with group "([^"]*)" with permissions "([^"]*)"$/ + * @Given /^user "([^"]*)" has shared (?:file|folder|entry) "([^"]*)" with group "([^"]*)"(?: with permissions (\d+))?$/ + * + * @param string $user + * @param string $filepath + * @param string $group + * @param string|int|string[]|int[] $permissions + * + * @return void + */ + public function userHasSharedFileWithGroupUsingTheSharingApi( + string $user, + string $filepath, + string $group, + $permissions = null + ) { + $this->shareFileWithGroupUsingTheSharingApi( + $user, + $filepath, + $group, + $permissions, + true + ); + + Assert::assertEquals( + true, + $this->isUserOrGroupInSharedData($group, "group", $permissions), + __METHOD__ + . " Could not assert that user '$user' has shared '$filepath' with group '$group' with permissions '$permissions'" + ); + } + + /** + * @When /^user "([^"]*)" tries to update the last share using the sharing API with$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userTriesToUpdateTheLastShareUsingTheSharingApiWith(string $user, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body); + } + + /** + * @When /^user "([^"]*)" tries to update the last public link share using the sharing API with$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function userTriesToUpdateTheLastPublicLinkShareUsingTheSharingApiWith(string $user, ?TableNode $body):void { + $this->updateLastShareWithSettings($user, $body, null, true); + } + + /** + * @Then /^user "([^"]*)" should not be able to share (file|folder|entry) "([^"]*)" with (user|group) "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @Then /^user "([^"]*)" should not be able to share (file|folder|entry) "([^"]*)" with (user|group) "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $sharer + * @param string $entry + * @param string $filepath + * @param string $userOrGroupShareType + * @param string $sharee + * @param string|int|string[]|int[] $permissions + * + * @return void + * @throws Exception + */ + public function userTriesToShareFileUsingTheSharingApi( + string $sharer, + string $entry, + string $filepath, + string $userOrGroupShareType, + string $sharee, + $permissions = null + ):void { + $sharee = $this->getActualUsername($sharee); + $this->asFileOrFolderShouldExist( + $this->getActualUsername($sharer), + $entry, + $filepath + ); + $this->createShare( + $sharer, + $filepath, + $userOrGroupShareType, + $sharee, + null, + null, + $permissions + ); + $statusCode = $this->ocsContext->getOCSResponseStatusCode($this->response); + Assert::assertTrue( + ($statusCode == 404) || ($statusCode == 403), + "Sharing should have failed with status code 403 or 404 but got status code $statusCode" + ); + } + + /** + * @Then /^user "([^"]*)" should be able to share (file|folder|entry) "([^"]*)" with (user|group) "([^"]*)"(?: with permissions (\d+))? using the sharing API$/ + * @Then /^user "([^"]*)" should be able to share (file|folder|entry) "([^"]*)" with (user|group) "([^"]*)" with permissions "([^"]*)" using the sharing API$/ + * + * @param string $sharer + * @param string $entry + * @param string $filepath + * @param string $userOrGroupShareType + * @param string $sharee + * @param string|int|string[]|int[] $permissions + * + * @return void + * @throws Exception + */ + public function userShouldBeAbleToShareUsingTheSharingApi( + string $sharer, + string $entry, + string $filepath, + string $userOrGroupShareType, + string $sharee, + $permissions = null + ):void { + $sharee = $this->getActualUsername($sharee); + $this->asFileOrFolderShouldExist($sharer, $entry, $filepath); + $this->createShare( + $sharer, + $filepath, + $userOrGroupShareType, + $sharee, + null, + null, + $permissions + ); + + //v1.php returns 100 as success code + //v2.php returns 200 in the same case + $this->ocsContext->theOCSStatusCodeShouldBe("100, 200"); + } + + /** + * @When /^the user deletes the last share using the sharing API$/ + * + * @return void + */ + public function theUserDeletesLastShareUsingTheSharingAPI():void { + $this->deleteLastShareUsingSharingApiByCurrentUser(); + } + + /** + * @Given /^the user has deleted the last share$/ + * + * @return void + */ + public function theUserHasDeletedLastShareUsingTheSharingAPI():void { + $this->deleteLastShareUsingSharingApiByCurrentUser(); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @param string $user the user who will do the delete request + * @param string|null $sharer the specific user whose share will be deleted (if specified) + * @param bool $deleteLastPublicLink + * + * @return void + */ + public function deleteLastShareUsingSharingApi(string $user, string $sharer = null, bool $deleteLastPublicLink = false):void { + $user = $this->getActualUsername($user); + if ($deleteLastPublicLink) { + $shareId = $this->getLastPublicLinkShareId(); + } else { + if ($sharer === null) { + $shareId = $this->getLastShareId(); + } else { + $shareId = $this->getLastShareIdForUser($sharer); + } + } + $url = $this->getSharesEndpointPath("/$shareId"); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "DELETE", + $url, + null + ); + } + + /** + * @return void + */ + public function deleteLastShareUsingSharingApiByCurrentUser():void { + $this->deleteLastShareUsingSharingApi($this->currentUser); + } + + /** + * @When /^user "([^"]*)" deletes the last share using the sharing API$/ + * @When /^user "([^"]*)" tries to delete the last share using the sharing API$/ + * + * @param string $user + * + * @return void + */ + public function userDeletesLastShareUsingTheSharingApi(string $user):void { + $this->deleteLastShareUsingSharingApi($user); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" deletes the last public link share using the sharing API$/ + * @When /^user "([^"]*)" tries to delete the last public link share using the sharing API$/ + * + * @param string $user + * + * @return void + */ + public function userDeletesLastPublicLinkShareUsingTheSharingApi(string $user):void { + $this->deleteLastShareUsingSharingApi($user, null, true); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" deletes the last share of user "([^"]*)" using the sharing API$/ + * @When /^user "([^"]*)" tries to delete the last share of user "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $sharer + * + * @return void + */ + public function userDeletesLastShareOfUserUsingTheSharingApi(string $user, string $sharer):void { + $this->deleteLastShareUsingSharingApi($user, $sharer); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has deleted the last share$/ + * + * @param string $user + * + * @return void + */ + public function userHasDeletedLastShareUsingTheSharingApi(string $user):void { + $this->deleteLastShareUsingSharingApi($user); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When /^the user gets the info of the last share using the sharing API$/ + * + * @return void + * @throws Exception + */ + public function theUserGetsInfoOfLastShareUsingTheSharingApi():void { + $this->userGetsInfoOfLastShareUsingTheSharingApi($this->currentUser); + } + + /** + * @When /^user "([^"]*)" gets the info of the last share in language "([^"]*)" using the sharing API$/ + * @When /^user "([^"]*)" gets the info of the last share using the sharing API$/ + * + * @param string $user username that requests the information (might not be the user that has initiated the share) + * @param string|null $language + * + * @return void + * @throws Exception + */ + public function userGetsInfoOfLastShareUsingTheSharingApi(string $user, ?string $language = null):void { + $shareId = $this->getLastShareId(); + $language = TranslationHelper::getLanguage($language); + $this->getShareData($user, $shareId, $language); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^the user gets the info of the last public link share using the sharing API$/ + * + * @return void + * @throws Exception + */ + public function theUserGetsInfoOfLastPublicLinkShareUsingTheSharingApi():void { + $this->userGetsInfoOfLastPublicLinkShareUsingTheSharingApi($this->getUserWhoCreatedLastPublicShare()); + } + + /** + * @When /^user "([^"]*)" gets the info of the last public link share in language "([^"]*)" using the sharing API$/ + * @When /^user "([^"]*)" gets the info of the last public link share using the sharing API$/ + * + * @param string $user username that requests the information (might not be the user that has initiated the share) + * @param string|null $language + * + * @return void + * @throws Exception + */ + public function userGetsInfoOfLastPublicLinkShareUsingTheSharingApi(string $user, ?string $language = null):void { + if ($this->lastPublicShareId !== null) { + $shareId = $this->lastPublicShareId; + } else { + throw new Exception( + __METHOD__ . " last public link share data was not found" + ); + } + $language = TranslationHelper::getLanguage($language); + $this->getShareData($user, $shareId, $language); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Then /^as "([^"]*)" the info about the last share by user "([^"]*)" with user "([^"]*)" should include$/ + * + * @param string $requestor + * @param string $sharer + * @param string $sharee + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function asLastShareInfoAboutUserSharingWithUserShouldInclude( + string $requestor, + string $sharer, + string $sharee, + TableNode $table + ) { + $this->userGetsInfoOfLastShareUsingTheSharingApi($requestor); + $this->ocsContext->assertOCSResponseIndicatesSuccess(); + $this->checkFieldsOfLastResponseToUser($sharer, $sharee, $table); + } + + /** + * @Then /^the info about the last share by user "([^"]*)" with (?:user|group) "([^"]*)" should include$/ + * + * @param string $sharer + * @param string $sharee + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theInfoAboutTheLastShareByUserWithUserShouldInclude( + string $sharer, + string $sharee, + TableNode $table + ):void { + $this->asLastShareInfoAboutUserSharingWithUserShouldInclude($sharer, $sharer, $sharee, $table); + } + + /** + * Sets the id of the last shared file + * + * @param string $user + * @param string $shareId + * + * @return void + */ + public function setLastShareIdOf(string $user, string $shareId):void { + $this->lastShareIdByUser[$user] = $shareId; + $this->userWhoCreatedLastShare = $user; + } + + /** + * Sets the id of the last public link shared file + * + * @param string $shareId + * + * @return void + */ + public function setLastPublicLinkShareId(string $shareId):void { + $this->lastPublicShareId = $shareId; + } + + /** + * Retrieves the id of the last public link shared file + * + * @return string|null + */ + public function getLastPublicLinkShareId():?string { + return $this->lastPublicShareId; + } + + /** + * Sets the user who created the last public link share + * + * @param string $user + * + * @return void + */ + public function setUserWhoCreatedLastPublicShare(string $user):void { + $this->userWhoCreatedLastPublicShare = $user; + } + + /** + * Gets the user who created the last public link share + * + * @return string|null + */ + public function getUserWhoCreatedLastPublicShare():?string { + return $this->userWhoCreatedLastPublicShare; + } + + /** + * Retrieves the id of the last shared file + * + * @return string|null + */ + public function getLastShareId():?string { + return $this->getLastShareIdForUser($this->userWhoCreatedLastShare); + } + + /** + * @param string $user + * + * @return string|null + */ + public function getLastShareIdForUser(string $user):?string { + if ($user === null) { + throw new Exception( + __METHOD__ . " user not specified. Probably no user or group shares have been created yet in the test scenario." + ); + } + if (isset($this->lastShareIdByUser[$user])) { + return $this->lastShareIdByUser[$user]; + } else { + throw new Exception(__METHOD__ . " last share id for user '$user' was not found"); + } + } + + /** + * Retrieves all the shares of the respective user + * + * @param string $user + * + * @return ResponseInterface + */ + public function getListOfShares(string $user):ResponseInterface { + $user = $this->getActualUsername($user); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath(), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + return $this->response; + } + + /** + * Get share data of specific share_id + * + * @param string $user + * @param string $share_id + * @param string|null $language + * + * @return void + */ + public function getShareData(string $user, string $share_id, ?string $language = null):void { + $user = $this->getActualUsername($user); + $url = $this->getSharesEndpointPath("/$share_id"); + $headers = []; + if ($language !== null) { + $headers['Accept-Language'] = $language; + } + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "GET", + $url, + null, + null, + $headers + ); + } + + /** + * @When user :user gets all the shares shared with him using the sharing API + * + * @param string $user + * + * @return void + */ + public function userGetsAllTheSharesSharedWithHimUsingTheSharingApi(string $user):void { + $user = $this->getActualUsername($user); + $url = "/apps/files_sharing/api/v1/shares?shared_with_me=true"; + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + 'GET', + $url, + null + ); + } + + /** + * @When /^user "([^"]*)" gets the (|pending)\s?(user|group|user and group|public link) shares shared with him using the sharing API$/ + * + * @param string $user + * @param string $pending + * @param string $shareType + * + * @return void + */ + public function userGetsFilteredSharesSharedWithHimUsingTheSharingApi(string $user, string $pending, string $shareType):void { + $user = $this->getActualUsername($user); + if ($pending === "pending") { + $pendingClause = "&state=" . SharingHelper::SHARE_STATES['pending']; + } else { + $pendingClause = ""; + } + if ($shareType === 'public link') { + $shareType = 'public_link'; + } + if ($shareType === 'user and group') { + $rawShareTypes = SharingHelper::SHARE_TYPES['user'] . "," . SharingHelper::SHARE_TYPES['group']; + } else { + $rawShareTypes = SharingHelper::SHARE_TYPES[$shareType]; + } + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + 'GET', + $this->getSharesEndpointPath( + "?shared_with_me=true" . $pendingClause . "&share_types=" . $rawShareTypes + ), + null + ); + } + + /** + * @When /^user "([^"]*)" gets all the shares shared with him that are received as (?:file|folder|entry) "([^"]*)" using the provisioning API$/ + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userGetsAllSharesSharedWithHimFromFileOrFolderUsingTheProvisioningApi(string $user, string $path):void { + $user = $this->getActualUsername($user); + $url = "/apps/files_sharing/api/" + . "v{$this->sharingApiVersion}/shares?shared_with_me=true&path=$path"; + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + 'GET', + $url, + null + ); + } + + /** + * @When user :user gets all shares shared by him using the sharing API + * + * @param string $user + * + * @return void + */ + public function userGetsAllSharesSharedByHimUsingTheSharingApi(string $user):void { + $user = $this->getActualUsername($user); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath(), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + + /** + * @When the administrator gets all shares shared by him using the sharing API + * + * @return void + */ + public function theAdministratorGetsAllSharesSharedByHimUsingTheSharingApi():void { + $this->userGetsAllSharesSharedByHimUsingTheSharingApi($this->getAdminUsername()); + } + + /** + * @When /^user "([^"]*)" gets the (user|group|user and group|public link) shares shared by him using the sharing API$/ + * + * @param string $user + * @param string $shareType + * + * @return void + */ + public function userGetsFilteredSharesSharedByHimUsingTheSharingApi(string $user, string $shareType):void { + $user = $this->getActualUsername($user); + if ($shareType === 'public link') { + $shareType = 'public_link'; + } + if ($shareType === 'user and group') { + $rawShareTypes = SharingHelper::SHARE_TYPES['user'] . "," . SharingHelper::SHARE_TYPES['group']; + } else { + $rawShareTypes = SharingHelper::SHARE_TYPES[$shareType]; + } + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath("?share_types=" . $rawShareTypes), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + + /** + * @When user :user gets all the shares from the file :path using the sharing API + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userGetsAllTheSharesFromTheFileUsingTheSharingApi(string $user, string $path):void { + $user = $this->getActualUsername($user); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath("?path=$path"), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + + /** + * @When user :user gets all the shares with reshares from the file :path using the sharing API + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userGetsAllTheSharesWithResharesFromTheFileUsingTheSharingApi( + string $user, + string $path + ):void { + $userActual = $this->getActualUsername($user); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $userActual, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath("?reshares=true&path=$path"), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + + /** + * @When user :user gets all the shares inside the folder :path using the sharing API + * + * @param string $user + * @param string $path + * + * @return void + */ + public function userGetsAllTheSharesInsideTheFolderUsingTheSharingApi(string $user, string $path):void { + $user = $this->getActualUsername($user); + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath("?path=$path&subfiles=true"), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + } + + /** + * @Then /^the response when user "([^"]*)" gets the info of the last share should include$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theResponseWhenUserGetsInfoOfLastShareShouldInclude( + string $user, + ?TableNode $body + ):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeRows($body, [], $this->shareResponseFields); + $this->getShareData($user, (string)$this->getLastShareData()->data[0]->id); + $this->theHTTPStatusCodeShouldBe( + 200, + "Error getting info of last share for user $user" + ); + $this->ocsContext->assertOCSResponseIndicatesSuccess( + __METHOD__ . + ' Error getting info of last share for user $user\n' . + $this->ocsContext->getOCSResponseStatusMessage( + $this->getResponse() + ) . '"' + ); + $this->checkFields($user, $body); + } + + /** + * @Then /^the response when user "([^"]*)" gets the info of the last public link share should include$/ + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function theResponseWhenUserGetsInfoOfLastPublicLinkShareShouldInclude( + string $user, + ?TableNode $body + ):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeRows($body, [], $this->shareResponseFields); + $this->getShareData($user, (string)$this->getLastPublicLinkShareId()); + $this->theHTTPStatusCodeShouldBe( + 200, + "Error getting info of last public link share for user $user" + ); + $this->ocsContext->assertOCSResponseIndicatesSuccess( + __METHOD__ . + ' Error getting info of last public link share for user $user\n' . + $this->ocsContext->getOCSResponseStatusMessage( + $this->getResponse() + ) . '"' + ); + $this->checkFields($user, $body); + } + + /** + * @Then the information of the last share of user :user should include + * + * @param string $user + * @param TableNode $body + * + * @return void + * @throws Exception + */ + public function informationOfLastShareShouldInclude( + string $user, + TableNode $body + ):void { + $user = $this->getActualUsername($user); + $shareId = $this->getLastShareIdForUser($user); + $this->getShareData($user, $shareId); + $this->theHTTPStatusCodeShouldBe( + 200, + "Error getting info of last share for user $user with share id $shareId" + ); + $this->verifyTableNodeRows($body, [], $this->shareResponseFields); + $this->checkFields($user, $body); + } + + /** + * @Then /^the information for user "((?:[^']*)|(?:[^"]*))" about the received share of (file|folder) "((?:[^']*)|(?:[^"]*))" shared with a (user|group) should include$/ + * + * @param string $user + * @param string $fileOrFolder + * @param string $fileName + * @param string $type + * @param TableNode $body should provide share_type + * + * @return void + * @throws Exception + */ + public function theFieldsOfTheResponseForUserForResourceShouldInclude( + string $user, + string $fileOrFolder, + string $fileName, + string $type, + TableNode $body + ):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumnsCount($body, 2); + $fileName = $fileName[0] === "/" ? $fileName : '/' . $fileName; + $data = $this->getAllSharesSharedWithUser($user); + Assert::assertNotEmpty($data, 'No shares found for ' . $user); + + $bodyRows = $body->getRowsHash(); + Assert::assertArrayHasKey('share_type', $bodyRows, 'share_type is not provided'); + $share_id = null; + foreach ($data as $share) { + if ($share['file_target'] === $fileName && $share['item_type'] === $fileOrFolder) { + if (($share['share_type'] === SharingHelper::getShareType($bodyRows['share_type'])) + ) { + $share_id = $share['id']; + } + } + } + + Assert::assertNotNull($share_id, "Could not find share id for " . $user); + + if (\array_key_exists('expiration', $bodyRows) && $bodyRows['expiration'] !== '') { + $bodyRows['expiration'] = \date('d-m-Y', \strtotime($bodyRows['expiration'])); + } + + $this->getShareData($user, $share_id); + foreach ($bodyRows as $field => $value) { + if ($type === "user" && \in_array($field, ["share_with"])) { + $value = $this->getActualUsername($value); + } + if (\in_array($field, ["uid_owner"])) { + $value = $this->getActualUsername($value); + } + $value = $this->replaceValuesFromTable($field, $value); + Assert::assertTrue( + $this->isFieldInResponse($field, $value), + "$field doesn't have value '$value'" + ); + } + } + + /** + * @Then /^the last share_id should be included in the response/ + * + * @return void + * @throws Exception + */ + public function checkingLastShareIDIsIncluded():void { + $shareId = $this->getLastShareId(); + if (!$this->isFieldInResponse('id', $shareId)) { + Assert::fail( + "Share id $shareId not found in response" + ); + } + } + + /** + * @Then /^the last share id should not be included in the response/ + * + * @return void + * @throws Exception + */ + public function checkLastShareIDIsNotIncluded():void { + $shareId = $this->getLastShareId(); + if ($this->isFieldInResponse('id', $shareId, false)) { + Assert::fail( + "Share id $shareId has been found in response" + ); + } + } + + /** + * @Then /^the last public link share id should not be included in the response/ + * + * @return void + * @throws Exception + */ + public function checkLastPublicLinkShareIDIsNotIncluded():void { + $shareId = $this->getLastPublicLinkShareId(); + if ($this->isFieldInResponse('id', $shareId, false)) { + Assert::fail( + "Public link share id $shareId has been found in response" + ); + } + } + + /** + * @Then /^the response should not contain any share ids/ + * + * @return void + */ + public function theResponseShouldNotContainAnyShareIds():void { + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + $fieldIsSet = false; + $receivedShareCount = 0; + + if (\count($data->element) > 0) { + foreach ($data as $element) { + if (isset($element->id)) { + $fieldIsSet = true; + $receivedShareCount += 1; + } + } + } else { + if (isset($data->id)) { + $fieldIsSet = true; + $receivedShareCount += 1; + } + } + Assert::assertFalse( + $fieldIsSet, + "response contains $receivedShareCount share ids but should not contain any share ids" + ); + } + + /** + * @Then user :user should not see the share id of the last share + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function userShouldNotSeeShareIdOfLastShare(string $user):void { + $this->userGetsAllTheSharesSharedWithHimUsingTheSharingApi($user); + $this->checkLastShareIDIsNotIncluded(); + } + + /** + * @Then user :user should not have any received shares + * + * @param string $user + * + * @return void + */ + public function userShouldNotHaveAnyReceivedShares(string $user):void { + $this->userGetsAllTheSharesSharedWithHimUsingTheSharingApi($user); + $this->theResponseShouldNotContainAnyShareIds(); + } + + /** + * @Then /^the response should contain ([0-9]+) entries$/ + * + * @param int $count + * + * @return void + */ + public function checkingTheResponseEntriesCount(int $count):void { + $actualCount = \count($this->getResponseXml(null, __METHOD__)->data[0]); + Assert::assertEquals( + $count, + $actualCount, + "Expected that the response should contain '$count' entries but got '$actualCount' entries" + ); + } + + /** + * @Then the fields of the last response to user :user should include + * + * @param string $user + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function checkFields(string $user, ?TableNode $body):void { + $this->verifyTableNodeColumnsCount($body, 2); + $bodyRows = $body->getRowsHash(); + $userRelatedFieldNames = [ + "owner", + "user", + "uid_owner", + "uid_file_owner", + "share_with", + "displayname_file_owner", + "displayname_owner" + ]; + foreach ($bodyRows as $field => $value) { + if (\in_array($field, $userRelatedFieldNames)) { + $value = $this->substituteInLineCodes($value, $user); + } + $value = $this->getActualUsername($value); + $value = $this->replaceValuesFromTable($field, $value); + Assert::assertTrue( + $this->isFieldInResponse($field, $value), + "$field doesn't have value '$value'" + ); + } + } + + /** + * @Then /^the fields of the last response (?:to|about) user "([^"]*)" sharing with (?:user|group) "([^"]*)" should include$/ + * + * @param string $sharer + * @param string $sharee + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function checkFieldsOfLastResponseToUser(string $sharer, string $sharee, ?TableNode $body):void { + $this->verifyTableNodeColumnsCount($body, 2); + $bodyRows = $body->getRowsHash(); + foreach ($bodyRows as $field => $value) { + if (\in_array($field, ["displayname_owner", "displayname_file_owner", "owner", "uid_owner", "uid_file_owner"])) { + $value = $this->substituteInLineCodes($value, $sharer); + } elseif (\in_array($field, ["share_with", "share_with_displayname", "user"])) { + $value = $this->substituteInLineCodes($value, $sharee); + } + $value = $this->replaceValuesFromTable($field, $value); + Assert::assertTrue( + $this->isFieldInResponse($field, $value), + "$field doesn't have value '$value'" + ); + } + } + + /** + * @Then the last response should be empty + * + * @return void + */ + public function theFieldsOfTheLastResponseShouldBeEmpty():void { + $data = $this->getResponseXml(null, __METHOD__)->data[0]; + Assert::assertEquals( + \count($data->element), + 0, + "last response contains data but was expected to be empty" + ); + } + + /** + * + * @return string + * + * @throws Exception + */ + public function getSharingAttributesFromLastResponse():string { + $responseXml = $this->getResponseXml(null, __METHOD__)->data[0]; + $actualAttributesElement = $responseXml->xpath('//attributes'); + + if ((bool) $actualAttributesElement) { + $actualAttributes = (array) $actualAttributesElement[0]; + if (empty($actualAttributes)) { + throw new Exception( + "No data inside 'attributes' element in the last response." + ); + } + return $actualAttributes[0]; + } + + throw new Exception("No 'attributes' found inside the response of the last share."); + } + + /** + * @Then the additional sharing attributes for the response should include + * + * @param TableNode $attributes + * + * @return void + * @throws Exception + */ + public function checkingAttributesInLastShareResponse(TableNode $attributes):void { + $this->verifyTableNodeColumns($attributes, ['scope', 'key', 'enabled']); + $attributes = $attributes->getHash(); + + // change string "true"/"false" to boolean inside array + \array_walk_recursive( + $attributes, + function (&$value, $key) { + if ($key !== 'enabled') { + return; + } + if ($value === 'true') { + $value = true; + } + if ($value === 'false') { + $value = false; + } + } + ); + + $actualAttributes = $this->getSharingAttributesFromLastResponse(); + + // parse json to array + $actualAttributesArray = \json_decode($actualAttributes, true); + if (\json_last_error() !== JSON_ERROR_NONE) { + $errMsg = \strtolower(\json_last_error_msg()); + throw new Exception( + "JSON decoding failed because of $errMsg in json\n" . + 'Expected data to be json with array of objects. ' . + "\nReceived:\n $actualAttributes" + ); + } + + // check if the expected attributes received from table match actualAttributes + foreach ($attributes as $row) { + $foundRow = false; + foreach ($actualAttributesArray as $item) { + if (($item['scope'] === $row['scope']) + && ($item['key'] === $row['key']) + && ($item['enabled'] === $row['enabled']) + ) { + $foundRow = true; + } + } + Assert::assertTrue( + $foundRow, + "Could not find expected attribute with scope '" . $row['scope'] . "' and key '" . $row['key'] . "'" + ); + } + } + + /** + * @Then the downloading of file :fileName for user :user should fail with error message + * + * @param string $fileName + * @param string $user + * @param PyStringNode $errorMessage + * + * @return void + * @throws Exception + */ + public function userDownloadsFailWithMessage(string $fileName, string $user, PyStringNode $errorMessage):void { + $user = $this->getActualUsername($user); + $this->downloadFileAsUserUsingPassword($user, $fileName); + $receivedErrorMessage = $this->getResponseXml(null, __METHOD__)->xpath('//s:message'); + if ((bool) $errorMessage) { + Assert::assertEquals( + $errorMessage, + (string) $receivedErrorMessage[0], + "Expected error message was '$errorMessage' but got '" + . (string) $receivedErrorMessage[0] + . "'" + ); + return; + } + throw new Exception("No 's:message' element found on the response."); + } + + /** + * @Then the fields of the last response should not include + * + * @param TableNode|null $body + * + * @return void + * @throws Exception + */ + public function checkFieldsNotInResponse(?TableNode $body):void { + $this->verifyTableNodeColumnsCount($body, 2); + $bodyRows = $body->getRowsHash(); + + foreach ($bodyRows as $field => $value) { + $value = $this->replaceValuesFromTable($field, $value); + Assert::assertFalse( + $this->isFieldInResponse($field, $value, false), + "$field has value $value but should not" + ); + } + } + + /** + * @param string $user + * @param string $fileName + * + * @return void + * @throws Exception + */ + public function removeAllSharesFromResource(string $user, string $fileName):void { + $headers = ['Content-Type' => 'application/json']; + $res = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath("?format=json"), + $this->getStepLineRef(), + [], + $this->ocsApiVersion, + $headers + ); + + $this->setResponse($res); + $this->theHTTPStatusCodeShouldBeSuccess(); + + $json = \json_decode($res->getBody()->getContents(), true); + $deleted = false; + foreach ($json['ocs']['data'] as $data) { + if (\stripslashes($data['path']) === $fileName) { + $id = $data['id']; + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "DELETE", + $this->getSharesEndpointPath("/{$id}"), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + + $this->setResponse($response); + $this->theHTTPStatusCodeShouldBeSuccess(); + + $deleted = true; + } + } + + if ($deleted === false) { + throw new Exception( + "Could not delete shares for user $user file $fileName" + ); + } + } + + /** + * @When user :user removes all shares from the file named :fileName using the sharing API + * + * @param string $user + * @param string $fileName + * + * @return void + * @throws Exception + */ + public function userRemovesAllSharesFromTheFileNamed(string $user, string $fileName):void { + $user = $this->getActualUsername($user); + $this->removeAllSharesFromResource($user, $fileName); + } + + /** + * @Given user :user has removed all shares from the file named :fileName + * + * @param string $user + * @param string $fileName + * + * @return void + * @throws Exception + */ + public function userHasRemovedAllSharesFromTheFileNamed(string $user, string $fileName):void { + $user = $this->getActualUsername($user); + $this->removeAllSharesFromResource($user, $fileName); + $dataResponded = $this->getShares($user, $fileName); + Assert::assertEquals( + 0, + \count($dataResponded), + __METHOD__ + . " Expected all shares to be removed from '$fileName' but got '" + . \count($dataResponded) + . "' shares still present" + ); + } + + /** + * Returns shares of a file or folder as a SimpleXMLElement + * + * Note: the "single" SimpleXMLElement may contain one or more actual + * shares (to users, groups or public links etc). If you access an item directly, + * for example, getShares()->id, then the value of "id" for the first element + * will be returned. To access all the elements, you can loop through the + * returned SimpleXMLElement with "foreach" - it will act like a PHP array + * of elements. + * + * @param string $user + * @param string $path + * + * @return SimpleXMLElement + */ + public function getShares(string $user, string $path):SimpleXMLElement { + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $this->getSharesEndpointPath("?path=$path"), + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + return $this->getResponseXml(null, __METHOD__)->data->element; + } + + /** + * @Then /^as user "([^"]*)" the public shares of (?:file|folder) "([^"]*)" should be$/ + * + * @param string $user + * @param string $path + * @param TableNode|null $TableNode + * + * @return void + * @throws Exception + */ + public function checkPublicShares(string $user, string $path, ?TableNode $TableNode):void { + $user = $this->getActualUsername($user); + $dataResponded = $this->getShares($user, $path); + + $this->verifyTableNodeColumns($TableNode, ['path', 'permissions', 'name']); + if ($TableNode instanceof TableNode) { + $elementRows = $TableNode->getHash(); + + foreach ($elementRows as $expectedElementsArray) { + $nameFound = false; + foreach ($dataResponded as $elementResponded) { + if ((string) $elementResponded->name[0] === $expectedElementsArray['name']) { + Assert::assertEquals( + $expectedElementsArray['path'], + (string) $elementResponded->path[0], + __METHOD__ + . " Expected '${expectedElementsArray['path']}' but got '" + . (string) $elementResponded->path[0] + . "'" + ); + Assert::assertEquals( + $expectedElementsArray['permissions'], + (string) $elementResponded->permissions[0], + __METHOD__ + . " Expected '${expectedElementsArray['permissions']}' but got '" + . (string) $elementResponded->permissions[0] + . "'" + ); + $nameFound = true; + break; + } + } + Assert::assertTrue( + $nameFound, + "Shared link name {$expectedElementsArray['name']} not found" + ); + } + } + } + + /** + * @Then /^as user "([^"]*)" the (file|folder) "([^"]*)" should not have any shares$/ + * + * @param string $user + * @param string $entry + * @param string $path + * + * @return void + * @throws Exception + */ + public function checkPublicSharesAreEmpty(string $user, string $entry, string $path):void { + $user = $this->getActualUsername($user); + $this->asFileOrFolderShouldExist($user, $entry, $path); + $dataResponded = $this->getShares($user, $path); + //It shouldn't have public shares + Assert::assertEquals( + 0, + \count($dataResponded), + __METHOD__ + . " As '$user', '$path' was expected to have no shares, but got '" + . \count($dataResponded) + . "' shares present" + ); + } + + /** + * @param string $user + * @param string $path to share + * @param string $name of share + * + * @return string|null + */ + public function getPublicShareIDByName(string $user, string $path, string $name):?string { + $dataResponded = $this->getShares($user, $path); + foreach ($dataResponded as $elementResponded) { + if ((string) $elementResponded->name[0] === $name) { + return (string) $elementResponded->id[0]; + } + } + return null; + } + + /** + * @param string $user + * @param string $name + * @param string $path + * + * @return void + */ + public function deletePublicLinkShareUsingTheSharingApi( + string $user, + string $name, + string $path + ):void { + $user = $this->getActualUsername($user); + $share_id = $this->getPublicShareIDByName($user, $path, $name); + $url = $this->getSharesEndpointPath("/$share_id"); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "DELETE", + $url, + null + ); + } + + /** + * @When /^user "([^"]*)" deletes public link share named "([^"]*)" in (?:file|folder) "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $name + * @param string $path + * + * @return void + */ + public function userDeletesPublicLinkShareNamedUsingTheSharingApi( + string $user, + string $name, + string $path + ):void { + $this->deletePublicLinkShareUsingTheSharingApi( + $user, + $name, + $path + ); + } + + /** + * @Given /^user "([^"]*)" has deleted public link share named "([^"]*)" in (?:file|folder) "([^"]*)"$/ + * + * @param string $user + * @param string $name + * @param string $path + * + * @return void + */ + public function userHasDeletedPublicLinkShareNamedUsingTheSharingApi( + string $user, + string $name, + string $path + ):void { + $this->deletePublicLinkShareUsingTheSharingApi( + $user, + $name, + $path + ); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When /^user "([^"]*)" (declines|accepts) share "([^"]*)" offered by user "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $action + * @param string $share + * @param string $offeredBy + * @param string|null $state specify 'accepted', 'pending', 'rejected' or 'declined' to only consider shares in that state + * + * @return void + * @throws Exception + */ + public function userReactsToShareOfferedBy(string $user, string $action, string $share, string $offeredBy, ?string $state = ''):void { + $user = $this->getActualUsername($user); + $offeredBy = $this->getActualUsername($offeredBy); + + $dataResponded = $this->getAllSharesSharedWithUser($user); + $shareId = null; + foreach ($dataResponded as $shareElement) { + $shareFolder = \trim( + SetupHelper::getSystemConfigValue('share_folder', $this->getStepLineRef()) + ); + + if ($shareFolder) { + $shareFolder = \ltrim($shareFolder, '/'); + } + + // Add share folder to share path if given + if ($shareFolder && !(strpos($share, "/$shareFolder") === 0)) { + $share = '/' . $shareFolder . $share; + } + + // SharingHelper::SHARE_STATES has the mapping between the words for share states + // like "accepted", "pending",... and the integer constants 0, 1,... that are in + // the "state" field of the share data. + if ($state === '') { + // Any share state is OK + $matchesShareState = true; + } else { + $requiredStateCode = SharingHelper::SHARE_STATES[$state]; + if ($shareElement['state'] === $requiredStateCode) { + $matchesShareState = true; + } else { + $matchesShareState = false; + } + } + + if ($matchesShareState + && (string) $shareElement['uid_owner'] === $offeredBy + && (string) $shareElement['path'] === $share + ) { + $shareId = (string) $shareElement['id']; + break; + } + } + Assert::assertNotNull( + $shareId, + __METHOD__ . " could not find share $share, offered by $offeredBy to $user" + ); + $url = "/apps/files_sharing/api/v{$this->sharingApiVersion}" . + "/shares/pending/$shareId"; + if (\substr($action, 0, 7) === "decline") { + $httpRequestMethod = "DELETE"; + } elseif (\substr($action, 0, 6) === "accept") { + $httpRequestMethod = "POST"; + } + + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $httpRequestMethod, + $url, + null + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" (declines|accepts) the following shares offered by user "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $action + * @param string $offeredBy + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userReactsToTheFollowingSharesOfferedBy(string $user, string $action, string $offeredBy, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $share) { + $this->userReactsToShareOfferedBy( + $user, + $action, + $share["path"], + $offeredBy + ); + } + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" (declines|accepts) share with ID "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $action + * @param string $share_id + * + * @return void + * @throws Exception + */ + public function userReactsToShareWithShareIDOfferedBy(string $user, string $action, string $share_id):void { + $user = $this->getActualUsername($user); + + $shareId = $this->substituteInLineCodes($share_id, $user); + + $url = "/apps/files_sharing/api/v{$this->sharingApiVersion}" . + "/shares/pending/$shareId"; + if (\substr($action, 0, 7) === "decline") { + $httpRequestMethod = "DELETE"; + } elseif (\substr($action, 0, 6) === "accept") { + $httpRequestMethod = "POST"; + } + + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + $httpRequestMethod, + $url, + null + ); + } + + /** + * @Given /^user "([^"]*)" has (declined|accepted) share "([^"]*)" offered by user "([^"]*)"$/ + * + * @param string $user + * @param string $action + * @param string $share + * @param string $offeredBy + * + * @return void + * @throws Exception + */ + public function userHasReactedToShareOfferedBy(string $user, string $action, string $share, string $offeredBy):void { + $this->userReactsToShareOfferedBy($user, $action, $share, $offeredBy); + if ($action === 'declined') { + $actionText = 'decline'; + } + if ($action === 'accepted') { + $actionText = 'accept'; + } + $this->theHTTPStatusCodeShouldBe( + 200, + __METHOD__ . " could not $actionText share $share to $user by $offeredBy" + ); + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + } + + /** + * @When /^user "([^"]*)" accepts the (?:first|next|) pending share "([^"]*)" offered by user "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $share + * @param string $offeredBy + * + * @return void + * @throws Exception + */ + public function userAcceptsThePendingShareOfferedBy(string $user, string $share, string $offeredBy):void { + $this->userReactsToShareOfferedBy($user, 'accepts', $share, $offeredBy, 'pending'); + } + + /** + * @Given /^user "([^"]*)" has accepted the (?:first|next|) pending share "([^"]*)" offered by user "([^"]*)"$/ + * + * @param string $user + * @param string $share + * @param string $offeredBy + * + * @return void + * @throws Exception + */ + public function userHasAcceptedThePendingShareOfferedBy(string $user, string $share, string $offeredBy) { + $this->userAcceptsThePendingShareOfferedBy($user, $share, $offeredBy); + $this->theHTTPStatusCodeShouldBe( + 200, + __METHOD__ . " could not accept the pending share $share to $user by $offeredBy" + ); + $this->ocsContext->assertOCSResponseIndicatesSuccess(); + } + + /** + * @Then /^user "([^"]*)" should be able to (decline|accept) pending share "([^"]*)" offered by user "([^"]*)"$/ + * + * @param string $user + * @param string $action + * @param string $share + * @param string $offeredBy + * + * @return void + * @throws Exception + */ + public function userShouldBeAbleToAcceptShareOfferedBy( + string $user, + string $action, + string $share, + string $offeredBy + ) { + if ($action === 'accept') { + $this->userHasAcceptedThePendingShareOfferedBy($user, $share, $offeredBy); + } elseif ($action === 'decline') { + $this->userHasReactedToShareOfferedBy($user, 'declined', $share, $offeredBy); + } + } + + /** + * + * @Then /^the sharing API should report to user "([^"]*)" that these shares are in the (pending|accepted|declined) state$/ + * + * @param string $user + * @param string $state + * @param TableNode $table table with headings that correspond to the attributes + * of the share e.g. "|path|uid_owner|" + * + * @return void + * @throws Exception + */ + public function assertSharesOfUserAreInState(string $user, string $state, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["path"], $this->shareResponseFields); + $usersShares = $this->getAllSharesSharedWithUser($user, $state); + foreach ($table as $row) { + $found = false; + //the API returns the path without trailing slash, but we want to + //be able to accept leading and/or trailing slashes in the step definition + $row['path'] = "/" . \trim($row['path'], "/"); + foreach ($usersShares as $share) { + try { + Assert::assertArrayHasKey('path', $share); + Assert::assertEquals($row['path'], $share['path']); + $found = true; + break; + } catch (PHPUnit\Framework\ExpectationFailedException $e) { + } + } + if (!$found) { + Assert::fail( + "could not find the share with this attributes " . + \print_r($row, true) + ); + } + } + } + + /** + * + * @Then /^the sharing API should report to user "([^"]*)" that no shares are in the (pending|accepted|declined) state$/ + * + * @param string $user + * @param string $state + * + * @return void + * @throws Exception + */ + public function assertNoSharesOfUserAreInState(string $user, string $state):void { + $usersShares = $this->getAllSharesSharedWithUser($user, $state); + Assert::assertEmpty( + $usersShares, + "user has " . \count($usersShares) . " share(s) in the $state state" + ); + } + + /** + * @Then the sharing API should report that no shares are shared with user :user + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function assertThatNoSharesAreSharedWithUser(string $user):void { + $usersShares = $this->getAllSharesSharedWithUser($user); + Assert::assertEmpty( + $usersShares, + "user has " . \count($usersShares) . " share(s)" + ); + } + + /** + * @When the administrator adds group :group to the exclude groups from receiving shares list using the occ command + * + * @param string $group + * + * @return int + * @throws Exception + */ + public function administratorAddsGroupToExcludeFromReceivingSharesList(string $group): int { + //get current groups + $occExitCode = $this->runOcc( + ['config:app:get files_sharing blacklisted_receiver_groups'] + ); + + $occStdOut = $this->getStdOutOfOccCommand(); + $occStdErr = $this->getStdErrOfOccCommand(); + + if (($occExitCode !== 0) && ($occExitCode !== 1)) { + throw new Exception( + "occ config:app:get files_sharing blacklisted_receiver_groups failed with exit code " . + $occExitCode . ", output " . + $occStdOut . ", error output " . + $occStdErr + ); + } + + //if the setting was never set before stdOut will be empty and return code will be 1 + if (\trim($occStdOut) === "") { + $occStdOut = "[]"; + } + + $currentGroups = \json_decode($occStdOut, true); + Assert::assertNotNull( + $currentGroups, + "could not json decode app setting 'blacklisted_receiver_groups' of 'files_sharing'\n" . + "stdOut: '" . $occStdOut . "'\n" . + "stdErr: '" . $occStdErr . "'" + ); + + $currentGroups[] = $group; + return $this->runOcc( + [ + 'config:app:set', + 'files_sharing blacklisted_receiver_groups', + '--value=' . \json_encode($currentGroups) + ] + ); + } + + /** + * @Given the administrator has added group :group to the exclude groups from receiving shares list + * + * @param string $group + * + * @return void + * @throws Exception + */ + public function administratorHasAddedGroupToExcludeFromReceivingSharesList(string $group):void { + $setSettingExitCode = $this->administratorAddsGroupToExcludeFromReceivingSharesList($group); + if ($setSettingExitCode !== 0) { + throw new Exception( + __METHOD__ . " could not set files_sharing blacklisted_receiver_groups " . + $setSettingExitCode . " " . + $this->getStdOutOfOccCommand() . " " . + $this->getStdErrOfOccCommand() + ); + } + } + + /** + * @When user :user gets share with id :share using the sharing API + * + * @param string $user + * @param string $share_id + * + * @return ResponseInterface|null + */ + public function userGetsTheLastShareWithTheShareIdUsingTheSharingApi(string $user, string $share_id): ?ResponseInterface { + $user = $this->getActualUsername($user); + $share_id = $this->substituteInLineCodes($share_id, $user); + $url = $this->getSharesEndpointPath("/$share_id"); + + $this->response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "GET", + $url, + $this->getStepLineRef(), + [], + $this->ocsApiVersion + ); + return $this->response; + } + + /** + * + * @param string $user + * @param string|null $state pending|accepted|declined|rejected|all + * + * @return array of shares that are shared with this user + * @throws Exception + */ + private function getAllSharesSharedWithUser(string $user, ?string $state = "all"): array { + switch ($state) { + case 'pending': + case 'accepted': + case 'declined': + case 'rejected': + $stateCode = SharingHelper::SHARE_STATES[$state]; + break; + case 'all': + $stateCode = "all"; + break; + default: + throw new InvalidArgumentException( + __METHOD__ . ' invalid "state" given' + ); + break; + } + + $url = $this->getSharesEndpointPath("?format=json&shared_with_me=true&state=$stateCode"); + $this->ocsContext->userSendsHTTPMethodToOcsApiEndpointWithBody( + $user, + "GET", + $url, + null + ); + if ($this->response->getStatusCode() !== 200) { + throw new Exception( + __METHOD__ . " could not retrieve information about shares" + ); + } + $result = $this->response->getBody()->getContents(); + $usersShares = \json_decode($result, true); + if (!\is_array($usersShares)) { + throw new Exception( + __METHOD__ . " API result about shares is not valid JSON" + ); + } + return $usersShares['ocs']['data']; + } + + /** + * The tests can create public link shares with the API or with the webUI. + * If lastPublicShareData is null, then there have not been any created with the API, + * so look for details of a public link share created with the webUI. + * + * @return string authorization token + */ + public function getLastPublicShareToken():string { + if ($this->lastPublicShareData === null) { + return $this->getLastCreatedPublicLinkToken(); + } else { + if (\count($this->lastPublicShareData->data->element) > 0) { + return (string)$this->lastPublicShareData->data[0]->token; + } + + return (string)$this->lastPublicShareData->data->token; + } + } + + /** + * Returns the attribute values from the last public link share data + * + * @param $attr - attribute name to get + * + * @return string + * @throws Exception + */ + public function getLastPublicShareAttribute(string $attr): string { + if ($this->lastPublicShareData === null) { + throw new Exception(__METHOD__ . "No public share data available."); + } + if (!\in_array($attr, $this->shareResponseFields)) { + throw new Exception( + __METHOD__ . " attribute $attr is not in the list of allowed attributes" + ); + } + if (\count($this->lastPublicShareData->data->element) > 0) { + if (!isset($this->lastPublicShareData->data[0]->$attr)) { + throw new Exception(__METHOD__ . " No attribute $attr available in the last share data."); + } + return (string)$this->lastPublicShareData->data[0]->{$attr}; + } + + if (!isset($this->lastPublicShareData->data->$attr)) { + throw new Exception(__METHOD__ . " No attribute $attr available in the last share data."); + } + + return (string)$this->lastPublicShareData->data->{$attr}; + } + + /** + * @return string path of file that was shared (relevant when a single file has been shared) + */ + public function getLastPublicSharePath():string { + if ($this->lastPublicShareData === null) { + // There have not been any public links created with the API + // so get the path of the last public link created with the webUI + return $this->getLastCreatedPublicLinkPath(); + } else { + if (\count($this->lastPublicShareData->data->element) > 0) { + return (string)$this->lastPublicShareData->data[0]->path; + } + + return (string)$this->lastPublicShareData->data->path; + } + } + + /** + * Send request for preview of a file in a public link + * + * @param string $fileName + * @param string $token + * + * @return void + */ + public function getPublicPreviewOfFile(string $fileName, string $token):void { + $url = $this->getBaseUrl() . + "/index.php/apps/files_sharing/ajax/publicpreview.php" . + "?file=$fileName&t=$token"; + $resp = HttpRequestHelper::get( + $url, + $this->getStepLineRef() + ); + $this->setResponse($resp); + } + + /** + * @When the public accesses the preview of file :path from the last shared public link using the sharing API + * + * @param string $path + * + * @return void + */ + public function thePublicAccessesThePreviewOfTheSharedFileUsingTheSharingApi(string $path):void { + $shareData = $this->getLastPublicShareData(); + $token = (string) $shareData->data->token; + $this->getPublicPreviewOfFile($path, $token); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When the public accesses the preview of the following files from the last shared public link using the sharing API + * + * @param TableNode $table + * + * @throws Exception + * @return void + */ + public function thePublicAccessesThePreviewOfTheFollowingSharedFileUsingTheSharingApi( + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + $this->emptyLastHTTPStatusCodesArray(); + $this->emptyLastOCSStatusCodesArray(); + foreach ($paths as $path) { + $shareData = $this->getLastPublicShareData(); + $token = (string) $shareData->data->token; + $this->getPublicPreviewOfFile($path["path"], $token); + $this->pushToLastStatusCodesArrays(); + } + } + + /** + * @param string $user + * @param string $shareServer + * @param string|null $password + * + * @return void + */ + public function saveLastSharedPublicLinkShare( + string $user, + string $shareServer, + ?string $password = "" + ):void { + $user = $this->getActualUsername($user); + $userPassword = $this->getPasswordForUser($user); + + $shareData = $this->getLastPublicShareData(); + $owner = (string) $shareData->data->uid_owner; + $name = $this->encodePath((string) $shareData->data->file_target); + $name = \trim($name, "/"); + $ownerDisplayName = (string) $shareData->data->displayname_owner; + $token = (string) $shareData->data->token; + + if (\strtolower($shareServer) == "remote") { + $remote = $this->getRemoteBaseUrl(); + } else { + $remote = $this->getLocalBaseUrl(); + } + + $body['remote'] = $remote; + $body['token'] = $token; + $body['owner'] = $owner; + $body['ownerDisplayName'] = $ownerDisplayName; + $body['name'] = $name; + $body['password'] = $password; + + Assert::assertNotNull( + $token, + __METHOD__ . " could not find any public share" + ); + + $url = $this->getBaseUrl() . "/index.php/apps/files_sharing/external"; + + $response = HttpRequestHelper::post( + $url, + $this->getStepLineRef(), + $user, + $userPassword, + null, + $body + ); + $this->setResponse($response); + } + + /** + * @Given /^user "([^"]*)" has added the public share created from server "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $shareServer + * + * @return void + */ + public function userHasAddedPublicShareCreatedByUser(string $user, string $shareServer):void { + $this->saveLastSharedPublicLinkShare($user, $shareServer); + + $resBody = json_decode($this->response->getBody()->getContents()); + $status = ''; + $message = ''; + if ($resBody) { + $status = $resBody->status; + $message = $resBody->data->message; + } + + Assert::assertEquals( + 200, + $this->response->getStatusCode(), + __METHOD__ + . " Expected status code is '200' but got '" + . $this->response->getStatusCode() + . "'" + ); + Assert::assertNotEquals( + 'error', + $status, + __METHOD__ + . "\nFailed to save public share.\n'$message'" + ); + } + + /** + * @param string $user + * @param string $path + * @param string $type + * @param int $permissions + * + * @return array + */ + public function preparePublicQuickLinkPayload(string $user, string $path, string $type, int $permissions = 1): array { + return [ + "permissions" => $permissions, + "expireDate" => "", + "shareType" => 3, + "itemType" => $type, + "itemSource" => $this->getFileIdForPath($user, $path), + "name" => "Public quick link", + "attributes" => [ + [ + "scope" => "files_sharing", + "key" => "isQuickLink", + "value" => true + ] + ], + "path" => $path + ]; + } + + /** + * @Given /^user "([^"]*)" has created a read only public link for (file|folder) "([^"]*)"$/ + * + * @param string $user + * @param string $type + * @param string $path + * + * @return void + * @throws Exception + */ + public function theUserHasCreatedAReadOnlyPublicLinkForFileFolder(string $user, string $type, string $path):void { + $user = $this->getActualUsername($user); + $userPassword = $this->getPasswordForUser($user); + + $requestPayload = $this->preparePublicQuickLinkPayload($user, $path, $type); + $url = $this->getBaseUrl() . "/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json"; + + $response = HttpRequestHelper::post( + $url, + $this->getStepLineRef(), + $user, + $userPassword, + null, + $requestPayload + ); + $this->setResponse($response); + $this->theHTTPStatusCodeShouldBe(200); + } + + /** + * @When /^user "([^"]*)" adds the public share created from server "([^"]*)" using the sharing API$/ + * + * @param string $user + * @param string $shareServer + * + * @return void + */ + public function userAddsPublicShareCreatedByUser(string $user, string $shareServer):void { + $this->saveLastSharedPublicLinkShare($user, $shareServer); + } + + /** + * Expires last created public link share using the testing API + * + * @return void + * @throws GuzzleException + */ + public function expireLastCreatedPublicLinkShare():void { + $shareId = $this->getLastPublicLinkShareId(); + $this->expireShare($shareId); + } + + /** + * Expires a share using the testing API + * + * @param string|null $shareId optional share id, if null then expire the last share that was created. + * + * @return void + * @throws GuzzleException + */ + public function expireShare(string $shareId = null):void { + $adminUser = $this->getAdminUsername(); + if ($shareId === null) { + $shareId = $this->getLastShareId(); + } + $response = OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $adminUser, + $this->getAdminPassword(), + 'POST', + "/apps/testing/api/v1/expire-share/{$shareId}", + $this->getStepLineRef(), + [], + $this->getOcsApiVersion() + ); + $this->setResponse($response); + } + + /** + * @Given the administrator has expired the last created share using the testing API + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorHasExpiredTheLastCreatedShare():void { + $this->expireShare(); + $httpStatus = $this->getResponse()->getStatusCode(); + Assert::assertSame( + 200, + $httpStatus, + "Request to expire last share failed. HTTP status was '$httpStatus'" + ); + $ocsStatusMessage = $this->ocsContext->getOCSResponseStatusMessage($this->getResponse()); + if ($this->getOcsApiVersion() === 1) { + $expectedOcsStatusCode = "100"; + } else { + $expectedOcsStatusCode = "200"; + } + $this->ocsContext->theOCSStatusCodeShouldBe( + $expectedOcsStatusCode, + "Request to expire last share failed: '$ocsStatusMessage'" + ); + } + + /** + * @Given the administrator has expired the last created public link share using the testing API + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorHasExpiredTheLastCreatedPublicLinkShare():void { + $this->expireLastCreatedPublicLinkShare(); + $httpStatus = $this->getResponse()->getStatusCode(); + Assert::assertSame( + 200, + $this->getResponse()->getStatusCode(), + "Request to expire last public link share failed. HTTP status was '$httpStatus'" + ); + $ocsStatusMessage = $this->ocsContext->getOCSResponseStatusMessage($this->getResponse()); + if ($this->getOcsApiVersion() === 1) { + $expectedOcsStatusCode = "100"; + } else { + $expectedOcsStatusCode = "200"; + } + $this->ocsContext->theOCSStatusCodeShouldBe( + $expectedOcsStatusCode, + "Request to expire last public link share failed: '$ocsStatusMessage'" + ); + } + + /** + * @When the administrator expires the last created share using the testing API + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorExpiresTheLastCreatedShare():void { + $this->expireShare(); + } + + /** + * @When the administrator expires the last created public link share using the testing API + * + * @return void + * @throws GuzzleException + */ + public function theAdministratorExpiresTheLastCreatedPublicLinkShare():void { + $this->expireLastCreatedPublicLinkShare(); + } + + /** + * replace values from table + * + * @param string $field + * @param string $value + * + * @return string + */ + public function replaceValuesFromTable(string $field, string $value):string { + if (\substr($field, 0, 10) === "share_with") { + $value = \str_replace( + "REMOTE", + $this->getRemoteBaseUrl(), + $value + ); + $value = \str_replace( + "LOCAL", + $this->getLocalBaseUrl(), + $value + ); + } + if (\substr($field, 0, 6) === "remote") { + $value = \str_replace( + "REMOTE", + $this->getRemoteBaseUrl(), + $value + ); + $value = \str_replace( + "LOCAL", + $this->getLocalBaseUrl(), + $value + ); + } + if ($field === "permissions") { + if (\is_string($value) && !\is_numeric($value)) { + $value = $this->splitPermissionsString($value); + } + $value = (string)SharingHelper::getPermissionSum($value); + } + if ($field === "share_type") { + $value = (string)SharingHelper::getShareType($value); + } + return $value; + } + + /** + * @return array of common sharing capability settings for testing + */ + protected function getCommonSharingConfigs():array { + return [ + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'auto_accept_share', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_auto_accept_share', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'api_enabled', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_enabled', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'public@@@enabled', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_links', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'public@@@upload', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_public_upload', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'group_sharing', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_group_sharing', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'share_with_group_members_only', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_only_share_with_group_members', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'share_with_membership_groups_only', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_only_share_with_membership_groups', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'exclude_groups_from_sharing', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_exclude_groups', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'exclude_groups_from_sharing_list', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_exclude_groups_list', + 'testingState' => '' + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => + 'user_enumeration@@@enabled', + 'testingApp' => 'core', + 'testingParameter' => + 'shareapi_allow_share_dialog_user_enumeration', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => + 'user_enumeration@@@group_members_only', + 'testingApp' => 'core', + 'testingParameter' => + 'shareapi_share_dialog_user_enumeration_group_members', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'resharing', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_resharing', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => + 'public@@@password@@@enforced_for@@@read_only', + 'testingApp' => 'core', + 'testingParameter' => + 'shareapi_enforce_links_password_read_only', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => + 'public@@@password@@@enforced_for@@@read_write', + 'testingApp' => 'core', + 'testingParameter' => + 'shareapi_enforce_links_password_read_write', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => + 'public@@@password@@@enforced_for@@@upload_only', + 'testingApp' => 'core', + 'testingParameter' => + 'shareapi_enforce_links_password_write_only', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'public@@@send_mail', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_public_notification', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'public@@@social_share', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_social_share', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'public@@@expire_date@@@enabled', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_default_expire_date', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'public@@@expire_date@@@enforced', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_enforce_expire_date', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'user@@@send_mail', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_allow_mail_notification', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'user@@@expire_date@@@enabled', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_default_expire_date_user_share', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'user@@@expire_date@@@enforced', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_enforce_expire_date_user_share', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'group@@@expire_date@@@enabled', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_default_expire_date_group_share', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'group@@@expire_date@@@enforced', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_enforce_expire_date_group_share', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'remote@@@expire_date@@@enabled', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_default_expire_date_remote_share', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'remote@@@expire_date@@@enforced', + 'testingApp' => 'core', + 'testingParameter' => 'shareapi_enforce_expire_date_remote_share', + 'testingState' => false + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'federation@@@outgoing', + 'testingApp' => 'files_sharing', + 'testingParameter' => 'outgoing_server2server_share_enabled', + 'testingState' => true + ], + [ + 'capabilitiesApp' => 'files_sharing', + 'capabilitiesParameter' => 'federation@@@incoming', + 'testingApp' => 'files_sharing', + 'testingParameter' => 'incoming_server2server_share_enabled', + 'testingState' => true + ] + ]; + } +} diff --git a/tests/acceptance/features/bootstrap/TUSContext.php b/tests/acceptance/features/bootstrap/TUSContext.php new file mode 100644 index 000000000..15df1b8a3 --- /dev/null +++ b/tests/acceptance/features/bootstrap/TUSContext.php @@ -0,0 +1,489 @@ + + * + * @copyright Copyright (c) 2020, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use GuzzleHttp\Exception\GuzzleException; +use TestHelpers\HttpRequestHelper; +use TestHelpers\WebDavHelper; +use TusPhp\Exception\ConnectionException; +use TusPhp\Exception\TusException; +use TusPhp\Tus\Client; +use PHPUnit\Framework\Assert; + +require_once 'bootstrap.php'; + +/** + * TUS related test steps + */ +class TUSContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + private $resourceLocation = null; + + /** + * @When user :user creates a new TUS resource on the WebDAV API with these headers: + * + * @param string $user + * @param TableNode $headers + * @param string $content + * + * @return void + * + * @throws Exception + * @throws GuzzleException + */ + public function createNewTUSResourceWithHeaders(string $user, TableNode $headers, string $content = ''): void { + $this->featureContext->verifyTableNodeColumnsCount($headers, 2); + $user = $this->featureContext->getActualUsername($user); + $password = $this->featureContext->getUserPassword($user); + $this->resourceLocation = null; + + $this->featureContext->setResponse( + $this->featureContext->makeDavRequest( + $user, + "POST", + null, + $headers->getRowsHash(), + $content, + "files", + null, + false, + $password + ) + ); + $locationHeader = $this->featureContext->getResponse()->getHeader('Location'); + if (\sizeof($locationHeader) > 0) { + $this->resourceLocation = $locationHeader[0]; + } + } + + /** + * @Given user :user has created a new TUS resource on the WebDAV API with these headers: + * + * @param string $user + * @param TableNode $headers Tus-Resumable: 1.0.0 header is added automatically + * + * @return void + * + * @throws Exception + * @throws GuzzleException + */ + public function createNewTUSResource(string $user, TableNode $headers): void { + $rows = $headers->getRows(); + $rows[] = ['Tus-Resumable', '1.0.0']; + $this->createNewTUSResourceWithHeaders($user, new TableNode($rows)); + $this->featureContext->theHTTPStatusCodeShouldBe(201); + } + + /** + * @When /^user "([^"]*)" sends a chunk to the last created TUS Location with offset "([^"]*)" and data "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $offset + * @param string $data + * @param string $checksum + * + * @return void + * + * @throws GuzzleException + * @throws JsonException + */ + public function sendsAChunkToTUSLocationWithOffsetAndData(string $user, string $offset, string $data, string $checksum = ''): void { + $user = $this->featureContext->getActualUsername($user); + $password = $this->featureContext->getUserPassword($user); + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $this->resourceLocation, + $this->featureContext->getStepLineRef(), + 'PATCH', + $user, + $password, + [ + 'Content-Type' => 'application/offset+octet-stream', + 'Tus-Resumable' => '1.0.0', + 'Upload-Checksum' => $checksum, + 'Upload-Offset' => $offset + ], + $data + ) + ); + WebDavHelper::$SPACE_ID_FROM_OCIS = ''; + } + + /** + * @When user :user uploads file :source to :destination using the TUS protocol on the WebDAV API + * + * @param string|null $user + * @param string $source + * @param string $destination + * @param array $uploadMetadata array of metadata to be placed in the + * `Upload-Metadata` header. + * see https://tus.io/protocols/resumable-upload.html#upload-metadata + * Don't Base64 encode the value. + * @param int $noOfChunks + * @param int|null $bytes + * @param string $checksum + * + * @return void + * @throws ConnectionException + * @throws GuzzleException + * @throws JsonException + * @throws ReflectionException + * @throws TusException + */ + public function userUploadsUsingTusAFileTo( + ?string $user, + string $source, + string $destination, + array $uploadMetadata = [], + int $noOfChunks = 1, + int $bytes = null, + string $checksum = '' + ): void { + $user = $this->featureContext->getActualUsername($user); + $password = $this->featureContext->getUserPassword($user); + $headers = [ + 'Authorization' => 'Basic ' . \base64_encode($user . ':' . $password) + ]; + if ($bytes !== null) { + $creationWithUploadHeader = [ + 'Content-Type' => 'application/offset+octet-stream', + 'Tus-Resumable' => '1.0.0' + ]; + $headers = \array_merge($headers, $creationWithUploadHeader); + } + if ($checksum != '') { + $checksumHeader = [ + 'Upload-Checksum' => $checksum + ]; + $headers = \array_merge($headers, $checksumHeader); + } + + $client = new Client( + $this->featureContext->getBaseUrl(), + ['verify' => false, + 'headers' => $headers + ] + ); + $client->setApiPath( + WebDavHelper::getDavPath( + $user, + $this->featureContext->getDavPathVersion(), + "files", + WebDavHelper::$SPACE_ID_FROM_OCIS + ? WebDavHelper::$SPACE_ID_FROM_OCIS + : $this->featureContext->getPersonalSpaceIdForUser($user) + ) + ); + WebDavHelper::$SPACE_ID_FROM_OCIS = ''; + $client->setMetadata($uploadMetadata); + $sourceFile = $this->featureContext->acceptanceTestsDirLocation() . $source; + $client->setKey((string)rand())->file($sourceFile, $destination); + $this->featureContext->pauseUploadDelete(); + + if ($bytes !== null) { + $client->file($sourceFile, $destination)->createWithUpload($client->getKey(), $bytes); + } elseif ($noOfChunks === 1) { + $client->file($sourceFile, $destination)->upload(); + } else { + $bytesPerChunk = (int)\ceil(\filesize($sourceFile) / $noOfChunks); + for ($i = 0; $i < $noOfChunks; $i++) { + $client->upload($bytesPerChunk); + } + } + $this->featureContext->setLastUploadDeleteTime(\time()); + } + + /** + * @When user :user uploads file with content :content to :destination using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $content + * @param string $destination + * + * @return void + * @throws GuzzleException + * @throws Exception + */ + public function userUploadsAFileWithContentToUsingTus( + string $user, + string $content, + string $destination + ): void { + $tmpfname = $this->writeDataToTempFile($content); + try { + $this->userUploadsUsingTusAFileTo( + $user, + \basename($tmpfname), + $destination + ); + } catch (Exception $e) { + Assert::assertStringContainsString('TusPhp\Exception\FileException: Unable to create resource', (string)$e); + } + \unlink($tmpfname); + } + + /** + * @When user :user uploads file with content :content in :noOfChunks chunks to :destination using the TUS protocol on the WebDAV API + * + * @param string|null $user + * @param string $content + * @param int|null $noOfChunks + * @param string $destination + * + * @return void + * @throws ConnectionException + * @throws GuzzleException + * @throws JsonException + * @throws ReflectionException + * @throws TusException + * @throws Exception + * @throws GuzzleException + */ + public function userUploadsAFileWithContentInChunksUsingTus( + ?string $user, + string $content, + ?int $noOfChunks, + string $destination + ): void { + $tmpfname = $this->writeDataToTempFile($content); + $this->userUploadsUsingTusAFileTo( + $user, + \basename($tmpfname), + $destination, + [], + $noOfChunks + ); + \unlink($tmpfname); + } + + /** + * @When user :user uploads file :source to :destination with mtime :mtime using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * @param string $mtime Time in human readable format is taken as input which is converted into milliseconds that is used by API + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function userUploadsFileWithContentToWithMtimeUsingTUS( + string $user, + string $source, + string $destination, + string $mtime + ): void { + $mtime = new DateTime($mtime); + $mtime = $mtime->format('U'); + $user = $this->featureContext->getActualUsername($user); + $this->userUploadsUsingTusAFileTo( + $user, + $source, + $destination, + ['mtime' => $mtime] + ); + } + + /** + * @param string $content + * + * @return string the file name + * @throws Exception + */ + private function writeDataToTempFile(string $content): string { + $tmpfname = \tempnam( + $this->featureContext->acceptanceTestsDirLocation(), + "tus-upload-test-" + ); + if ($tmpfname === false) { + throw new \Exception("could not create a temporary filename"); + } + $tempfile = \fopen($tmpfname, "w"); + if ($tempfile === false) { + throw new \Exception("could not open " . $tmpfname . " for write"); + } + \fwrite($tempfile, $content); + \fclose($tempfile); + return $tmpfname; + } + + /** + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function setUpScenario(BeforeScenarioScope $scope): void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } + + /** + * @When user :user creates a new TUS resource with content :content on the WebDAV API with these headers: + * + * @param string $user + * @param string $content + * @param TableNode $headers + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function userCreatesWithUpload( + string $user, + string $content, + TableNode $headers + ): void { + $this->createNewTUSResourceWithHeaders($user, $headers, $content); + } + + /** + * @When user :user creates file :source and uploads content :content in the same request using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $source + * @param string $content + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function userUploadsWithCreatesWithUpload( + string $user, + string $source, + string $content + ): void { + $tmpfname = $this->writeDataToTempFile($content); + $this->userUploadsUsingTusAFileTo( + $user, + \basename($tmpfname), + $source, + [], + 1, + -1 + ); + \unlink($tmpfname); + } + + /** + * @When user :user uploads file with checksum :checksum to the last created TUS Location with offset :offset and content :content using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $checksum + * @param string $offset + * @param string $content + * + * @return void + * @throws Exception + */ + public function userUploadsFileWithChecksum( + string $user, + string $checksum, + string $offset, + string $content + ): void { + $this->sendsAChunkToTUSLocationWithOffsetAndData($user, $offset, $content, $checksum); + } + + /** + * @Given user :user has uploaded file with checksum :checksum to the last created TUS Location with offset :offset and content :content using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $checksum + * @param string $offset + * @param string $content + * + * @return void + * @throws Exception + */ + public function userHasUploadedFileWithChecksum( + string $user, + string $checksum, + string $offset, + string $content + ): void { + $this->sendsAChunkToTUSLocationWithOffsetAndData($user, $offset, $content, $checksum); + $this->featureContext->theHTTPStatusCodeShouldBe(204, ""); + } + + /** + * @When user :user sends a chunk to the last created TUS Location with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $offset + * @param string $data + * @param string $checksum + * + * @return void + * @throws Exception + */ + public function userUploadsChunkFileWithChecksum(string $user, string $offset, string $data, string $checksum): void { + $this->sendsAChunkToTUSLocationWithOffsetAndData($user, $offset, $data, $checksum); + } + + /** + * @Given user :user has uploaded a chunk to the last created TUS Location with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API + * + * @param string $user + * @param string $offset + * @param string $data + * @param string $checksum + * + * @return void + * @throws Exception + */ + public function userHasUploadedChunkFileWithChecksum(string $user, string $offset, string $data, string $checksum): void { + $this->sendsAChunkToTUSLocationWithOffsetAndData($user, $offset, $data, $checksum); + $this->featureContext->theHTTPStatusCodeShouldBe(204, ""); + } + + /** + * @When user :user overwrites recently shared file with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API with these headers: + * @When user :user overwrites existing file with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API with these headers: + * + * @param string $user + * @param string $offset + * @param string $data + * @param string $checksum + * @param TableNode $headers Tus-Resumable: 1.0.0 header is added automatically + * + * @return void + * + * @throws GuzzleException + * @throws Exception + */ + public function userOverwritesFileWithChecksum(string $user, string $offset, string $data, string $checksum, TableNode $headers): void { + $this->createNewTUSResource($user, $headers); + $this->userHasUploadedChunkFileWithChecksum($user, $offset, $data, $checksum); + } +} diff --git a/tests/acceptance/features/bootstrap/TrashbinContext.php b/tests/acceptance/features/bootstrap/TrashbinContext.php new file mode 100644 index 000000000..ed0e90c81 --- /dev/null +++ b/tests/acceptance/features/bootstrap/TrashbinContext.php @@ -0,0 +1,1182 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; +use TestHelpers\HttpRequestHelper; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * Trashbin context + */ +class TrashbinContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * + * @var OccContext + */ + private $occContext; + + /** + * @When user :user empties the trashbin using the trashbin API + * + * @param string|null $user user + * + * @return ResponseInterface + */ + public function emptyTrashbin(?string $user):ResponseInterface { + $user = $this->featureContext->getActualUsername($user); + $davPathVersion = $this->featureContext->getDavPathVersion(); + $response = WebDavHelper::makeDavRequest( + $this->featureContext->getBaseUrl(), + $user, + $this->featureContext->getPasswordForUser($user), + 'DELETE', + null, + [], + $this->featureContext->getStepLineRef(), + null, + $davPathVersion, + 'trash-bin' + ); + + $this->featureContext->setResponse($response); + return $response; + } + + /** + * @Given user :user has emptied the trashbin + * + * @param string $user user + * + * @return void + */ + public function userHasEmptiedTrashbin(string $user):void { + $response = $this->emptyTrashbin($user); + + Assert::assertEquals( + 204, + $response->getStatusCode(), + __METHOD__ . " Expected status code was '204' but got '" . $response->getStatusCode() . "'" + ); + } + + /** + * Get files list from the response from trashbin api + * + * @param SimpleXMLElement|null $responseXml + * + * @return array + */ + public function getTrashbinContentFromResponseXml(?SimpleXMLElement $responseXml): array { + $xmlElements = $responseXml->xpath('//d:response'); + $files = \array_map( + static function (SimpleXMLElement $element) { + $href = $element->xpath('./d:href')[0]; + + $propStats = $element->xpath('./d:propstat'); + $successPropStat = \array_filter( + $propStats, + static function (SimpleXMLElement $propStat) { + $status = $propStat->xpath('./d:status'); + return (string) $status[0] === 'HTTP/1.1 200 OK'; + } + ); + if (isset($successPropStat[0])) { + $successPropStat = $successPropStat[0]; + + $name = $successPropStat->xpath('./d:prop/oc:trashbin-original-filename'); + $mtime = $successPropStat->xpath('./d:prop/oc:trashbin-delete-timestamp'); + $resourcetype = $successPropStat->xpath('./d:prop/d:resourcetype'); + if (\array_key_exists(0, $resourcetype) && ($resourcetype[0]->asXML() === "")) { + $collection[0] = true; + } else { + $collection[0] = false; + } + $originalLocation = $successPropStat->xpath('./d:prop/oc:trashbin-original-location'); + } else { + $name = []; + $mtime = []; + $collection = []; + $originalLocation = []; + } + + return [ + 'href' => (string) $href, + 'name' => isset($name[0]) ? (string) $name[0] : null, + 'mtime' => isset($mtime[0]) ? (string) $mtime[0] : null, + 'collection' => isset($collection[0]) ? $collection[0] : false, + 'original-location' => isset($originalLocation[0]) ? (string) $originalLocation[0] : null + ]; + }, + $xmlElements + ); + + return $files; + } + + /** + * List the top of the trashbin folder for a user + * + * @param string|null $user user + * @param string $depth + * + * @return array response + * @throws Exception + */ + public function listTopOfTrashbinFolder(?string $user, string $depth = "infinity"):array { + $password = $this->featureContext->getPasswordForUser($user); + $davPathVersion = $this->featureContext->getDavPathVersion(); + $response = WebDavHelper::listFolder( + $this->featureContext->getBaseUrl(), + $user, + $password, + "", + $depth, + $this->featureContext->getStepLineRef(), + [ + 'oc:trashbin-original-filename', + 'oc:trashbin-original-location', + 'oc:trashbin-delete-timestamp', + 'd:getlastmodified' + ], + 'trash-bin', + $davPathVersion + ); + $this->featureContext->setResponse($response); + $responseXml = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + + $this->featureContext->setResponseXmlObject($responseXml); + $files = $this->getTrashbinContentFromResponseXml($responseXml); + // filter root element + $files = \array_filter( + $files, + static function ($element) use ($user) { + return ($element['href'] !== "/remote.php/dav/trash-bin/$user/"); + } + ); + return $files; + } + + /** + * List trashbin folder + * + * @param string|null $user user + * @param string $depth + * + * @return array of all the items in the trashbin of the user + * @throws Exception + */ + public function listTrashbinFolder(?string $user, string $depth = "1"):array { + return $this->listTrashbinFolderCollection( + $user, + "", + $depth + ); + } + + /** + * List a collection in the trashbin + * + * @param string|null $user user + * @param string|null $collectionPath the string of ids of the folder and sub-folders + * @param string $depth + * @param int $level + * + * @return array response + * @throws Exception + */ + public function listTrashbinFolderCollection(?string $user, ?string $collectionPath = "", string $depth = "1", int $level = 1):array { + // $collectionPath should be some list of file-ids like 2147497661/2147497662 + // or the empty string, which will list the whole trashbin from the top. + $collectionPath = \trim($collectionPath, "/"); + $password = $this->featureContext->getPasswordForUser($user); + $davPathVersion = $this->featureContext->getDavPathVersion(); + $response = WebDavHelper::listFolder( + $this->featureContext->getBaseUrl(), + $user, + $password, + $collectionPath, + $depth, + $this->featureContext->getStepLineRef(), + [ + 'oc:trashbin-original-filename', + 'oc:trashbin-original-location', + 'oc:trashbin-delete-timestamp', + 'd:resourcetype', + 'd:getlastmodified' + ], + 'trash-bin', + $davPathVersion + ); + $responseXml = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ . " $collectionPath" + ); + + $files = $this->getTrashbinContentFromResponseXml($responseXml); + // filter out the collection itself, we only want to return the members + $files = \array_filter( + $files, + static function ($element) use ($user, $collectionPath) { + $path = $collectionPath; + if ($path !== "") { + $path = $path . "/"; + } + return ($element['href'] !== "/remote.php/dav/trash-bin/$user/$path"); + } + ); + + foreach ($files as $file) { + // check for unexpected/invalid href values and fail early in order to + // avoid "common" situations that could cause infinite recursion. + $trashbinRef = $file["href"]; + $trimmedTrashbinRef = \trim($trashbinRef, "/"); + $expectedStart = "remote.php/dav/trash-bin/$user"; + $expectedStartLength = \strlen($expectedStart); + if ((\substr($trimmedTrashbinRef, 0, $expectedStartLength) !== $expectedStart) + || (\strlen($trimmedTrashbinRef) === $expectedStartLength) + ) { + // A top href (maybe without even the username) has been returned + // in the response. That should never happen, or have been filtered out + // by code above. + throw new Exception( + __METHOD__ . " Error: unexpected href in trashbin propfind at level $level: '$trashbinRef'" + ); + } + if ($file["collection"]) { + $trimmedHref = \trim($trashbinRef, "/"); + $explodedHref = \explode("/", $trimmedHref); + $trashbinId = $collectionPath . "/" . end($explodedHref); + $nextFiles = $this->listTrashbinFolderCollection( + $user, + $trashbinId, + $depth, + $level + 1 + ); + // filter the collection element. We only want the members. + $nextFiles = \array_filter( + $nextFiles, + static function ($element) use ($user, $trashbinRef) { + return ($element['href'] !== $trashbinRef); + } + ); + \array_push($files, ...$nextFiles); + } + } + return $files; + } + + /** + * @When user :user lists the resources in the trashbin with depth :depth using the WebDAV API + * + * @param string $user + * @param string $depth + * + * @return void + * @throws Exception + */ + public function userGetsFilesInTheTrashbinWithDepthUsingTheWebdavApi(string $user, string $depth):void { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + $this->listTopOfTrashbinFolder($user, $depth); + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + } + + /** + * @Then the trashbin DAV response should not contain these nodes + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theTrashbinDavResponseShouldNotContainTheseNodes(TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['name']); + $responseXml = $this->featureContext->getResponseXmlObject(); + $files = $this->getTrashbinContentFromResponseXml($responseXml); + + foreach ($table->getHash() as $row) { + $path = trim((string)$row['name'], "/"); + foreach ($files as $file) { + if (trim((string)$file['original-location'], "/") === $path) { + throw new Exception("file $path was not expected in trashbin response but was found"); + } + } + } + } + + /** + * @Then the trashbin DAV response should contain these nodes + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theTrashbinDavResponseShouldContainTheseNodes(TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['name']); + $responseXml = $this->featureContext->getResponseXmlObject(); + + $files = $this->getTrashbinContentFromResponseXml($responseXml); + + foreach ($table->getHash() as $row) { + $path = trim($row['name'], "/"); + $found = false; + foreach ($files as $file) { + if (trim((string)$file['original-location'], "/") === $path) { + $found = true; + break; + } + } + if (!$found) { + throw new Exception("file $path was expected in trashbin response but was not found"); + } + } + } + + /** + * Send a webdav request to list the trashbin content + * + * @param string $user user + * @param string|null $asUser - To send request as another user + * @param string|null $password + * + * @return void + * @throws Exception + */ + public function sendTrashbinListRequest(string $user, ?string $asUser = null, ?string $password = null):void { + $asUser = $asUser ?? $user; + $password = $password ?? $this->featureContext->getPasswordForUser($asUser); + $davPathVersion = $this->featureContext->getDavPathVersion(); + $response = WebDavHelper::propfind( + $this->featureContext->getBaseUrl(), + $asUser, + $password, + null, + [ + 'oc:trashbin-original-filename', + 'oc:trashbin-original-location', + 'oc:trashbin-delete-timestamp', + 'd:getlastmodified' + ], + $this->featureContext->getStepLineRef(), + '1', + 'trash-bin', + $davPathVersion, + $user + ); + $this->featureContext->setResponse($response); + try { + $responseXmlObject = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $this->featureContext->setResponseXmlObject($responseXmlObject); + } catch (Exception $e) { + $this->featureContext->clearResponseXmlObject(); + } + } + + /** + * @When user :asUser tries to list the trashbin content for user :user + * + * @param string $asUser + * @param string $user + * + * @return void + * @throws Exception + */ + public function userTriesToListTheTrashbinContentForUser(string $asUser, string $user) { + $user = $this->featureContext->getActualUsername($user); + $asUser = $this->featureContext->getActualUsername($asUser); + $this->sendTrashbinListRequest($user, $asUser); + } + + /** + * @When user :asUser tries to list the trashbin content for user :user using password :password + * + * @param string $asUser + * @param string $user + * @param string $password + * + * @return void + * @throws Exception + */ + public function userTriesToListTheTrashbinContentForUserUsingPassword(string $asUser, string $user, string $password):void { + $this->sendTrashbinListRequest($user, $asUser, $password); + } + + /** + * @Then the last webdav response should contain the following elements + * + * @param TableNode $elements + * + * @return void + */ + public function theLastWebdavResponseShouldContainFollowingElements(TableNode $elements):void { + $files = $this->getTrashbinContentFromResponseXml($this->featureContext->getResponseXmlObject()); + if (!($elements instanceof TableNode)) { + throw new InvalidArgumentException( + '$expectedElements has to be an instance of TableNode' + ); + } + $elementRows = $elements->getHash(); + foreach ($elementRows as $expectedElement) { + $found = false; + $expectedPath = $expectedElement['path']; + foreach ($files as $file) { + if (\ltrim($expectedPath, "/") === \ltrim($file['original-location'], "/")) { + $found = true; + break; + } + } + Assert::assertTrue($found, "$expectedPath expected to be listed in response but not found"); + } + } + + /** + * @Then the last webdav response should not contain the following elements + * + * @param TableNode $elements + * + * @return void + * @throws Exception + */ + public function theLastWebdavResponseShouldNotContainFollowingElements(TableNode $elements):void { + $files = $this->getTrashbinContentFromResponseXml($this->featureContext->getResponseXmlObject()); + + // 'user' is also allowed in the table even though it is not used anywhere + // This for better readability in feature files + $this->featureContext->verifyTableNodeColumns($elements, ['path'], ['path', 'user']); + $elementRows = $elements->getHash(); + foreach ($elementRows as $expectedElement) { + $notFound = true; + $expectedPath = "/" . \ltrim($expectedElement['path'], "/"); + foreach ($files as $file) { + // Allow the table of expected elements to have entries that do + // not have to specify the "implied" leading slash, or have multiple + // leading slashes, to make scenario outlines more flexible + if ($expectedPath === $file['original-location']) { + $notFound = false; + } + } + Assert::assertTrue($notFound, "$expectedPath expected not to be listed in response but found"); + } + } + + /** + * @When user :asUser tries to delete the file with original path :path from the trashbin of user :user using the trashbin API + * + * @param string $asUser + * @param string $path + * @param string $user + * + * @return void + * @throws Exception + */ + public function userTriesToDeleteFromTrashbinOfUser(string $asUser, string $path, string $user):void { + $numItemsDeleted = $this->tryToDeleteFileFromTrashbin($user, $path, $asUser); + } + + /** + * @When user :asUser tries to delete the file with original path :path from the trashbin of user :user using the password :password and the trashbin API + * + * @param string|null $asUser + * @param string|null $path + * @param string|null $user + * @param string|null $password + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function userTriesToDeleteFromTrashbinOfUserUsingPassword(?string $asUser, ?string $path, ?string $user, ?string $password):void { + $user = $this->featureContext->getActualUsername($user); + $asUser = $this->featureContext->getActualUsername($asUser); + $numItemsDeleted = $this->tryToDeleteFileFromTrashbin($user, $path, $asUser, $password); + } + + /** + * @When user :asUser tries to restore the file with original path :path from the trashbin of user :user using the trashbin API + * + * @param string|null $asUser + * @param string|null $path + * @param string|null $user + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function userTriesToRestoreFromTrashbinOfUser(?string $asUser, ?string $path, ?string $user):void { + $user = $this->featureContext->getActualUsername($user); + $asUser = $this->featureContext->getActualUsername($asUser); + $this->restoreElement($user, $path, null, true, $asUser); + } + + /** + * @When user :asUser tries to restore the file with original path :path from the trashbin of user :user using the password :password and the trashbin API + * + * @param string|null $asUser + * @param string|null $path + * @param string|null $user + * @param string|null $password + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function userTriesToRestoreFromTrashbinOfUserUsingPassword(?string $asUser, ?string $path, ?string $user, ?string $password):void { + $asUser = $this->featureContext->getActualUsername($asUser); + $user = $this->featureContext->getActualUsername($user); + $this->restoreElement($user, $path, null, true, $asUser, $password); + } + + /** + * converts the trashItemHRef from //remote.php/dav/trash-bin/// to /trash-bin// + * + * @param string $href + * + * @return string + */ + private function convertTrashbinHref(string $href):string { + $trashItemHRef = \trim($href, '/'); + $trashItemHRef = \strstr($trashItemHRef, '/trash-bin'); + $trashItemHRef = \trim($trashItemHRef, '/'); + $parts = \explode('/', $trashItemHRef); + $decodedParts = \array_slice($parts, 2); + return '/' . \join('/', $decodedParts); + } + + /** + * @When /^user "([^"]*)" tries to delete the (?:file|folder|entry) with original path "([^"]*)" from the trashbin using the trashbin API$/ + * + * @param string|null $user + * @param string|null $originalPath + * @param string|null $asUser + * @param string|null $password + * + * @return int the number of items that were matched and requested for delete + * @throws JsonException + * @throws Exception + */ + public function tryToDeleteFileFromTrashbin(?string $user, ?string $originalPath, ?string $asUser = null, ?string $password = null):int { + $user = $this->featureContext->getActualUsername($user); + $asUser = $asUser ?? $user; + $listing = $this->listTrashbinFolder($user); + $originalPath = \trim($originalPath, '/'); + $numItemsDeleted = 0; + + foreach ($listing as $entry) { + if ($entry['original-location'] === $originalPath) { + $trashItemHRef = $this->convertTrashbinHref($entry['href']); + $response = $this->featureContext->makeDavRequest( + $asUser, + 'DELETE', + $trashItemHRef, + [], + null, + 'trash-bin', + null, + false, + $password, + [], + $user + ); + $this->featureContext->setResponse($response); + $numItemsDeleted++; + } + } + + return $numItemsDeleted; + } + + /** + * @When /^user "([^"]*)" deletes the (?:file|folder|entry) with original path "([^"]*)" from the trashbin using the trashbin API$/ + * + * @param string $user + * @param string $originalPath + * + * @return void + * @throws Exception + */ + public function deleteFileFromTrashbin(string $user, string $originalPath):void { + $numItemsDeleted = $this->tryToDeleteFileFromTrashbin($user, $originalPath); + + Assert::assertEquals( + 1, + $numItemsDeleted, + "Expected to delete exactly one item from the trashbin but $numItemsDeleted were deleted" + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" deletes the following (?:files|folders|entries) with original path from the trashbin$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function deleteFollowingFilesFromTrashbin(string $user, TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $path) { + $this->deleteFileFromTrashbin($user, $path["path"]); + + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @Then /^as "([^"]*)" (?:file|folder|entry) "([^"]*)" should exist in the trashbin$/ + * + * @param string|null $user + * @param string|null $path + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function asFileOrFolderExistsInTrash(?string $user, ?string $path):void { + $user = $this->featureContext->getActualUsername($user); + $path = \trim($path, '/'); + $sections = \explode('/', $path, 2); + + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + + $firstEntry = $this->findFirstTrashedEntry($user, \trim($sections[0], '/')); + + Assert::assertNotNull( + $firstEntry, + "The first trash entry was not found while looking for trashbin entry '$path' of user '$user'" + ); + + if (\count($sections) !== 1) { + // TODO: handle deeper structures + $listing = $this->listTrashbinFolderCollection($user, \basename(\rtrim($firstEntry['href'], '/'))); + } + + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + + // query was on the main element ? + if (\count($sections) === 1) { + // already found, return + return; + } + + $checkedName = \basename($path); + + $found = false; + foreach ($listing as $entry) { + if ($entry['name'] === $checkedName) { + $found = true; + break; + } + } + + Assert::assertTrue( + $found, + __METHOD__ + . " Could not find expected resource '$path' in the trash" + ); + } + + /** + * Function to check if an element is in the trashbin + * + * @param string|null $user + * @param string|null $originalPath + * + * @return bool + * @throws Exception + */ + private function isInTrash(?string $user, ?string $originalPath):bool { + $techPreviewHadToBeEnabled = $this->occContext->enableDAVTechPreview(); + $res = $this->featureContext->getResponse(); + $listing = $this->listTrashbinFolder($user); + + $this->featureContext->setResponse($res); + if ($techPreviewHadToBeEnabled) { + $this->occContext->disableDAVTechPreview(); + } + + // we don't care if the test step writes a leading "/" or not + $originalPath = \ltrim($originalPath, '/'); + + foreach ($listing as $entry) { + if ($entry['original-location'] !== null && \ltrim($entry['original-location'], '/') === $originalPath) { + return true; + } + } + return false; + } + + /** + * @param string $user + * @param string $trashItemHRef + * @param string $destinationPath + * @param string|null $asUser - To send request as another user + * @param string|null $password + * + * @return ResponseInterface + */ + private function sendUndeleteRequest(string $user, string $trashItemHRef, string $destinationPath, ?string $asUser = null, ?string $password = null):ResponseInterface { + $asUser = $asUser ?? $user; + $destinationPath = \trim($destinationPath, '/'); + $destinationValue = $this->featureContext->getBaseUrl() . "/remote.php/dav/files/$user/$destinationPath"; + + $trashItemHRef = $this->convertTrashbinHref($trashItemHRef); + $headers['Destination'] = $destinationValue; + $response = $this->featureContext->makeDavRequest( + $asUser, + 'MOVE', + $trashItemHRef, + $headers, + null, + 'trash-bin', + '2', + false, + $password, + [], + $user + ); + $this->featureContext->setResponse($response); + return $response; + } + + /** + * @param string $user + * @param string $originalPath + * @param string|null $destinationPath + * @param bool $throwExceptionIfNotFound + * @param string|null $asUser - To send request as another user + * @param string|null $password + * + * @return ResponseInterface|null + * @throws Exception + */ + private function restoreElement(string $user, string $originalPath, ?string $destinationPath = null, bool $throwExceptionIfNotFound = true, ?string $asUser = null, ?string $password = null):?ResponseInterface { + $asUser = $asUser ?? $user; + $listing = $this->listTrashbinFolder($user); + $originalPath = \trim($originalPath, '/'); + if ($destinationPath === null) { + $destinationPath = $originalPath; + } + foreach ($listing as $entry) { + if ($entry['original-location'] === $originalPath) { + return $this->sendUndeleteRequest( + $user, + $entry['href'], + $destinationPath, + $asUser, + $password + ); + } + } + // The requested element to restore was not even in the trashbin. + // Throw an exception, because there was not any API call, and so there + // is also no up-to-date response to examine in later test steps. + if ($throwExceptionIfNotFound) { + throw new \Exception( + __METHOD__ + . " cannot restore from trashbin because no element was found for user $user at original path $originalPath" + ); + } + return null; + } + + /** + * @When user :user restores the folder with original path :originalPath without specifying the destination using the trashbin API + * + * @param $user string + * @param $originalPath string + * + * @return ResponseInterface + * @throws Exception + */ + public function restoreFileWithoutDestination(string $user, string $originalPath):ResponseInterface { + $asUser = $asUser ?? $user; + $listing = $this->listTrashbinFolder($user); + $originalPath = \trim($originalPath, '/'); + + foreach ($listing as $entry) { + if ($entry['original-location'] === $originalPath) { + $trashItemHRef = $this->convertTrashbinHref($entry['href']); + $response = $this->featureContext->makeDavRequest( + $asUser, + 'MOVE', + $trashItemHRef, + [], + null, + 'trash-bin' + ); + $this->featureContext->setResponse($response); + // this gives empty response in ocis + try { + $responseXml = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + $this->featureContext->setResponseXmlObject($responseXml); + } catch (Exception $e) { + } + + return $response; + } + } + throw new \Exception( + __METHOD__ + . " cannot restore from trashbin because no element was found for user $user at original path $originalPath" + ); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" if the file is also in the trashbin should be "([^"]*)" otherwise "([^"]*)"$/ + * + * Note: this is a special step for an unusual bug combination. + * Delete it when the bug is fixed and the step is no longer needed. + * + * @param string|null $fileName + * @param string|null $user + * @param string|null $content + * @param string|null $alternativeContent + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function contentOfFileForUserIfAlsoInTrashShouldBeOtherwise( + ?string $fileName, + ?string $user, + ?string $content, + ?string $alternativeContent + ):void { + $isInTrash = $this->isInTrash($user, $fileName); + $user = $this->featureContext->getActualUsername($user); + $this->featureContext->downloadFileAsUserUsingPassword($user, $fileName); + if ($isInTrash) { + $this->featureContext->downloadedContentShouldBe($content); + } else { + $this->featureContext->downloadedContentShouldBe($alternativeContent); + } + } + + /** + * @When /^user "([^"]*)" tries to restore the (?:file|folder|entry) with original path "([^"]*)" using the trashbin API$/ + * + * @param string $user + * @param string $originalPath + * + * @return void + * @throws Exception + */ + public function userTriesToRestoreElementInTrash(string $user, string $originalPath):void { + $this->restoreElement($user, $originalPath, null, false); + } + + /** + * @When /^user "([^"]*)" restores the (?:file|folder|entry) with original path "([^"]*)" using the trashbin API$/ + * + * @param string|null $user + * @param string $originalPath + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function elementInTrashIsRestored(?string $user, string $originalPath):void { + $user = $this->featureContext->getActualUsername($user); + $this->restoreElement($user, $originalPath); + } + + /** + * @When /^user "([^"]*)" restores the following (?:files|folders|entries) with original path$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userRestoresFollowingFiles(string $user, TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $originalPath) { + $this->elementInTrashIsRestored($user, $originalPath["path"]); + + $this->featureContext->pushToLastStatusCodesArrays(); + } + } + + /** + * @Given /^user "([^"]*)" has restored the (?:file|folder|entry) with original path "([^"]*)"$/ + * + * @param string $user + * @param string $originalPath + * + * @return void + * @throws Exception + */ + public function elementInTrashHasBeenRestored(string $user, string $originalPath):void { + $this->restoreElement($user, $originalPath); + if ($this->isInTrash($user, $originalPath)) { + throw new Exception("File previously located at $originalPath is still in the trashbin"); + } + } + + /** + * @When /^user "([^"]*)" restores the (?:file|folder|entry) with original path "([^"]*)" to "([^"]*)" using the trashbin API$/ + * + * @param string|null $user + * @param string|null $originalPath + * @param string|null $destinationPath + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function userRestoresTheFileWithOriginalPathToUsingTheTrashbinApi( + ?string $user, + ?string $originalPath, + ?string $destinationPath + ):void { + $user = $this->featureContext->getActualUsername($user); + $this->restoreElement($user, $originalPath, $destinationPath); + } + + /** + * @Then /^as "([^"]*)" the (?:file|folder|entry) with original path "([^"]*)" should exist in the trashbin$/ + * + * @param string|null $user + * @param string|null $originalPath + * + * @return void + * @throws JsonException + * @throws Exception + */ + public function elementIsInTrashCheckingOriginalPath( + ?string $user, + ?string $originalPath + ):void { + $user = $this->featureContext->getActualUsername($user); + Assert::assertTrue( + $this->isInTrash($user, $originalPath), + "File previously located at $originalPath wasn't found in the trashbin of user $user" + ); + } + + /** + * @Then /^as "([^"]*)" the (?:file|folder|entry) with original path "([^"]*)" should not exist in the trashbin/ + * + * @param string|null $user + * @param string $originalPath + * + * @return void + * @throws Exception + */ + public function elementIsNotInTrashCheckingOriginalPath( + ?string $user, + string $originalPath + ):void { + $user = $this->featureContext->getActualUsername($user); + Assert::assertFalse( + $this->isInTrash($user, $originalPath), + "File previously located at $originalPath was found in the trashbin of user $user" + ); + } + + /** + * @Then /^as "([^"]*)" the (?:files|folders|entries) with following original paths should not exist in the trashbin$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function followingElementsAreNotInTrashCheckingOriginalPath( + string $user, + TableNode $table + ):void { + $this->featureContext->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash($table); + + foreach ($paths as $originalPath) { + $this->elementIsNotInTrashCheckingOriginalPath($user, $originalPath["path"]); + } + } + + /** + * @Then /^as "([^"]*)" the (?:files|folders|entries) with following original paths should exist in the trashbin$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function followingElementsAreInTrashCheckingOriginalPath( + string $user, + TableNode $table + ):void { + $this->featureContext->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash($table); + + foreach ($paths as $originalPath) { + $this->elementIsInTrashCheckingOriginalPath($user, $originalPath["path"]); + } + } + + /** + * Finds the first trashed entry matching the given name + * + * @param string $user + * @param string $name + * + * @return array|null real entry name with timestamp suffix or null if not found + * @throws Exception + */ + private function findFirstTrashedEntry(string $user, string $name):?array { + $listing = $this->listTrashbinFolder($user); + + foreach ($listing as $entry) { + if ($entry['name'] === $name) { + return $entry; + } + } + + return null; + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + $this->occContext = $environment->getContext('OccContext'); + } + + /** + * @Then /^the deleted (?:file|folder) "([^"]*)" should have the correct deletion mtime in the response$/ + * + * @param string $resource file or folder in trashbin + * + * @return void + */ + public function theDeletedFileFolderShouldHaveCorrectDeletionMtimeInTheResponse(string $resource):void { + $files = $this->getTrashbinContentFromResponseXml( + $this->featureContext->getResponseXmlObject() + ); + + $found = false; + $expectedMtime = $this->featureContext->getLastUploadDeleteTime(); + $responseMtime = ''; + + foreach ($files as $file) { + if (\ltrim((string)$resource, "/") === \ltrim((string)$file['original-location'], "/")) { + $responseMtime = $file['mtime']; + $mtime_difference = \abs((int)\trim((string)$expectedMtime) - (int)\trim($responseMtime)); + + if ($mtime_difference <= 2) { + $found = true; + break; + } + } + } + Assert::assertTrue( + $found, + "$resource expected to be listed in response with mtime '$expectedMtime' but found '$responseMtime'" + ); + } + + /** + * @Given the administrator has set the following file extensions to be skipped from the trashbin + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetFollowingFileExtensionsToBeSkippedFromTrashbin(TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['extension']); + foreach ($table->getHash() as $idx => $row) { + $this->featureContext->runOcc(['config:system:set', 'trashbin_skip_extensions', $idx, '--value=' . $row['extension']]); + } + } + + /** + * @Given the administrator has set the following directories to be skipped from the trashbin + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetFollowingDirectoriesToBeSkippedFromTrashbin(TableNode $table):void { + $this->featureContext->verifyTableNodeColumns($table, ['directory']); + foreach ($table->getHash() as $idx => $row) { + $this->featureContext->runOcc(['config:system:set', 'trashbin_skip_directories', $idx, '--value=' . $row['directory']]); + } + } + + /** + * @Given the administrator has set the trashbin skip size threshold to :threshold + * + * @param string $threshold + * + * @return void + * @throws Exception + */ + public function theAdministratorHasSetTrashbinSkipSizethreshold(string $threshold) { + $this->featureContext->runOcc(['config:system:set', 'trashbin_skip_size_threshold', '--value=' . $threshold]); + } +} diff --git a/tests/acceptance/features/bootstrap/WebDav.php b/tests/acceptance/features/bootstrap/WebDav.php new file mode 100644 index 000000000..d966840cd --- /dev/null +++ b/tests/acceptance/features/bootstrap/WebDav.php @@ -0,0 +1,5527 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +use Behat\Gherkin\Node\PyStringNode; +use Behat\Gherkin\Node\TableNode; +use GuzzleHttp\Exception\BadResponseException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Ring\Exception\ConnectException; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; +use GuzzleHttp\Stream\StreamInterface; +use TestHelpers\OcisHelper; +use TestHelpers\OcsApiHelper; +use TestHelpers\SetupHelper; +use TestHelpers\UploadHelper; +use TestHelpers\WebDavHelper; +use TestHelpers\HttpRequestHelper; +use TestHelpers\Asserts\WebDav as WebDavAssert; + +/** + * WebDav functions + */ +trait WebDav { + /** + * @var string + */ + private $davPath = "remote.php/webdav"; + + /** + * @var boolean + */ + private $usingOldDavPath = true; + + /** + * @var boolean + */ + private $usingSpacesDavPath = false; + + /** + * @var ResponseInterface[] + */ + private $uploadResponses; + + /** + * @var integer + */ + private $storedFileID = null; + + /** + * @var int + */ + private $lastUploadDeleteTime = null; + + /** + * a variable that contains the DAV path without "remote.php/(web)dav" + * when setting $this->davPath directly by usingDavPath() + * + * @var string + */ + private $customDavPath = null; + + private $previousAsyncSetting = null; + + private $previousDavSlowdownSetting = null; + + /** + * @var int + */ + private $currentDavSlowdownSettingSeconds = 0; + + /** + * response content parsed from XML to an array + * + * @var array + */ + private $responseXml = []; + + /** + * add resource created by admin in an array + * This array is used while cleaning up the resource created by admin during test run + * As of now it tracks only for (files|folder) creation + * This can be expanded and modified to track other actions like (upload, deleted ..) + * + * @var array + */ + private $adminResources = []; + + /** + * response content parsed into a SimpleXMLElement + * + * @var SimpleXMLElement + */ + private $responseXmlObject; + + private $httpRequestTimeout = 0; + + private $chunkingToUse = null; + + /** + * The ability to do requests with depth infinity is disabled by default. + * This remembers when the setting dav.propfind.depth_infinity has been + * enabled, so that test code can make use of it as appropriate. + * + * @var bool + */ + private $davPropfindDepthInfinityEnabled = false; + + /** + * @return void + */ + public function davPropfindDepthInfinityEnabled():void { + $this->davPropfindDepthInfinityEnabled = true; + } + + /** + * @return void + */ + public function davPropfindDepthInfinityDisabled():void { + $this->davPropfindDepthInfinityEnabled = false; + } + + /** + * @return bool + */ + public function davPropfindDepthInfinityIsEnabled():bool { + return $this->davPropfindDepthInfinityEnabled; + } + + /** + * @param int $lastUploadDeleteTime + * + * @return void + */ + public function setLastUploadDeleteTime(int $lastUploadDeleteTime):void { + $this->lastUploadDeleteTime = $lastUploadDeleteTime; + } + + /** + * @return number + */ + public function getLastUploadDeleteTime():int { + return $this->lastUploadDeleteTime; + } + + /** + * @return SimpleXMLElement|null + */ + public function getResponseXmlObject():?SimpleXMLElement { + return $this->responseXmlObject; + } + + /** + * @param SimpleXMLElement $responseXmlObject + * + * @return void + */ + public function setResponseXmlObject(SimpleXMLElement $responseXmlObject):void { + $this->responseXmlObject = $responseXmlObject; + } + + /** + * @return void + */ + public function clearResponseXmlObject():void { + $this->responseXmlObject = null; + } + + /** + * + * @return string the etag or an empty string if the getetag property does not exist + */ + public function getEtagFromResponseXmlObject():string { + $xmlObject = $this->getResponseXmlObject(); + $xmlPart = $xmlObject->xpath("//d:prop/d:getetag"); + if (!\is_array($xmlPart) || (\count($xmlPart) === 0)) { + return ''; + } + return $xmlPart[0]->__toString(); + } + + /** + * + * @param string|null $eTag if null then get eTag from response XML object + * + * @return boolean + */ + public function isEtagValid(?string $eTag = null):bool { + if ($eTag === null) { + $eTag = $this->getEtagFromResponseXmlObject(); + } + if (\preg_match("/^\"[a-f0-9:\.]{1,32}\"$/", $eTag) + ) { + return true; + } else { + return false; + } + } + + /** + * @param array $responseXml + * + * @return void + */ + public function setResponseXml(array $responseXml):void { + $this->responseXml = $responseXml; + } + + /** + * @param ResponseInterface[] $uploadResponses + * + * @return void + */ + public function setUploadResponses(array $uploadResponses):void { + $this->uploadResponses = $uploadResponses; + } + + /** + * @Given /^using DAV path "([^"]*)"$/ + * + * @param string $davPath + * + * @return void + */ + public function usingDavPath(string $davPath):void { + $this->davPath = $davPath; + $this->customDavPath = \preg_replace( + "/remote\.php\/(web)?dav\//", + "", + $davPath + ); + } + + /** + * @return string + */ + public function getOldDavPath():string { + return "remote.php/webdav"; + } + + /** + * @return string + */ + public function getNewDavPath():string { + return "remote.php/dav"; + } + + /** + * @return string + */ + public function getSpacesDavPath():string { + return "dav/spaces"; + } + + /** + * @Given /^using (old|new|spaces) (?:dav|DAV) path$/ + * + * @param string $davChoice + * + * @return void + */ + public function usingOldOrNewDavPath(string $davChoice):void { + if ($davChoice === 'old') { + $this->usingOldDavPath(); + } elseif ($davChoice === 'new') { + $this->usingNewDavPath(); + } else { + $this->usingSpacesDavPath(); + } + } + + /** + * Select the old DAV path as the default for later scenario steps + * + * @return void + */ + public function usingOldDavPath():void { + $this->davPath = $this->getOldDavPath(); + $this->usingOldDavPath = true; + $this->customDavPath = null; + } + + /** + * Select the new DAV path as the default for later scenario steps + * + * @return void + */ + public function usingNewDavPath():void { + $this->davPath = $this->getNewDavPath(); + $this->usingOldDavPath = false; + $this->customDavPath = null; + $this->usingSpacesDavPath = false; + } + + /** + * Select the spaces dav path as the default for later scenario steps + * + * @return void + */ + public function usingSpacesDavPath():void { + $this->davPath = $this->getSpacesDavPath(); + $this->usingOldDavPath = false; + $this->customDavPath = null; + $this->usingSpacesDavPath = true; + } + + /** + * gives the DAV path of a file including the subfolder of the webserver + * e.g. when the server runs in `http://localhost/owncloud/` + * this function will return `owncloud/remote.php/webdav/prueba.txt` + * + * @param string $user + * + * @return string + * @throws GuzzleException + */ + public function getFullDavFilesPath(string $user):string { + $spaceId = null; + if ($this->getDavPathVersion() === WebDavHelper::DAV_VERSION_SPACES) { + $spaceId = (WebDavHelper::$SPACE_ID_FROM_OCIS) ? WebDavHelper::$SPACE_ID_FROM_OCIS : WebDavHelper::getPersonalSpaceIdForUser( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + $this->getStepLineRef() + ); + } + $path = $this->getBasePath() . "/" . + WebDavHelper::getDavPath($user, $this->getDavPathVersion(), "files", $spaceId); + $path = WebDavHelper::sanitizeUrl($path); + return \ltrim($path, "/"); + } + + /** + * @param string $token + * @param string $type + * + * @return string + */ + public function getPublicLinkDavPath(string $token, string $type):string { + $path = $this->getBasePath() . "/" . + WebDavHelper::getDavPath($token, $this->getDavPathVersion(), $type); + $path = WebDavHelper::sanitizeUrl($path); + return \ltrim($path, "/"); + } + + /** + * Select a suitable DAV path version number. + * Some endpoints have only existed since a certain point in time, so for + * those make sure to return a DAV path version that works for that endpoint. + * Otherwise return the currently selected DAV path version. + * + * @param string|null $for the category of endpoint that the DAV path will be used for + * + * @return int DAV path version (1, 2 or 3) selected, or appropriate for the endpoint + */ + public function getDavPathVersion(?string $for = null):?int { + if ($this->usingSpacesDavPath) { + return WebDavHelper::DAV_VERSION_SPACES; + } + if ($for === 'systemtags') { + // systemtags only exists since DAV v2 + return WebDavHelper::DAV_VERSION_NEW; + } + if ($for === 'file_versions') { + // file_versions only exists since DAV v2 + return WebDavHelper::DAV_VERSION_NEW; + } + if ($this->usingOldDavPath === true) { + return WebDavHelper::DAV_VERSION_OLD; + } else { + return WebDavHelper::DAV_VERSION_NEW; + } + } + + /** + * Select a suitable DAV path. + * Some endpoints have only existed since a certain point in time, so for + * those make sure to return a DAV path that works for that endpoint. + * Otherwise return the currently selected DAV path. + * + * @param string|null $for the category of endpoint that the DAV path will be used for + * + * @return string DAV path selected, or appropriate for the endpoint + */ + public function getDavPath(?string $for = null):string { + $davPathVersion = $this->getDavPathVersion($for); + if ($davPathVersion === WebDavHelper::DAV_VERSION_OLD) { + return $this->getOldDavPath(); + } + + if ($davPathVersion === WebDavHelper::DAV_VERSION_NEW) { + return $this->getNewDavPath(); + } + + return $this->getSpacesDavPath(); + } + + /** + * @param string|null $user + * @param string|null $method + * @param string|null $path + * @param array|null $headers + * @param StreamInterface|null $body + * @param string|null $type + * @param string|null $davPathVersion + * @param bool $stream Set to true to stream a response rather + * than download it all up-front. + * @param string|null $password + * @param array|null $urlParameter + * @param string|null $doDavRequestAsUser + * + * @return ResponseInterface + * @throws GuzzleException|JsonException + */ + public function makeDavRequest( + ?string $user, + ?string $method, + ?string $path, + ?array $headers, + $body = null, + ?string $type = "files", + ?string $davPathVersion = null, + bool $stream = false, + ?string $password = null, + ?array $urlParameter = [], + ?string $doDavRequestAsUser = null + ):ResponseInterface { + $user = $this->getActualUsername($user); + if ($this->customDavPath !== null) { + $path = $this->customDavPath . $path; + } + + if ($davPathVersion === null) { + $davPathVersion = $this->getDavPathVersion(); + } else { + $davPathVersion = (int) $davPathVersion; + } + + if ($password === null) { + $password = $this->getPasswordForUser($user); + } + + return WebDavHelper::makeDavRequest( + $this->getBaseUrl(), + $user, + $password, + $method, + $path, + $headers, + $this->getStepLineRef(), + $body, + $davPathVersion, + $type, + null, + "basic", + $stream, + $this->httpRequestTimeout, + null, + $urlParameter, + $doDavRequestAsUser + ); + } + + /** + * @param string $user + * @param string|null $path + * @param string|null $doDavRequestAsUser + * @param string|null $width + * @param string|null $height + * + * @return void + */ + public function downloadPreviews(string $user, ?string $path, ?string $doDavRequestAsUser, ?string $width, ?string $height):void { + $user = $this->getActualUsername($user); + $doDavRequestAsUser = $this->getActualUsername($doDavRequestAsUser); + $urlParameter = [ + 'x' => $width, + 'y' => $height, + 'forceIcon' => '0', + 'preview' => '1' + ]; + $this->response = $this->makeDavRequest( + $user, + "GET", + $path, + [], + null, + "files", + '2', + false, + null, + $urlParameter, + $doDavRequestAsUser + ); + } + + /** + * @Then the number of versions should be :arg1 + * + * @param int $number + * + * @return void + * @throws Exception + */ + public function theNumberOfVersionsShouldBe(int $number):void { + $resXml = $this->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->getResponse(), + __METHOD__ + ); + $this->setResponseXmlObject($resXml); + } + $xmlPart = $resXml->xpath("//d:getlastmodified"); + $actualNumber = \count($xmlPart); + Assert::assertEquals( + $number, + $actualNumber, + "Expected number of versions was '$number', but got '$actualNumber'" + ); + } + + /** + * @Then the number of etag elements in the response should be :number + * + * @param int $number + * + * @return void + * @throws Exception + */ + public function theNumberOfEtagElementInTheResponseShouldBe(int $number):void { + $resXml = $this->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->getResponse(), + __METHOD__ + ); + } + $xmlPart = $resXml->xpath("//d:getetag"); + $actualNumber = \count($xmlPart); + Assert::assertEquals( + $number, + $actualNumber, + "Expected number of etag elements was '$number', but got '$actualNumber'" + ); + } + + /** + * @Given /^the administrator has (enabled|disabled) async operations$/ + * + * @param string $enabledOrDisabled + * + * @return void + * @throws Exception + */ + public function triggerAsyncUpload(string $enabledOrDisabled):void { + $switch = ($enabledOrDisabled !== "disabled"); + if ($switch) { + $value = 'true'; + } else { + $value = 'false'; + } + if ($this->previousAsyncSetting === null) { + $previousAsyncSetting = SetupHelper::runOcc( + ['config:system:get', 'dav.enable.async'], + $this->getStepLineRef() + )['stdOut']; + $this->previousAsyncSetting = \trim($previousAsyncSetting); + } + $this->runOcc( + [ + 'config:system:set', + 'dav.enable.async', + '--type', + 'boolean', + '--value', + $value + ] + ); + } + + /** + * @Given the HTTP-Request-timeout is set to :seconds seconds + * + * @param int $timeout + * + * @return void + */ + public function setHttpTimeout(int $timeout):void { + $this->httpRequestTimeout = (int) $timeout; + } + + /** + * @Given the :method DAV requests are slowed down by :seconds seconds + * + * @param string $method + * @param int $seconds + * + * @return void + * @throws Exception + */ + public function slowdownDavRequests(string $method, int $seconds):void { + if ($this->previousDavSlowdownSetting === null) { + $previousDavSlowdownSetting = SetupHelper::runOcc( + ['config:system:get', 'dav.slowdown'], + $this->getStepLineRef() + )['stdOut']; + $this->previousDavSlowdownSetting = \trim($previousDavSlowdownSetting); + } + OcsApiHelper::sendRequest( + $this->getBaseUrl(), + $this->getAdminUsername(), + $this->getAdminPassword(), + "PUT", + "/apps/testing/api/v1/davslowdown/$method/$seconds", + $this->getStepLineRef() + ); + $this->currentDavSlowdownSettingSeconds = $seconds; + } + + /** + * Wait for possible slowed-down DAV requests to finish + * + * @return void + */ + public function waitForDavRequestsToFinish():void { + if ($this->currentDavSlowdownSettingSeconds > 0) { + // There could be a slowed-down request still happening on the server + // Wait just-in-case so that we do not accidentally have an effect on + // the next scenario. + \sleep($this->currentDavSlowdownSettingSeconds); + } + } + + /** + * @param string $user + * @param string $fileDestination + * + * @return string + * @throws GuzzleException + */ + public function destinationHeaderValue(string $user, string $fileDestination):string { + $spaceId = $this->getPersonalSpaceIdForUser($user); + $fullUrl = $this->getBaseUrl() . '/' . + WebDavHelper::getDavPath($user, $this->getDavPathVersion(), "files", $spaceId); + return \rtrim($fullUrl, '/') . '/' . \ltrim($fileDestination, '/'); + } + + /** + * @Given /^user "([^"]*)" has moved (?:file|folder|entry) "([^"]*)" to "([^"]*)"$/ + * + * @param string|null $user + * @param string|null $fileSource + * @param string|null $fileDestination + * + * @return void + */ + public function userHasMovedFile( + ?string $user, + ?string $fileSource, + ?string $fileDestination + ):void { + $user = $this->getActualUsername($user); + $headers['Destination'] = $this->destinationHeaderValue( + $user, + $fileDestination + ); + $this->response = $this->makeDavRequest( + $user, + "MOVE", + $fileSource, + $headers + ); + $expectedStatusCode = 201; + $actualStatusCode = $this->response->getStatusCode(); + Assert::assertEquals( + $expectedStatusCode, + $actualStatusCode, + __METHOD__ . " Failed moving resource '$fileSource' to '$fileDestination'." + . " Expected status code was '$expectedStatusCode' but got '$actualStatusCode'" + ); + } + + /** + * @Given /^the user has moved (?:file|folder|entry) "([^"]*)" to "([^"]*)"$/ + * + * @param string $fileSource + * @param string $fileDestination + * + * @return void + */ + public function theUserHasMovedFile(string $fileSource, string $fileDestination):void { + $this->userHasMovedFile($this->getCurrentUser(), $fileSource, $fileDestination); + } + + /** + * @When /^user "([^"]*)" moves (file|folder|entry) "([^"]*)"\s?(asynchronously|) to these (?:filenames|foldernames|entries) using the webDAV API then the results should be as listed$/ + * + * @param string $user + * @param string $entry + * @param string $fileSource + * @param string $type "asynchronously" or empty + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userMovesEntriesUsingTheAPI( + string $user, + string $entry, + string $fileSource, + string $type, + TableNode $table + ):void { + $user = $this->getActualUsername($user); + foreach ($table->getHash() as $row) { + // Allow the "filename" column to be optionally be called "foldername" + // to help readability of scenarios that test moving folders + if (isset($row['foldername'])) { + $targetName = $row['foldername']; + } else { + $targetName = $row['filename']; + } + $this->userMovesFileUsingTheAPI( + $user, + $fileSource, + $type, + $targetName + ); + $this->theHTTPStatusCodeShouldBe( + $row['http-code'], + "HTTP status code is not the expected value while trying to move " . $targetName + ); + if ($row['exists'] === "yes") { + $this->asFileOrFolderShouldExist($user, $entry, $targetName); + // The move was successful. + // Move the file/folder back so the source file/folder exists for the next move + $this->userMovesFileUsingTheAPI( + $user, + $targetName, + '', + $fileSource + ); + } else { + $this->asFileOrFolderShouldNotExist($user, $entry, $targetName); + } + } + } + + /** + * @When /^user "([^"]*)" moves (?:file|folder|entry) "([^"]*)"\s?(asynchronously|) to "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $fileSource + * @param string $type "asynchronously" or empty + * @param string $fileDestination + * + * @return void + * @throws JsonException + * @throws GuzzleException + */ + public function userMovesFileUsingTheAPI( + string $user, + string $fileSource, + string $type, + string $fileDestination + ):void { + $user = $this->getActualUsername($user); + $headers['Destination'] = $this->destinationHeaderValue( + $user, + $fileDestination + ); + $stream = false; + if ($type === "asynchronously") { + $headers['OC-LazyOps'] = 'true'; + if ($this->httpRequestTimeout > 0) { + //LazyOps is set and a request timeout, so we want to use stream + //to be able to read data from the request before its times out + //when doing LazyOps the server does not close the connection + //before its really finished + //but we want to read JobStatus-Location before the end of the job + //to see if it reports the correct values + $stream = true; + } + } + try { + $this->response = $this->makeDavRequest( + $user, + "MOVE", + $fileSource, + $headers, + null, + "files", + null, + $stream + ); + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + $this->pushToLastHttpStatusCodesArray( + (string) $this->getResponse()->getStatusCode() + ); + } catch (ConnectException $e) { + } + } + + /** + * @When user :user moves the following file using the WebDAV API + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userMovesTheFollowingFileUsingTheWebdavApi(string $user, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["source", "destination"]); + $rows = $table->getHash(); + foreach ($rows as $row) { + $this->userMovesFileUsingTheAPI($user, $row["source"], "", $row["destination"]); + } + } + + /** + * @When /^user "([^"]*)" moves the following (?:files|folders|entries)\s?(asynchronously|) using the WebDAV API$/ + * + * @param string $user + * @param string $type "asynchronously" or empty + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userMovesFollowingFileUsingTheAPI( + string $user, + string $type, + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["from", "to"]); + $paths = $table->getHash(); + + foreach ($paths as $file) { + $this->userMovesFileUsingTheAPI($user, $file['from'], $type, $file['to']); + $this->pushToLastStatusCodesArrays(); + } + } + + /** + * @Then /^user "([^"]*)" should be able to rename (file|folder|entry) "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $entry + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function theUserShouldBeAbleToRenameEntryTo(string $user, string $entry, string $source, string $destination):void { + $user = $this->getActualUsername($user); + $this->asFileOrFolderShouldExist($user, $entry, $source); + $this->userMovesFileUsingTheAPI($user, $source, "", $destination); + $this->asFileOrFolderShouldNotExist($user, $entry, $source); + $this->asFileOrFolderShouldExist($user, $entry, $destination); + } + + /** + * @Then /^user "([^"]*)" should not be able to rename (file|folder|entry) "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $entry + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function theUserShouldNotBeAbleToRenameEntryTo(string $user, string $entry, string $source, string $destination):void { + $this->asFileOrFolderShouldExist($user, $entry, $source); + $this->userMovesFileUsingTheAPI($user, $source, "", $destination); + $this->asFileOrFolderShouldExist($user, $entry, $source); + $this->asFileOrFolderShouldNotExist($user, $entry, $destination); + } + + /** + * @When /^user "([^"]*)" on "(LOCAL|REMOTE)" moves (?:file|folder|entry) "([^"]*)" to "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $server + * @param string $fileSource + * @param string $fileDestination + * + * @return void + */ + public function userOnMovesFileUsingTheAPI( + string $user, + string $server, + string $fileSource, + string $fileDestination + ):void { + $previousServer = $this->usingServer($server); + $this->userMovesFileUsingTheAPI($user, $fileSource, "", $fileDestination); + $this->usingServer($previousServer); + } + + /** + * @When /^user "([^"]*)" copies (?:file|folder) "([^"]*)" to "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $fileSource + * @param string $fileDestination + * + * @return void + */ + public function userCopiesFileUsingTheAPI( + string $user, + string $fileSource, + string $fileDestination + ):void { + $user = $this->getActualUsername($user); + $headers['Destination'] = $this->destinationHeaderValue( + $user, + $fileDestination + ); + $this->response = $this->makeDavRequest( + $user, + "COPY", + $fileSource, + $headers + ); + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + $this->pushToLastHttpStatusCodesArray( + (string) $this->getResponse()->getStatusCode() + ); + } + + /** + * @Given /^user "([^"]*)" has copied file "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $fileSource + * @param string $fileDestination + * + * @return void + */ + public function userHasCopiedFileUsingTheAPI( + string $user, + string $fileSource, + string $fileDestination + ):void { + $this->userCopiesFileUsingTheAPI($user, $fileSource, $fileDestination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to copy file '$fileSource' to '$fileDestination' for user '$user'" + ); + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @When /^the user copies file "([^"]*)" to "([^"]*)" using the WebDAV API$/ + * + * @param string $fileSource + * @param string $fileDestination + * + * @return void + */ + public function theUserCopiesFileUsingTheAPI(string $fileSource, string $fileDestination):void { + $this->userCopiesFileUsingTheAPI($this->getCurrentUser(), $fileSource, $fileDestination); + } + + /** + * @Given /^the user has copied file "([^"]*)" to "([^"]*)"$/ + * + * @param string $fileSource + * @param string $fileDestination + * + * @return void + */ + public function theUserHasCopiedFileUsingTheAPI(string $fileSource, string $fileDestination):void { + $this->theUserCopiesFileUsingTheAPI($fileSource, $fileDestination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to copy file '$fileSource' to '$fileDestination'" + ); + } + + /** + * @When /^the user downloads file "([^"]*)" with range "([^"]*)" using the WebDAV API$/ + * + * @param string $fileSource + * @param string $range + * + * @return void + */ + public function downloadFileWithRange(string $fileSource, string $range):void { + $this->userDownloadsFileWithRange( + $this->currentUser, + $fileSource, + $range + ); + } + + /** + * @When /^user "([^"]*)" downloads file "([^"]*)" with range "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $fileSource + * @param string $range + * + * @return void + */ + public function userDownloadsFileWithRange(string $user, string $fileSource, string $range):void { + $user = $this->getActualUsername($user); + $headers['Range'] = $range; + $this->response = $this->makeDavRequest( + $user, + "GET", + $fileSource, + $headers + ); + } + + /** + * @Then /^user "([^"]*)" using password "([^"]*)" should not be able to download file "([^"]*)"$/ + * + * @param string $user + * @param string $password + * @param string $fileName + * + * @return void + */ + public function userUsingPasswordShouldNotBeAbleToDownloadFile( + string $user, + string $password, + string $fileName + ):void { + $user = $this->getActualUsername($user); + $password = $this->getActualPassword($password); + $this->downloadFileAsUserUsingPassword($user, $fileName, $password); + Assert::assertGreaterThanOrEqual( + 400, + $this->getResponse()->getStatusCode(), + __METHOD__ + . ' download must fail' + ); + Assert::assertLessThanOrEqual( + 499, + $this->getResponse()->getStatusCode(), + __METHOD__ + . ' 4xx error expected but got status code "' + . $this->getResponse()->getStatusCode() . '"' + ); + } + + /** + * @Then /^user "([^"]*)" should not be able to download file "([^"]*)"$/ + * + * @param string $user + * @param string $fileName + * + * @return void + * @throws JsonException + */ + public function userShouldNotBeAbleToDownloadFile( + string $user, + string $fileName + ):void { + $user = $this->getActualUsername($user); + $password = $this->getPasswordForUser($user); + $this->downloadFileAsUserUsingPassword($user, $fileName, $password); + Assert::assertGreaterThanOrEqual( + 400, + $this->getResponse()->getStatusCode(), + __METHOD__ + . ' download must fail' + ); + Assert::assertLessThanOrEqual( + 499, + $this->getResponse()->getStatusCode(), + __METHOD__ + . ' 4xx error expected but got status code "' + . $this->getResponse()->getStatusCode() . '"' + ); + } + + /** + * @Then /^user "([^"]*)" should be able to access a skeleton file$/ + * + * @param string $user + * + * @return void + */ + public function userShouldBeAbleToAccessASkeletonFile(string $user):void { + $this->contentOfFileForUserShouldBePlusEndOfLine( + "textfile0.txt", + $user, + "ownCloud test text file 0" + ); + } + + /** + * @Then the size of the downloaded file should be :size bytes + * + * @param string $size + * + * @return void + */ + public function sizeOfDownloadedFileShouldBe(string $size):void { + $actualSize = \strlen((string) $this->response->getBody()); + Assert::assertEquals( + $size, + $actualSize, + "Expected size of the downloaded file was '$size' but got '$actualSize'" + ); + } + + /** + * @Then /^the downloaded content should end with "([^"]*)"$/ + * + * @param string $content + * + * @return void + */ + public function downloadedContentShouldEndWith(string $content):void { + $actualContent = \substr((string) $this->response->getBody(), -\strlen($content)); + Assert::assertEquals( + $content, + $actualContent, + "The downloaded content was expected to end with '$content', but actually ended with '$actualContent'." + ); + } + + /** + * @Then /^the downloaded content should be "([^"]*)"$/ + * + * @param string $content + * + * @return void + */ + public function downloadedContentShouldBe(string $content):void { + $this->checkDownloadedContentMatches($content); + } + + /** + * @param string $expectedContent + * @param string $extraErrorText + * + * @return void + */ + public function checkDownloadedContentMatches( + string $expectedContent, + string $extraErrorText = "" + ):void { + $actualContent = (string) $this->response->getBody(); + // For this test we really care about the content. + // A separate "Then" step can specifically check the HTTP status. + // But if the content is wrong (e.g. empty) then it is useful to + // report the HTTP status to give some clue what might be the problem. + $actualStatus = $this->response->getStatusCode(); + if ($extraErrorText !== "") { + $extraErrorText .= "\n"; + } + Assert::assertEquals( + $expectedContent, + $actualContent, + $extraErrorText . "The content was expected to be '$expectedContent', but actually is '$actualContent'. HTTP status was $actualStatus" + ); + } + + /** + * @Then the content in the response should match the following content: + * + * @param PyStringNode $content + * + * @return void + */ + public function theContentInTheResponseShouldMatchTheFollowingContent(PyStringNode $content): void { + $this->checkDownloadedContentMatches($content->getRaw()); + } + + /** + * @Then /^if the HTTP status code was "([^"]*)" then the downloaded content for multipart byterange should be:$/ + * + * @param int $statusCode + * @param PyStringNode $content + * + * @return void + * + */ + public function theDownloadedContentForMultipartByterangeShouldBe(int $statusCode, PyStringNode $content):void { + $actualStatusCode = $this->response->getStatusCode(); + if ($actualStatusCode === $statusCode) { + $actualContent = (string) $this->response->getBody(); + $pattern = ["/--\w*/", "/\s*/m"]; + $actualContent = \preg_replace($pattern, "", $actualContent); + $content = \preg_replace("/\s*/m", '', $content->getRaw()); + Assert::assertEquals( + $content, + $actualContent, + "The downloaded content was expected to be '$content', but actually is '$actualContent'. HTTP status was $actualStatusCode" + ); + } + } + + /** + * @Then /^if the HTTP status code was "([^"]*)" then the downloaded content should be "([^"]*)"$/ + * + * @param int $statusCode + * @param string $content + * + * @return void + */ + public function checkStatusCodeForDownloadedContentShouldBe(int $statusCode, string $content):void { + $actualStatusCode = $this->response->getStatusCode(); + if ($actualStatusCode === $statusCode) { + $this->downloadedContentShouldBe($content); + } + } + + /** + * @Then /^the downloaded content should be "([^"]*)" plus end-of-line$/ + * + * @param string $content + * + * @return void + */ + public function downloadedContentShouldBePlusEndOfLine(string $content):void { + $this->downloadedContentShouldBe("$content\n"); + } + + /** + * @Then /^the content of file "([^"]*)" should be "([^"]*)"$/ + * + * @param string $fileName + * @param string $content + * + * @return void + */ + public function contentOfFileShouldBe(string $fileName, string $content):void { + $this->theUserDownloadsTheFileUsingTheAPI($fileName); + $this->downloadedContentShouldBe($content); + } + + /** + * @Then /^the content of file "([^"]*)" should be:$/ + * + * @param string $fileName + * @param PyStringNode $content + * + * @return void + */ + public function contentOfFileShouldBePyString( + string $fileName, + PyStringNode $content + ):void { + $this->contentOfFileShouldBe($fileName, $content->getRaw()); + } + + /** + * @Then /^the content of file "([^"]*)" should be "([^"]*)" plus end-of-line$/ + * + * @param string $fileName + * @param string $content + * + * @return void + */ + public function contentOfFileShouldBePlusEndOfLine(string $fileName, string $content):void { + $this->theUserDownloadsTheFileUsingTheAPI($fileName); + $this->downloadedContentShouldBePlusEndOfLine($content); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $fileName + * @param string $user + * @param string $content + * + * @return void + */ + public function contentOfFileForUserShouldBe(string $fileName, string $user, string $content):void { + $user = $this->getActualUsername($user); + $this->downloadFileAsUserUsingPassword($user, $fileName); + $this->downloadedContentShouldBe($content); + } + + /** + * @Then /^the content of the following files for user "([^"]*)" should be "([^"]*)"$/ + * + * @param string $user + * @param string $content + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function contentOfFollowingFilesShouldBe(string $user, string $content, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $file) { + $this->contentOfFileForUserShouldBe($file["path"], $user, $content); + } + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" on server "([^"]*)" should be "([^"]*)"$/ + * + * @param string $fileName + * @param string $user + * @param string $server + * @param string $content + * + * @return void + */ + public function theContentOfFileForUserOnServerShouldBe( + string $fileName, + string $user, + string $server, + string $content + ):void { + $previousServer = $this->usingServer($server); + $this->contentOfFileForUserShouldBe($fileName, $user, $content); + $this->usingServer($previousServer); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" using password "([^"]*)" should be "([^"]*)"$/ + * + * @param string $fileName + * @param string $user + * @param string|null $password + * @param string $content + * + * @return void + */ + public function contentOfFileForUserUsingPasswordShouldBe( + string $fileName, + string $user, + ?string $password, + string $content + ):void { + $user = $this->getActualUsername($user); + $password = $this->getActualPassword($password); + $this->downloadFileAsUserUsingPassword($user, $fileName, $password); + $this->downloadedContentShouldBe($content); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" should be:$/ + * + * @param string $fileName + * @param string $user + * @param PyStringNode $content + * + * @return void + */ + public function contentOfFileForUserShouldBePyString( + string $fileName, + string $user, + PyStringNode $content + ):void { + $this->contentOfFileForUserShouldBe($fileName, $user, $content->getRaw()); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" using password "([^"]*)" should be:$/ + * + * @param string $fileName + * @param string $user + * @param string|null $password + * @param PyStringNode $content + * + * @return void + */ + public function contentOfFileForUserUsingPasswordShouldBePyString( + string $fileName, + string $user, + ?string $password, + PyStringNode $content + ):void { + $this->contentOfFileForUserUsingPasswordShouldBe( + $fileName, + $user, + $password, + $content->getRaw() + ); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" should be "([^"]*)" plus end-of-line$/ + * + * @param string $fileName + * @param string $user + * @param string $content + * + * @return void + */ + public function contentOfFileForUserShouldBePlusEndOfLine(string $fileName, string $user, string $content):void { + $this->contentOfFileForUserShouldBe( + $fileName, + $user, + "$content\n" + ); + } + + /** + * @Then the content of the following files for user :user should be the following plus end-of-line + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theContentOfTheFollowingFilesForUserShouldBeTheFollowingPlusEndOfLine(string $user, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["filename", "content"]); + $rows = $table->getHash(); + foreach ($rows as $row) { + $this->contentOfFileForUserShouldBePlusEndOfLine($row["filename"], $user, $row["content"]); + } + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" on server "([^"]*)" should be "([^"]*)" plus end-of-line$/ + * + * @param string $fileName + * @param string $user + * @param string $server + * @param string $content + * + * @return void + */ + public function theContentOfFileForUserOnServerShouldBePlusEndOfLine( + string $fileName, + string $user, + string $server, + string $content + ):void { + $previousServer = $this->usingServer($server); + $this->contentOfFileForUserShouldBePlusEndOfLine($fileName, $user, $content); + $this->usingServer($previousServer); + } + + /** + * @Then /^the content of file "([^"]*)" for user "([^"]*)" using password "([^"]*)" should be "([^"]*)" plus end-of-line$/ + * + * @param string $fileName + * @param string $user + * @param string|null $password + * @param string $content + * + * @return void + */ + public function contentOfFileForUserUsingPasswordShouldBePlusEndOfLine( + string $fileName, + string $user, + ?string $password, + string $content + ):void { + $user = $this->getActualUsername($user); + $this->contentOfFileForUserUsingPasswordShouldBe( + $fileName, + $user, + $password, + "$content\n" + ); + } + + /** + * @Then /^the downloaded content when downloading file "([^"]*)" with range "([^"]*)" should be "([^"]*)"$/ + * + * @param string $fileSource + * @param string $range + * @param string $content + * + * @return void + */ + public function downloadedContentWhenDownloadingWithRangeShouldBe( + string $fileSource, + string $range, + string $content + ):void { + $this->downloadFileWithRange($fileSource, $range); + $this->downloadedContentShouldBe($content); + } + + /** + * @Then /^the downloaded content when downloading file "([^"]*)" for user "([^"]*)" with range "([^"]*)" should be "([^"]*)"$/ + * + * @param string $fileSource + * @param string $user + * @param string $range + * @param string $content + * + * @return void + */ + public function downloadedContentWhenDownloadingForUserWithRangeShouldBe( + string $fileSource, + string $user, + string $range, + string $content + ):void { + $user = $this->getActualUsername($user); + $this->userDownloadsFileWithRange($user, $fileSource, $range); + $this->downloadedContentShouldBe($content); + } + + /** + * @When the user downloads the file :fileName using the WebDAV API + * + * @param string $fileName + * + * @return void + */ + public function theUserDownloadsTheFileUsingTheAPI(string $fileName):void { + $this->downloadFileAsUserUsingPassword($this->currentUser, $fileName); + } + + /** + * @When user :user downloads file :fileName using the WebDAV API + * + * @param string $user + * @param string $fileName + * + * @return void + */ + public function userDownloadsFileUsingTheAPI( + string $user, + string $fileName + ):void { + $this->downloadFileAsUserUsingPassword($user, $fileName); + } + + /** + * @When user :user using password :password downloads the file :fileName using the WebDAV API + * + * @param string $user + * @param string|null $password + * @param string $fileName + * + * @return void + */ + public function userUsingPasswordDownloadsTheFileUsingTheAPI( + string $user, + ?string $password, + string $fileName + ):void { + $this->downloadFileAsUserUsingPassword($user, $fileName, $password); + } + + /** + * @param string $user + * @param string $fileName + * @param string|null $password + * @param array|null $headers + * + * @return void + */ + public function downloadFileAsUserUsingPassword( + string $user, + string $fileName, + ?string $password = null, + ?array $headers = [] + ):void { + $user = $this->getActualUsername($user); + $password = $this->getActualPassword($password); + $this->response = $this->makeDavRequest( + $user, + 'GET', + $fileName, + $headers, + null, + "files", + null, + false, + $password + ); + } + + /** + * @When the public gets the size of the last shared public link using the WebDAV API + * + * @return void + * @throws Exception + */ + public function publicGetsSizeOfLastSharedPublicLinkUsingTheWebdavApi():void { + $tokenArray = $this->getLastPublicShareData()->data->token; + $token = (string)$tokenArray[0]; + $url = $this->getBaseUrl() . "/remote.php/dav/public-files/{$token}"; + $this->response = HttpRequestHelper::sendRequest( + $url, + $this->getStepLineRef(), + "PROPFIND", + null, + null, + null + ); + } + + /** + * @When user :user gets the size of file :resource using the WebDAV API + * + * @param string $user + * @param string $resource + * + * @return void + * @throws Exception + */ + public function userGetsSizeOfFileUsingTheWebdavApi(string $user, string $resource):void { + $user = $this->getActualUsername($user); + $password = $this->getPasswordForUser($user); + $this->response = WebDavHelper::propfind( + $this->getBaseUrl(), + $user, + $password, + $resource, + [], + $this->getStepLineRef(), + "0", + "files", + $this->getDavPathVersion() + ); + } + + /** + * @Then the size of the file should be :size + * + * @param string $size + * + * @return void + * @throws Exception + */ + public function theSizeOfTheFileShouldBe(string $size):void { + $responseXml = HttpRequestHelper::getResponseXml( + $this->response, + __METHOD__ + ); + $xmlPart = $responseXml->xpath("//d:prop/d:getcontentlength"); + $actualSize = (string) $xmlPart[0]; + Assert::assertEquals( + $size, + $actualSize, + __METHOD__ + . " Expected size of the file was '$size', but got '$actualSize' instead." + ); + } + + /** + * @Then the following headers should be set + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingHeadersShouldBeSet(TableNode $table):void { + $this->verifyTableNodeColumns( + $table, + ['header', 'value'] + ); + foreach ($table->getColumnsHash() as $header) { + $headerName = $header['header']; + $expectedHeaderValue = $header['value']; + $returnedHeader = $this->response->getHeader($headerName); + $expectedHeaderValue = $this->substituteInLineCodes($expectedHeaderValue); + + if (\is_array($returnedHeader)) { + if (empty($returnedHeader)) { + throw new Exception( + \sprintf( + "Missing expected header '%s'", + $headerName + ) + ); + } + $headerValue = $returnedHeader[0]; + } else { + $headerValue = $returnedHeader; + } + + Assert::assertEquals( + $expectedHeaderValue, + $headerValue, + __METHOD__ + . " Expected value for header '$headerName' was '$expectedHeaderValue', but got '$headerValue' instead." + ); + } + } + + /** + * @Then the downloaded content should start with :start + * + * @param string $start + * + * @return void + * @throws Exception + */ + public function downloadedContentShouldStartWith(string $start):void { + Assert::assertEquals( + 0, + \strpos($this->response->getBody()->getContents(), $start), + __METHOD__ + . " The downloaded content was expected to start with '$start', but actually started with '{$this->response->getBody()->getContents()}'" + ); + } + + /** + * @Then the oc job status values of last request for user :user should match these regular expressions + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function jobStatusValuesShouldMatchRegEx(string $user, TableNode $table):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumnsCount($table, 2); + $headerArray = $this->response->getHeader("OC-JobStatus-Location"); + $url = $headerArray[0]; + $url = $this->getBaseUrlWithoutPath() . $url; + $response = HttpRequestHelper::get( + $url, + $this->getStepLineRef(), + $user, + $this->getPasswordForUser($user) + ); + $contents = $response->getBody()->getContents(); + $result = \json_decode($contents, true); + PHPUnit\Framework\Assert::assertNotNull($result, "'$contents' is not valid JSON"); + foreach ($table->getTable() as $row) { + $expectedKey = $row[0]; + Assert::assertArrayHasKey( + $expectedKey, + $result, + "response does not have expected key '$expectedKey'" + ); + $expectedValue = $this->substituteInLineCodes( + $row[1], + $user, + ['preg_quote' => ['/']] + ); + Assert::assertNotFalse( + (bool) \preg_match($expectedValue, (string)$result[$expectedKey]), + "'$expectedValue' does not match '$result[$expectedKey]'" + ); + } + } + + /** + * @Then /^as "([^"]*)" (file|folder|entry) "([^"]*)" should not exist$/ + * + * @param string $user + * @param string $entry + * @param string|null $path + * @param string $type + * + * @return ResponseInterface + * @throws Exception + */ + public function asFileOrFolderShouldNotExist( + string $user, + string $entry = "file", + ?string $path = null, + string $type = "files" + ):ResponseInterface { + $user = $this->getActualUsername($user); + $path = $this->substituteInLineCodes($path); + $response = $this->listFolder( + $user, + $path, + '0', + null, + $type + ); + $statusCode = $response->getStatusCode(); + if ($statusCode < 401 || $statusCode > 404) { + try { + $this->responseXmlObject = HttpRequestHelper::getResponseXml( + $response, + __METHOD__ + ); + } catch (Exception $e) { + Assert::fail( + "$entry '$path' should not exist. But API returned $statusCode without XML in the body" + ); + } + Assert::assertTrue( + $this->isEtagValid(), + "$entry '$path' should not exist. But API returned $statusCode without an etag in the body" + ); + $isCollection = $this->getResponseXmlObject()->xpath("//d:prop/d:resourcetype/d:collection"); + if (\count($isCollection) === 0) { + $actualResourceType = "file"; + } else { + $actualResourceType = "folder"; + } + Assert::fail( + "$entry '$path' should not exist. But it does exist and is a $actualResourceType" + ); + } + return $response; + } + + /** + * @Then /^as "([^"]*)" the following (files|folders) should not exist$/ + * + * @param string $user + * @param string $entry + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function followingFilesShouldNotExist( + string $user, + string $entry, + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + $entry = \rtrim($entry, "s"); + + foreach ($paths as $file) { + $this->asFileOrFolderShouldNotExist($user, $entry, $file["path"]); + } + } + + /** + * @Then /^as "([^"]*)" (file|folder|entry) "([^"]*)" should exist$/ + * + * @param string $user + * @param string $entry + * @param string $path + * @param string $type + * + * @return void + * @throws Exception + */ + public function asFileOrFolderShouldExist( + string $user, + string $entry, + string $path, + string $type = "files" + ):void { + $user = $this->getActualUsername($user); + $path = $this->substituteInLineCodes($path); + $this->responseXmlObject = $this->listFolderAndReturnResponseXml( + $user, + $path, + '0', + null, + $type + ); + Assert::assertTrue( + $this->isEtagValid(), + "$entry '$path' expected to exist for user $user but not found" + ); + $isCollection = $this->getResponseXmlObject()->xpath("//d:prop/d:resourcetype/d:collection"); + if ($entry === "folder") { + Assert::assertEquals(\count($isCollection), 1, "Unexpectedly, `$path` is not a folder"); + } elseif ($entry === "file") { + Assert::assertEquals(\count($isCollection), 0, "Unexpectedly, `$path` is not a file"); + } + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @Then /^as "([^"]*)" the following (files|folders) should exist$/ + * + * @param string $user + * @param string $entry + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function followingFilesOrFoldersShouldExist( + string $user, + string $entry, + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + $entry = \rtrim($entry, "s"); + + foreach ($paths as $file) { + $this->asFileOrFolderShouldExist($user, $entry, $file["path"]); + } + } + + /** + * + * @param string $user + * @param string $entry + * @param string $path + * @param string $type + * + * @return bool + */ + public function fileOrFolderExists( + string $user, + string $entry, + string $path, + string $type = "files" + ):bool { + try { + $this->asFileOrFolderShouldExist($user, $entry, $path, $type); + return true; + } catch (Exception $e) { + return false; + } + } + + /** + * @Then /^as "([^"]*)" exactly one of these (files|folders|entries) should exist$/ + * + * @param string $user + * @param string $entries + * @param TableNode $table of file, folder or entry paths + * + * @return void + * @throws Exception + */ + public function asExactlyOneOfTheseFilesOrFoldersShouldExist(string $user, string $entries, TableNode $table):void { + $numEntriesThatExist = 0; + foreach ($table->getTable() as $row) { + $path = $this->substituteInLineCodes($row[0]); + $this->responseXmlObject = $this->listFolderAndReturnResponseXml( + $user, + $path, + '0' + ); + if ($this->isEtagValid()) { + $numEntriesThatExist = $numEntriesThatExist + 1; + } + } + Assert::assertEquals( + 1, + $numEntriesThatExist, + "exactly one of these $entries should exist but found $numEntriesThatExist $entries" + ); + } + + /** + * + * @param string $user + * @param string $path + * @param string $folderDepth requires 1 to see elements without children + * @param array|null $properties + * @param string $type + * + * @return ResponseInterface + * @throws Exception + */ + public function listFolder( + string $user, + string $path, + string $folderDepth, + ?array $properties = null, + string $type = "files" + ):ResponseInterface { + if ($this->customDavPath !== null) { + $path = $this->customDavPath . $path; + } + + return WebDavHelper::listFolder( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getPasswordForUser($user), + $path, + $folderDepth, + $this->getStepLineRef(), + $properties, + $type, + $this->getDavPathVersion() + ); + } + + /** + * + * @param string $user + * @param string $path + * @param string $folderDepth requires 1 to see elements without children + * @param array|null $properties + * @param string $type + * + * @return SimpleXMLElement + * @throws Exception + */ + public function listFolderAndReturnResponseXml( + string $user, + string $path, + string $folderDepth, + ?array $properties = null, + string $type = "files" + ):SimpleXMLElement { + return HttpRequestHelper::getResponseXml( + $this->listFolder( + $user, + $path, + $folderDepth, + $properties, + $type + ), + __METHOD__ + ); + } + + /** + * @Then /^user "([^"]*)" should (not|)\s?see the following elements$/ + * + * @param string $user + * @param string $shouldOrNot + * @param TableNode $elements + * + * @return void + * @throws InvalidArgumentException|Exception + * + */ + public function userShouldSeeTheElements(string $user, string $shouldOrNot, TableNode $elements):void { + $should = ($shouldOrNot !== "not"); + $this->checkElementList($user, $elements, $should); + } + + /** + * @Then /^user "([^"]*)" should not see the following elements if the upper and lower case username are different/ + * + * @param string $user + * @param TableNode $elements + * + * @return void + * @throws InvalidArgumentException|Exception + * + */ + public function userShouldNotSeeTheElementsIfUpperAndLowerCaseUsernameDifferent(string $user, TableNode $elements):void { + $effectiveUser = $this->getActualUsername($user); + if (\strtoupper($effectiveUser) === \strtolower($effectiveUser)) { + $expectedToBeListed = true; + } else { + $expectedToBeListed = false; + } + $this->checkElementList($user, $elements, $expectedToBeListed); + } + + /** + * asserts that the user can or cannot see a list of files/folders by propfind + * + * @param string $user + * @param TableNode $elements + * @param boolean $expectedToBeListed + * + * @return void + * @throws InvalidArgumentException + * @throws Exception + * + */ + public function checkElementList( + string $user, + TableNode $elements, + bool $expectedToBeListed = true + ):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumnsCount($elements, 1); + $elementRows = $elements->getRows(); + $elementsSimplified = $this->simplifyArray($elementRows); + if ($this->davPropfindDepthInfinityIsEnabled()) { + // get a full "infinite" list of the user's root folder in one request + // and process that to check the elements (resources) + $responseXmlObject = $this->listFolderAndReturnResponseXml( + $user, + "/", + "infinity" + ); + foreach ($elementsSimplified as $expectedElement) { + // Allow the table of expected elements to have entries that do + // not have to specify the "implied" leading slash, or have multiple + // leading slashes, to make scenario outlines more flexible + $expectedElement = $this->encodePath($expectedElement); + $expectedElement = "/" . \ltrim($expectedElement, "/"); + $webdavPath = "/" . $this->getFullDavFilesPath($user) . $expectedElement; + $element = $responseXmlObject->xpath( + "//d:response/d:href[text() = \"$webdavPath\"]" + ); + if ($expectedToBeListed + && (!isset($element[0]) || urldecode($element[0]->__toString()) !== urldecode($webdavPath)) + ) { + Assert::fail( + "$webdavPath is not in propfind answer but should be" + ); + } elseif (!$expectedToBeListed && isset($element[0]) + ) { + Assert::fail( + "$webdavPath is in propfind answer but should not be" + ); + } + } + } else { + // do a PROPFIND for each element + foreach ($elementsSimplified as $elementToRequest) { + // Allow the table of expected elements to have entries that do + // not have to specify the "implied" leading slash, or have multiple + // leading slashes, to make scenario outlines more flexible + $elementToRequest = "/" . \ltrim($elementToRequest, "/"); + // Note: in the request we ask to do a PROPFIND on a resource like: + // /some-folder with spaces/sub-folder + // but the response has encoded values for the special characters like: + // /some-folder%20with%20spaces/sub-folder + // So we need both $elementToRequest and $expectedElement + $expectedElement = $this->encodePath($elementToRequest); + $responseXmlObject = $this->listFolderAndReturnResponseXml( + $user, + $elementToRequest, + "1" + ); + $webdavPath = "/" . $this->getFullDavFilesPath($user) . $expectedElement; + $element = $responseXmlObject->xpath( + "//d:response/d:href[text() = \"$webdavPath\"]" + ); + if ($expectedToBeListed + && (!isset($element[0]) || urldecode($element[0]->__toString()) !== urldecode($webdavPath)) + ) { + Assert::fail( + "$webdavPath is not in propfind answer but should be" + ); + } elseif (!$expectedToBeListed && isset($element[0]) + ) { + Assert::fail( + "$webdavPath is in propfind answer but should not be" + ); + } + } + } + } + + /** + * @When user :user uploads file :source to :destination using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + */ + public function userUploadsAFileTo(string $user, string $source, string $destination):void { + $user = $this->getActualUsername($user); + $file = \fopen($this->acceptanceTestsDirLocation() . $source, 'r'); + $this->pauseUploadDelete(); + $this->response = $this->makeDavRequest( + $user, + "PUT", + $destination, + [], + $file + ); + $this->lastUploadDeleteTime = \time(); + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + $this->pushToLastHttpStatusCodesArray( + (string) $this->getResponse()->getStatusCode() + ); + } + + /** + * @Given user :user has uploaded file :source to :destination + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + */ + public function userHasUploadedAFileTo(string $user, string $source, string $destination):void { + $this->userUploadsAFileTo($user, $source, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$source' to '$destination' for user '$user'" + ); + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @When the user uploads file :source to :destination using the WebDAV API + * + * @param string $source + * @param string $destination + * + * @return void + */ + public function theUserUploadsAFileTo(string $source, string $destination):void { + $this->userUploadsAFileTo($this->currentUser, $source, $destination); + } + + /** + * @Given the user has uploaded file :source to :destination + * + * @param string $source + * @param string $destination + * + * @return void + */ + public function theUserHasUploadedFileTo(string $source, string $destination):void { + $this->theUserUploadsAFileTo($source, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$source' to '$destination'" + ); + } + + /** + * @When /^user "([^"]*)" on "(LOCAL|REMOTE)" uploads file "([^"]*)" to "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $server + * @param string $source + * @param string $destination + * + * @return void + */ + public function userOnUploadsAFileTo(string $user, string $server, string $source, string $destination):void { + $previousServer = $this->usingServer($server); + $this->userUploadsAFileTo($user, $source, $destination); + $this->usingServer($previousServer); + } + + /** + * @Given /^user "([^"]*)" on "(LOCAL|REMOTE)" has uploaded file "([^"]*)" to "([^"]*)"$/ + * + * @param string $user + * @param string $server + * @param string $source + * @param string $destination + * + * @return void + */ + public function userOnHasUploadedAFileTo(string $user, string $server, string $source, string $destination):void { + $this->userOnUploadsAFileTo($user, $server, $source, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$source' to '$destination' for user '$user' on server '$server'" + ); + } + + /** + * Upload file as a user with different headers + * + * @param string $user + * @param string $source + * @param string $destination + * @param array|null $headers + * @param int|null $noOfChunks Only use for chunked upload when $this->chunkingToUse is not null + * + * @return void + * @throws Exception + */ + public function uploadFileWithHeaders( + string $user, + string $source, + string $destination, + ?array $headers = [], + ?int $noOfChunks = 0 + ):void { + $chunkingVersion = $this->chunkingToUse; + if ($noOfChunks <= 0) { + $chunkingVersion = null; + } + try { + $this->responseXml = []; + $this->pauseUploadDelete(); + $this->response = UploadHelper::upload( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getUserPassword($user), + $source, + $destination, + $this->getStepLineRef(), + $headers, + $this->getDavPathVersion(), + $chunkingVersion, + $noOfChunks + ); + $this->lastUploadDeleteTime = \time(); + } catch (BadResponseException $e) { + // 4xx and 5xx responses cause an exception + $this->response = $e->getResponse(); + } + } + + /** + * @When /^user "([^"]*)" uploads file "([^"]*)" to "([^"]*)" in (\d+) chunks (?:with (new|old|v1|v2) chunking and)?\s?using the WebDAV API$/ + * @When user :user uploads file :source to :destination with chunks using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * @param int $noOfChunks + * @param string|null $chunkingVersion old|v1|new|v2 null for autodetect + * @param bool $async use asynchronous move at the end or not + * @param array|null $headers + * + * @return void + * @throws Exception + */ + public function userUploadsAFileToWithChunks( + string $user, + string $source, + string $destination, + int $noOfChunks = 2, + ?string $chunkingVersion = null, + bool $async = false, + ?array $headers = [] + ):void { + $user = $this->getActualUsername($user); + Assert::assertGreaterThan( + 0, + $noOfChunks, + "What does it mean to have $noOfChunks chunks?" + ); + //use the chunking version that works with the set DAV version + if ($chunkingVersion === null) { + if ($this->usingOldDavPath || $this->usingSpacesDavPath) { + $chunkingVersion = "v1"; + } else { + $chunkingVersion = "v2"; + } + } + $this->useSpecificChunking($chunkingVersion); + Assert::assertTrue( + WebDavHelper::isValidDavChunkingCombination( + $this->getDavPathVersion(), + $this->chunkingToUse + ), + "invalid chunking/webdav version combination" + ); + + if ($async === true) { + $headers['OC-LazyOps'] = 'true'; + } + $this->uploadFileWithHeaders( + $user, + $this->acceptanceTestsDirLocation() . $source, + $destination, + $headers, + $noOfChunks + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @When /^user "([^"]*)" uploads file "([^"]*)" asynchronously to "([^"]*)" in (\d+) chunks (?:with (new|old|v1|v2) chunking and)?\s?using the WebDAV API$/ + * + * @param string $user + * @param string $source + * @param string $destination + * @param int $noOfChunks + * @param string|null $chunkingVersion old|v1|new|v2 null for autodetect + * + * @return void + * @throws Exception + */ + public function userUploadsAFileAsyncToWithChunks( + string $user, + string $source, + string $destination, + int $noOfChunks = 2, + ?string $chunkingVersion = null + ):void { + $user = $this->getActualUsername($user); + $this->userUploadsAFileToWithChunks( + $user, + $source, + $destination, + $noOfChunks, + $chunkingVersion, + true + ); + } + + /** + * sets the chunking version from human readable format + * + * @param string $version (no|v1|v2|new|old) + * + * @return void + */ + public function useSpecificChunking(string $version):void { + if ($version === "v1" || $version === "old") { + $this->chunkingToUse = 1; + } elseif ($version === "v2" || $version === "new") { + $this->chunkingToUse = 2; + } elseif ($version === "no") { + $this->chunkingToUse = null; + } else { + throw new InvalidArgumentException( + "cannot set chunking version to $version" + ); + } + } + + /** + * Uploading with old/new DAV and chunked/non-chunked. + * + * @When user :user uploads file :source to filenames based on :destination with all mechanisms using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userUploadsAFileToWithAllMechanisms( + string $user, + string $source, + string $destination + ):void { + $user = $this->getActualUsername($user); + $this->uploadResponses = UploadHelper::uploadWithAllMechanisms( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getUserPassword($user), + $this->acceptanceTestsDirLocation() . $source, + $destination, + $this->getStepLineRef() + ); + } + + /** + * Uploading with old/new DAV and chunked/non-chunked. + * Except do not do the new-DAV-new-chunking combination. That is not being + * supported on all implementations. + * + * @When user :user uploads file :source to filenames based on :destination with all mechanisms except new chunking using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userUploadsAFileToWithAllMechanismsExceptNewChunking( + string $user, + string $source, + string $destination + ):void { + $user = $this->getActualUsername($user); + $this->uploadResponses = UploadHelper::uploadWithAllMechanisms( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getUserPassword($user), + $this->acceptanceTestsDirLocation() . $source, + $destination, + $this->getStepLineRef(), + false, + 'new' + ); + + $this->pushToLastStatusCodesArrays(); + } + + /** + * Overwriting with old/new DAV and chunked/non-chunked. + * + * @When user :user overwrites from file :source to file :destination with all mechanisms using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userOverwritesAFileToWithAllMechanisms( + string $user, + string $source, + string $destination + ):void { + $user = $this->getActualUsername($user); + $this->uploadResponses = UploadHelper::uploadWithAllMechanisms( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getUserPassword($user), + $this->acceptanceTestsDirLocation() . $source, + $destination, + $this->getStepLineRef(), + true + ); + } + + /** + * Overwriting with old/new DAV and chunked/non-chunked. + * Except do not do the new-DAV-new-chunking combination. That is not being + * supported on all implementations. + * + * @When user :user overwrites from file :source to file :destination with all mechanisms except new chunking using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userOverwritesAFileToWithAllMechanismsExceptNewChunking( + string $user, + string $source, + string $destination + ):void { + $user = $this->getActualUsername($user); + $this->uploadResponses = UploadHelper::uploadWithAllMechanisms( + $this->getBaseUrl(), + $this->getActualUsername($user), + $this->getUserPassword($user), + $this->acceptanceTestsDirLocation() . $source, + $destination, + $this->getStepLineRef(), + true, + 'new' + ); + } + + /** + * @Then /^the HTTP status code of all upload responses should be "([^"]*)"$/ + * + * @param int $statusCode + * + * @return void + */ + public function theHTTPStatusCodeOfAllUploadResponsesShouldBe(int $statusCode):void { + foreach ($this->uploadResponses as $response) { + Assert::assertEquals( + $statusCode, + $response->getStatusCode(), + 'Response did not return expected status code' + ); + } + } + + /** + * @Then the HTTP status code of responses on all endpoints should be :statusCode + * + * @param int $statusCode + * + * @return void + * @throws Exception + */ + public function theHTTPStatusCodeOfResponsesOnAllEndpointsShouldBe(int $statusCode):void { + $duplicateRemovedStatusCodes = \array_unique($this->lastHttpStatusCodesArray); + if (\count($duplicateRemovedStatusCodes) === 1) { + Assert::assertSame( + \intval($statusCode), + \intval($duplicateRemovedStatusCodes[0]), + 'Responses did not return expected http status code' + ); + $this->emptyLastHTTPStatusCodesArray(); + } else { + throw new Exception( + 'Expected same but found different http status codes of last requested responses.' . + 'Found status codes: ' . \implode(',', $this->lastHttpStatusCodesArray) + ); + } + } + + /** + * @Then the HTTP status code of responses on each endpoint should be :statusCode respectively + * + * @param string $statusCodes + * + * @return void + * @throws Exception + */ + public function theHTTPStatusCodeOfResponsesOnEachEndpointShouldBe(string $statusCodes):void { + $statusCodes = \explode(',', $statusCodes); + $count = \count($statusCodes); + if ($count === \count($this->lastHttpStatusCodesArray)) { + for ($i = 0; $i < $count; $i++) { + Assert::assertSame( + (int)\trim($statusCodes[$i]), + (int)$this->lastHttpStatusCodesArray[$i], + 'Responses did not return expected HTTP status code' + ); + } + $this->emptyLastHTTPStatusCodesArray(); + } else { + throw new Exception( + 'Expected HTTP status codes: "' . \implode(',', $statusCodes) . + '". Found HTTP status codes: "' . \implode(',', $this->lastHttpStatusCodesArray) . '"' + ); + } + } + + /** + * @Then the OCS status code of responses on each endpoint should be :statusCode respectively + * + * @param string $statusCodes + * + * @return void + * @throws Exception + */ + public function theOCStatusCodeOfResponsesOnEachEndpointShouldBe(string $statusCodes):void { + $statusCodes = \explode(',', $statusCodes); + $count = \count($statusCodes); + if ($count === \count($this->lastOCSStatusCodesArray)) { + for ($i = 0; $i < $count; $i++) { + Assert::assertSame( + (int)\trim($statusCodes[$i]), + (int)$this->lastOCSStatusCodesArray[$i], + 'Responses did not return expected OCS status code' + ); + } + } else { + throw new Exception( + 'Expected OCS status codes: "' . \implode(',', $statusCodes) . + '". Found OCS status codes: "' . \implode(',', $this->lastOCSStatusCodesArray) . '"' + ); + } + } + + /** + * @Then the HTTP status code of responses on all endpoints should be :statusCode1 or :statusCode2 + * + * @param string $statusCode1 + * @param string $statusCode2 + * + * @return void + * @throws Exception + */ + public function theHTTPStatusCodeOfResponsesOnAllEndpointsShouldBeOr(string $statusCode1, string $statusCode2):void { + $duplicateRemovedStatusCodes = \array_unique($this->lastHttpStatusCodesArray); + foreach ($duplicateRemovedStatusCodes as $status) { + $status = (string)$status; + if (($status != $statusCode1) && ($status != $statusCode2)) { + Assert::fail("Unexpected status code received " . $status); + } + } + } + + /** + * @Then the OCS status code of responses on all endpoints should be :statusCode + * + * @param string $statusCode + * + * @return void + * @throws Exception + */ + public function theOCSStatusCodeOfResponsesOnAllEndpointsShouldBe(string $statusCode):void { + $duplicateRemovedStatusCodes = \array_unique($this->lastOCSStatusCodesArray); + if (\count($duplicateRemovedStatusCodes) === 1) { + Assert::assertSame( + \intval($statusCode), + \intval($duplicateRemovedStatusCodes[0]), + 'Responses did not return expected ocs status code' + ); + $this->emptyLastOCSStatusCodesArray(); + } else { + throw new Exception( + 'Expected same but found different ocs status codes of last requested responses.' . + 'Found status codes: ' . \implode(',', $this->lastOCSStatusCodesArray) + ); + } + } + + /** + * @Then /^the HTTP reason phrase of all upload responses should be "([^"]*)"$/ + * + * @param string $reasonPhrase + * + * @return void + */ + public function theHTTPReasonPhraseOfAllUploadResponsesShouldBe(string $reasonPhrase):void { + foreach ($this->uploadResponses as $response) { + Assert::assertEquals( + $reasonPhrase, + $response->getReasonPhrase(), + 'Response did not return expected reason phrase' + ); + } + } + + /** + * @Then user :user should be able to upload file :source to :destination + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userShouldBeAbleToUploadFileTo(string $user, string $source, string $destination):void { + $user = $this->getActualUsername($user); + $this->userUploadsAFileTo($user, $source, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$destination'" + ); + $this->asFileOrFolderShouldExist($user, "file", $destination); + } + + /** + * @Then the following users should be able to upload file :source to :destination + * + * @param string $source + * @param string $destination + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function usersShouldBeAbleToUploadFileTo( + string $source, + string $destination, + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["username"]); + $usernames = $table->getHash(); + foreach ($usernames as $username) { + $actualUser = $this->getActualUsername($username["username"]); + $this->userUploadsAFileTo($actualUser, $source, $destination); + $this->asFileOrFolderShouldExist($actualUser, "file", $destination); + } + } + + /** + * @Then user :user should not be able to upload file :source to :destination + * + * @param string $user + * @param string $source + * @param string $destination + * + * @return void + * @throws Exception + */ + public function theUserShouldNotBeAbleToUploadFileTo(string $user, string $source, string $destination):void { + $fileAlreadyExists = $this->fileOrFolderExists($user, "file", $destination); + if ($fileAlreadyExists) { + $this->downloadFileAsUserUsingPassword($user, $destination); + $initialContent = (string) $this->response->getBody(); + } + $this->userUploadsAFileTo($user, $source, $destination); + $this->theHTTPStatusCodeShouldBe(["403", "423"]); + if ($fileAlreadyExists) { + $this->downloadFileAsUserUsingPassword($user, $destination); + $currentContent = (string) $this->response->getBody(); + Assert::assertSame( + $initialContent, + $currentContent, + __METHOD__ . " user $user was unexpectedly able to upload $source to $destination - the content has changed:" + ); + } else { + $this->asFileOrFolderShouldNotExist($user, "file", $destination); + } + } + + /** + * @Then /^the HTTP status code of all upload responses should be between "(\d+)" and "(\d+)"$/ + * + * @param int $minStatusCode + * @param int $maxStatusCode + * + * @return void + */ + public function theHTTPStatusCodeOfAllUploadResponsesShouldBeBetween( + int $minStatusCode, + int $maxStatusCode + ):void { + foreach ($this->uploadResponses as $response) { + Assert::assertGreaterThanOrEqual( + $minStatusCode, + $response->getStatusCode(), + 'Response did not return expected status code' + ); + Assert::assertLessThanOrEqual( + $maxStatusCode, + $response->getStatusCode(), + 'Response did not return expected status code' + ); + } + } + + /** + * Check that all the files uploaded with old/new DAV and chunked/non-chunked exist. + * + * @Then /^as "([^"]*)" the files uploaded to "([^"]*)" with all mechanisms should (not|)\s?exist$/ + * + * @param string $user + * @param string $destination + * @param string $shouldOrNot + * @param string|null $exceptChunkingType empty string or "old" or "new" + * + * @return void + * @throws Exception + */ + public function filesUploadedToWithAllMechanismsShouldExist( + string $user, + string $destination, + string $shouldOrNot, + ?string $exceptChunkingType = '' + ):void { + switch ($exceptChunkingType) { + case 'old': + $exceptChunkingSuffix = 'olddav-oldchunking'; + break; + case 'new': + $exceptChunkingSuffix = 'newdav-newchunking'; + break; + default: + $exceptChunkingSuffix = ''; + break; + } + + if ($shouldOrNot !== "not") { + foreach (['old', 'new'] as $davVersion) { + foreach (["{$davVersion}dav-regular", "{$davVersion}dav-{$davVersion}chunking"] as $suffix) { + if ($suffix !== $exceptChunkingSuffix) { + $this->asFileOrFolderShouldExist( + $user, + 'file', + "$destination-$suffix" + ); + } + } + } + } else { + foreach (['old', 'new'] as $davVersion) { + foreach (["{$davVersion}dav-regular", "{$davVersion}dav-{$davVersion}chunking"] as $suffix) { + if ($suffix !== $exceptChunkingSuffix) { + $this->asFileOrFolderShouldNotExist( + $user, + 'file', + "$destination-$suffix" + ); + } + } + } + } + } + + /** + * Check that all the files uploaded with old/new DAV and chunked/non-chunked exist. + * Except do not check the new-DAV-new-chunking combination. That is not being + * supported on all implementations. + * + * @Then /^as "([^"]*)" the files uploaded to "([^"]*)" with all mechanisms except new chunking should (not|)\s?exist$/ + * + * @param string $user + * @param string $destination + * @param string $shouldOrNot + * + * @return void + * @throws Exception + */ + public function filesUploadedToWithAllMechanismsExceptNewChunkingShouldExist( + string $user, + string $destination, + string $shouldOrNot + ):void { + $this->filesUploadedToWithAllMechanismsShouldExist( + $user, + $destination, + $shouldOrNot, + 'new' + ); + } + + /** + * @Then /^as user "([^"]*)" on server "([^"]*)" the files uploaded to "([^"]*)" with all mechanisms should (not|)\s?exist$/ + * + * @param string $user + * @param string $server + * @param string $destination + * @param string $shouldOrNot + * + * @return void + * @throws Exception + */ + public function asUserOnServerTheFilesUploadedToWithAllMechanismsShouldExit( + string $user, + string $server, + string $destination, + string $shouldOrNot + ):void { + $previousServer = $this->usingServer($server); + $this->filesUploadedToWithAllMechanismsShouldExist($user, $destination, $shouldOrNot); + $this->usingServer($previousServer); + } + + /** + * @Given user :user has uploaded file :destination of size :bytes bytes + * + * @param string $user + * @param string $destination + * @param string $bytes + * + * @return void + * @throws Exception + */ + public function userHasUploadedFileToOfSizeBytes(string $user, string $destination, string $bytes):void { + $user = $this->getActualUsername($user); + $this->userUploadsAFileToOfSizeBytes($user, $destination, $bytes); + $expectedElements = new TableNode([["$destination"]]); + $this->checkElementList($user, $expectedElements); + } + + /** + * @When user :user uploads file :destination of size :bytes bytes + * + * @param string $user + * @param string $destination + * @param string $bytes + * + * @return void + */ + public function userUploadsAFileToOfSizeBytes(string $user, string $destination, string $bytes):void { + $this->userUploadsAFileToEndingWithOfSizeBytes($user, $destination, 'a', $bytes); + } + + /** + * @Given user :user has uploaded file :destination ending with :text of size :bytes bytes + * + * @param string $user + * @param string $destination + * @param string $text + * @param string $bytes + * + * @return void + * @throws Exception + */ + public function userHasUploadedFileToEndingWithOfSizeBytes(string $user, string $destination, string $text, string $bytes):void { + $this->userUploadsAFileToEndingWithOfSizeBytes($user, $destination, $text, $bytes); + $expectedElements = new TableNode([["$destination"]]); + $this->checkElementList($user, $expectedElements); + } + + /** + * @When user :user uploads file :destination ending with :text of size :bytes bytes + * + * @param string $user + * @param string $destination + * @param string $text + * @param string $bytes + * + * @return void + */ + public function userUploadsAFileToEndingWithOfSizeBytes(string $user, string $destination, string $text, string $bytes):void { + $filename = "filespecificSize.txt"; + $this->createLocalFileOfSpecificSize($filename, $bytes, $text); + Assert::assertFileExists($this->workStorageDirLocation() . $filename); + $this->userUploadsAFileTo( + $user, + $this->temporaryStorageSubfolderName() . "/$filename", + $destination + ); + $this->removeFile($this->workStorageDirLocation(), $filename); + } + + /** + * @When user :user uploads to these filenames with content :content using the webDAV API then the results should be as listed + * + * @param string $user + * @param string $content + * @param TableNode $table + * + * @return void + * @throws Exception + * @throws GuzzleException + */ + public function userUploadsFilesWithContentTo( + string $user, + string $content, + TableNode $table + ):void { + $user = $this->getActualUsername($user); + foreach ($table->getHash() as $row) { + $this->userUploadsAFileWithContentTo( + $user, + $content, + $row['filename'] + ); + $this->theHTTPStatusCodeShouldBe( + $row['http-code'], + "HTTP status code was not the expected value " . $row['http-code'] . " while trying to upload " . $row['filename'] + ); + if ($row['exists'] === "yes") { + $this->asFileOrFolderShouldExist($user, "file", $row['filename']); + } else { + $this->asFileOrFolderShouldNotExist($user, "file", $row['filename']); + } + } + } + + /** + * @param string $user + * @param string|null $content + * @param string $destination + * + * @return string[] + * @throws JsonException + * @throws GuzzleException + */ + public function uploadFileWithContent( + string $user, + ?string $content, + string $destination + ): array { + $user = $this->getActualUsername($user); + $this->pauseUploadDelete(); + $this->response = $this->makeDavRequest( + $user, + "PUT", + $destination, + [], + $content + ); + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + $this->lastUploadDeleteTime = \time(); + return $this->response->getHeader('oc-fileid'); + } + + /** + * @When the administrator uploads file with content :content to :destination using the WebDAV API + * + * @param string|null $content + * @param string $destination + * + * @return string[] + */ + public function adminUploadsAFileWithContentTo( + ?string $content, + string $destination + ):array { + return $this->uploadFileWithContent($this->getAdminUsername(), $content, $destination); + } + + /** + * @Given the administrator has uploaded file with content :content to :destination + * + * @param string|null $content + * @param string $destination + * + * @return string[] + */ + public function adminHasUploadedAFileWithContentTo( + ?string $content, + string $destination + ):array { + $fileId = $this->uploadFileWithContent($this->getAdminUsername(), $content, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$destination'" + ); + return $fileId; + } + + /** + * @When user :user uploads file with content :content to :destination using the WebDAV API + * + * @param string $user + * @param string|null $content + * @param string $destination + * + * @return void + * @throws GuzzleException + * @throws JsonException + */ + public function userUploadsAFileWithContentTo( + string $user, + ?string $content, + string $destination + ):void { + $this->uploadFileWithContent($user, $content, $destination); + $this->pushToLastHttpStatusCodesArray(); + } + + /** + * @When /^user "([^"]*)" uploads the following files with content "([^"]*)"$/ + * + * @param string $user + * @param string|null $content + * @param TableNode $table + * + * @return void + * @throws Exception|GuzzleException + */ + public function userUploadsFollowingFilesWithContentTo( + string $user, + ?string $content, + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $destination) { + $this->uploadFileWithContent($user, $content, $destination["path"]); + $this->pushToLastStatusCodesArrays(); + } + } + + /** + * @When user :user uploads file :source to :destination with mtime :mtime using the WebDAV API + * @Given user :user has uploaded file :source to :destination with mtime :mtime using the WebDAV API + * + * @param string $user + * @param string $source + * @param string $destination + * @param string $mtime Time in human readable format is taken as input which is converted into milliseconds that is used by API + * + * @return void + * @throws Exception + */ + public function userUploadsFileToWithMtimeUsingTheWebdavApi( + string $user, + string $source, + string $destination, + string $mtime + ):void { + $mtime = new DateTime($mtime); + $mtime = $mtime->format('U'); + $user = $this->getActualUsername($user); + $this->response = UploadHelper::upload( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + $this->acceptanceTestsDirLocation() . $source, + $destination, + $this->getStepLineRef(), + ["X-OC-Mtime" => $mtime], + $this->getDavPathVersion() + ); + } + + /** + * @Given user :user has uploaded file :filename with content :content and mtime of :days days ago using the WebDAV API + * + * @param string $user + * @param string $filename + * @param string $content + * @param string $days In days, e.g. "7" + * + * @return void + * @throws Exception + */ + public function userUploadsFileWithContentAndWithMtimeOfDaysAgoUsingWebdavApi( + string $user, + string $filename, + string $content, + string $days + ): void { + $today = new DateTime(); + $interval = new DateInterval('P' . $days . 'D'); + $mtime = $today->sub($interval)->format('U'); + + $user = $this->getActualUsername($user); + + $this->response = WebDavHelper::makeDavRequest( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + "PUT", + $filename, + ["X-OC-Mtime" => $mtime], + $this->getStepLineRef(), + $content + ); + } + + /** + * @Then as :user the mtime of the file :resource should be :mtime + * + * @param string $user + * @param string $resource + * @param string $mtime + * + * @return void + * @throws Exception + */ + public function theMtimeOfTheFileShouldBe( + string $user, + string $resource, + string $mtime + ):void { + $user = $this->getActualUsername($user); + $password = $this->getPasswordForUser($user); + $baseUrl = $this->getBaseUrl(); + $mtime = new DateTime($mtime); + $mtime = $mtime->format('U'); + Assert::assertEquals( + $mtime, + WebDavHelper::getMtimeOfResource( + $user, + $password, + $baseUrl, + $resource, + $this->getStepLineRef(), + $this->getDavPathVersion() + ) + ); + } + + /** + * @Then as :user the mtime of the file :resource should not be :mtime + * + * @param string $user + * @param string $resource + * @param string $mtime + * + * @return void + * @throws Exception + */ + public function theMtimeOfTheFileShouldNotBe( + string $user, + string $resource, + string $mtime + ):void { + $user = $this->getActualUsername($user); + $password = $this->getPasswordForUser($user); + $baseUrl = $this->getBaseUrl(); + $mtime = new DateTime($mtime); + $mtime = $mtime->format('U'); + Assert::assertNotEquals( + $mtime, + WebDavHelper::getMtimeOfResource( + $user, + $password, + $baseUrl, + $resource, + $this->getStepLineRef(), + $this->getDavPathVersion() + ) + ); + } + + /** + * @Given user :user has uploaded file with content :content to :destination + * + * @param string $user + * @param string|null $content + * @param string $destination + * + * @return string[] + */ + public function userHasUploadedAFileWithContentTo( + string $user, + ?string $content, + string $destination + ):array { + $user = $this->getActualUsername($user); + $fileId = $this->uploadFileWithContent($user, $content, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$destination' for user '$user'" + ); + $this->emptyLastHTTPStatusCodesArray(); + return $fileId; + } + + /** + * @Given /^user "([^"]*)" has uploaded the following files with content "([^"]*)"$/ + * + * @param string $user + * @param string|null $content + * @param TableNode $table + * + * @return array + * @throws Exception + */ + public function userHasUploadedFollowingFilesWithContent( + string $user, + ?string $content, + TableNode $table + ):array { + $this->verifyTableNodeColumns($table, ["path"]); + $files = $table->getHash(); + + $fileIds = []; + foreach ($files as $destination) { + $fileId = $this->userHasUploadedAFileWithContentTo($user, $content, $destination["path"])[0]; + \array_push($fileIds, $fileId); + } + return $fileIds; + } + + /** + * @When /^user "([^"]*)" downloads the following files using the WebDAV API$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userDownloadsFollowingFiles( + string $user, + TableNode $table + ):void { + $this->verifyTableNodeColumns($table, ["path"]); + $files = $table->getHash(); + $this->emptyLastHTTPStatusCodesArray(); + foreach ($files as $fileName) { + $this->downloadFileAsUserUsingPassword($user, $fileName["path"]); + $this->pushToLastStatusCodesArrays(); + } + } + + /** + * @When user :user uploads a file with content :content and mtime :mtime to :destination using the WebDAV API + * + * @param string $user + * @param string|null $content + * @param string $mtime + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userUploadsAFileWithContentAndMtimeTo( + string $user, + ?string $content, + string $mtime, + string $destination + ):void { + $user = $this->getActualUsername($user); + $mtime = new DateTime($mtime); + $mtime = $mtime->format('U'); + $this->makeDavRequest( + $user, + "PUT", + $destination, + ["X-OC-Mtime" => $mtime], + $content + ); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file '$destination' with mtime $mtime for user '$user'" + ); + } + + /** + * @When user :user uploads file with checksum :checksum and content :content to :destination using the WebDAV API + * + * @param string $user + * @param string $checksum + * @param string|null $content + * @param string $destination + * + * @return void + */ + public function userUploadsAFileWithChecksumAndContentTo( + string $user, + string $checksum, + ?string $content, + string $destination + ):void { + $this->pauseUploadDelete(); + $this->response = $this->makeDavRequest( + $user, + "PUT", + $destination, + ['OC-Checksum' => $checksum], + $content + ); + $this->lastUploadDeleteTime = \time(); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given user :user has uploaded file with checksum :checksum and content :content to :destination + * + * @param string $user + * @param string $checksum + * @param string|null $content + * @param string $destination + * + * @return void + */ + public function userHasUploadedAFileWithChecksumAndContentTo( + string $user, + string $checksum, + ?string $content, + string $destination + ):void { + $this->userUploadsAFileWithChecksumAndContentTo( + $user, + $checksum, + $content, + $destination + ); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload file with checksum '$checksum' to '$destination' for user '$user'" + ); + } + + /** + * @Then /^user "([^"]*)" should be able to delete (file|folder|entry) "([^"]*)"$/ + * + * @param string $user + * @param string $entry + * @param string $source + * + * @return void + * @throws Exception + */ + public function userShouldBeAbleToDeleteEntry(string $user, string $entry, string $source):void { + $user = $this->getActualUsername($user); + $this->asFileOrFolderShouldExist($user, $entry, $source); + $this->userDeletesFile($user, $source); + $this->asFileOrFolderShouldNotExist($user, $entry, $source); + } + + /** + * @Then /^user "([^"]*)" should not be able to delete (file|folder|entry) "([^"]*)"$/ + * + * @param string $user + * @param string $entry + * @param string $source + * + * @return void + * @throws Exception + */ + public function theUserShouldNotBeAbleToDeleteEntry(string $user, string $entry, string $source):void { + $this->asFileOrFolderShouldExist($user, $entry, $source); + $this->userDeletesFile($user, $source); + $this->asFileOrFolderShouldExist($user, $entry, $source); + } + + /** + * @Given file :file has been deleted for user :user + * + * @param string $file + * @param string $user + * + * @return void + */ + public function fileHasBeenDeleted(string $file, string $user):void { + $this->userHasDeletedFile($user, "deleted", "file", $file); + } + + /** + * @When /^user "([^"]*)" (?:deletes|unshares) (?:file|folder) "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $file + * + * @return void + */ + public function userDeletesFile(string $user, string $file):void { + $user = $this->getActualUsername($user); + $this->pauseUploadDelete(); + $this->response = $this->makeDavRequest($user, 'DELETE', $file, []); + $this->lastUploadDeleteTime = \time(); + $this->pushToLastHttpStatusCodesArray((string) $this->getResponse()->getStatusCode()); + } + + /** + * @Given /^user "([^"]*)" has (deleted|unshared) (file|folder) "([^"]*)"$/ + * + * @param string $user + * @param string $deletedOrUnshared + * @param string $fileOrFolder + * @param string $entry + * + * @return void + */ + public function userHasDeletedFile(string $user, string $deletedOrUnshared, string $fileOrFolder, string $entry):void { + $user = $this->getActualUsername($user); + $this->userDeletesFile($user, $entry); + // If the file or folder was there and got deleted then we get a 204 + // That is good and the expected status + // If the file or folder was already not there then then we get a 404 + // That is not expected. Scenarios that use "Given user has deleted..." + // should only be using such steps when it is a file that exists and needs + // to be deleted. + if ($deletedOrUnshared === "deleted") { + $deleteText = "delete"; + } else { + $deleteText = "unshare"; + } + + $this->theHTTPStatusCodeShouldBe( + ["204"], + "HTTP status code was not 204 while trying to $deleteText $fileOrFolder '$entry' for user '$user'" + ); + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @Given /^user "([^"]*)" has (deleted|unshared) the following (files|folders|resources)$/ + * + * @param string $user + * @param string $deletedOrUnshared + * @param string $fileOrFolder + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userHasDeletedFollowingFiles(string $user, string $deletedOrUnshared, string $fileOrFolder, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $file) { + $this->userHasDeletedFile($user, $deletedOrUnshared, $fileOrFolder, $file["path"]); + } + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @When /^user "([^"]*)" (?:deletes|unshares) the following (?:files|folders)$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userDeletesFollowingFiles(string $user, TableNode $table):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $file) { + $this->userDeletesFile($user, $file["path"]); + $this->pushToLastStatusCodesArrays(); + } + } + + /** + * @When /^the user (?:deletes|unshares) (?:file|folder) "([^"]*)" using the WebDAV API$/ + * + * @param string $file + * + * @return void + */ + public function theUserDeletesFile(string $file):void { + $this->userDeletesFile($this->getCurrentUser(), $file); + } + + /** + * @Given /^the user has (deleted|unshared) (file|folder) "([^"]*)"$/ + * + * @param string $deletedOrUnshared + * @param string $fileOrFolder + * @param string $file + * + * @return void + */ + public function theUserHasDeletedFile(string $deletedOrUnshared, string $fileOrFolder, string $file):void { + $this->userHasDeletedFile($this->getCurrentUser(), $deletedOrUnshared, $fileOrFolder, $file); + } + + /** + * @When /^user "([^"]*)" (?:deletes|unshares) these (?:files|folders|entries) without delays using the WebDAV API$/ + * + * @param string $user + * @param TableNode $table of files or folders to delete + * + * @return void + * @throws Exception + */ + public function userDeletesFilesFoldersWithoutDelays(string $user, TableNode $table):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumnsCount($table, 1); + foreach ($table->getTable() as $entry) { + $entryName = $entry[0]; + $this->response = $this->makeDavRequest($user, 'DELETE', $entryName, []); + $this->pushToLastStatusCodesArrays(); + } + $this->lastUploadDeleteTime = \time(); + } + + /** + * @When /^the user (?:deletes|unshares) these (?:files|folders|entries) without delays using the WebDAV API$/ + * + * @param TableNode $table of files or folders to delete + * + * @return void + * @throws Exception + */ + public function theUserDeletesFilesFoldersWithoutDelays(TableNode $table):void { + $this->userDeletesFilesFoldersWithoutDelays($this->getCurrentUser(), $table); + } + + /** + * @When /^user "([^"]*)" on "(LOCAL|REMOTE)" (?:deletes|unshares) (?:file|folder) "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $server + * @param string $file + * + * @return void + */ + public function userOnDeletesFile(string $user, string $server, string $file):void { + $previousServer = $this->usingServer($server); + $this->userDeletesFile($user, $file); + $this->usingServer($previousServer); + } + + /** + * @Given /^user "([^"]*)" on "(LOCAL|REMOTE)" has (deleted|unshared) (file|folder) "([^"]*)"$/ + * + * @param string $user + * @param string $server + * @param string $deletedOrUnshared + * @param string $fileOrFolder + * @param string $entry + * + * @return void + */ + public function userOnHasDeletedFile(string $user, string $server, string $deletedOrUnshared, string $fileOrFolder, string $entry):void { + $this->userOnDeletesFile($user, $server, $entry); + // If the file was there and got deleted then we get a 204 + // If the file was already not there then then get a 404 + // Either way, the outcome of the "given" step is OK + if ($deletedOrUnshared === "deleted") { + $deleteText = "delete"; + } else { + $deleteText = "unshare"; + } + + $this->theHTTPStatusCodeShouldBe( + ["204", "404"], + "HTTP status code was not 204 or 404 while trying to $deleteText $fileOrFolder '$entry' for user '$user' on server '$server'" + ); + } + + /** + * @When user :user creates folder :destination using the WebDAV API + * + * @param string $user + * @param string $destination + * + * @return void + * @throws JsonException | GuzzleException + */ + public function userCreatesFolder(string $user, string $destination):void { + $user = $this->getActualUsername($user); + $destination = '/' . \ltrim($destination, '/'); + $this->response = $this->makeDavRequest( + $user, + "MKCOL", + $destination, + [] + ); + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + $this->pushToLastHttpStatusCodesArray(); + } + + /** + * @Given user :user has created folder :destination + * + * @param string $user + * @param string $destination + * + * @return void + * @throws JsonException + * @throws GuzzleException + */ + public function userHasCreatedFolder(string $user, string $destination):void { + $user = $this->getActualUsername($user); + $this->userCreatesFolder($user, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to create folder '$destination' for user '$user'" + ); + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @Given admin has created folder :destination + * + * @param string $destination + * + * @return void + * @throws JsonException + * @throws GuzzleException + */ + public function adminHasCreatedFolder(string $destination):void { + $admin = $this->getAdminUsername(); + Assert::assertEquals( + "admin", + $admin, + __METHOD__ . "The provided user is not admin but '" . $admin . "'" + ); + $this->userCreatesFolder($admin, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to create folder '$destination' for admin '$admin'" + ); + $this->adminResources[] = $destination; + $this->emptyLastHTTPStatusCodesArray(); + } + + /** + * @Given /^user "([^"]*)" has created the following folders$/ + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function userHasCreatedFollowingFolders(string $user, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["path"]); + $paths = $table->getHash(); + + foreach ($paths as $path) { + $this->userHasCreatedFolder($user, $path["path"]); + } + } + + /** + * @When the user creates folder :destination using the WebDAV API + * + * @param string $destination + * + * @return void + */ + public function theUserCreatesFolder(string $destination):void { + $this->userCreatesFolder($this->getCurrentUser(), $destination); + } + + /** + * @Given the user has created folder :destination + * + * @param string $destination + * + * @return void + */ + public function theUserHasCreatedFolder(string $destination):void { + $this->theUserCreatesFolder($destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to create folder '$destination'" + ); + } + + /** + * @Then user :user should be able to create folder :destination + * + * @param string $user + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userShouldBeAbleToCreateFolder(string $user, string $destination):void { + $this->userHasCreatedFolder($user, $destination); + $this->asFileOrFolderShouldExist( + $user, + "folder", + $destination + ); + } + + /** + * @Then user :user should not be able to create folder :destination + * + * @param string $user + * @param string $destination + * + * @return void + * @throws Exception + */ + public function userShouldNotBeAbleToCreateFolder(string $user, string $destination):void { + $user = $this->getActualUsername($user); + $this->userCreatesFolder($user, $destination); + $this->theHTTPStatusCodeShouldBeFailure(); + $this->asFileOrFolderShouldNotExist( + $user, + "folder", + $destination + ); + } + + /** + * Old style chunking upload + * + * @When user :user uploads the following :total chunks to :file with old chunking and using the WebDAV API + * + * @param string $user + * @param string $total + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content with column headings, e.g. + * | number | content | + * | 1 | first data | + * | 2 | followed by second data | + * Chunks may be numbered out-of-order if desired. + * + * @return void + * @throws Exception + */ + public function userUploadsTheFollowingTotalChunksUsingOldChunking( + string $user, + string $total, + string $file, + TableNode $chunkDetails + ):void { + $this->verifyTableNodeColumns($chunkDetails, ['number', 'content']); + foreach ($chunkDetails->getHash() as $chunkDetail) { + $chunkNumber = (int)$chunkDetail['number']; + $chunkContent = $chunkDetail['content']; + $this->userUploadsChunkedFile($user, $chunkNumber, (int) $total, $chunkContent, $file); + } + } + + /** + * Old style chunking upload + * + * @Given user :user has uploaded the following :total chunks to :file with old chunking + * + * @param string $user + * @param string $total + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content with following headings, e.g. + * | number | content | + * | 1 | first data | + * | 2 | followed by second data | + * Chunks may be numbered out-of-order if desired. + * + * @return void + * @throws Exception + */ + public function userHasUploadedTheFollowingTotalChunksUsingOldChunking( + string $user, + string $total, + string $file, + TableNode $chunkDetails + ):void { + $this->verifyTableNodeColumns($chunkDetails, ['number', 'content']); + foreach ($chunkDetails->getHash() as $chunkDetail) { + $chunkNumber = (int) $chunkDetail['number']; + $chunkContent = $chunkDetail['content']; + $this->userHasUploadedChunkedFile($user, $chunkNumber, (int) $total, $chunkContent, $file); + } + } + + /** + * Old style chunking upload + * + * @When user :user uploads the following chunks to :file with old chunking and using the WebDAV API + * + * @param string $user + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content with column headings, e.g. + * | number | content | + * | 1 | first data | + * | 2 | followed by second data | + * Chunks may be numbered out-of-order if desired. + * + * @return void + * @throws Exception + */ + public function userUploadsTheFollowingChunksUsingOldChunking( + string $user, + string $file, + TableNode $chunkDetails + ):void { + $total = \count($chunkDetails->getHash()); + $this->userUploadsTheFollowingTotalChunksUsingOldChunking( + $user, + (string) $total, + $file, + $chunkDetails + ); + } + + /** + * Old style chunking upload + * + * @Given user :user has uploaded the following chunks to :file with old chunking + * + * @param string $user + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content with headings, e.g. + * | number | content | + * | 1 | first data | + * | 2 | followed by second data | + * Chunks may be numbered out-of-order if desired. + * + * @return void + * @throws Exception + */ + public function userHasUploadedTheFollowingChunksUsingOldChunking( + string $user, + string $file, + TableNode $chunkDetails + ):void { + $total = \count($chunkDetails->getRows()); + $this->userHasUploadedTheFollowingTotalChunksUsingOldChunking( + $user, + (string) $total, + $file, + $chunkDetails + ); + } + + /** + * Old style chunking upload + * + * @When user :user uploads chunk file :num of :total with :data to :destination using the WebDAV API + * + * @param string $user + * @param int $num + * @param int $total + * @param string|null $data + * @param string $destination + * + * @return void + */ + public function userUploadsChunkedFile( + string $user, + int $num, + int $total, + ?string $data, + string $destination + ):void { + $user = $this->getActualUsername($user); + $num -= 1; + $file = "$destination-chunking-42-$total-$num"; + $this->pauseUploadDelete(); + $this->response = $this->makeDavRequest( + $user, + 'PUT', + $file, + ['OC-Chunked' => '1'], + $data, + "uploads" + ); + $this->lastUploadDeleteTime = \time(); + } + + /** + * Old style chunking upload + * + * @Given user :user has uploaded chunk file :num of :total with :data to :destination + * + * @param string $user + * @param int|null $num + * @param int|null $total + * @param string|null $data + * @param string $destination + * + * @return void + */ + public function userHasUploadedChunkedFile( + string $user, + ?int $num, + ?int $total, + ?string $data, + string $destination + ):void { + $user = $this->getActualUsername($user); + $this->userUploadsChunkedFile($user, $num, $total, $data, $destination); + $this->theHTTPStatusCodeShouldBe( + ["201", "204"], + "HTTP status code was not 201 or 204 while trying to upload chunk $num of $total to file '$destination' for user '$user'" + ); + } + + /** + * New style chunking upload + * + * @When /^user "([^"]*)" uploads the following chunks\s?(asynchronously|) to "([^"]*)" with new chunking and using the WebDAV API$/ + * + * @param string $user + * @param string $type "asynchronously" or empty + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content, with headings e.g. + * | number | content | + * | 1 | first data | + * | 2 | second data | + * Chunks may be numbered out-of-order if desired. + * + * @return void + */ + public function userUploadsTheFollowingChunksUsingNewChunking( + string $user, + string $type, + string $file, + TableNode $chunkDetails + ):void { + $this->uploadTheFollowingChunksUsingNewChunking( + $user, + $type, + $file, + $chunkDetails + ); + } + + /** + * New style chunking upload + * + * @Given /^user "([^"]*)" has uploaded the following chunks\s?(asynchronously|) to "([^"]*)" with new chunking$/ + * + * @param string $user + * @param string $type "asynchronously" or empty + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content without column headings, e.g. + * | number | content | + * | 1 | first data | + * | 2 | followed by second data | + * Chunks may be numbered out-of-order if desired. + * + * @return void + */ + public function userHasUploadedTheFollowingChunksUsingNewChunking( + string $user, + string $type, + string $file, + TableNode $chunkDetails + ):void { + $this->uploadTheFollowingChunksUsingNewChunking( + $user, + $type, + $file, + $chunkDetails, + true + ); + } + + /** + * New style chunking upload + * + * @param string $user + * @param string $type "asynchronously" or empty + * @param string $file + * @param TableNode $chunkDetails table of 2 columns, chunk number and chunk + * content with column headings, e.g. + * | number | content | + * | 1 | first data | + * | 2 | second data | + * Chunks may be numbered out-of-order if desired. + * @param bool $checkActions + * + * @return void + * @throws Exception + */ + public function uploadTheFollowingChunksUsingNewChunking( + string $user, + string $type, + string $file, + TableNode $chunkDetails, + bool $checkActions = false + ):void { + $user = $this->getActualUsername($user); + $async = false; + if ($type === "asynchronously") { + $async = true; + } + $this->verifyTableNodeColumns($chunkDetails, ["number", "content"]); + $this->userUploadsChunksUsingNewChunking( + $user, + $file, + 'chunking-42', + $chunkDetails->getHash(), + $async, + $checkActions + ); + } + + /** + * New style chunking upload + * + * @param string $user + * @param string $file + * @param string $chunkingId + * @param array $chunkDetails of chunks of the file. Each array entry is + * itself an array of 2 items: + * [number] the chunk number + * [content] data content of the chunk + * Chunks may be numbered out-of-order if desired. + * @param bool $async use asynchronous MOVE at the end or not + * @param bool $checkActions + * + * @return void + */ + public function userUploadsChunksUsingNewChunking( + string $user, + string $file, + string $chunkingId, + array $chunkDetails, + bool $async = false, + bool $checkActions = false + ):void { + $this->pauseUploadDelete(); + if ($checkActions) { + $this->userHasCreatedANewChunkingUploadWithId($user, $chunkingId); + } else { + $this->userCreatesANewChunkingUploadWithId($user, $chunkingId); + } + foreach ($chunkDetails as $chunkDetail) { + $chunkNumber = (int)$chunkDetail['number']; + $chunkContent = $chunkDetail['content']; + if ($checkActions) { + $this->userHasUploadedNewChunkFileOfWithToId($user, $chunkNumber, $chunkContent, $chunkingId); + } else { + $this->userUploadsNewChunkFileOfWithToId($user, $chunkNumber, $chunkContent, $chunkingId); + } + } + $headers = []; + if ($async === true) { + $headers = ['OC-LazyOps' => 'true']; + } + $this->moveNewDavChunkToFinalFile($user, $chunkingId, $file, $headers); + if ($checkActions) { + $this->theHTTPStatusCodeShouldBeSuccess(); + } + $this->lastUploadDeleteTime = \time(); + } + + /** + * @When user :user creates a new chunking upload with id :id using the WebDAV API + * + * @param string $user + * @param string $id + * + * @return void + */ + public function userCreatesANewChunkingUploadWithId(string $user, string $id):void { + $user = $this->getActualUsername($user); + $destination = "/uploads/$user/$id"; + $this->response = $this->makeDavRequest( + $user, + 'MKCOL', + $destination, + [], + null, + "uploads" + ); + } + + /** + * @Given user :user has created a new chunking upload with id :id + * + * @param string $user + * @param string $id + * + * @return void + */ + public function userHasCreatedANewChunkingUploadWithId(string $user, string $id):void { + $this->userCreatesANewChunkingUploadWithId($user, $id); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When user :user uploads new chunk file :num with :data to id :id using the WebDAV API + * + * @param string $user + * @param int $num + * @param string|null $data + * @param string $id + * + * @return void + */ + public function userUploadsNewChunkFileOfWithToId(string $user, int $num, ?string $data, string $id):void { + $user = $this->getActualUsername($user); + $destination = "/uploads/$user/$id/$num"; + $this->response = $this->makeDavRequest( + $user, + 'PUT', + $destination, + [], + $data, + "uploads" + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given user :user has uploaded new chunk file :num with :data to id :id + * + * @param string $user + * @param int $num + * @param string|null $data + * @param string $id + * + * @return void + */ + public function userHasUploadedNewChunkFileOfWithToId(string $user, int $num, ?string $data, string $id):void { + $this->userUploadsNewChunkFileOfWithToId($user, $num, $data, $id); + $this->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When /^user "([^"]*)" moves new chunk file with id "([^"]*)"\s?(asynchronously|) to "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $id + * @param string $type "asynchronously" or empty + * @param string $dest + * + * @return void + */ + public function userMovesNewChunkFileWithIdToMychunkedfile( + string $user, + string $id, + string $type, + string $dest + ):void { + $headers = []; + if ($type === "asynchronously") { + $headers = ['OC-LazyOps' => 'true']; + } + $this->moveNewDavChunkToFinalFile($user, $id, $dest, $headers); + } + + /** + * @Given /^user "([^"]*)" has moved new chunk file with id "([^"]*)"\s?(asynchronously|) to "([^"]*)"$/ + * + * @param string $user + * @param string $id + * @param string $type "asynchronously" or empty + * @param string $dest + * + * @return void + */ + public function userHasMovedNewChunkFileWithIdToMychunkedfile( + string $user, + string $id, + string $type, + string $dest + ):void { + $this->userMovesNewChunkFileWithIdToMychunkedfile($user, $id, $type, $dest); + $this->theHTTPStatusCodeShouldBe("201"); + } + + /** + * @When user :user cancels chunking-upload with id :id using the WebDAV API + * + * @param string $user + * @param string $id + * + * @return void + */ + public function userCancelsUploadWithId( + string $user, + string $id + ):void { + $this->deleteUpload($user, $id, []); + } + + /** + * @Given user :user has canceled new chunking-upload with id :id + * + * @param string $user + * @param string $id + * + * @return void + */ + public function userHasCanceledUploadWithId( + string $user, + string $id + ):void { + $this->userCancelsUploadWithId($user, $id); + $this->theHTTPStatusCodeShouldBe("201"); + } + + /** + * @When /^user "([^"]*)" moves new chunk file with id "([^"]*)"\s?(asynchronously|) to "([^"]*)" with size (.*) using the WebDAV API$/ + * + * @param string $user + * @param string $id + * @param string $type "asynchronously" or empty + * @param string $dest + * @param int $size + * + * @return void + */ + public function userMovesNewChunkFileWithIdToMychunkedfileWithSize( + string $user, + string $id, + string $type, + string $dest, + int $size + ):void { + $headers = ['OC-Total-Length' => $size]; + if ($type === "asynchronously") { + $headers['OC-LazyOps'] = 'true'; + } + $this->moveNewDavChunkToFinalFile( + $user, + $id, + $dest, + $headers + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has moved new chunk file with id "([^"]*)"\s?(asynchronously|) to "([^"]*)" with size (.*)$/ + * + * @param string $user + * @param string $id + * @param string $type "asynchronously" or empty + * @param string $dest + * @param int $size + * + * @return void + */ + public function userHasMovedNewChunkFileWithIdToMychunkedfileWithSize( + string $user, + string $id, + string $type, + string $dest, + int $size + ):void { + $this->userMovesNewChunkFileWithIdToMychunkedfileWithSize( + $user, + $id, + $type, + $dest, + $size + ); + $this->theHTTPStatusCodeShouldBe("201"); + } + + /** + * @When /^user "([^"]*)" moves new chunk file with id "([^"]*)"\s?(asynchronously|) to "([^"]*)" with checksum "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $id + * @param string $type "asynchronously" or empty + * @param string $dest + * @param string $checksum + * + * @return void + */ + public function userMovesNewChunkFileWithIdToMychunkedfileWithChecksum( + string $user, + string $id, + string $type, + string $dest, + string $checksum + ):void { + $headers = ['OC-Checksum' => $checksum]; + if ($type === "asynchronously") { + $headers['OC-LazyOps'] = 'true'; + } + $this->moveNewDavChunkToFinalFile( + $user, + $id, + $dest, + $headers + ); + $this->pushToLastStatusCodesArrays(); + } + + /** + * @Given /^user "([^"]*)" has moved new chunk file with id "([^"]*)"\s?(asynchronously|) to "([^"]*)" with checksum "([^"]*)" + * + * @param string $user + * @param string $id + * @param string $type "asynchronously" or empty + * @param string $dest + * @param string $checksum + * + * @return void + */ + public function userHasMovedNewChunkFileWithIdToMychunkedfileWithChecksum( + string $user, + string $id, + string $type, + string $dest, + string $checksum + ):void { + $this->userMovesNewChunkFileWithIdToMychunkedfileWithChecksum( + $user, + $id, + $type, + $dest, + $checksum + ); + $this->theHTTPStatusCodeShouldBe("201"); + } + + /** + * Move chunked new DAV file to final file + * + * @param string $user user + * @param string $id upload id + * @param string $destination destination path + * @param array $headers extra headers + * + * @return void + */ + private function moveNewDavChunkToFinalFile(string $user, string $id, string $destination, array $headers):void { + $user = $this->getActualUsername($user); + $source = "/uploads/$user/$id/.file"; + $headers['Destination'] = $this->destinationHeaderValue( + $user, + $destination + ); + + $this->response = $this->makeDavRequest( + $user, + 'MOVE', + $source, + $headers, + null, + "uploads" + ); + } + + /** + * Delete chunked-upload directory + * + * @param string $user user + * @param string $id upload id + * @param array $headers extra headers + * + * @return void + */ + private function deleteUpload(string $user, string $id, array $headers) { + $source = "/uploads/$user/$id"; + $this->response = $this->makeDavRequest( + $user, + 'DELETE', + $source, + $headers, + null, + "uploads" + ); + } + + /** + * URL encodes the given path but keeps the slashes + * + * @param string $path to encode + * + * @return string encoded path + */ + public function encodePath(string $path):string { + // slashes need to stay + $encodedPath = \str_replace('%2F', '/', \rawurlencode($path)); + // in ocis even brackets are encoded + if (OcisHelper::isTestingOnOcisOrReva()) { + return $encodedPath; + } + // do not encode '(' and ')' for oc10 + $encodedPath = \str_replace('%28', '(', $encodedPath); + $encodedPath = \str_replace('%29', ')', $encodedPath); + return $encodedPath; + } + + /** + * @When an unauthenticated client connects to the DAV endpoint using the WebDAV API + * + * @return void + */ + public function connectingToDavEndpoint():void { + $this->response = $this->makeDavRequest( + null, + 'PROPFIND', + '', + [] + ); + } + + /** + * @Given an unauthenticated client has connected to the DAV endpoint + * + * @return void + */ + public function hasConnectedToDavEndpoint():void { + $this->connectingToDavEndpoint(); + $this->theHTTPStatusCodeShouldBe("401"); + } + + /** + * @Then there should be no duplicate headers + * + * @return void + * @throws Exception + */ + public function thereAreNoDuplicateHeaders():void { + $headers = $this->response->getHeaders(); + foreach ($headers as $headerName => $headerValues) { + // if a header has multiple values, they must be different + if (\count($headerValues) > 1 + && \count(\array_unique($headerValues)) < \count($headerValues) + ) { + throw new Exception("Duplicate header found: $headerName"); + } + } + } + + /** + * @Then the following headers should not be set + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theFollowingHeadersShouldNotBeSet(TableNode $table):void { + $this->verifyTableNodeColumns( + $table, + ['header'] + ); + foreach ($table->getColumnsHash() as $header) { + $headerName = $header['header']; + $headerValue = $this->response->getHeader($headerName); + //Note: getHeader returns an empty array if the named header does not exist + if (isset($headerValue[0])) { + $headerValue0 = $headerValue[0]; + } else { + $headerValue0 = ''; + } + Assert::assertEmpty( + $headerValue, + "header $headerName should not exist " . + "but does and is set to $headerValue0" + ); + } + } + + /** + * @Then the following headers should match these regular expressions + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function headersShouldMatchRegularExpressions(TableNode $table):void { + $this->verifyTableNodeColumnsCount($table, 2); + foreach ($table->getTable() as $header) { + $headerName = $header[0]; + $expectedHeaderValue = $header[1]; + $expectedHeaderValue = $this->substituteInLineCodes( + $expectedHeaderValue, + null, + ['preg_quote' => ['/']] + ); + + $returnedHeaders = $this->response->getHeader($headerName); + $returnedHeader = $returnedHeaders[0]; + Assert::assertNotFalse( + (bool) \preg_match($expectedHeaderValue, $returnedHeader), + "'$expectedHeaderValue' does not match '$returnedHeader'" + ); + } + } + + /** + * @Then /^if the HTTP status code was "([^"]*)" then the following headers should match these regular expressions$/ + * + * @param int $statusCode + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function statusCodeShouldMatchTheseRegularExpressions(int $statusCode, TableNode $table):void { + $actualStatusCode = $this->response->getStatusCode(); + if ($actualStatusCode === $statusCode) { + $this->headersShouldMatchRegularExpressions($table); + } + } + + /** + * @Then the following headers should match these regular expressions for user :user + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function headersShouldMatchRegularExpressionsForUser(string $user, TableNode $table):void { + $this->verifyTableNodeColumnsCount($table, 2); + $user = $this->getActualUsername($user); + foreach ($table->getTable() as $header) { + $headerName = $header[0]; + $expectedHeaderValue = $header[1]; + $expectedHeaderValue = $this->substituteInLineCodes( + $expectedHeaderValue, + $user, + ['preg_quote' => ['/']] + ); + + $returnedHeaders = $this->response->getHeader($headerName); + $returnedHeader = $returnedHeaders[0]; + Assert::assertNotFalse( + (bool) \preg_match($expectedHeaderValue, $returnedHeader), + "'$expectedHeaderValue' does not match '$returnedHeader'" + ); + } + } + + /** + * @When /^user "([^"]*)" deletes everything from folder "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $folder + * @param bool $checkEachDelete + * + * @return void + * @throws Exception + */ + public function userDeletesEverythingInFolder( + string $user, + string $folder, + bool $checkEachDelete = false + ):void { + $user = $this->getActualUsername($user); + $responseXmlObject = $this->listFolderAndReturnResponseXml( + $user, + $folder, + '1' + ); + $elementList = $responseXmlObject->xpath("//d:response/d:href"); + if (\is_array($elementList) && \count($elementList)) { + \array_shift($elementList); //don't delete the folder itself + $davPrefix = "/" . $this->getFullDavFilesPath($user); + foreach ($elementList as $element) { + $element = \substr((string)$element, \strlen($davPrefix)); + if ($checkEachDelete) { + $this->userHasDeletedFile($user, "deleted", "file", $element); + } else { + $this->userDeletesFile($user, $element); + } + } + } + } + + /** + * @Given /^user "([^"]*)" has deleted everything from folder "([^"]*)"$/ + * + * @param string $user + * @param string $folder + * + * @return void + * @throws Exception + */ + public function userHasDeletedEverythingInFolder(string $user, string $folder):void { + $this->userDeletesEverythingInFolder($user, $folder, true); + } + + /** + * @When user :user downloads the preview of :path with width :width and height :height using the WebDAV API + * + * @param string $user + * @param string $path + * @param string $width + * @param string $height + * + * @return void + */ + public function downloadPreviewOfFiles(string $user, string $path, string $width, string $height):void { + $this->downloadPreviews( + $user, + $path, + null, + $width, + $height + ); + } + + /** + * @When user :user1 downloads the preview of :path of :user2 with width :width and height :height using the WebDAV API + * + * @param string $user1 + * @param string $path + * @param string $doDavRequestAsUser + * @param string $width + * @param string $height + * + * @return void + */ + public function downloadPreviewOfOtherUser(string $user1, string $path, string $doDavRequestAsUser, string $width, string $height):void { + $this->downloadPreviews( + $user1, + $path, + $doDavRequestAsUser, + $width, + $height + ); + } + + /** + * @Then the downloaded image for user :user should be :width pixels wide and :height pixels high + * + * @param string $user + * @param string $width + * @param string $height + * + * @return void + */ + public function imageDimensionsForAUserShouldBe(string $user, string $width, string $height):void { + if ($this->userResponseBodyContents[$user] === null) { + $this->userResponseBodyContents[$user] = $this->response->getBody()->getContents(); + } + $size = \getimagesizefromstring($this->userResponseBodyContents[$user]); + Assert::assertNotFalse($size, "could not get size of image"); + Assert::assertEquals($width, $size[0], "width not as expected"); + Assert::assertEquals($height, $size[1], "height not as expected"); + } + + /** + * @Then the downloaded image should be :width pixels wide and :height pixels high + * + * @param string $width + * @param string $height + * + * @return void + */ + public function imageDimensionsShouldBe(string $width, string $height): void { + if ($this->responseBodyContent === null) { + $this->responseBodyContent = $this->response->getBody()->getContents(); + } + $size = \getimagesizefromstring($this->responseBodyContent); + Assert::assertNotFalse($size, "could not get size of image"); + Assert::assertEquals($width, $size[0], "width not as expected"); + Assert::assertEquals($height, $size[1], "height not as expected"); + } + + /** + * @Then the requested JPEG image should have a quality value of :size + * + * @param string $value + * + * @return void + */ + public function jpgQualityValueShouldBe(string $value): void { + $this->responseBodyContent = $this->response->getBody()->getContents(); + // quality value is embedded in the string content for JPEG images + $qualityString = "quality = $value"; + Assert::assertStringContainsString($qualityString, $this->responseBodyContent); + } + + /** + * @Then the downloaded preview content should match with :preview fixtures preview content + * + * @param string $filename relative path from fixtures directory + * + * @return void + * @throws Exception + */ + public function theDownloadedPreviewContentShouldMatchWithFixturesPreviewContentFor(string $filename):void { + $expectedPreview = \file_get_contents(__DIR__ . "/../../fixtures/" . $filename); + Assert::assertEquals($expectedPreview, $this->responseBodyContent); + } + + /** + * @Given user :user has downloaded the preview of :path with width :width and height :height + * + * @param string $user + * @param string $path + * @param string $width + * @param string $height + * + * @return void + */ + public function userDownloadsThePreviewOfWithWidthAndHeight(string $user, string $path, string $width, string $height):void { + $this->downloadPreviewOfFiles($user, $path, $width, $height); + $this->theHTTPStatusCodeShouldBe(200); + $this->imageDimensionsShouldBe($width, $height); + // save response to user response dictionary for further comparisons + $this->userResponseBodyContents[$user] = $this->responseBodyContent; + } + + /** + * @Then as user :user the preview of :path with width :width and height :height should have been changed + * + * @param string $user + * @param string $path + * @param string $width + * @param string $height + * + * @return void + */ + public function asUserThePreviewOfPathWithHeightAndWidthShouldHaveBeenChanged(string $user, string $path, string $width, string $height):void { + $this->downloadPreviewOfFiles($user, $path, $width, $height); + $this->theHTTPStatusCodeShouldBe(200); + $newResponseBodyContents = $this->response->getBody()->getContents(); + Assert::assertNotEquals( + $newResponseBodyContents, + // different users can download files before and after an update is made to a file + // previous response content is fetched from user response body content array for that user + $this->userResponseBodyContents[$user], + __METHOD__ . " previous and current previews content is same but expected to be different", + ); + // update the saved content for the next comparison + $this->userResponseBodyContents[$user] = $newResponseBodyContents; + } + + /** + * @param string $user + * @param string $path + * + * @return string|null + */ + public function getFileIdForPath(string $user, string $path): ?string { + $user = $this->getActualUsername($user); + try { + return WebDavHelper::getFileIdForPath( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + $path, + $this->getStepLineRef(), + $this->getDavPathVersion() + ); + } catch (Exception $e) { + return null; + } + } + + /** + * @Given /^user "([^"]*)" has stored id of (file|folder) "([^"]*)"$/ + * @When /^user "([^"]*)" stores id of (file|folder) "([^"]*)"$/ + * + * @param string $user + * @param string $fileOrFolder + * @param string $path + * + * @return void + */ + public function userStoresFileIdForPath(string $user, string $fileOrFolder, string $path):void { + $this->storedFileID = $this->getFileIdForPath($user, $path); + } + + /** + * @Then /^user "([^"]*)" (file|folder) "([^"]*)" should have the previously stored id$/ + * + * @param string $user + * @param string $fileOrFolder + * @param string $path + * + * @return void + */ + public function userFileShouldHaveStoredId(string $user, string $fileOrFolder, string $path):void { + $user = $this->getActualUsername($user); + $currentFileID = $this->getFileIdForPath($user, $path); + Assert::assertEquals( + $currentFileID, + $this->storedFileID, + __METHOD__ + . " User '$user' $fileOrFolder '$path' does not have the previously stored id '{$this->storedFileID}', but has '$currentFileID'." + ); + } + + /** + * @Then /^the (?:Cal|Card)?DAV (exception|message|reason) should be "([^"]*)"$/ + * + * @param string $element exception|message|reason + * @param string $message + * + * @return void + * @throws Exception + */ + public function theDavElementShouldBe(string $element, string $message):void { + WebDavAssert::assertDavResponseElementIs( + $element, + $message, + $this->responseXml, + __METHOD__ + ); + } + + /** + * @param string $shouldOrNot (not|) + * @param TableNode $expectedFiles + * @param string|null $user + * @param string|null $method + * @param string|null $folderpath + * + * @return void + * @throws GuzzleException + */ + public function propfindResultShouldContainEntries( + string $shouldOrNot, + TableNode $expectedFiles, + ?string $user = null, + ?string $method = 'REPORT', + ?string $folderpath = '' + ):void { + $this->verifyTableNodeColumnsCount($expectedFiles, 1); + $elementRows = $expectedFiles->getRows(); + $should = ($shouldOrNot !== "not"); + foreach ($elementRows as $expectedFile) { + $fileFound = $this->findEntryFromPropfindResponse( + $expectedFile[0], + $user, + $method, + "files", + $folderpath + ); + if ($should) { + Assert::assertNotEmpty( + $fileFound, + "response does not contain the entry '$expectedFile[0]'" + ); + } else { + Assert::assertFalse( + $fileFound, + "response does contain the entry '$expectedFile[0]' but should not" + ); + } + } + } + + /** + * @Then /^the (?:propfind|search) result of user "([^"]*)" should (not|)\s?contain these (?:files|entries):$/ + * + * @param string $user + * @param string $shouldOrNot (not|) + * @param TableNode $expectedFiles + * + * @return void + * @throws Exception + */ + public function thePropfindResultShouldContainEntries( + string $user, + string $shouldOrNot, + TableNode $expectedFiles + ):void { + $user = $this->getActualUsername($user); + $this->propfindResultShouldContainEntries( + $shouldOrNot, + $expectedFiles, + $user + ); + } + + /** + * @Then /^the (?:propfind|search) result of user "([^"]*)" should not contain any (?:files|entries)$/ + * + * @param string $user + * + * @return void + * @throws Exception + */ + public function thePropfindResultShouldNotContainAnyEntries( + string $user + ):void { + $multistatusResults = $this->getMultistatusResultFromPropfindResult($user); + Assert::assertEmpty($multistatusResults, 'The propfind response was expected to be empty but was not'); + } + + /** + * @Then /^the (?:propfind|search) result of user "([^"]*)" should contain only these (?:files|entries):$/ + * + * @param string $user + * @param TableNode $expectedFiles + * + * @return void + * @throws Exception + */ + public function thePropfindResultShouldContainOnlyEntries( + string $user, + TableNode $expectedFiles + ):void { + $user = $this->getActualUsername($user); + + Assert::assertEquals( + \count($expectedFiles->getTable()), + $this->getNumberOfEntriesInPropfindResponse( + $user + ), + "The number of elements in the response doesn't matches with expected number of elements" + ); + $this->propfindResultShouldContainEntries( + '', + $expectedFiles, + $user + ); + } + + /** + * @Then the propfind/search result should contain :numFiles files/entries + * + * @param int $numFiles + * + * @return void + */ + public function propfindResultShouldContainNumEntries(int $numFiles):void { + //if we are using that step the second time in a scenario e.g. 'But ... should not' + //then don't parse the result again, because the result in a ResponseInterface + if (empty($this->responseXml)) { + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + } + Assert::assertIsArray( + $this->responseXml, + __METHOD__ . " responseXml is not an array" + ); + Assert::assertArrayHasKey( + "value", + $this->responseXml, + __METHOD__ . " responseXml does not have key 'value'" + ); + $multistatusResults = $this->responseXml["value"]; + if ($multistatusResults === null) { + $multistatusResults = []; + } + Assert::assertEquals( + (int) $numFiles, + \count($multistatusResults), + __METHOD__ + . " Expected result to contain '" + . (int) $numFiles + . "' files/entries, but got '" + . \count($multistatusResults) + . "' files/entries." + ); + } + + /** + * @Then the propfind/search result should contain any :expectedNumber of these files/entries: + * + * @param integer $expectedNumber + * @param TableNode $expectedFiles + * + * @return void + * @throws Exception + */ + public function theSearchResultShouldContainAnyOfTheseEntries( + int $expectedNumber, + TableNode $expectedFiles + ):void { + $this->theSearchResultOfUserShouldContainAnyOfTheseEntries( + $this->getCurrentUser(), + $expectedNumber, + $expectedFiles + ); + } + + /** + * @Then the propfind/search result of user :user should contain any :expectedNumber of these files/entries: + * + * @param string $user + * @param integer $expectedNumber + * @param TableNode $expectedFiles + * + * @return void + * @throws Exception + */ + public function theSearchResultOfUserShouldContainAnyOfTheseEntries( + string $user, + int $expectedNumber, + TableNode $expectedFiles + ):void { + $user = $this->getActualUsername($user); + $this->verifyTableNodeColumnsCount($expectedFiles, 1); + $this->propfindResultShouldContainNumEntries($expectedNumber); + $elementRows = $expectedFiles->getColumn(0); + // Remove any "/" from the front (or back) of the expected values passed + // into the step. findEntryFromPropfindResponse returns entries without + // any leading (or trailing) slash + $expectedEntries = \array_map( + function ($value) { + return \trim($value, "/"); + }, + $elementRows + ); + $resultEntries = $this->findEntryFromPropfindResponse(null, $user, "REPORT"); + foreach ($resultEntries as $resultEntry) { + Assert::assertContains($resultEntry, $expectedEntries); + } + } + + /** + * @When user :arg1 lists the resources in :path with depth :depth using the WebDAV API + * + * @param string $user + * @param string $path + * @param string $depth + * + * @return void + * @throws Exception + */ + public function userListsTheResourcesInPathWithDepthUsingTheWebdavApi(string $user, string $path, string $depth):void { + $response = $this->listFolder( + $user, + $path, + $depth + ); + $this->setResponse($response); + $this->setResponseXml(HttpRequestHelper::parseResponseAsXml($this->response)); + } + + /** + * @Then the last DAV response for user :user should contain these nodes/elements + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theLastDavResponseShouldContainTheseNodes(string $user, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["name"]); + foreach ($table->getHash() as $row) { + $path = $this->substituteInLineCodes($row['name']); + $res = $this->findEntryFromPropfindResponse($path, $user); + Assert::assertNotFalse($res, "expected $path to be in DAV response but was not found"); + } + } + + /** + * @Then the last DAV response for user :user should not contain these nodes/elements + * + * @param string $user + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theLastDavResponseShouldNotContainTheseNodes(string $user, TableNode $table):void { + $this->verifyTableNodeColumns($table, ["name"]); + foreach ($table->getHash() as $row) { + $path = $this->substituteInLineCodes($row['name']); + $res = $this->findEntryFromPropfindResponse($path, $user); + Assert::assertFalse($res, "expected $path to not be in DAV response but was found"); + } + } + + /** + * @Then the last public link DAV response should contain these nodes/elements + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theLastPublicDavResponseShouldContainTheseNodes(TableNode $table):void { + $user = $this->getLastPublicShareToken(); + $this->verifyTableNodeColumns($table, ["name"]); + $type = $this->usingOldDavPath ? "public-files" : "public-files-new"; + foreach ($table->getHash() as $row) { + $path = $this->substituteInLineCodes($row['name']); + $res = $this->findEntryFromPropfindResponse($path, $user, null, $type); + Assert::assertNotFalse($res, "expected $path to be in DAV response but was not found"); + } + } + + /** + * @Then the last public link DAV response should not contain these nodes/elements + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theLastPublicDavResponseShouldNotContainTheseNodes(TableNode $table):void { + $user = $this->getLastPublicShareToken(); + $this->verifyTableNodeColumns($table, ["name"]); + $type = $this->usingOldDavPath ? "public-files" : "public-files-new"; + foreach ($table->getHash() as $row) { + $path = $this->substituteInLineCodes($row['name']); + $res = $this->findEntryFromPropfindResponse($path, $user, null, $type); + Assert::assertFalse($res, "expected $path to not be in DAV response but was found"); + } + } + + /** + * @When the public lists the resources in the last created public link with depth :depth using the WebDAV API + * + * @param string $depth + * + * @return void + * @throws Exception + */ + public function thePublicListsTheResourcesInTheLastCreatedPublicLinkWithDepthUsingTheWebdavApi(string $depth):void { + $user = $this->getLastPublicShareToken(); + $response = $this->listFolder( + $user, + '/', + $depth, + null, + $this->usingOldDavPath ? "public-files" : "public-files-new" + ); + $this->setResponse($response); + $this->setResponseXml(HttpRequestHelper::parseResponseAsXml($this->response)); + } + + /** + * @param string|null $user + * + * @return array + */ + public function findEntryFromReportResponse(?string $user):array { + $responseXmlObj = $this->getResponseXmlObject(); + $responseResources = []; + $hrefs = $responseXmlObj->xpath('//d:href'); + foreach ($hrefs as $href) { + $hrefParts = \explode("/", (string)$href[0]); + if (\in_array($user, $hrefParts)) { + $entry = \urldecode(\end($hrefParts)); + \array_push($responseResources, $entry); + } else { + throw new Error("Expected user: $hrefParts[5] but found: $user"); + } + } + return $responseResources; + } + + /** + * parses a PROPFIND response from $this->response into xml + * and returns found search results if found else returns false + * + * @param string|null $user + * + * @return int + */ + public function getNumberOfEntriesInPropfindResponse( + ?string $user = null + ):int { + $multistatusResults = $this->getMultistatusResultFromPropfindResult($user); + return \count($multistatusResults); + } + + /** + * parses a PROPFIND response from $this->response + * and returns multistatus data from the response + * + * @param string|null $user + * + * @return array + */ + public function getMultistatusResultFromPropfindResult( + ?string $user = null + ):array { + //if we are using that step the second time in a scenario e.g. 'But ... should not' + //then don't parse the result again, because the result in a ResponseInterface + if (empty($this->responseXml)) { + $this->setResponseXml( + HttpRequestHelper::parseResponseAsXml($this->response) + ); + } + Assert::assertNotEmpty($this->responseXml, __METHOD__ . ' Response is empty'); + if ($user === null) { + $user = $this->getCurrentUser(); + } + + Assert::assertIsArray( + $this->responseXml, + __METHOD__ . " responseXml for user $user is not an array" + ); + Assert::assertArrayHasKey( + "value", + $this->responseXml, + __METHOD__ . " responseXml for user $user does not have key 'value'" + ); + $multistatus = $this->responseXml["value"]; + if ($multistatus == null) { + $multistatus = []; + } + return $multistatus; + } + + /** + * Escapes the given string for + * 1. Space --> %20 + * 2. Opening Small Bracket --> %28 + * 3. Closing Small Bracket --> %29 + * + * @param string $path - File path to parse + * + * @return string + */ + public function escapePath(string $path): string { + return \str_replace([" ", "(", ")"], ["%20", "%28", "%29"], $path); + } + + /** + * parses a PROPFIND response from $this->response into xml + * and returns found search results if found else returns false + * + * @param string|null $entryNameToSearch + * @param string|null $user + * @param string|null $method + * @param string $type + * @param string $folderPath + * + * @return string|array|boolean + * + * string if $entryNameToSearch is given and is found + * array if $entryNameToSearch is not given + * boolean false if $entryNameToSearch is given and is not found + * + * @throws GuzzleException + */ + public function findEntryFromPropfindResponse( + ?string $entryNameToSearch = null, + ?string $user = null, + ?string $method = null, + string $type = "files", + string $folderPath = '' + ) { + $trimmedEntryNameToSearch = ''; + // trim any leading "/" passed by the caller, we can just match the "raw" name + if ($entryNameToSearch != null) { + $trimmedEntryNameToSearch = \trim($entryNameToSearch, "/"); + } + // url encode for spaces and brackets that may appear in the filePath + $folderPath = $this->escapePath($folderPath); + // topWebDavPath should be something like /remote.php/webdav/ or + // /remote.php/dav/files/alice/ + $topWebDavPath = "/" . $this->getFullDavFilesPath($user) . "/" . $folderPath; + switch ($type) { + case "files": + break; + case "public-files": + case "public-files-old": + case "public-files-new": + $topWebDavPath = "/" . $this->getPublicLinkDavPath($user, $type) . "/"; + break; + default: + throw new Exception("error"); + } + $multistatusResults = $this->getMultistatusResultFromPropfindResult($user); + $results = []; + if ($multistatusResults !== null) { + foreach ($multistatusResults as $multistatusResult) { + $entryPath = $multistatusResult['value'][0]['value']; + if (OcisHelper::isTestingOnOcis() && $method === "REPORT") { + if ($entryNameToSearch !== null && str_ends_with($entryPath, $entryNameToSearch)) { + return $multistatusResult; + } else { + $spaceId = (WebDavHelper::$SPACE_ID_FROM_OCIS) ? WebDavHelper::$SPACE_ID_FROM_OCIS : WebDavHelper::getPersonalSpaceIdForUser( + $this->getBaseUrl(), + $user, + $this->getPasswordForUser($user), + $this->getStepLineRef() + ); + $topWebDavPath = "/remote.php/dav/spaces/" . $spaceId . "/" . $folderPath; + } + } + $entryName = \str_replace($topWebDavPath, "", $entryPath); + $entryName = \rawurldecode($entryName); + $entryName = \trim($entryName, "/"); + if ($trimmedEntryNameToSearch === $entryName) { + return $multistatusResult; + } + \array_push($results, $entryName); + } + } + if ($entryNameToSearch === null) { + return $results; + } + return false; + } + + /** + * Prevent creating two uploads and/or deletes with the same "stime" + * That is based on seconds in some implementations. + * This prevents duplication of etags or other problems with + * trashbin/versions save/restore. + * + * Set env var UPLOAD_DELETE_WAIT_TIME to 1 to activate a 1-second pause. + * By default, there is no pause. That allows testing of implementations + * which should be able to cope with multiple upload/delete actions in the + * same second. + * + * @return void + */ + public function pauseUploadDelete():void { + $time = \time(); + $uploadWaitTime = \getenv("UPLOAD_DELETE_WAIT_TIME"); + + $uploadWaitTime = $uploadWaitTime ? (int)$uploadWaitTime : 0; + + if (($this->lastUploadDeleteTime !== null) + && ($uploadWaitTime > 0) + && (($time - $this->lastUploadDeleteTime) < $uploadWaitTime) + ) { + \sleep($uploadWaitTime); + } + } + + /** + * reset settings if they were set in the scenario + * + * @AfterScenario + * + * @return void + * @throws Exception + */ + public function resetPreviousSettingsAfterScenario():void { + if ($this->previousAsyncSetting === "") { + SetupHelper::runOcc( + ['config:system:delete', 'dav.enable.async'], + $this->getStepLineRef() + ); + } elseif ($this->previousAsyncSetting !== null) { + SetupHelper::runOcc( + [ + 'config:system:set', + 'dav.enable.async', + '--type', + 'boolean', + '--value', + $this->previousAsyncSetting + ], + $this->getStepLineRef() + ); + } + if ($this->previousDavSlowdownSetting === "") { + SetupHelper::runOcc( + ['config:system:delete', 'dav.slowdown'], + $this->getStepLineRef() + ); + } elseif ($this->previousDavSlowdownSetting !== null) { + SetupHelper::runOcc( + [ + 'config:system:set', + 'dav.slowdown', + '--value', + $this->previousDavSlowdownSetting + ], + $this->getStepLineRef() + ); + } + } + + /** + * @Given /^the administrator has (enabled|disabled) the file version storage feature/ + * + * @param string $enabledOrDisabled + * + * @return void + * @throws Exception + */ + public function theAdministratorHasEnabledTheFileVersionStorage(string $enabledOrDisabled): void { + $switch = ($enabledOrDisabled !== "disabled"); + if ($switch) { + $value = 'true'; + } else { + $value = 'false'; + } + $this->runOcc( + [ + 'config:system:set', + 'file_storage.save_version_author', + '--type', + 'boolean', + '--value', + $value] + ); + } + + /** + * @Then the author of the created version with index :index should be :expectedUsername + * + * @param string $index + * @param string $expectedUsername + * + * @return void + * @throws Exception + */ + public function theAuthorOfEditedVersionFile(string $index, string $expectedUsername): void { + $expectedUserDisplayName = $this->getUserDisplayName($expectedUsername); + $resXml = $this->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->getResponse(), + __METHOD__ + ); + $this->setResponseXmlObject($resXml); + } + + // the username should be in oc:meta-version-edited-by + $xmlPart = $resXml->xpath("//oc:meta-version-edited-by//text()"); + if (!isset($xmlPart[$index - 1])) { + Assert::fail( + 'could not find version with index "' . $index . '" for oc:meta-version-edited-by property in response to user "' . $this->responseUser . '"' + ); + } + $actualUser = $xmlPart[$index - 1][0]; + Assert::assertEquals( + $expectedUsername, + $actualUser, + "Expected user of version with index $index in response to user '$this->responseUser' was '$expectedUsername', but got '$actualUser'" + ); + + // the user's display name should be in oc:meta-version-edited-by-name + $xmlPart = $resXml->xpath("//oc:meta-version-edited-by-name//text()"); + if (!isset($xmlPart[$index - 1])) { + Assert::fail( + 'could not find version with index "' . $index . '" for oc:meta-version-edited-by-name property in response to user "' . $this->responseUser . '"' + ); + } + $actualUserDisplayName = $xmlPart[$index - 1][0]; + Assert::assertEquals( + $expectedUserDisplayName, + $actualUserDisplayName, + "Expected display name of version with index $index in response to user '$this->responseUser' was '$expectedUsername', but got '$actualUser'" + ); + } +} diff --git a/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php b/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php new file mode 100644 index 000000000..62275ef27 --- /dev/null +++ b/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php @@ -0,0 +1,1396 @@ + + * @copyright Copyright (c) 2019, ownCloud GmbH + * + * This code 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 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 + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; +use TestHelpers\Asserts\WebDav as WebDavTest; +use TestHelpers\HttpRequestHelper; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * Steps that relate to managing file/folder properties via WebDav + */ +class WebDavPropertiesContext implements Context { + /** + * + * @var FeatureContext + */ + private $featureContext; + + /** + * @var array map with user as key and another map as value, + * which has path as key and etag as value + */ + private $storedETAG = null; + + /** + * @When /^user "([^"]*)" gets the properties of (?:file|folder|entry) "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $path + * + * @return void + * @throws Exception + */ + public function userGetsThePropertiesOfFolder( + string $user, + string $path + ):void { + $this->featureContext->setResponseXmlObject( + $this->featureContext->listFolderAndReturnResponseXml( + $user, + $path, + '0' + ) + ); + } + + /** + * @When /^user "([^"]*)" gets the properties of (?:file|folder|entry) "([^"]*)" with depth (\d+) using the WebDAV API$/ + * + * @param string $user + * @param string $path + * @param string $depth + * + * @return void + * @throws Exception + */ + public function userGetsThePropertiesOfFolderWithDepth( + string $user, + string $path, + string $depth + ):void { + $this->featureContext->setResponseXmlObject( + $this->featureContext->listFolderAndReturnResponseXml( + $user, + $path, + $depth + ) + ); + } + + /** + * @When /^user "([^"]*)" gets the following properties of (?:file|folder|entry) "([^"]*)" using the WebDAV API$/ + * + * @param string $user + * @param string $path + * @param TableNode|null $propertiesTable + * + * @return void + * @throws Exception + */ + public function userGetsPropertiesOfFolder( + string $user, + string $path, + TableNode $propertiesTable + ):void { + $user = $this->featureContext->getActualUsername($user); + $properties = null; + $this->featureContext->verifyTableNodeColumns($propertiesTable, ["propertyName"]); + $this->featureContext->verifyTableNodeColumnsCount($propertiesTable, 1); + if ($propertiesTable instanceof TableNode) { + foreach ($propertiesTable->getColumnsHash() as $row) { + $properties[] = $row["propertyName"]; + } + } + $depth = "1"; + $this->featureContext->setResponseXmlObject( + $this->featureContext->listFolderAndReturnResponseXml( + $user, + $path, + $depth, + $properties + ) + ); + $this->featureContext->pushToLastStatusCodesArrays(); + } + + /** + * @param string $user + * @param string $path + * @param TableNode $propertiesTable + * @param string $depth + * + * @return void + * @throws Exception + */ + public function getFollowingCommentPropertiesOfFileUsingWebDAVPropfindApi( + string $user, + string $path, + TableNode $propertiesTable, + string $depth = "1" + ):void { + $properties = null; + $this->featureContext->verifyTableNodeColumns($propertiesTable, ["propertyName"]); + $this->featureContext->verifyTableNodeColumnsCount($propertiesTable, 1); + if ($propertiesTable instanceof TableNode) { + foreach ($propertiesTable->getColumnsHash() as $row) { + $properties[] = $row["propertyName"]; + } + } + + $user = $this->featureContext->getActualUsername($user); + $fileId = $this->featureContext->getFileIdForPath($user, $path); + $commentsPath = "/comments/files/$fileId/"; + $this->featureContext->setResponseXmlObject( + $this->featureContext->listFolderAndReturnResponseXml( + $user, + $commentsPath, + $depth, + $properties, + "comments" + ) + ); + } + + /** + * @When user :user gets the following comment properties of file :path using the WebDAV PROPFIND API + * + * @param string $user + * @param string $path + * @param TableNode $propertiesTable + * + * @return void + * @throws Exception + */ + public function userGetsFollowingCommentPropertiesOfFileUsingWebDAVPropfindApi(string $user, string $path, TableNode $propertiesTable) { + $this->getFollowingCommentPropertiesOfFileUsingWebDAVPropfindApi( + $user, + $path, + $propertiesTable + ); + } + + /** + * @When the user gets the following comment properties of file :arg1 using the WebDAV PROPFIND API + * + * @param string $path + * @param TableNode $propertiesTable + * + * @return void + * @throws Exception + */ + public function theUserGetsFollowingCommentPropertiesOfFileUsingWebDAVPropfindApi(string $path, TableNode $propertiesTable) { + $this->getFollowingCommentPropertiesOfFileUsingWebDAVPropfindApi( + $this->featureContext->getCurrentUser(), + $path, + $propertiesTable + ); + } + + /** + * @When /^the user gets the following properties of (?:file|folder|entry) "([^"]*)" using the WebDAV API$/ + * + * @param string $path + * @param TableNode|null $propertiesTable + * + * @return void + * @throws Exception + */ + public function theUserGetsPropertiesOfFolder(string $path, TableNode $propertiesTable) { + $this->userGetsPropertiesOfFolder( + $this->featureContext->getCurrentUser(), + $path, + $propertiesTable + ); + } + + /** + * @Given /^user "([^"]*)" has set the following properties of (?:file|folder|entry) "([^"]*)" using the WebDav API$/ + * + * if no namespace prefix is provided before property, default `oc:` prefix is set for added props + * only and everything rest on xml is set to prefix `d:` + * + * @param string $username + * @param string $path + * @param TableNode|null $propertiesTable with following columns with column header as: + * property: name of prop to be set + * value: value of prop to be set + * + * @return void + * @throws Exception + */ + public function userHasSetFollowingPropertiesUsingProppatch(string $username, string $path, TableNode $propertiesTable) { + $username = $this->featureContext->getActualUsername($username); + $this->featureContext->verifyTableNodeColumns($propertiesTable, ['propertyName', 'propertyValue']); + $properties = $propertiesTable->getColumnsHash(); + $this->featureContext->setResponse( + WebDavHelper::proppatchWithMultipleProps( + $this->featureContext->getBaseUrl(), + $username, + $this->featureContext->getPasswordForUser($username), + $path, + $properties, + $this->featureContext->getStepLineRef(), + $this->featureContext->getDavPathVersion() + ) + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @When user :user gets a custom property :propertyName with namespace :namespace of file :path + * + * @param string $user + * @param string $propertyName + * @param string $namespace namespace in form of "x1='http://whatever.org/ns'" + * @param string $path + * + * @return void + * @throws Exception + */ + public function userGetsPropertiesOfFile( + string $user, + string $propertyName, + string $namespace, + string $path + ):void { + $user = $this->featureContext->getActualUsername($user); + $properties = [ + $namespace => $propertyName + ]; + $this->featureContext->setResponse( + WebDavHelper::propfind( + $this->featureContext->getBaseUrl(), + $this->featureContext->getActualUsername($user), + $this->featureContext->getUserPassword($user), + $path, + $properties, + $this->featureContext->getStepLineRef(), + "0", + "files", + $this->featureContext->getDavPathVersion() + ) + ); + } + + /** + * @When /^the public gets the following properties of (?:file|folder|entry) "([^"]*)" in the last created public link using the WebDAV API$/ + * + * @param string $path + * @param TableNode $propertiesTable + * + * @return void + * @throws Exception + */ + public function publicGetsThePropertiesOfFolder(string $path, TableNode $propertiesTable):void { + $user = $this->featureContext->getLastPublicShareToken(); + $properties = null; + if ($propertiesTable instanceof TableNode) { + foreach ($propertiesTable->getRows() as $row) { + $properties[] = $row[0]; + } + } + $this->featureContext->setResponseXmlObject( + $this->featureContext->listFolderAndReturnResponseXml( + $user, + $path, + '0', + $properties, + $this->featureContext->getDavPathVersion() === 1 ? "public-files" : "public-files-new" + ) + ); + } + + /** + * @param string $user user id who sets the property + * @param string $propertyName name of property in Clark notation + * @param string $namespace namespace in form of "x1='http://whatever.org/ns'" + * @param string $path path on which to set properties to + * @param string $propertyValue property value + * + * @return void + * @throws Exception + */ + public function setPropertyWithNamespaceOfResource( + string $user, + string $propertyName, + string $namespace, + string $path, + string $propertyValue + ):void { + $user = $this->featureContext->getActualUsername($user); + $response = WebDavHelper::proppatch( + $this->featureContext->getBaseUrl(), + $this->featureContext->getActualUsername($user), + $this->featureContext->getUserPassword($user), + $path, + $propertyName, + $propertyValue, + $this->featureContext->getStepLineRef(), + $namespace, + $this->featureContext->getDavPathVersion() + ); + $this->featureContext->setResponse($response); + } + + /** + * @When /^user "([^"]*)" sets property "([^"]*)" with namespace "([^"]*)" of (?:file|folder|entry) "([^"]*)" to "([^"]*)" using the WebDAV API$/ + * + * @param string $user user id who sets the property + * @param string $propertyName name of property in Clark notation + * @param string $namespace namespace in form of "x1='http://whatever.org/ns'" + * @param string $path path on which to set properties to + * @param string $propertyValue property value + * + * @return void + * @throws Exception + */ + public function userSetsPropertyWithNamespaceOfEntryTo( + string $user, + string $propertyName, + string $namespace, + string $path, + string $propertyValue + ):void { + $this->setPropertyWithNamespaceOfResource( + $user, + $propertyName, + $namespace, + $path, + $propertyValue + ); + } + + /** + * @Given /^user "([^"]*)" has set property "([^"]*)" with namespace "([^"]*)" of (?:file|folder|entry) "([^"]*)" to "([^"]*)"$/ + * + * @param string $user user id who sets the property + * @param string $propertyName name of property in Clark notation + * @param string $namespace namespace in form of "x1='http://whatever.org/ns'" + * @param string $path path on which to set properties to + * @param string $propertyValue property value + * + * @return void + * @throws Exception + */ + public function userHasSetPropertyWithNamespaceOfEntryTo( + string $user, + string $propertyName, + string $namespace, + string $path, + string $propertyValue + ):void { + $this->setPropertyWithNamespaceOfResource( + $user, + $propertyName, + $namespace, + $path, + $propertyValue + ); + $this->featureContext->theHTTPStatusCodeShouldBeSuccess(); + } + + /** + * @Then /^the response should contain a custom "([^"]*)" property with namespace "([^"]*)" and value "([^"]*)"$/ + * + * @param string $propertyName + * @param string $namespaceString + * @param string $propertyValue + * + * @return void + * @throws Exception + */ + public function theResponseShouldContainACustomPropertyWithValue( + string $propertyName, + string $namespaceString, + string $propertyValue + ):void { + $this->featureContext->setResponseXmlObject( + HttpRequestHelper::getResponseXml( + $this->featureContext->getResponse(), + __METHOD__ + ) + ); + $responseXmlObject = $this->featureContext->getResponseXmlObject(); + //calculate the namespace prefix and namespace + $matches = []; + \preg_match("/^(.*)='(.*)'$/", $namespaceString, $matches); + $nameSpace = $matches[2]; + $nameSpacePrefix = $matches[1]; + $responseXmlObject->registerXPathNamespace( + $nameSpacePrefix, + $nameSpace + ); + $xmlPart = $responseXmlObject->xpath( + "//d:prop/" . "$nameSpacePrefix:$propertyName" + ); + Assert::assertArrayHasKey( + 0, + $xmlPart, + "Cannot find property \"$propertyName\"" + ); + Assert::assertEquals( + $propertyValue, + $xmlPart[0]->__toString(), + "\"$propertyName\" has a value \"" . + $xmlPart[0]->__toString() . "\" but \"$propertyValue\" expected" + ); + } + + /** + * @Then /^the response should contain a custom "([^"]*)" property with namespace "([^"]*)" and complex value "(([^"\\]|\\.)*)"$/ + * + * @param string $propertyName + * @param string $namespaceString + * @param string $propertyValue + * + * @return void + * @throws Exception + */ + public function theResponseShouldContainACustomPropertyWithComplexValue( + string $propertyName, + string $namespaceString, + string $propertyValue + ):void { + // let's unescape quotes first + $propertyValue = \str_replace('\"', '"', $propertyValue); + $this->featureContext->setResponseXmlObject( + HttpRequestHelper::getResponseXml( + $this->featureContext->getResponse(), + __METHOD__ + ) + ); + $responseXmlObject = $this->featureContext->getResponseXmlObject(); + //calculate the namespace prefix and namespace + $matches = []; + \preg_match("/^(.*)='(.*)'$/", $namespaceString, $matches); + $nameSpace = $matches[2]; + $nameSpacePrefix = $matches[1]; + $responseXmlObject->registerXPathNamespace( + $nameSpacePrefix, + $nameSpace + ); + $xmlPart = $responseXmlObject->xpath( + "//d:prop/" . "$nameSpacePrefix:$propertyName" . "/*" + ); + Assert::assertArrayHasKey( + 0, + $xmlPart, + "Cannot find property \"$propertyName\"" + ); + Assert::assertEquals( + $propertyValue, + $xmlPart[0]->asXML(), + "\"$propertyName\" has a value \"" . + $xmlPart[0]->asXML() . "\" but \"$propertyValue\" expected" + ); + } + + /** + * @Then the single response should contain a property :property with a child property :childProperty + * + * @param string $property + * @param string $childProperty + * + * @return void + * + * @throws Exception + */ + public function theSingleResponseShouldContainAPropertyWithChildProperty( + string $property, + string $childProperty + ):void { + $xmlPart = $this->featureContext->getResponseXmlObject()->xpath( + "//d:prop/$property/$childProperty" + ); + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find property \"$property/$childProperty\"" + ); + } + + /** + * @Then the single response should contain a property :key with value :value + * + * @param string $key + * @param string $expectedValue + * + * @return void + * @throws Exception + */ + public function theSingleResponseShouldContainAPropertyWithValue( + string $key, + string $expectedValue + ):void { + $this->checkSingleResponseContainsAPropertyWithValueAndAlternative( + $key, + $expectedValue, + $expectedValue + ); + } + + /** + * @Then the single response about the file owned by :user should contain a property :key with value :value + * + * @param string $user + * @param string $key + * @param string $expectedValue + * + * @return void + * @throws Exception + */ + public function theSingleResponseAboutTheFileOwnedByShouldContainAPropertyWithValue( + string $user, + string $key, + string $expectedValue + ):void { + $this->checkSingleResponseContainsAPropertyWithValueAndAlternative( + $key, + $expectedValue, + $expectedValue, + $user + ); + } + + /** + * @Then the single response should contain a property :key with value :value or with value :altValue + * + * @param string $key + * @param string $expectedValue + * @param string $altExpectedValue + * + * @return void + * @throws Exception + */ + public function theSingleResponseShouldContainAPropertyWithValueAndAlternative( + string $key, + string $expectedValue, + string $altExpectedValue + ):void { + $this->checkSingleResponseContainsAPropertyWithValueAndAlternative( + $key, + $expectedValue, + $altExpectedValue + ); + } + + /** + * @param string $key + * @param string $expectedValue + * @param string $altExpectedValue + * @param string|null $user + * + * @return void + * @throws Exception + */ + public function checkSingleResponseContainsAPropertyWithValueAndAlternative( + string $key, + string $expectedValue, + string $altExpectedValue, + ?string $user = null + ):void { + $xmlPart = $this->featureContext->getResponseXmlObject()->xpath( + "//d:prop/$key" + ); + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find property \"$key\"" + ); + $value = $xmlPart[0]->__toString(); + $expectedValue = $this->featureContext->substituteInLineCodes( + $expectedValue, + $user + ); + $expectedValue = "#^$expectedValue$#"; + $altExpectedValue = "#^$altExpectedValue$#"; + if (\preg_match($expectedValue, $value) !== 1 + && \preg_match($altExpectedValue, $value) !== 1 + ) { + Assert::fail( + "Property \"$key\" found with value \"$value\", " . + "expected \"$expectedValue\" or \"$altExpectedValue\"" + ); + } + } + + /** + * @Then the value of the item :xpath in the response should be :value + * + * @param string $xpath + * @param string $expectedValue + * + * @return void + * @throws Exception + */ + public function assertValueOfItemInResponseIs(string $xpath, string $expectedValue):void { + $this->assertValueOfItemInResponseAboutUserIs( + $xpath, + null, + $expectedValue + ); + } + + /** + * @Then the value of the item :xpath in the response about user :user should be :value + * + * @param string $xpath + * @param string|null $user + * @param string $expectedValue + * + * @return void + * @throws Exception + */ + public function assertValueOfItemInResponseAboutUserIs(string $xpath, ?string $user, string $expectedValue):void { + $resXml = $this->featureContext->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->featureContext->getResponse(), + __METHOD__ + ); + } + $xmlPart = $resXml->xpath($xpath); + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find item with xpath \"$xpath\"" + ); + $value = $xmlPart[0]->__toString(); + $user = $this->featureContext->getActualUsername($user); + $expectedValue = $this->featureContext->substituteInLineCodes( + $expectedValue, + $user + ); + + // The expected value can contain /%base_path%/ which can be empty some time + // This will result in urls such as //remote.php, so replace that + $expectedValue = preg_replace("/\/\/remote\.php/i", "/remote.php", $expectedValue); + Assert::assertEquals( + $expectedValue, + $value, + "item \"$xpath\" found with value \"$value\", " . + "expected \"$expectedValue\"" + ); + } + + /** + * @Then the value of the item :xpath in the response about user :user should be :value1 or :value2 + * + * @param string $xpath + * @param string|null $user + * @param string $expectedValue1 + * @param string $expectedValue2 + * + * @return void + * @throws Exception + */ + public function assertValueOfItemInResponseAboutUserIsEitherOr(string $xpath, ?string $user, string $expectedValue1, string $expectedValue2):void { + if (!$expectedValue2) { + $expectedValue2 = $expectedValue1; + } + $resXml = $this->featureContext->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->featureContext->getResponse(), + __METHOD__ + ); + } + $xmlPart = $resXml->xpath($xpath); + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find item with xpath \"$xpath\"" + ); + $value = $xmlPart[0]->__toString(); + $user = $this->featureContext->getActualUsername($user); + $expectedValue1 = $this->featureContext->substituteInLineCodes( + $expectedValue1, + $user + ); + + $expectedValue2 = $this->featureContext->substituteInLineCodes( + $expectedValue2, + $user + ); + + // The expected value can contain /%base_path%/ which can be empty some time + // This will result in urls such as //remote.php, so replace that + $expectedValue1 = preg_replace("/\/\/remote\.php/i", "/remote.php", $expectedValue1); + $expectedValue2 = preg_replace("/\/\/remote\.php/i", "/remote.php", $expectedValue2); + $expectedValues = [$expectedValue1, $expectedValue2]; + $isExpectedValueInMessage = \in_array($value, $expectedValues); + Assert::assertTrue($isExpectedValueInMessage, "The actual value \"$value\" is not one of the expected values: \"$expectedValue1\" or \"$expectedValue2\""); + } + + /** + * @Then the value of the item :xpath in the response should match :value + * + * @param string $xpath + * @param string $pattern + * + * @return void + * @throws Exception + */ + public function assertValueOfItemInResponseRegExp(string $xpath, string $pattern):void { + $this->assertValueOfItemInResponseToUserRegExp( + $xpath, + null, + $pattern + ); + } + + /** + * @Then /^as a public the lock discovery property "([^"]*)" of the (?:file|folder|entry) "([^"]*)" should match "([^"]*)"$/ + * + * @param string $xpath + * @param string $path + * @param string $pattern + * + * @return void + * @throws Exception + */ + public function publicGetsThePropertiesOfFolderAndAssertValueOfItemInResponseRegExp(string $xpath, string $path, string $pattern):void { + $propertiesTable = new TableNode([['propertyName'],['d:lockdiscovery']]); + $this->publicGetsThePropertiesOfFolder($path, $propertiesTable); + + $this->featureContext->theHTTPStatusCodeShouldBe('200'); + $this->assertValueOfItemInResponseToUserRegExp( + $xpath, + null, + $pattern + ); + } + + /** + * @Then there should be an entry with href containing :expectedHref in the response to user :user + * + * @param string $expectedHref + * @param string $user + * + * @return void + * @throws Exception + */ + public function assertEntryWithHrefMatchingRegExpInResponseToUser(string $expectedHref, string $user):void { + $resXml = $this->featureContext->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->featureContext->getResponse(), + __METHOD__ + ); + } + + $user = $this->featureContext->getActualUsername($user); + $expectedHref = $this->featureContext->substituteInLineCodes( + $expectedHref, + $user, + ['preg_quote' => ['/']] + ); + + $index = 0; + while (true) { + $index++; + $xpath = "//d:response[$index]/d:href"; + $xmlPart = $resXml->xpath($xpath); + // If we have run out of entries in the response, then fail the test + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find any entry having href with value $expectedHref in response to $user" + ); + $value = $xmlPart[0]->__toString(); + $decodedValue = \rawurldecode($value); + // for folders, decoded value will be like: "/owncloud/core/remote.php/webdav/strängé folder/" + // expected href should be like: "remote.php/webdav/strängé folder/" + // for files, decoded value will be like: "/owncloud/core/remote.php/webdav/strängé folder/file.txt" + // expected href should be like: "remote.php/webdav/strängé folder/file.txt" + $explodeDecoded = \explode('/', $decodedValue); + // get the first item of the expected href. + // i.e remote.php from "remote.php/webdav/strängé folder/file.txt" + // or dav from "dav/spaces/%spaceid%/C++ file.cpp" + $explodeExpected = \explode('/', $expectedHref); + $remotePhpIndex = \array_search($explodeExpected[0], $explodeDecoded); + if ($remotePhpIndex) { + $explodedHrefPartArray = \array_slice($explodeDecoded, $remotePhpIndex); + $actualHrefPart = \implode('/', $explodedHrefPartArray); + if ($this->featureContext->getDavPathVersion() === WebDavHelper::DAV_VERSION_SPACES) { + // for spaces webdav, space id is included in the href + // space id from our helper is returned as d8c029e0\-2bc9\-4b9a\-8613\-c727e5417f05 + // so we've to remove "\" before every "-" + $expectedHref = str_replace('\-', '-', $expectedHref); + $expectedHref = str_replace('\$', '$', $expectedHref); + $expectedHref = str_replace('\!', '!', $expectedHref); + } + if ($actualHrefPart === $expectedHref) { + break; + } + } + } + } + + /** + * @Then the value of the item :xpath in the response to user :user should match :value + * + * @param string $xpath + * @param string|null $user + * @param string $pattern + * + * @return void + * @throws Exception + */ + public function assertValueOfItemInResponseToUserRegExp(string $xpath, ?string $user, string $pattern):void { + $resXml = $this->featureContext->getResponseXmlObject(); + if ($resXml === null) { + $resXml = HttpRequestHelper::getResponseXml( + $this->featureContext->getResponse(), + __METHOD__ + ); + } + $xmlPart = $resXml->xpath($xpath); + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find item with xpath \"$xpath\"" + ); + $user = $this->featureContext->getActualUsername($user); + $value = $xmlPart[0]->__toString(); + $pattern = $this->featureContext->substituteInLineCodes( + $pattern, + $user, + ['preg_quote' => ['/']] + ); + Assert::assertMatchesRegularExpression( + $pattern, + $value, + "item \"$xpath\" found with value \"$value\", " . + "expected to match regex pattern: \"$pattern\"" + ); + } + + /** + * @Then /^as user "([^"]*)" the lock discovery property "([^"]*)" of the (?:file|folder|entry) "([^"]*)" should match "([^"]*)"$/ + * + * @param string|null $user + * @param string $xpath + * @param string $path + * @param string $pattern + * + * @return void + * @throws Exception + */ + public function userGetsPropertiesOfFolderAndAssertValueOfItemInResponseToUserRegExp(string $user, string $xpath, string $path, string $pattern):void { + $propertiesTable = new TableNode([['propertyName'],['d:lockdiscovery']]); + $this->userGetsPropertiesOfFolder( + $user, + $path, + $propertiesTable + ); + + $this->featureContext->theHTTPStatusCodeShouldBe('200'); + $this->assertValueOfItemInResponseToUserRegExp( + $xpath, + $user, + $pattern + ); + } + + /** + * @Then the item :xpath in the response should not exist + * + * @param string $xpath + * + * @return void + * @throws Exception + */ + public function assertItemInResponseDoesNotExist(string $xpath):void { + $xmlPart = $this->featureContext->getResponseXmlObject()->xpath($xpath); + Assert::assertFalse( + isset($xmlPart[0]), + "Found item with xpath \"$xpath\" but it should not exist" + ); + } + + /** + * @Then /^as user "([^"]*)" (?:file|folder|entry) "([^"]*)" should contain a property "([^"]*)" with value "([^"]*)" or with value "([^"]*)"$/ + * + * @param string $user + * @param string $path + * @param string $property + * @param string $expectedValue + * @param string $altExpectedValue + * + * @return void + * @throws Exception + */ + public function asUserFolderShouldContainAPropertyWithValueOrWithValue( + string $user, + string $path, + string $property, + string $expectedValue, + string $altExpectedValue + ):void { + $this->featureContext->setResponseXmlObject( + $this->featureContext->listFolderAndReturnResponseXml( + $user, + $path, + '0', + [$property] + ) + ); + $this->theSingleResponseShouldContainAPropertyWithValueAndAlternative( + $property, + $expectedValue, + $altExpectedValue + ); + } + + /** + * @Then /^as user "([^"]*)" (?:file|folder|entry) "([^"]*)" should contain a property "([^"]*)" with value "([^"]*)"$/ + * + * @param string $user + * @param string $path + * @param string $property + * @param string $value + * + * @return void + * @throws Exception + */ + public function asUserFolderShouldContainAPropertyWithValue( + string $user, + string $path, + string $property, + string $value + ):void { + $this->asUserFolderShouldContainAPropertyWithValueOrWithValue( + $user, + $path, + $property, + $value, + $value + ); + } + + /** + * @Then the single response should contain a property :key with value like :regex + * + * @param string $key + * @param string $regex + * + * @return void + * @throws Exception + */ + public function theSingleResponseShouldContainAPropertyWithValueLike( + string $key, + string $regex + ):void { + $xmlPart = $this->featureContext->getResponseXmlObject()->xpath( + "//d:prop/$key" + ); + Assert::assertTrue( + isset($xmlPart[0]), + "Cannot find property \"$key\"" + ); + $value = $xmlPart[0]->__toString(); + Assert::assertMatchesRegularExpression( + $regex, + $value, + "Property \"$key\" found with value \"$value\", expected \"$regex\"" + ); + } + + /** + * @Then the response should contain a share-types property with + * + * @param TableNode $table + * + * @return void + * @throws Exception + */ + public function theResponseShouldContainAShareTypesPropertyWith(TableNode $table):void { + $this->featureContext->verifyTableNodeColumnsCount($table, 1); + WebdavTest::assertResponseContainsShareTypes( + $this->featureContext->getResponseXmlObject(), + $table->getRows() + ); + } + + /** + * @Then the response should contain an empty property :property + * + * @param string $property + * + * @return void + * @throws Exception + */ + public function theResponseShouldContainAnEmptyProperty(string $property):void { + $xmlPart = $this->featureContext->getResponseXmlObject()->xpath( + "//d:prop/$property" + ); + Assert::assertCount( + 1, + $xmlPart, + "Cannot find property \"$property\"" + ); + Assert::assertEmpty( + $xmlPart[0], + "Property \"$property\" is not empty" + ); + } + + /** + * @param string $user + * @param string $path + * @param string|null $storePath + * + * @return void + * @throws Exception + */ + public function storeEtagOfElement(string $user, string $path, ?string $storePath = ""):void { + if ($storePath === "") { + $storePath = $path; + } + $user = $this->featureContext->getActualUsername($user); + $propertiesTable = new TableNode([['propertyName'],['getetag']]); + $this->userGetsPropertiesOfFolder( + $user, + $path, + $propertiesTable + ); + $this->storedETAG[$user][$storePath] + = $this->featureContext->getEtagFromResponseXmlObject(); + } + + /** + * @When user :user stores etag of element :path using the WebDAV API + * + * @param string $user + * @param string $path + * + * @return void + * @throws Exception + */ + public function userStoresEtagOfElement(string $user, string $path):void { + $this->storeEtagOfElement( + $user, + $path + ); + } + + /** + * @Given user :user has stored etag of element :path on path :storePath + * + * @param string $user + * @param string $path + * @param string $storePath + * + * @return void + * @throws Exception + */ + public function userStoresEtagOfElementOnPath(string $user, string $path, string $storePath):void { + $user = $this->featureContext->getActualUsername($user); + $this->storeEtagOfElement( + $user, + $path, + $storePath + ); + if ($storePath == "") { + $storePath = $path; + } + if ($this->storedETAG[$user][$storePath] === null || $this->storedETAG[$user][$path] === "") { + throw new Exception("Expected stored etag to be some string but found null!"); + } + } + + /** + * @Given user :user has stored etag of element :path + * + * @param string $user + * @param string $path + * + * @return void + * @throws Exception + */ + public function userHasStoredEtagOfElement(string $user, string $path):void { + $user = $this->featureContext->getActualUsername($user); + $this->storeEtagOfElement( + $user, + $path + ); + if ($this->storedETAG[$user][$path] === "" || $this->storedETAG[$user][$path] === null) { + throw new Exception("Expected stored etag to be some string but found null!"); + } + } + + /** + * @Then /^the properties response should contain an etag$/ + * + * @return void + * @throws Exception + */ + public function thePropertiesResponseShouldContainAnEtag():void { + Assert::assertTrue( + $this->featureContext->isEtagValid(), + __METHOD__ + . " getetag not found in response" + ); + } + + /** + * @Then as user :username the last response should have the following properties + * + * only supports new DAV version + * + * @param string $username + * @param TableNode $expectedPropTable with following columns: + * resource: full path of resource(file/folder/entry) from root of your oc storage + * property: expected name of property to be asserted, eg: status, href, customPropName + * value: expected value of expected property + * + * @return void + * @throws Exception + */ + public function theResponseShouldHavePropertyWithValue(string $username, TableNode $expectedPropTable):void { + $username = $this->featureContext->getActualUsername($username); + $this->featureContext->verifyTableNodeColumns($expectedPropTable, ['resource', 'propertyName', 'propertyValue']); + $responseXmlObject = $this->featureContext->getResponseXmlObject(); + + $hrefSplittedUptoUsername = \explode("/", (string)$responseXmlObject->xpath("//d:href")[0]); + $xmlHrefSplittedArray = \array_slice( + $hrefSplittedUptoUsername, + 0, + \array_search($username, $hrefSplittedUptoUsername) + 1 + ); + $xmlHref = \implode("/", $xmlHrefSplittedArray); + foreach ($expectedPropTable->getColumnsHash() as $col) { + if ($col["propertyName"] === "status") { + $xmlPart = $responseXmlObject->xpath( + "//d:href[.='" . + $xmlHref . $col["resource"] . + "']/following-sibling::d:propstat//d:" . + $col["propertyName"] + ); + } else { + $xmlPart = $responseXmlObject->xpath( + "//d:href[.= '" . + $xmlHref . $col["resource"] . + "']/..//oc:" . $col["propertyName"] + ); + } + Assert::assertEquals( + $col["propertyValue"], + $xmlPart[0], + __METHOD__ + . " Expected '" . $col["propertyValue"] . "' but got '" . $xmlPart[0] . "'" + ); + } + } + + /** + * @param string $path + * @param string $user + * + * @return string + * @throws Exception + */ + public function getCurrentEtagOfElement(string $path, string $user):string { + $user = $this->featureContext->getActualUsername($user); + $propertiesTable = new TableNode([['propertyName'],['getetag']]); + $this->userGetsPropertiesOfFolder( + $user, + $path, + $propertiesTable + ); + return $this->featureContext->getEtagFromResponseXmlObject(); + } + + /** + * @param string $path + * @param string $user + * @param string $messageStart + * + * @return string + */ + public function getStoredEtagOfElement(string $path, string $user, string $messageStart = ''):string { + if ($messageStart === '') { + $messageStart = __METHOD__; + } + Assert::assertArrayHasKey( + $user, + $this->storedETAG, + $messageStart + . " Trying to check etag of element $path of user $user but the user does not have any stored etags" + ); + Assert::assertArrayHasKey( + $path, + $this->storedETAG[$user], + $messageStart + . " Trying to check etag of element $path of user $user but the user does not have a stored etag for the element" + ); + return $this->storedETAG[$user][$path]; + } + + /** + * @Then these etags should not have changed: + * + * @param TableNode $etagTable + * + * @return void + * @throws Exception + */ + public function theseEtagsShouldNotHaveChanged(TableNode $etagTable):void { + $this->featureContext->verifyTableNodeColumns($etagTable, ["user", "path"]); + $this->featureContext->verifyTableNodeColumnsCount($etagTable, 2); + $changedEtagCount = 0; + $changedEtagMessage = __METHOD__; + foreach ($etagTable->getColumnsHash() as $row) { + $user = $row["user"]; + $path = $row["path"]; + $user = $this->featureContext->getActualUsername($user); + $actualEtag = $this->getCurrentEtagOfElement($path, $user); + $storedEtag = $this->getStoredEtagOfElement($path, $user, __METHOD__); + if ($actualEtag !== $storedEtag) { + $changedEtagCount = $changedEtagCount + 1; + $changedEtagMessage + .= "\nThe etag '$storedEtag' of element '$path' of user '$user' changed to '$actualEtag'."; + } + } + Assert::assertEquals(0, $changedEtagCount, $changedEtagMessage); + } + + /** + * @Then the etag of element :path of user :user should not have changed + * + * @param string $path + * @param string $user + * + * @return void + * @throws Exception + */ + public function etagOfElementOfUserShouldNotHaveChanged(string $path, string $user):void { + $user = $this->featureContext->getActualUsername($user); + $actualEtag = $this->getCurrentEtagOfElement($path, $user); + $storedEtag = $this->getStoredEtagOfElement($path, $user, __METHOD__); + Assert::assertEquals( + $storedEtag, + $actualEtag, + __METHOD__ + . " The etag of element '$path' of user '$user' was not expected to change." + . " The stored etag was '$storedEtag' but got '$actualEtag' from the response" + ); + } + + /** + * @Then these etags should have changed: + * + * @param TableNode $etagTable + * + * @return void + * @throws Exception + */ + public function theseEtagsShouldHaveChanged(TableNode $etagTable):void { + $this->featureContext->verifyTableNodeColumns($etagTable, ["user", "path"]); + $this->featureContext->verifyTableNodeColumnsCount($etagTable, 2); + $unchangedEtagCount = 0; + $unchangedEtagMessage = __METHOD__; + foreach ($etagTable->getColumnsHash() as $row) { + $user = $row["user"]; + $path = $row["path"]; + $user = $this->featureContext->getActualUsername($user); + $actualEtag = $this->getCurrentEtagOfElement($path, $user); + $storedEtag = $this->getStoredEtagOfElement($path, $user, __METHOD__); + if ($actualEtag === $storedEtag) { + $unchangedEtagCount = $unchangedEtagCount + 1; + $unchangedEtagMessage + .= "\nThe etag '$storedEtag' of element '$path' of user '$user' did not change."; + } + } + Assert::assertEquals(0, $unchangedEtagCount, $unchangedEtagMessage); + } + + /** + * @Then the etag of element :path of user :user should have changed + * + * @param string $path + * @param string $user + * + * @return void + * @throws Exception + */ + public function etagOfElementOfUserShouldHaveChanged(string $path, string $user):void { + $user = $this->featureContext->getActualUsername($user); + $actualEtag = $this->getCurrentEtagOfElement($path, $user); + $storedEtag = $this->getStoredEtagOfElement($path, $user, __METHOD__); + Assert::assertNotEquals( + $storedEtag, + $actualEtag, + __METHOD__ + . " The etag of element '$path' of user '$user' was expected to change." + . " The stored etag was '$storedEtag' and also got '$actualEtag' from the response" + ); + } + + /** + * @Then the etag of element :path of user :user on server :server should have changed + * + * @param string $path + * @param string $user + * @param string $server + * + * @return void + * @throws Exception + */ + public function theEtagOfElementOfUserOnServerShouldHaveChanged( + string $path, + string $user, + string $server + ):void { + $previousServer = $this->featureContext->usingServer($server); + $this->etagOfElementOfUserShouldHaveChanged($path, $user); + $this->featureContext->usingServer($previousServer); + } + + /** + * @Then the etag of element :path of user :user on server :server should not have changed + * + * @param string $path + * @param string $user + * @param string $server + * + * @return void + * @throws Exception + */ + public function theEtagOfElementOfUserOnServerShouldNotHaveChanged( + string $path, + string $user, + string $server + ):void { + $previousServer = $this->featureContext->usingServer($server); + $this->etagOfElementOfUserShouldNotHaveChanged($path, $user); + $this->featureContext->usingServer($previousServer); + } + + /** + * This will run before EVERY scenario. + * It will set the properties for this object. + * + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope):void { + // Get the environment + $environment = $scope->getEnvironment(); + // Get all the contexts you need in this context + $this->featureContext = $environment->getContext('FeatureContext'); + } +} diff --git a/tests/acceptance/features/bootstrap/bootstrap.php b/tests/acceptance/features/bootstrap/bootstrap.php index a8302894b..b6e18a60e 100644 --- a/tests/acceptance/features/bootstrap/bootstrap.php +++ b/tests/acceptance/features/bootstrap/bootstrap.php @@ -20,19 +20,44 @@ * */ -$pathToCore = \getenv('PATH_TO_CORE'); -if ($pathToCore === false) { - $pathToCore = "../core"; -} +use Composer\Autoload\ClassLoader; -require_once $pathToCore . '/tests/acceptance/features/bootstrap/bootstrap.php'; +$classLoader = new ClassLoader(); -$classLoader = new \Composer\Autoload\ClassLoader(); -$classLoader->addPsr4( - "", - $pathToCore . "/tests/acceptance/features/bootstrap", - true -); $classLoader->addPsr4("TestHelpers\\", __DIR__ . "/../../../TestHelpers", true); $classLoader->register(); + +// Sleep for 10 milliseconds +const STANDARD_SLEEP_TIME_MILLISEC = 10; +const STANDARD_SLEEP_TIME_MICROSEC = STANDARD_SLEEP_TIME_MILLISEC * 1000; + +// Long timeout for use in code that needs to wait for known slow UI +const LONG_UI_WAIT_TIMEOUT_MILLISEC = 60000; +// Default timeout for use in code that needs to wait for the UI +const STANDARD_UI_WAIT_TIMEOUT_MILLISEC = 10000; +// Minimum timeout for use in code that needs to wait for the UI +const MINIMUM_UI_WAIT_TIMEOUT_MILLISEC = 500; +const MINIMUM_UI_WAIT_TIMEOUT_MICROSEC = MINIMUM_UI_WAIT_TIMEOUT_MILLISEC * 1000; + +// Minimum timeout for emails +const EMAIL_WAIT_TIMEOUT_SEC = 10; +const EMAIL_WAIT_TIMEOUT_MILLISEC = EMAIL_WAIT_TIMEOUT_SEC * 1000; + +// Default number of times to retry where retries are useful +const STANDARD_RETRY_COUNT = 5; +// Minimum number of times to retry where retries are useful +const MINIMUM_RETRY_COUNT = 2; + +// The remote server-under-test might or might not happen to have this directory. +// If it does not exist, then the tests may end up creating it. +const ACCEPTANCE_TEST_DIR_ON_REMOTE_SERVER = "tests/acceptance"; + +// The following directory should NOT already exist on the remote server-under-test. +// Acceptance tests are free to do anything needed in this directory, and to +// delete it during or at the end of testing. +const TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER = ACCEPTANCE_TEST_DIR_ON_REMOTE_SERVER . "/server_tmp"; + +// The following directory is created, used, and deleted by tests that need to +// use some "local external storage" on the server. +const LOCAL_STORAGE_DIR_ON_REMOTE_SERVER = TEMPORARY_STORAGE_DIR_ON_REMOTE_SERVER . "/local_storage"; diff --git a/tests/acceptance/filesForUpload/'single'quotes.txt b/tests/acceptance/filesForUpload/'single'quotes.txt new file mode 100644 index 000000000..96abcb4f0 --- /dev/null +++ b/tests/acceptance/filesForUpload/'single'quotes.txt @@ -0,0 +1,9 @@ +a file with a single quote i its name + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/data.tar.gz b/tests/acceptance/filesForUpload/data.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..05c74a335f044fcf0cb12dc0b83e979222ee8765 GIT binary patch literal 4361 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 D{Bx!& literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/data.zip b/tests/acceptance/filesForUpload/data.zip new file mode 100644 index 0000000000000000000000000000000000000000..d79780eada8fd501a1ffa6fa36f28b7e4f49ca89 GIT binary patch literal 4919 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 L#?>%SeYyJ=;ik{^ literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/davtest.txt b/tests/acceptance/filesForUpload/davtest.txt new file mode 100644 index 000000000..09c3a96be --- /dev/null +++ b/tests/acceptance/filesForUpload/davtest.txt @@ -0,0 +1 @@ +Dav-Test \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/example.gif b/tests/acceptance/filesForUpload/example.gif new file mode 100644 index 0000000000000000000000000000000000000000..9639afcdb5f34b4d52425d397f2d378dd9605c74 GIT binary patch literal 231207 zcmWifRa_Gc7seM1Mh_TWqemmsB8+Yv-6^GnfQYmk-BP2wTM$G*P`ahNq!f^p5>e;( zez)gbor@FC`TZXaEp-`L2NdueP@xF;_wVlR=KIdh=H1Qe-RaQHYQ^2Rw$;_ezP`@R z&bHdxiqzDko5h^CxTu_*)UmOlo3XKyk^)s#)wgdOXJ;qBf8V*gnB3f1y*T}Lv(uQG z6elk)ANaxt8yYy&SkYOK^nGZ^#02f>>3s2hcCs_+`y`IrVfEYU&}?U0K}k;I+uCm* zJ4{=H32DjBq7@{J(GjAs&VC))ZoTJyPyF>LFJ&UG1mcVEJab-hccg=+jnHfUZd1)In>|XRi-m z&f7!u->jY8c7;uy{r-o+hDSt3MaRU(#U~^tCBIHdO-s+fWoBjPg#=2YjCJQOMI@rXJRAY_g zRa5I(v^1=5n2?CC(9tng;*H~UA_^8#R&gZk>kS)eGt=q`K50fy=3pAw`w)7ALdHVg ztbB@z1QBS$CVN2?Y$(KfdtU^RA2|ykLX$cL1Et*#m5GH75>eiRBjFkK0FH}FNz{W}hN0{I4i6fNv>w<=K^)G$QSC}5Mq?c{o$1Xs znZG+cM*D#V!%sMu(p2LdxlPf%9OVju;-}bKR9T@_Floy|L7j7R*@qOaUC$6RfV??f zVB83_?`sTgjCASkcuDx*i0JqxoLmzmZw7&TAQSAH-M9u_FyX*;Pxky5AdiD^$h;<$>u`dVT@v(wMhQfj-L{))t!)Yf(E`0#9=9Ey0*@6zrsd4WyrUbep zF}(z#aUBaT6aq{cy65QP?Tke=3#e^@7jdOHRWYWA=#Z)v1EQ2+I(h?y0w{z^rBJms zCH&=y#-Ehqp-onfJqciu9<;uz$ib`*5kCTjO`FyaGOR8YL4=dyT{+z;LF4*jH5q?d zw*{EDzsFXIpRPQ9P3r3T!Trs(wiF-+Oy;dj5=c$r1Qs{16A2Mq&ebOX+PO7*Ji1UP z%%FX#nF^-LC+;H=I+!(5BbK1|>ZK-^x9^1*A*MuLmAKy5i{pGCPQvzsMF-69S6`Hm zAC-OfvXRH3WSWtpCqj@S`_Nt%IZ{k2wX!D*gnaZL^PLlWfNRR`oGw?YK_(xk5+VN( zfIc(|^?>KBUFyH&uj?JKY!FZg^;i({2pShcE%(mIt>3c%CPXfWWtYTX%Xy22QI*}0tjWBg$}^%C0I0*>{H!TD=DR zxf5ymB=fuHb+N*PJQRa@Es?d>yW+Oif4Q#~-s>xeemHA$hOxb0+=Jljx>1|tpZA@< zTy6u2QITqz?-D6-0wO?@qP}O(5r2?;pD5FsB6(Wvs>R-mQFv1*{BzH%A*gp_W? zIfk9V8I)HK^dmok*(l{<)c1RnT}4xEI`H9{o@xj}kQm3ga{B|gr9l`tw)BtJz#ud2 zGjpB#74Jq!KSwuAPpnC^%qzQJO5ZDj9>3kVV|*fRfJg zsomNb0Qh8p5PKMyAiy3(73)M`eojxPdI7$`M2%uvNB^eIyaI$Ukc;|;pO8-_L5&gP z>h0}>lP;0xG(85sH^r0XuIb}6w3dDqfJR2@sMvA<2##P^h~LWQ9Dyszz`|YR?h|lg z1_dY5$3I<_B8#1@{=p&PPAR^jKB~?=t4k^)z2X61%Xek#Vi_&zYVv;1X-M`jlahN+ zNAL+1iY4%eyJ{EbP{+muCPL7}3=+A)om=urJd!2ZK6#bjE7fFBSmjcqeC50y%B2;V zWM4mmEcb-YT1Ux1!{_OxBt)mRcxgpdPJ58SHs-bJ`BXTQ1VPIj&3zU7=|bN7B|A+P z8b_WR<$(T z{g6)~f9&EHMV+H?pZ?7j#+tH|?_qZ-*aIi1{4-;`8i^4ujyrf~+Orz_?uJjR0qLl> zqHT&l4UH;j@N%{4S{&S}UXo*LW@8>wo_SaFndL-Kd2Q|^0gnAm?2!x3{c}Keb0(El zp8xv~8{NKJ1aun>4#PG0c0!P8*FnQ~6B`&f$@-|ff;v%l%&cW6KCbvPq}FAP2~-@^ zMxS^6#5JNESa%?Z{C7E$vdF8j80P!#hXq~0T-};*>hlM65>-9OiEXNzOv``FX|WpX zEp6CjcDtlw>Z;L+HaYr6Pqk|FR14ps*J4MmETPm(sSL>b&z?ail?qUX&6-N-ruT#1 zFh0IvlhdYtw97hF-&~S)00$YV}r3Y5H9==FF1~!h!-2$q-3>^Q;f5dbgqBg9XKlzg_ASw&5R(mzM{e zM_4hhiM;G^1WTX7mA1zDKUx^8KeSwx#eis`K@cQHAga4#^Iy69Hq>xSNYK2OQ^ee?%%(o$G`i zFINp;jYgw2dV@3g7*Boz5<#!Zqdla*2F?uH{Z^p~xYDQce?R~W00e}BmV>ywMMY99 zEqR_;^D0adu}2tK_L)ATW*|MJ_iLRAQ|04)@Dg_vY8vN%_tF8i^$?lQ>1YW zKi_#Iq$S*-J?6YD=4da*L=C`)0F&CYh-F~Ph^%40hCcf~j!?g&m&~8CqSa$uo-7)9 zj9|qp`SOf>Eoq;LPrHhNV?*3L%SU7c-GHF#_&`knK}t;RNIZBerb;H^9Uhu+%%#}$ zKGc}Yk+(87>}Qmk+pGMSF3rXM&h4xxFM(d~#ZVB-&wkG|vix=c7QdgnhDwWP?*RD_ z2`%RF0@d*ZDe-jY@ofwVb+O5ZvB~=knD^#lD;iN;m(=A|vBw5du&m&l#YZ<9LKMYe zF0ss?h7&32pXR*qp9Lg|M!%-tmeZGxI8F`PsE#>W?j7&Rd&%pC38v6&}1#&o548+|AT}r-1 z=P=O9FWB9s@T5W!A7280d^1so*x;8n3L|w^wQ^Q4<5PlE zw0sd+|o&Wj$o&GbGD_I&T-*v4m22H1%c*wR9VMN)#fP7@4$64<`2Z> ze~rxpjbwKar*-ouAJ-%sN`MGbfX1573!b}rMF>u);UlLtJrFP9DGVtDj)4UHoH;*8 zs-yx%&p{qP0i;;QfIz*kFfjWxux+L2P$r)Cu#k-h_gyCY&ta*EcK-2`Qc=0GYuU1o zf%%3~&XGu83m!OcnrOu1Fp||ke3X(@P9lqVoL3B8bid#lia=bPpezOOX4*uBHxDHc zBokE1SyQ@$OBD_(la4R@`=s*5qU+Ave?Q`V}t5@(T1q@pRP6lMZxF}hRX~Ek-m!Rx2%^E?0 zngYxEp`iL-vVco)ak!ggmm9aojAHAP`(DcyXdgQB-6|UIber(#rk|wm1R%~n6_MeJ zmnlG-S_MhV979}b=s}}_dQsnK&4}fjA#I%bKs}dODPo991r))b#dm3g43H)PAs*FCU=J9S z!!i7-U?#@3-Zo}3)&GbujbSXqU&X(*t*a5t(>wq=D)*X?`pWatau35Px=O>8n@YsD zOU8=*S39`i#h}Vmh;you1h$#+cWctGq@&pE@Pv5t;AC@RoH28I>TdHs_nT`ioZW*C zK2nO8PQoxrncPYRLgm++MtXUCk%{H|?4^);dM( zTG7(j8$kjh7?2YEUe>VHPK*SuLBnX@4N9zc8taA%6{Y>II}hwn9nV|W>F0jXtB{yC zF;>Wjpd9N7g)W9fMEG+k1&)3op+Waq8v?1OKUt&5NJoL((|yaq_0F~N!xsH~BSpq5 z?>?Q9cs#}gy{}FwtUO<0C(^EP-cD2(VB+Scv=kvR5Iq#bxvw^PA*W!MMqD_ z)M+{e2gh^`{=^F;$9t4YY4;?ARHm+1q~-S~c2#D{_a4;=9=~8{R*_i^ueMH!_MGKl z@CNXwyjPhXV6ucTN`nlL~27e<`ZAQAbV?D%P# zu4%Ev$}gltWDmzPk8xM9QsM~mlRpqoqA8G8bxm*=q5&XG=gMO5qr^u-;;lQi2WU-Yr}Q$&38}X7`dR=|Py{XF2Qrb#ejH2tR`)ztX70zw2D! zLEu7gx5)8W_tDZ@`AO`_NF?j3%Xc7mxZvMXK*C=F@zN6apR3B}AmS~gsvh%0O|pE~ zm4D^_$$Aw;J++dN0MOsrs)CQD?slD7q2 za1?vT3D2a?=Z~!RoGCnRHU$0Tc|zXnMFrDSdL2(|i!OzGBieQUAdDWJ5HMO+G>YLz zLbm~On388ltC6|sncGvqHXsFli!$tS5i=JpYW)H>T~x4rrMDU?Ts9v1rQmM*ql@le z=OsPZQnj4Nlc>Zh4Dh{YUs`o_ji4Db0tgG+rD%N8$hs~%y$N2JJ^wp9LcW99C{5Gd z`{>>~ysz7+x5gg|D0Nxk*y_rNnVLFc?a=@p^7+VYewAcR3gATb0fk|EDim{? zwsY1jz3zn{`BH#xw4m&Qm2a*q3!#CH4>zeD0{hDWd0)TUdc7J7A)qY=huz~eF4#_N z+7U(~A5K?nn974rPG29k?Cg%x_R*pm>S3;$s86W9WDd+t`TZQuL0 z_iQbo13~$Q?I>xp^-1AToiw1SE1sx@2}|qbzXGhx8;u~*CeY7LS+TKm)TI>bTR(0eRE0q?54T#=7;C6?aWLhF1!>F^5y4rqsMoJ&p*(SWrINWX;5s@ZM@=M*3|Xt=`Bz2O>X?4XLk5Ovt*sem=8S&gUGk{&rED(83p!5?J}SV(;iXvxRobz1=s$Lt_ajG+ z@KO*L$|u!_3LL?ytn>`cdG1?g$hlY2*Rs=tEmKi#d0YJzsudScdtxG>>z^q_c0(t< zq?Od*SyIy5l6;z6W(d3Q-K4bLR7$ma@2dEU;klSm`fkr90+?Nvabe6YPy6Hrzn+Z^ zy}v-h6P=H3n~SW9pW>I^CzU`q9cgx-@Pwo=2mxBDR5D_WP}Um(pz4n< zhRchYQq|VHV!|?#|1=mu+B4Dl*(*`oMEf}!@mjoiUZE?h-&6b|L1#2KjC9kUEPxNF zC*{*)SHC_G7mw^4k~Qv)wY;aUvv4jZ z$s8V^;Lj)1hia^kD2FUo^E}W{2owgPW7(Q1iw&d>-s=$pLdt=i4^b)h;dX~KF!^F{ zRCMiR33;`|){4o~mqv~45}z~w{F*&o?QqU=Nlc?_cp)Woo<3Psr5wYS(Zf+vo^hd< zsv!{T7P~4$!XY@6_5e`xi2F(v0HkO?566=TR;Pp?lT0VK7;7W2k<)4RI|HW!I)X)4 z#`0y|v^g?|A3&Bl_rMI6b@k--ayd=m1aOHFDI=C1%^r0MGOrqP_H0DBU9#Xn6Ol+F zY($OMiDHC=>bAC=PMaZhKwMf!tDxD7_>uZg|G4J3^ptij;`FWh=5lEprJ8pX4D+=S zgw#E6^Zv#sXiJx83yGyN$e|E2U&7Oyl%d2BYB8;M0~yK?1-IS_N>$-Y7TKdcGChgi z4=tbbCWFFpQ=N|&$YeivzW88$;;5i()2vXPF57c4K7pY26`op1X7&}_*c?Wh#Z z_V+4QNvY$<01)!pc^ZoiCM8Ryak|CWK?v+GsMOoKWvrqFYzLG_xOL9!e18m@2|d`Y zcwu_B{CRlj1^VrMedM|)q~Pp(*Gng3EA>OLlms9eZWJps*+lSB)<0d5^>&8lDdWm{ zL=49_6qdC7FS&cKSUZ650G^Ei63}gJaZzg{?I-f5gnv1iJ~Ji$iZgjU_swQSzu9^E zY4n0={sOOsK6(WZ?LCl2;lS5a_8c4G9T^~Bx688pS(})3rcH%e^)Dvc^)Z9PZv8uQ z)?BZmD?#j-U^`-(L-hc^HAnfcaAUK1*2LuW{)8j*qmUoR52rFbD?D&)UOD$-)q zpo|PGI}ayCW)}v1f72x~=OzZjKt4;;FA$O|6eAwbFbc!quY22kcU0xIiX)(#Zr~f^ z<4>QgpKFQKIJFr`%JOu^S}B~W%dgT-V+P!n2tyXIb@SgICB>o3>j!Ht3`oIjDXTxF z_`vijlOc1ClRyLw>v!ZnV{-8{sJaaB>xCM@&d%01&5Rza4RSsIew)*J42l^Ki`}-_TwU=NUY4?3Eak!mWmQG?-ditp93`?8i*C6P=2+3Sh)B;hzQgBoL3Au#5jng zLO&*u=>gqO#gho3Le|DT-V1JDdt;=Cci4Va3-(H$`jpwL7&T=yB=8VNSkhn!-NaNV z>j7zO?T%1VRUi!~hPmf(`FZ*Mf@cz6rE zkkax?*9B;OPur;LKvg^1+nptwlRJ)ow9Z)%Ack_dD=r&)W3n&DWVrzfTB(4BjD-#7 zIX8y5y;++PHtBn;7Hn)9?OAwf{f@$IF$<85_^!ukiI~d&m0DDT7}Dw zaLQvEe8LQZ=LO5|V3j8_5stwQSG)9yZyNiEA7J2GVvPx>XP$E0<1C*>r8|?hs}7t{ z>YlO+d@iCD?5q=u17@=!Zvlh`3yaZ)(D>ni&r$9fk)$oNK=S%CASI^PvH;xZ(|xbz zmpI!$H#l9%jVPuw_DCvA?g2U!1(*ksElOM)fK6snHJ^;bjKj)LmGVo`O5}FRH@3xUbx$*Yayy|;KZjB zd1L1-YjRq14;2V=6GR-nt{0lOh>lM~GyUdtz$JhvBh1?n@_-kh@q9Z|0FZsT(oN%L zU7q&bWwUjm-qFS0bU_9yjc3j&9RgxSH2j#EL?hqJV9DIc!S`-4F>6^+z(^)l=6pkl z0a-rHE&aqR!Id*z!L62_w>H=zr0aZ#u?9VA;+FG%&1#3xmTs&C9sW*${u7#{SyW}iH z!cX3hKhgi*8V@9BeP7OWUhM%918(Fp$|xDpFckJ!WcRPik&S^>uyd>t*quE1WU zPMGE&=Wk(|N>6J%%J#q4*&r=r9-I$emA$k&eD)~FHM{n-`XI1H31Dh;`hG% z_Sa1pW+zYyVxe^XhA0NoFkIAvi_g{EL#~ZPbO14( zjeY{{OZ9_Z2QSGF7c3R8=l3TzN1Z?2t5A-xOIP)UP9YTp4Z-TRK-%Cp_qKX^qF9lN z6J{SBz4*RFZ*U8&BQ^!{9xGmh#O}-~KO`m}5}VkPhNGw&h|vEgT@LcTxwX(f7feLvtiDYiPzeP!J40EnUr-Xia!fc zMbv^xaN!w45F)#jnS6IoUmi(9x5L_V7$rWHiahno`kc(*9yO1TE(3-@9sw|YD(!Vf zF8Jmi;w)RH=`#yg6t9}2_Dr+T6YFGqI#=)QJ>)Z}0byM#^M~du@6_l?b%0LKI}!qT zXx*1}O5zWVq2I?H*#za%b`#PPe)>K$9#EzGkg_WxFy87-sx!^Zh@b_}LPo^^qt5h1 zX-msB^Kd1`FQammlYj1>SQ*E}R5g;AVPaaexsO z!r(0sBF+P8s!}hWD*VQBNDdSbR6&Z(m&YXTG{3zSW2yyjt8alPdD#b32Iuv?JWjcJ z40x_n0n8}^b%q=Zo~j6lyM6p;d|_GNtTtZ-apmHKtW3?E<A|vs>|#Vj)7Srf-Z7FR0{; zRqtweg6QGW++kvm?lO(Su93pNk-~Voz!#|ErIF&# zbj9CuivKRbH%)MQWdNN${w)PuiKbO<%>C`yxDq>DnPVQtsRsB?HOQhIETIKl(%}{@k0pUe~aiB3yOkdEv2EVRS zgt6v(s8;r4Q0rxT{q7T~DB_rTEh-5@NBf4#3~gLZP$OKY#aQQkhRz2+9ntb2uerCi zt=d&F+GX=PbH;j08G51vdSB-CYPd8chhScFQnDnE`bkGFG9Fz@1W_Os6o6bzSgChT z=x{KQ6A^TmVZgj#z)ow(Wn#$p&OkWI@CRJw)EIE=_efAxciYcEl=hK|q+yY_e#tgS zO54~B_efTpa|S=bK~Q|}>If+HREo^O=%iIj7H9TvUN4wdKf=U324^1iB4{&1Z^5s@ z7iX5RU{OSC86jy=g$rUS&eF$GQA;Yn$60-NVMXf)N-X69?sDKuKf;O`Q4XM#U5&K` z8@>s(UEG|U7SI@Ddq!(_X=3;D+IDbEH2;a*t%*I6zuk;x7z>Jey~lnq-htUPX&MQE zEjlbAA&YKYn#pNoks`c{4m?thT&78eC?~VF1iLsZuY%XXr*JA zSOgw4z=ZsP@#=6gF1ev-k%K;4X3SHX_a5|FBHsKy5t(kpi=xIowk8Njh~RyhVxO0p zPd-Htm+opAVi+y`o>3Ze0Wxh_+XM}VW#|1x>idA4^|+1H;IudjiiLA)3X zia_j%FLkj`WB*$@+#^v{I4>V18$yL4yNm+maV;nY#su?c8)!ip0)DzP)255n+#9N6 zF~q>bV2>2Bk7zM&$>p9FCee~k(LC1xSXK%rctjaz{16u`Y(UAAUB<2AfyIVhUMaIA zg6Mkqd$6I1YarRxv!vVTq#FlxW_9+~D zX1^p7&831k!^28c(B{>KH^yCtJ=!7!Ejwmhn-Yl;CIID};6o6c9*^)KgD3nmb`&B| zSq%Uh+ru}rQ-t?G^#f+uN4Jq_Bg@4xsR3P1jKJh_M#q=fdkaAnJvequ(9cXYp z`bCEFl>}UjAM=_3{j$uTAA^10p3TLX+z)+`5|N6XNHakIL*Dt{b3|p`BFSW5NP%8< zm)xt5jGJM2R1(2uq?g0r6QIIT%53iZCE)cZ4iAyYTuxlw$DB%5NjUWG^?#D2WMH+_ zTviO{+|A9QKI+Dc0tvo*+ILX6;m6m$;#XLv|1Tp|HmDYF5tu+7jU9BcY0&e4o1+tX ziw~EgcvK7yEyR&`W#Ot}$=PLHklQ|BI%k=jmwEWgoh%DD9ljiH$Mo{|d`?SFu@;m< zY&2)3vr_a$<);-lscYq9F3yQxkA52$h!Swd1|=N#rv#5n8Ep|c$Z^Qw(p7&}Y1P#8 zX;eWVKtp>_bYT1UTXz}+q*28~{sbVBmCF?#oaY_B;FeZM2KF{HG0Rp#>=cL$h<@t? z7BYk>&B%3acj>Q+%4LT^epgg+v`|`A{_-mlm87O)sNmS^6m$lLt)_LW)>-70Dd+`N z2Bk$Y7%?)Ji(*j7~a3X)E^cB=ec{ zznIN^T+|k0G3?Fgf5tTM;g6xaNf()17nJ~Hz}&>)0SBtnuz;~jv^a=Q(tu;now_of z#sqSjf&F1!6a2EVEIW7ZRw_ovnG#4M>s>z|^0{bTyDI=YW7SnYJO&P$klic)Eo+`& zZLWw*N_deh${KnfH~hsfDdI(~=$WM|aPI>3+^;pi-DO|X>#$2$TS$$*0*^H=iWZuMaKUeD^PYwo+g7~~QYirv=~s|Q1ws zY!lM`c7t>`mer%^=da3V0nfBXYJXUqjWYr#>g9yk&uFfah>k|XuoWLS{T`qW=r50W z5_iAcZdw^{^|HyiysRr&dZL$g0}i_yb=&kR8unnf{YtA@q=G)pVZ6fm8rb=t&~0oZ zzJH>3$RbM*!4>i^zYX6^U1NO#yA89kea^!bcD`V1L2~^Gc*S1>Iw-g@u?tJ?56f!% z`diYYZ$Xr9(Iw(fbSwL!o3gqp7psa$lx1S) zVn?I>bk!tOg%r7s1QnqiB(#GSuH5fM|61r~dAs6@@?3Y+(oD1iyc8HdVlFyI&pD8f_H!;CME7S*OAsjfjD`06DCQtMJnjRC_SZr6mst(}AbzxzA#x6C!r3HC z`1*T^PeNs?VD%PaNr^~KNG^7Pp%wbOI*3`NB)&{7oSaDMZ7+Ikisdw!MTQ4F zi@B)N8G4L>aLO^v<-H~&N}xi4`8=xQBRYnu;60B6>=W<;OE0M6e}l^0Slf5V_3-0!K%sYyD_g)y$MAe8)v;zd zNf8Hz(rDtv7*Ho9S8~AL#pe08k6>79fx@;@HkDrh9_pHkD4+{31AFB2bj)!U01(UNSe9Z3}Z zC>O6t4$-DXP!hNegdNtuW za^FLRZ^Zl$!Qgy+axBqH(ZyM{Ntkp@K zt~AxHLz_*Dd9TxCY-=YuVuOBj^!V<@z{|x4M8|GlT2ym&GSKc8FWkdEchUF50L)r+ zv+`IVF=oS>z2?^Y0J()z|Avv=!sLwDF)@w+A+8CCKOg%$rEw-1(1^uOTUOz=8))B6 zgPFxF=MYK45n!bKYJQ%N@o$;dPp!;E@tcnq5`yyLq)YyezD42kuI!PWwptU8*;z;u z8^ZLt0w`6F5;vCQ+5G}-LawsMemsRDFgbe8DGqP$f3&G)4*xB$GH$8vT8Pl?x3KvP>-}42St8rptyCj~#6C z8dM?pi7Ax}Zucl)U!wjvzHqGg zzGQ1O>8&51{23xzCS+S1mkT(tOvPx{DATktYRllAu3O=ME-4I_`}u6SNVyywC~`*n zXuhGN^*gvLeh%!@8K>OSRx>I*xKh1YrWA|-v*jjENSE*eDtxaQ6KCGqM;>hnaEQf=RYEFi^z+*>a9m${VIAN|Se zxQn&q=Hm3d9U`S+rhKNintarG%cAvA#6-yO<1gy`qa+y|s^}5XyM>P!xi-3^fF&^k zCQIq7YT~K}HOnC&xitQDJ?TkI;ZSeq!j2SQ!ZL*0a1=xJM4pTG`1PH$2_yaWs~9JSS{p1k*K%`&>{WO9S}UxCyDZ ztjwQEp&-JO&5t9VBBdSmKPjqsypd^I`0tRXo@04RFk4YBgnUYE;+E@|=ua6>5J=^W zP@^ujHxO$HsCrCC%vN|j`GDI6=tcPK2Kgg0IFo~v;)M#q`tc-0;st5AiE&{W;E|X) zoKkSZZn+WT60gh6Dc3C`f-J?)aV__Lc6MpVKO=q=FGfouV4C5y{FwEjrNJlVlGY=6 zA7a`pH+mlgD_`r1o^HUYq6mM>ORbZ++SOL@lG>_^sAH;Qggg8Q)+_PE;n=Evm*@DG z6OS!)@=TmobD^SUGis#R&^(9H$&3RoOX7xOmHM!I!SiA;qmXZ;nU4i(Z+8X`Ne zmY(NUWD+0q`oQPY+lBE1aYBxXU&I~j zv5b57_;O0y4Pi0jgp(nEV==E{@8Nz&CL^EsjN%Y{jotbAWL0DO?{=C<*$Pjite@D) z7^_1V{+2z{&8Zl(sd2)0UP}vF3rzrx(UipdgzA)v>JPU7EK*7}@z4?uyMc-1j(`%= zqU~}c-(I#9Ypt(#^$AF0y4aUInCxm17)|jHcjpl79*aj2EYh65KhOoa&jr?ZG^eLy z-iEe3r3 z;+U;*=r_oLRh0`FK!#X&n!|d95%SYCL7v0m*-@|TiDkPY{MN0dLfGr}Z2oQ}NTh;vJ-C?(O0XLl1dGf^x z@!xkGg)BpI@~=O&>$cXbWS&oOHE1P=kWslIc(kD%9z_+^`DgIxDiqBDTFqI4j%5*X zK!8sf{7c+Z0Gluwk1XaG${@c@9F#DRQkcLBH^!ZE^faOnh~xQ{7EN67sRy*}63~8&VRm+fX0-wzYrss*Fm01es)gK{X^NL`p@cdN9bP%)`mS&3MR~SZF`= zU5^W|29h1v@P{Q1R8zPh82=b+kVg+_*hfio0^qrWvZW;znwsY)Xuoc|wR%S#mVx)i zP-Xvd$%E|=T@dDgvXSMeFDW*2e@A_~$nWie5lLFVozlpa#GKl4U!RCY+TTMt-VvVi+o0~z4>%X+HLW8nB#Dg#G_cBHLWfcCQxJ&R(n zyWh0fvPQtwE=PXi0bmFLbwV;Jsq11N>zA}JL^_+O+IMNb(`CM+g%ay&0Mxk6p!ufa z#{7PJs#08ktikQ7gE_}+uZ&~p(b^onuhxMLT59pQuqv;T>jZdv;K_{~1#<=OKY(F6+N=&jGELRFl4h&EJ5hMSl6hyAx>$PsG2 zdo<6bB7z>t9GjNcqLwXFI%t`cZX|5Pf{A+Nfo=H35TM8Fauxo#n8qms+4Hde>KYT7E(fNA35k2PqUGC+ z*-$H7ERTGI;rT#?W37~yp0oKzxnX*W&tzQ9Lax`)<_5l!j-zTVfTE^T33xY;EN7@u z57#*EOKdQu{exw&UlK+DCG0a>aCw2vDux7g!sV2Y^69;ll;u6l-hftF^y-@F7T1sw zcy3dkUz6A|>J3d(xy-0QaWbjI-WGgzTKUnFnl*it@}$sFM|o6d*8pHA!aJ**go-DOZ9sjj1^Q4@s?^cmVKRUe0{U~2s5Q6c&obo}9N1*SSp zLtQ~wsVxRxbO4c>>Z;l7CJ^}N zStSAtLyA65DsGWlUWo{i%_@PLrB=!!NtDKK&lNg))_n%RaHj}BbVlXltNzLH-5%!#_ETMn{*rL%E0Eg8TgDo zk*#AkhF%^Ir86oliMd(H_c`fh#ytuzg$;{BNX-I{PQ=@bdM}5^J6p*fT*?-8DI?gJ z*0)oSjqHe+c?ezZV<}9Te7L}dx&|UsG-)+!rK3E$FP=JfM^FJ&S^x9ILcQF+f~e+u z*{#7{Pux_Bqb~2J1K07YECg$#hA;MXOT*m| zV?Z>vQETJ^^02o6gQ4uQiGAxc@QkP5%6s@;bxi3puw|S4iF&2&1Xh;^yT(fTDsdRs zO_9Rz-@NFBt4>hxY;EKc88gIN>PL(@5e84%yoy!2nU1<;$p?P;!2o5HH;4EOuni_! zYs6w(O|@v+b=dAA(~d`e{#6l@b;Qa8Vw14 zJn7!nF#R>;Q7oWr1YfM?ME1kHUpZ2lxPR&u9fa2)!EScv1vXN}Wdm*A%aFncIj$G7 zro|A7Wyy!nUuAm8Jb~{H*>))zPwQ5~z28Cen9c?V|MZ3ecVsepqvX9s?mrJK*Xc~% zhnYiu_7uOwB&nFj{G(F8W0{oD%MT~(`&q7m4lx+#kB6r0%jlm-oY@B2BL-BL6xp6% zv1kPZ?99B#x0q?jsvm)&lv!9OX{=omxwVC~&cT`@LeedTn4wE(Vo|Iv=?cfnRSflw ziA|qPG9sR&FGk$$=s|fe4s*`=i+oeYVs3DZ=0x?i(`zC=b8F4v-U26k#x2Oa>E`+) z=9y{3dK)-CB6{@?I5ps^J77Yrc-Q1*(x3@Tpq`&|h~C_PMv|d^b@%Xf4iZhPDc&Tf zFqw>dm*;d-W~3dSshBl}c| z$SNd1v)(KTkPe1d0%8YAXkic*DO185wl0rR6Y59I4v$s1GF+i)bb+y4AypKn4xT$^ zic(S2`2=0(U_g!(XDoPsuQ)N~s%=|O=2^71aZHwck~xt0UN!-9_2a&K6Ndma?_OGJCf0d|3nOv^~o{tvDVnHHn=^o?h%WeJdI6EotOa$iJN{Q`ia)q0ytJ- z2BoJ#g#85!k-gm29K~P9T>0MsNvNonuik{P)BcH|krrmTs|_yJl8KDyMgEVjyZ&oB zZvO?oE^2hc=+P+*5Ew}3Xrvq+BGM)3=tjE5Q4-Rv0#XVhA|MUwK%^7}6~&r|`~E)8 zd7SgZ`2+UZ&+qHHUeDKfDXCgTc;eBZzgw(HW7nl*_UCK1Nmiy}Zkr3ia)j=gtqQIL zP1Fc3iL7;z&_A$g&{(XA?;kjx``!x%7^hvP3TjI+Vlqa*_OdaOz+!5XH!BmL4pwtK zh}XqJ>&XinEqt2nkdMY-F>^d>KRLB==3>O0UWtMGuK|3vN-lwxJE>iG_`)@LXMYL3 z^7{{plHfj{YC;^IPemtrWl}Szd3N3QFEJ*Whx05%eY;@)W1P+Q!Jm_HZoo6eMiTpX zcGez>ORS^~|M*>5@rOaP;Y;B^;(7m^ffe3lrm9T#AV}@b{PQhmsyJbz0zr?Dhbs%! z7u;iP^25}5;mk_U8^G?v17fA#s=VGzWgLyDf^RBP$g30JHjd~TGC6MIGxjsDyl!E2zwiTunIhu==MsCG3Tddl$K}SIn zsKYt22J%F+Xvj6t#7#t1(&m;+KLzx*+LQ+4DjU=|lP9mVi&bl=>K}h++EFvE*N#o* zhPZn7$S-gs`oB0-Q5&STZDQiT`GoBmAd4(*uP=a@UpdDc#XhaP{WZyUqz%Y;$4c&k zK-;m$cTu5v|65c)+`a6a+A|MPgcZo!?~`~f_)=X2BK@0HBbkyZ%ype zF|hX^Xy!9jn~6^-X&1S$tTbXW?1vSRPx+%(O$oL8u--gX%66hWA>!KLT^68S?-q`A zB#X4xDQMFDggd1-u>>@o#Oa0)Xcp?PI{YrEyIfd3%A8Oiz&Yogz_FnRv9U~gEA3^Y z(i!;e_aV2BlfT~4K>^gSah<8e zQCuun8_{J=t!SkwJY`Y6V=^2&^9dZUgA+!H73lhe7zWuG`0A+L5p2qt*g8%l3|$El zt4S-grVrz~!^O>*28kWj_GQS7>h5&FP59M#szNh*X$H9pbnNJ2_Mppuiu;t)8r zKH12`q~~Ghw910j0Aw~*(c+ce3rI#HOY*X#lTGTXywe-I)a2BZG*@?Ms&p!Hdt;LR zCC(|iDF^l_bxO4E{gJDi(tCKIb&Kvve`?S|I*dLUwr>>{ww(k^`VbZU_vbPMYJPFo zA6ZMXAd8!^pebeSfp0a^=GFeFIr!_qQ9ATi!7KiE2XE5ZoV`Pj=IBEtXl-{&==qw& z8L3^q}silubHjhj5V`Dnsi-$dhh?|0y zmWh|q+t#_>F}ri|>!#~BaG~G3NTDayafZwiimyaP;Z3}avwUd;_mE(gw0+E-9+#1) zW3rg`4fRt*rFm11Q^8v8(Z^=@#VHe z@x@mm_(_Djbo~AX8T2fqIeI1&r4v%ZME*D$Isfb!D86GvmZ@76ct7f38jI&bh&7s^ zJVGu88#0R%jWfb=bGZ|6^H@N-1D~&0JWy!?xWK8cF4Fnx(s_>kJnUhrw z^I1Y&lnk8!k0}+*pd?B|XVfPQ-4oPbT*S}N@KqcjF8xxnnQ1OOFafIeMw!lD5=U?G z!gYP~bq)6}j+mh!_>*RsSBB!uww7@$)+<%D>&tfG~0Fs(Z`+7IVAzQIlGe+dqT`Bt9gS%I##<4BB3gD$0+I$_k zi5yQQbNX1(uZ@8K{fRXBrI6(NDYnHDd2#O7G-6x`^(ZGg-Dj~`Q~66Wbu6?^Y> z2v^h*(-H2^SYC?N9o%lZ#nzPwdfF1cXlHXo76q}&INLixE)L;LMX85?*U-l5_r^Ee zqocnW9_5cC^TzA$9qPKB+>q| z5;7VcTaWmy)W!FOx+s{F`9v2Ffwqi}OGIlV3k!Ko4w7G`^p+;K4D}NfK{9}+-PRWq z55^xj9$7BTv1B~WFF-S|==l3!!fg*X`$VtWqN;{598?bev<+)WbBjKWN3+@B*5kXw zU(4+uKT*$wS?XdEBIeB1!r?MdO(%u?l)BF(WR^$^*t<`EoaYK#|7mGTNDmLrmvz`e z{RORJsR7kp#p=F|4~S--xWq>H^soMfNa=im#I>pSW}BwNEgBlk)$g&I#lPN^65fkS zxdCm}o9&q$ziU8dOcAHn44zE5#w92X8~gg^Eq|{^b!LJI&94eURU|<0-AokfZ*n9a>Se7=&<;J2BzDsSaTQkOo)0hnXux1fo6w`H_ z)B4SltjSyfpn5ZRRj%DV6w%#)^k|&zLnPDR%X%d9txFsK{*}V)mX`smTuG$EoPYjp}FtN&-D! zpY|$(AvyIrJ=aBFLj~iB0){1x#?#hucs7rH)EQ|^ZwLuAKJ--B*Q1tJss{tFv5|E^ zG2QE> zl~!lc%_Wl*NAphWU5OJ3eEgGQRNoV)lPwX0D4D|7H6|-<%eLlY{=+iQi~v^SXp1a)2t=y0@>9|<=Mo;yyQvRM!`?%< zx}`>sA>r#$BVz$*{ZdmnA}m!vlWW}`!X=TR4AhWu9@hq+-{_ z2=6=y;)&M2RYwjecAdI-TUi&kV?3N@fCNIF|1%IyBTTqb5Qf6NjBcI3!hkjke z6Hg*3FrLBeg1KFH-*bm68*^Aa<@g3n2MWkF)RyLmj^sw|J|M-!4a0rO7lOM;K^_so z`!x=pj$$BQT&_dX#sFzIjmF{du+0TIQr-|FYB-L9W}|M$_V@-J1Ot9GvfsBno~~2z zAFFc+?a3V&fruGz>=#JyHpL@kSg(HUq~^IOfr4&TJ0{(p6WqI&0U~pbDr?P6lWF(n z6D0ee&(hQYA{Rg(^#m?P1(KCrl7Q{$HGQ=sSSz$-HY3-Lc6w`enlKcoqKYr5DE5`> zpiAp`GZt^$Xgb9b$|+iUDXSzUwe%b7S5k)>r4Y`C-uMC+3`?+)7SSgj08AB!>Rw^h zj;`Xx`j>NHhtghBH?Zg%LNn@_k&V7S0Rz?SZt(pu2v_5QrH6TIFWs`fK^a?lag2Ny ziT77uzrC4?^hg13tYv?dlrLSQ0Ul150D2ol_FON78$5^-qA~Z>c2@ZmuZ&fSJewDO zNWRJ&-|*&}w^Jj1_$#3-{4rG$Y2?=%TC0Gw-VD$-ck3^Ke?EE(L^D(!0v8{V#_DFj zskW@zyx?gwc@+5fbk#>|1kq-V29|H5c8t_{F51?9%8m<^xyUy&280r5WdD(#rY`?F zB>C_ae~)(>B!TSxpTivpN<^dgg1*~!9A}gG)oZfnvqCZeVV7CJd6WP$%H0O_K6;sM ztakYq@2g{-G5M@HzxEHP6IhTIn=(40qulb7_ZX1g*(|ndYzgj;A+bBkCg6A8fCUDI z1apXdqF)PPeREt)Ly5|?2~I1^a6Jay-8h|=w}1L@Tt|N1*NM8-47UC9oS+O0UoP-W zEf^PcmZ@v}VT*>Eh#f^%;r|Qbd`EyeCNMAro&+C;YZ5#2R5{{b=zHc5i{)gfjgzjt zGB&)or9`;?le_j*bPmYwaNtZUIKLDa)seXg14d4`Lil|RP#7X8QE8__7jMG2mE)~|9KBes z1X1SD7p9Fm(2;ztcqR!0uD93?H+Xd0jjTePPJ zr7FzRp64Gj*X{`YM#e|$GTqR^R%tUhHfUoDZ_!=@(D3}4%Z7T^u<&|aeG@g0bS2mF z%K0FS!?Fu%Sn35SyJ}ZXX74u_R!A=u(Dogh+*~M3?A`~^GNE8A2Vvo}AD@^%9vHj3 zZd))Ns!i*lpF4T^D;JYqv$8Z-ynxBRzdV9uFYv59|tL>g-ZblEs+6e6KMaklI z_GhFwr^(NF8acE~9FN`?1>bdg#R5LVxew@f)XfOg<4ESn)u8(>L(QHWowM{i%Jun0 zzK)(9_ZWzFr9N~5c=7a9neET5_1SITJ>d=2V|0N^0B<3Cb)_6ATOa(LzcKFyl@t*Z zet#uO%H!STnrBvp90_4_cXKxso~*76f9!r1@>6~V_&&KU_-{fOyhROY6RG{fWwe|W z@p`~4m>Y#-3K-8QCJlnRJ_%o+4tumDBNZ|9Dd4l`miovVOXk%^ub@QxnMdE_rb{8; zt5Me?wDw`Em$c>tQBL-QoDDJ!^7LJYjTQHXsw_t(0)-WXW3}rAZHMe%NJPhRzYI5L z>Bkh43a@C>vS7)M8POXV8V&2Lr9Wr9KXG+x;%TH%KO3Yy@13%xX>eU1eG1e>ex$7w zt*N4U7E~~Q<+9q=x8fiDeZ61o{3KsS1jW~UP?Aln&+0`+d1@=HxojpCJ5$jeKLy32 zLjB$cXaH6?C#6k!tMpn4g6bnLW1&hkJx`F+u5{0{ilmgIemA9kTJRlup>R?B2*8e| zgwDQe^Y$x}`4v^Xq(iMiRhu0ha7D3oXiDbml}N3&F^^-H!UH@6G>GlY^LgRd#hj0U z5DL&F4{7+(*x@LKVRdP3St>EWi*Zz6?YZE0-K#7?Bwg%CPzp@Nqd{$Lxq};sxF?G} z<&O8+_`S{1u5wqqGvF-1!-}V3pJ5;?FbcNm+fBwp?b_18sGYZCvq9r!??}>EtGB=O z@E;XszgBfWbnTuBm^_6RsT}=UW@n2SncpTsTK*h>qVN!@w5x7Z>xY5%9;2hTIw*^p zrM+K%vd~I>`r1`7v^4ERnoDhyWr;m3PbpSP+jwMiJ{$){-cp0+%MXW6@>D%~eF5(K z4A?3bJ#%$i34R&|>bm>)Bo{8>2*8;^BVUI;>ITv%TbP8Zkauh&WUVT5;uo}}0FiIi zmsI}zK>IpCoJ*TQJAHJ|`89hp;&~b)`F_6n&38lFg)dIMU!4orXmK!pF>$hfiNR;> zRIy|UdfZmFU{3*bHOhTR)i^s1{Q36t#Re0>Pr<^GbFX{_wm+}Dq#oJUt$x^ex*_ws zd*UrmlKk<$#uM(Z_39L0RAh{+Tg*yHTyIC*7hNw`5%<|is;hdB_JUjVR08AS%1t&z zw9v7}lkeL5gR0fih~vrn&OoyPrP5A>@6WT=?3 zSBK_A#%uZjAFF^WdjOYMx%r5(=6Q(>r?}|t$Xo1|hCmW%D&gJ=3ii%P0H3%a7D`xl zsLI7LHOOZf00#FGIV0hChie8blKzoNMjR>UL3*=Ql3iqixC5}>Erz2*{F!@L`=ncD z@zu>mBlN0*P!!115a7)b?L4BRMFc{Kz-(V2egIlnnzC{!eKj=QeMG4X4Caxek)p5# zAp|l65?q2vMkQ7~NxpMG=&W$b)zzu7IM{t^qgRe?0%4U$2A$%z0%Rtoc@3m#MWyHa zravvsdN!q0I+ZnzgOxX)uM0DD&$NA>gkN=+WLlO-A)-Ehb80hSh;)c~>=wf=2YgnO zvEyaySnAYXs;vY}VASDd*9Jm@Zfh83&3k2mgR+SD^t}D^ujT$lsDu50FTbb#SWRaL z1GQbSspp7)R;i?9D$qAKa?c^umFUZ^FY|%JVzk#x5CeWT>9XGd z7{H|3>AOtlrM{EqE4n8>njJ6m(nl=cP_-ZGZbTz7-a&Zh4pR@B`>& z$5Ms8LR-HOz@iGzvOO>sD`GzmrRMaSrGB?Hs239?1;oSDCRo6D&U7Vl`#g^97@=Hz zQRE9^P*z4``g0C@nrF~hqufi#+`NJE8e~PBswad52m;-z zT_}6G1#(8a?|8X-8o8RL7VRMTV$$MFzquraW5h05XvrB1&Y3_2n9r#m*$bd+2WfSe z%X0^E!RQLDvJ%AuXysX2$!alPweYa1s+SYzS!&>G6@Vqe4#5nP6w%j>rGwDKbp1+c zLCMSFe2{_2(?Xkd6{?qTS7q%x-5*c#<>#VwISc9QZbvL->8wFpf8L4td534cYWo0+ zQLDlDXH_NN1`(B{(Be!7EG~15!VADXue^5&nG=3x3_+FuI*VR+R2nieiPQj)#{g&v zA`?(&Dy%U#?G;X7*y|=k>x^3g9L#JbZF9nFl96hBX@O$ zl4JMOY1anF7lAQk1cJ!5kY93=D%jov=?+qnED<6b6eln*YpW<@q0KABSsq(fKv=&M zK`!WQs?X(W%3rTqS1s2f-+`dP51QXInatZ7)|&#Yox(+F3KT}JNLVh@II^8Za)aSZD7dM6)^_CfS*d4Q%20DRR-=w|{*3LrM9EZ7$&jKbGB{0xKkBl)Z^{Ob>q z_#zkW!$FmY%2Jqx>@Qqc1S(K~{l6M{V>+8_3yOTJDmb#)xIN9eI}2I5t~hGb6WghT zMV6V!OKjg!S#u3W1X7O>Yls?I{EHj|KFDsafdzejIs#zy2aNuLGG$inm-i3(mqa~i zjM?Y`KY(SZtCGFR6`h-$u%6#T`NmcF2lBex&?9+0 z6amk=2#gSe#$^Bj)d00e!qe(ZSyn(o0viwUej>SNfUAp(Ye*zx=wAhOsMxrkQZX_b z1ERrzzu|=pIFw-3`mSi;8wsfDd>?UK5!HbXA_3vK8SkN;2_4nYnSuA>u-r3fEN6*$ za%z9}r@iMo(zkPFhuj$nf=MuY& zimIfFuSrEYxq$LN{2)12$!k_)#mO}2SLVkcDg(i3e-UJ1`f%*<)te7(otY^b+4Jsn z=|$!9N6k$=au4=`xrJUsv?WnBdWA$bP2Y&7#6ZYlCGn1gW*2Y=>xuulWWt*(k zdFT2JjVAhZ(F;A$7Zt7k=pM-5FztYqOSyoNue^~B0Ezw(dNjx%O;5T5xh3Is!P8tJ z=vB(mQa0~~w*h~kCrWn|X#gC6 z^*4Wb+l5y@B*Prp#!5&_g_6_4L9bKI<|E}QHm^~-KTI74toxEoymQ?YGlA{jUc7hM z0_+2_Y~Nmd^G@@Vty3golV|(aZ=i6_>$>0IwbpH=KQpE|1H|Upxoa@KDp;Ra<#B6Y zblPKGv-F9LXsOjPA#ID^iAyV^bU8amt)ExDI=6(Ba^yXU!t zdcLV%wT~YTVZ;L~q_`rf{j^0bua5Fl>tcgT6e_QIl(>AHb69^ve8jTz_{VZg)nTVbc|muX*r5 z=26I}owrV~fZ^Sw0GQD%6gKkdvV*Ij8SHYL?cdpVu4oXP7Z|3-)nvc)=gkSR$_ntg zS$%438VMsZicqw(=i9xBXzO zWFF+m3HIMR>WR|Fg}(sCCtggj11Rwrp>^=-*ZRQhj<*@YFz^%N@pLHup`i=I7G?CN zBQQkeG>+clIVRudxLGOND(uRSPoAeb#|?lR4qH>FP%=Da@QGX&J4g0T{;$(q{-5{wL*AQ5vbo?xU8gr^Wzut}PC>`{*ZVtVzyHB6 z9+rIMc zdi}UV_UFLt@020d&i1cww5Z*8fx-mC@wrjMpg0!{FdXoO?p(RU0G`hQtls{>K6zf6 z{n)^rYChiQiy!gtz4_$I(_9;wvU4Wqp%fPa;@=zdI7uU^t{%e|Q8moh2f*M_vMqH1AZs;^Rc`}H$i2w4H>0FSd;h>7H`hWlC z2PlR!KH-wWo>HDyMdknX<`#UO( zBn)#$w83!t+1{(-371m)XgsG#l98V_qq{zcj>46SiGy1CES=j)^Q>UTboRYF40g2R zbc;>O)7qmz+7PASuisg61ud3a)b2YjZ6`u_aG`AT+Ry~BCo>jt4HaxzTg%=NBznCn z@6WH$=ZEc1a(20A*J(kw#EE3PN+gVp9ySW;wOZ97GT&<(4-}+o7+X<*pF752DF~J{ zO6`kfCbJ>b{TqOIh$x4A0tf3cEfD1p1@Gf|2h30|%V~sTH(5;Q@PHfTVi`s|Ctg$h zzT-Xy;GMD%83nFIimK(RHXRF`2{ZZdOB+o?JknH0n4|=j(*dwM50IMy!@ue8e^(<+ zDuv@2uO(cgZz7npb}|t)Q1obkrLGr7dvZX*mqm-T&4?%#mbk#weq$j@p;!VwKAFb% zIo?M3QF*Dsg^6VWqdN!<$@`q!JcJPh?V|HnI4ydP1ZA(Qc(wdF0|!uKe-R5vIiS(npZG>?i{W?>rYutF`DNbVuCVQ71izOY zm{Sc~Q)p|1if^Uo2^s~yn6ROFh}$*|H3w##k?c`y!NumZ50o5Z4yxO86-mHW%0?_TH=lRvr=ERC+>`o!y4=M2`3cnOpf71W?)swggDmc< zs47pjwVnc3fUnjn7K!;r(8>E|H@JOvJ8AA}JS`rWL0{(&WIVIl zmpPN-pPmf|HUeld9HWS7@+GzaGK>+R)`YZ7OUuB+yDp4zjNOjjLlw6My!XF0I<)3kXjjVMzWPw1u#1;=L|R84h<%-a=d#NxdIGd0-R;`#CzM*%w~s{|w`# zDeKVsz4i*@$Adt660?0@v_)*Q>_tL|{PCX5x9ikxY`ve=yd=`sn)TFIpYL04%J1>! zOe~{uu>OEvGPMeD$4OH<{pGFit+WzF2f__cHlyehQu=mpdgM>etLlDR@Biq>M* z?e4gft`-1*G?XxdYp#^gzKe^@vN$Zz<`Zy7)dH)Fndu)rYEJipuwU6)YR?hq%+nLP zeD&90%oDQ2gLOY!9K4N1Z0$~ zC31bIk6VP%@vu5c!H636%O4YgR^pW_{4IL`c6_1tljwK`68oSOMp_%K?{-N5W!Cd* z2{g6+6gpCpAM{YSmNWqs38#$=GJ=IrJLImI z2Jpq$&BDPr#ySu`EI7(V?#;D(3TZEOMnFV@m`Y5KhyY#4zqYBjZ+Ee`z9bmEi4k%Z zY2kFeWG8ev-NRwPivngulLT*_U6APhUc;e5(#a$N7y_c9F5exUj`lBfYb*g-s0eP* zy{Pg?hu|(~$94CsJhCcxO>E4W_)HR6h^%ioUuI|Q3T8cDC8fhg@qiMcd^S^zjXumkD33X=;gYcIfqc_ z9x|Fw&g+il2T3Y1h3}XW^5TP|^&5pJI#SC%*lq%?FXteZT%;i>3V;rZM^s+m2eBM) zI~PdgyT=**A$nAlLVvKTqkc%ne!Oz?DXrCO(Y9a)sb>PT;776#WkC7K?!z!*@#yzg zb^T3}40sHMj~v~7#|+4hCAV>8tx8=_I&%2e-f5h$7PRw6!Rf0gj^Cd8<6qy4quheQ z!+#$f&Z(!`k4Mht@;-Z=v`<56G2DZE03Wd{slY^8k`A$dH4;r3uHB2=R4Q0_fPyPl zs%jsiWfg?J#}Zp;Y?gw7@3$Eq`xx%}e&`dyg7kw3U*{Jbsw%KACBG*!kTFN{m^X_d zKS|I5{Hu`E)iADKLA>dMS@qP7n^7hM);}G#SLc-P-kOWbfA#QqSVcxzmu|ZCE4>qB zDY~&+iea*XlPw1y|0Z*FWc z+`aeN;NsRZu9BQ!QyCGB`?P{1=3GzWD?rHKo3qf74`vx+=E@*yj*@h>dy|AH1w26a zy-KD9SI=jh@?Df(mg3d2Zb3B+?9%L@rp>(0usB&j{8LMT61Kp@Iq{`0J`UGD8q93V z5;6;cwsGa?RMxi!=z_!~e{>n&jCyVvSWEk{MQq&+QqnQvpMeCsL&Wa`@@I+)v!J4s z4;@HYMYg#N8Q?e9GeGz)O0;8Z1+p6h+Oq$CqopRGA8Y0~duAi=5|G-26U5j+4>UXw zvOSc zS?rPY**mAWVEU6>Cge-b!WI_GIVaq`AC4nneLx!%`5zjCz7pIGB5kpZfQNV4Xy{?| zpGdprr)i~)n82^(_JBL>sR|*qFA?cW3|4ou%spS^QAw_LBhAK&S+^_If>%u z2{A(oWOo9wkd&)rAlRMC*eADD{=SFmaJyK)?%74G0B;_*KprF|^+$@8^b=!p?w21~ zS+8%B>vpZU?o|7Xb)-)30YXCFJAQhPy;d@ts%Jz0s4fxi)s>?^!hSnsG#(aKG0xdydlnR3yuoAuFpp`YZ0pkL&n`oVjn{@07_oEapun!2D3yNt?iXX zu`3J84_+kiE2z}xDSWuTyqPETx?QE#NRd~r!ksaWjg0Vy=(~F=QN5JhlP(mY-u4_^ zcpV5rE1ETX;S|4o8ZaEG5zca@Ysp9zmo1QqvtTS|;u_ME8P9-(nfEZwhnKf>WA{K` ztj8}(_JRmBx6-;FT1d_lU@?Mhyd-f8Q*I?cy;67HzmrT^ds3WeAhn(+=-K{i}=m*I5i6Btzw{YVsS~ zqXcV}B-&F74KsTTU3As|nZfJ|jfBS1s#uI`ZHyZVjqfiS*M|W)a=+g5u%l-Q%^B6< zl>?Z*#JP`{>a`o$Bgg4UjG{AUuNO_Os>Jy$_e19*2J%vvr=d-LCs8=$xinW({2^aye&m z)|MW=x!_VC%E*R+atJ_Y&Y7GG&`%d6%D3Ua!mkJcauCbe%JF+0HDFk=+e_9fKs&}i z;Y=uHeJhG;XYX3y>5a5KRrO^LEjCYeWp3dJjLis6SC^?<}BcJj%)w!Ni5X zw+^_nN@UJT1o7x@6N?!|&z}N7c}X&z&UaO2kY8RO!Y%^q72$p-{*KKnKYQdOM~G*^ zvN!cr4?>ijUH23Rqu0Jf1j&FsZZt~}lo173Gf6nN=`yQfgi>lR0a#;y(1-cbZFLK8 z4KBv9;F+Uj?6cehy^OEj!lZll6?%3_j4|21d}hhZ9-lx^?R{q{8^FXy0e+8gym&Q= zp5%AhXLX^({v1D|8Z+WXgkkdt2_G$0d-aH3L{tQ$l9b@_q2mKa&Q4p9=s~<+<*^MD1NxpuL|=|HD_Oxw_)KKsBRIFBR)m21NX9U>DD5T7&&$ly zcGV&Mzhl|sUaeRHSqT>+&*!BvsVPkuR)X0IVmUDMYXnAu-Gy7gH@MjV=uS1wB!?HR zaPoPC|180vi(=U&0H(HaLJo1Pqc_tV!dbPUUlcLT>{ryL!m~#b;WZ@YCk0lN1Uh?5 zj{|slWRk`Qpc)`Pn;b3v1MMu3a*OgSMMP3*@grYpf-px~TnR~aAZ%rvbDKRhYns*Z zjP$p^hsD9{^(7PWRs^LuLB%ww8i$cE2vO?+tqY}zltx*R!jJ<|SJ^XD-^Xp)hgP2z zE<|Ow<6@7R5)@kFKOiJUOQJqKNtPj7Sgoo(<_PFjZpj+J>C;}940lm)4Eq{EcxTUe z9FcV4Ecf4`Z3!DdvCmeBQ~QFcEj+J#-6L|mv{0J09h;V~j=aS{<$Po_Z6)Sb)VY#> zYv~(o2_{YnO%==2V&$GnkibW)xeCWfiaBIPYuvQeq8lr{dYUIGRuX<$OY8zBq9ok9 ztl$Id75^Ov>w+kUk2%$!;gu07OZlqSn73WF%%US#uzhYDWo|xohwX%s5q_;_ zq&1?KE$MDH>F&R^YxXPUkJF=IYnWfGn8CG=t`N+`X_)IyJ7(H z+i=EJKNnU2`R7{g+LGgQVQ2Q$Z*5mWY(zVK-Fv2rm_P?<1_8I%u2)L$6GB|^pHh>{U6?vB9XayP{Jgd)K zcbv3!e!PSrasYWqx2uaWY9pq@ORWXC{PbuocsWVna{bQQHDorlP>^T4jl2PVS30q87>-y@j2B zuW`~o%&@d?=X}WIw3G6%tesUGXeZV=9?>zBV+HodFB+OC z3es+{E|g~9iQg)Ae6(raqrTQ{)7y9C+Zex5H6=)f>fElq6!wmTdp7*K)@cenTw|8i z+V-ow^BCv7<$f>jD?1M5TP2M5malsp1MY3$qByCTr}3r8da2n5trCup(mk@PZ!o2C zv~xMN?5p)#q$fC)hJ25V^9z6A;#Mej;pRLtf*lypE@={d!X^)Z@oXX%PHQb6VbXAY z*(4#vV5fQ3P_MyO|ILE7 zt1+1dVZoI+`jEu>A5X6KV^S&^pGx^^Yh%!O=wm40p_x8PXwoh|x@S=`kZ zs~GArEQ7aQrbdOrnP@NfYyZI-Bm8Zpke8 zJN?q%G4$o^@IeHqd{!i9v~_J3TGP3;ZY^@*N9pfyb~i6}iX(Fxj$GgAM$UD6eFObv z=6doaw$W)J&=a~iY-e1As zT+1Xkz3+AD-&k=G7_PQ!vE++&2@Ihc4r7Mn_TV*A=e(WN?Wg~&8XBOX1<{x=K8*e) zOxV=k%_m4L}Dk)IHLfrgf83W#T zzP@v)VWfnt`8WGU?V!$-MIhv&(8(Qv_fYp&*_3ug2_P5wD-g6cx%r9z^^5pFaEV*r zEaJ3j+VBh5qq6r~PW(?Y|yXwY*amblg$= z7WM6#b-bus?%IRTJ}B*gj5a7peIuxSvA-Nt8z;>>`J98|B%|_-W}=?u()1t zxysSx3)ubFU)zshR?K%-K-h0;osb}us)<?l6YW=WYX8;5fnYQV_4LXxyS$` zM;8Z8l&627znZ(ZYfyZOnk_{&P1rf8t1E_WSp=6`*16VZi;!x>2v3OsZUPOw_2F`WP@M8Nbn@Tp?>^Kw;dip~KwWE#xOP4LpSDwYka|tzv-zR< zH{WaXG&;V+IPSoIAv(vqiKA?cI#Su)?1z>0VE7lF>K%w+*L6OjPDc^x%1Muc>7qyW z`&L8zaECy*vrF&uLa9-k$npr`WF}UWpc2{kIX^e8J`@*JO^&yKN{HgK)vD)bAQBLs zT>H;s&qWVS+Y0sR?y-7cUl#w~YW18DV!`z;Hm81ks-x!Qzq3H*7H8#+)gzF}iGbQm z>CN)SFd%$Xg3lsK;>-u$t|Q2CLr=iNBsS*D!r!bu=Pn^n&1-6^YQp^imNr7Q{^rmd95GjMbS~MH&Soh;x+uAH( z=VS8kC*Rg8A0x9~@m$Qfos{Lf0;Cfbm8+g->@#&&(5=Lzh z_?2leGB;J!7`AL!k>i3XE%)Qh*tTRREP4tXY0W)&l3nFH>aB6DQ;94Da)xFU(GW1! zU_lH)KaP0sWF!&6b15DaDFF1SxVzn%(a`F)(x5hhXJV6li_bT@8`8Q1Q9?QhHx?#u z8)npKIj>=3%Ox=4^?g#&;G}i82z~TRXD@FT0Ls8 zM}*x+kqkq2HF;^pECqi?XgBQzKQ8^Xgex*ZgT2qdbG&5yt>mnAreG&sNk}l9e^8cv zD&F4(VpE+#to$$`a=SNqq_`>yq}Id(osH=rZB;@x{)ctsM#pUMAW9d#kFBv9};-4g-;gNW}^flwJk|5Yes)YqR7DgXQ;dH6-j^^pYX*B9BdHV}`f z@FYyETuA@BoXUA~d^X%UYD(dPO6Y{^AN1+5JmQ)-j_-@=Uzk%$rZDWd9w2j(Fl3OB z$9NaqAmlW2FX}8f%H&zaXP!RBA%Do)a7x7h73R)x_hx8}X-d zL4d8Ox)=ru9?muozpGW(C^>tDIMm}%F+ZCtx#e@01=#Zjq;D1D32rSbWf{?m$@xbb?#~>~h!iU$6;byo8Bz=)h&&#l=p4wNF&}B{?)wS+ z(G6XcpQ51*kBO8s50~2Q2gVm0DPdVk&W-sVv=zRR#y8h@7~dFGTt_O=*pcd777chu zTV64yd`wW%n17VvdX?tx#JKKN#a@3a8;02(9SC*C@3zZ4td<4Z6e&?QS1#P;Yq`3= zH6U0Cj$n2SoOutQ7p-kySQ1z<>h+)Pae*S%8czu z#b{BxTjUNQT{7;l+h5>j&5IVf3#jIb2KG}@nJSgY?fJswi=BLjg;@);ORtW`6>Nh= zZ=Ubn5HsCVJZlk&rIv6f#WfjVTN;tV^~V#|a5%q`%5iF4_WrVt_d!STreX!>DFlaH zFFqee+!J_j%StJq{Sz+Qs>s;7NKIjd6TkVCNk4C_Gg@vZCF?Amr@eESOj8t34t_nO6(-k6}2<(()2y|=atgT0X&l-gE*v_PRScxpyc7BO3 zc@|B7y?ltc+oU!sa3}bSBqW<^`(VgDu-x^1Cd1yIG>ssLhw_E3Sl>Sx+ZJ>h6%779 zhF?>w(P;3#HeP9*<^#2OE?j@I<<0xpb+w$EC+Mh%M*gM9%4ay?XO9s22$^)D&aH!Q zA@Q?unrqlYb?XBZ@5><&^jYY`j7FedaZ#&$B1(n2cLFoGSXzSSUM& z6!WRCE$DoQuR6Cc1HnEzB`bPyXXcuyRWB{Po{|q6GD&)v@T{e4wEg#C$r`xphzy4ynd)9VRAu+Ye%8BH_1o^+iQVi!|sjdHn zrAF@lzn$VXztjlW)bX(Y6P7ZOWJtc(IVZ&g?Earl@t@kAzP>yp8eS~`Ao_ng#Z5T= zQyVUWk|Alu!IM+6|I;a6ZOo1epp!yiu*EdzPt@d^uI`>*FCar)9z7N_jF6#e<%I(~ z`=*{vKi2`(x1KMs(Zj>BBs$d7$(h%0-ljgFBb{G(q4&{R_jl+zHYq1xzB;oadEZ@A zXu=#U^0g)flRL7RhY}IY^hgW_G)&^Xc=jcAdmK($?YU+>)mhtr-lif&K@)N_{SPFJ zA&fKe9Vn>Abfu~!AOXf}?4LV-pP!n<%621RWS`_@HkAist7ySQi~RM(?cDArf(wkECk54b zZ2nRXbLuj)xx!c1^!0}amRuv3jeNsXTr6OIH4`@IT=)caMf-0P_gEWNJ2ypDC^H*< zT%DqNS8b#&{*nZX(#;lvOOl9kTin(&YQ})!m5q3DM99x?lb`6NY&vrd2L}@HG*!$s zLl+N1Pv#A*+Fjeg8B=^V?J_wS0vPyA`;x3|MrbnmDkH!Q$LYzEDXJswqJ5_NO7WcU_7;lt&P z<4gTwI%g~*<9mF*r*@xWq)ou4JxkCDBgexv>bsL<8EF$q+16*1wNjuRCG88?Mgb!* zVuZk)c3_~CLk>&}kv7RGU)kzdw=~v9jZ*5MGKflJn0`{nCO8rH_OM=(wraDo(vXdN~9d7Mw zX<78&Pft($A*^;~zw+&oZ#d>in4j|Zzd%5#9x&)hN|i0#;P%I%c|+3NK{Jg(9oyvN zmWY6sYgNY3#<5?vIk}$qR#w-%{os52Jp9%WrimZ*Fqv)1)=H#XGuvFRJFcZ)ZLtwD*T*pf$A-w{!LbSYeqRDlaFwy1|AVHDI z5D0J=cr}vR{_wIFY;+XsP-NwupLui3eUbr}Ob>h9PzwL-(=n))?we>fk!tVw-O607Ex7ynlu~4o+GeUM(#qVPb7ey zt?-EI4aHOQ-_=jpggat;a!Xc6(Wu zWCHpWP>~tSR=(_Ru+eLJw!!U(CMR7+|4ACU@Xg?TssLG-Pi_!@CX}KeDQT0wHH~9X zve`fH6k7|@cmZr0A#IEH$&|5P@MsjMmLozbWQ%z`zi*7QbwI%HnX{$G zvWsAQrYJGkWIX+rtO_1H)Lsl7J9+MWtQ{brymL@aeZc1Zxf+$;40n97+%$ipwQD9n z^i)p@pTB0|c@b_C`;*Ry+ZxcK%(>ACzu8ZUyqyoy07S#QzP!sa7)+8#Gcg!tyJ$81 zYXR}$KvwFl4tum19uPP6->}f{YU1VH%aruAMqNTqi(j9C5bJ+j#?Xg; zUq(#y!9c0K!>OxJ5N92Y;gWRPX0KlDbJgO4h|#l3B6}oeM>+~UadDn5y`m{zQPwS4 z8RbDJ=`;U0<(a2gkWLjfu%^Zb2&?MLQ@Hu{lvp2m4{m=GXcrS->1a*i#$t#c-C0Pm zw+*k}*_J{l*Uvtvw>{pPcg6dapJM-j5s90V78p2Q6~FkXlfQ*aWwIy4;{ijfQVsQl z^E?$MfE@y5@prN;-PSlp>!XBQmUK*wRS$=zcDYz;o*$TF_8aRSZ-gbV_jibUyDTZP zpK=zz713l}d3;1Fo9v#d3X*AO;j(v*meF0cRJhtl>QUMQhE2ZsskK;2;bbmasoK5HqgDZD_xX5=UA zKU%zw_6Y<>|v}k2t^HbO(=h-zusn>?3^sCS}FMj`reuM)bCP)6bb^K{?5^ zlb7aWzn{3goAX0qO2*+ouNTLLBgE+KM_uu4Ug zo7v;yhW4q1z~6N}rI(j2)M||l4a!Ks!cyQ1IhJy%i!YE7(O>{ATqprzz;HB_^%?f8 zI>@#a%Y+5;$F=h1plZD6Kr7*n9Q1ujT0;Kn`4h%^^C!8v0+zqV1M(h2zTjKB&cYV3 zy@4k=h4=EnG1^eKcY?`#9(Fud@JJA(9n0H=f4p_2g)f2qf@O3E@*_3D6zgwde;O#h zcE2n9jbx&8J5ECzf(0PUpN9|4ne7|#{*^-j1`Zo%;`rWO{Pv!QnHVHEp)#}-hpvUR zJ7cf^h_V-Of0gKRaGWQ5EdiLg`iw8d81QC4mSWzLtg(NUg`k?HU2%ZEw=xPoa62UBOlIdP% zs1f8PpJh$Yw4~|X2z6E8i}j#f7zi`tiVmxP$0vz)MDlDUQn*&e z@!k(R_5^*>7)*l(OPk0%P0FH0Lu1QYCiKj22hg|WKtzb+QqmbB*)b z8Dn)$E++B$+b{4YNNQVNq5~*mD*8FZ5yQl#1A*$YMaP_Z>m^~wk_W!4{rCK|>jX|h z2in_4XlUnJ0l72JWkx%09JO0pbHjxQmJGUS9y{Wyc?OrG(qOp+I1$JdRuLLlSTNa}c*zB>p>pl4kKQ z&DAX(x?hnVa@M|hG6*s)S#yC_CMX_s3Z@5ZQV}sm0_z0tB$JRiMB7V&wy(Hd5&_b$ z1Fw){MxBDpQUF}lIlXU3ogXA55*=Rz=$>A@Jl5`WgzD>-_zTRDZNkguUh|5>#nS4 z93@-$S7q}le*$t7@~+6oGF4sJXk~Ks=5r&eYm;k`q~h~bJ+flGIqBNovMY(?I+O!_ zpk9t~RKLSG;e8bBa={gaxiTMhBr3KUb_ii;JV2ayluYoayp9fej{<+xXO?Zqq9SJq`Ehk3iajdC`6z)_p96)|Yz1Hehin_xoT9BcBLi)a}I zfA|3Qc))2A`Ym)Tp*Pc`B$uT{^a&D7ly{$!hN!>c%q)nr1m=B(7;hS1tqwAU)RK!s ze{(}RylnMtY4*W(FohjO45CrXrH`MnY))RTtp+@a9Lxk(`X59TQ;p9atSiNyA#k$! zlyJwq8?-o8*7A#cf0!gs#6KWfUV4cLq{?r?kp!zo*C2Ft{zYsqD7XhV7Vd1rs_W#~ zH2^&N640G@)~9|UxSd%eRv;c6C}#wz;d<2i5LZ~7BR0Y7EbNYgNc?@w56jCvttZbM z7vB`LzZVV^-0yzO-{nb#-KMb>SoPc&Mx@YzVquuZTzi}qOTYX*T#Lt1B2X1C=aS=a z^Fy2{75)>3cpzZr1@_DzI|$Hs)iSJq37q#Kce>{PubVo*<5jsUHtz z>)UfS{6wj5z|v15vwz;F{uB=-po0cmL{cNyOWAZ>`z%9)CVw&Dul7NqQquUYBI<{PhSSo;x`*I*wY znN6J_X}6)-SG`Bk4Nf3z=_Am@H-r~~VT| z*z51S0+w$FSz&BP<{-5_)-OY#G;!==Kdamx*z>h20e{yq3%ctypoSlh{0lP|vVM3m zh#7Bh*a8agO;SasOt5Bs>W|H^V|(p@#vW+ND4Uof0rxtAMHvjp+OAr9`<`HtD=~=a zBowyOYeyUnz@}E{O-<-coyJcgMPLs*TX^3im)9P)G)_z|fRiU9hlLO*AktjVvSI5% zm(yf3A$9%|*&dB)moOWqo^DBVJg5s3p6)%tKy?<8m9OT$bsyu{vWnlXr z0}AVjwZ^b-z4Jdc3y21Q_}=rUBEo#D(Zm6w)jv2#sLbg4vtKVky1PAw4Ix2A_>hsD z#~th1d+dlyE(1g7T9%Bc&pN<|_87mYdPG;sf^q1TmlQu+6~yf8{%y-e-(g8*%8<*0 zX45B0w?yX}VuAB25a|JQv=nEjTC56$an!Cj5bUd6p|cdp0Lzl)91yVwFZO)pNm#X+ zYZN5e@V%Q3@q{ivK}2mI!K-zxo6lcCibO4rIrLkHQGlUAd@8s(8Wu)lkRda&l zrsKjw->ZQc214@asIzi&G(rCK0OArq4OuZ2km92*g>Jw6_RpQSvG-qIpnxWbjH?#~ z0*@?G1xVbH$ubX!-<~~#&3L3derb)b0-Z202vfnWo+bd-f?i%AuKn?hOq|0q6D!SW zd|3|$F-})(DS%k+o5`6rrP)brn?rb{bL~EOOcl~%z_lNt1>zpPdT^x~xMP8NzWW-{ z@dp}Llw=ZHd2Kw;OM#o}`OsglO5ooi@EeAdcW$Rx0?RPh?_b<|0%mv%c(WrrEU)PG z2qXSY5@^n(xS;E}NWvF4Nj*zx+{Tqh8`Qb?J4ryMp+F)7@e+S`YlcsU0DTZ$mQZb4 zWC&(F(lYz50xQVgX>aMW)quVqRv&pJ?|wlfGsL4@^qRNEQ(pjqgd>UQO!dNQ)rdeG z7N$aYXCC@CmAWzD{_&f05K*;f==-ZPIB)bGvmGr?dvd`T@I$hWAhQDc<^v?(6+Upu zCt*O1Iar%2OR4)u&6?gwC5Vak`R&QuZ*M~Rzeui>Z6nOk`%|`y2rta_;HZXy#|X3n zb%2`$z@;~evfmw_g`PFBru4ichwbKd2y0vP-1S0>H*I?WaP64c+JDS}_~qS~!E1V8 zxNER>NDz#IfJNRHNb!0M`)q>WhzbUxXQi-e9HrMgE-T=wEqYF&Y^k%~7-hdJObAIA zIZ?c%;I}!SZ z@zq7%F9kR2EzfKrJxnl{Ue}DvR;|n6ao5QZPwM$zH(~h24;m{YU4WQ5Rum&G0EL=rU7e|l4+In!Mxh5;;cj^S?c4( z49iNspeJ4lhD7tv+lHooY46O8*1BL)d*eNV&CMZAFJIO-1!E+58fo_f&^#t{J8C9k-11 zL~@8|YHs81qYE&0JQMj{-^TBRDH{qgy)<|!5*?w~VNC_LKtD~TWf4uK%~`(Fv0@Le z^M}k+ukKzbX8GYnZ|ENZpRDZIYDwd|GrsPPDe8f^UWw??3d6e(7gkK^xU+SyxvFML zo_w6dU%d9+)~9z#s56I8u$s9t&}}@9ti5%IB&G8;(KL)_B1dU=ra}5* zSszg%m}}dV40(m&GsSDsYl(Z(j=mjtEDzR>GrnsW-x_8g4dNmUH_V zuP06bWd$(xusis{RGwm@GeT;)fRXrAL*H=7>(*%KaFo7?8c&2v_ckuzLQP7od5OWW zkge$ffKc~n(OSeqM0BsjQ0s(jmhYL5(+0h3q~r)Km0g#tRU58zS5Flar%di}lW3Q` zw0}FnISyre{WL0&AP2ViC7S;W%eyz%OUVXgc-ix=2h6e)-`34uFBWt8oH+N4GvEgmc(;nG))!b}Z7=c_Yk%3kUIIr(0 zjb5#g_WunN{rCsPyueA0m-#djK%wV(-5?I#6S@T;w>p5z_0^FEr5f!JVHYJZ&WbtznIp1_A3UTh?m$6F) zCJ7BNZ(S-$<7+996^a(k^lsgEBH1OJVJ*uakA49Tqdm99a7lv*W>AI)G0YKcY)zG? zlKB-#(MwY+Clr~+9sgl(46j#f97~x#`LFq;k>VgvYG}nBo?#1ae2&F}ael5N*3}4S zt9^Y39~{#VWCjtE5_V=KMV?nwL|00gTU1>--4?43H{(xJK%@6D6+INHz$2WPIT)!phTc83lEjCspL~Gw4Quorg%H{5 z4(hB9YEJsMJlz8K42^J4_ z&$KS>0&*sMUW#Bs`^86S?}pli(w)L2Woe~3D>n;Q{|ig(r_=BPI^@)}v?b5Y)y$a8 zA$Oa@W_X|aSuAoNUSy3AJs(S(Y`Q`|?MYLSkbQZVd4W=JbCYZus~B1EHQOMLHhBFE zPB!lTV`i0|cnu1!@uDO7^o?fW)GRzo6JKdzLPKE4=zO6GDa_xln=cn@8{9svizh*Y z<=;MI_@?RlCAqX%^!v(y0uoXa(w!p@E;bIg%+Q}i+~oa!xWEH4oj@JQ3h_+dpXPdE zVv94!uotLm9UFhW`nliLRCND9)wAc@ETWkh~B3V+eo)Qv+sg_$%f0RDU)at`*;HNpj0oQ7*8 zB(}k^iP|Bub4VM3CHbB=-2#euOj@H7f(pDJ{R%^{YvP3Zn@GqnH zv1k?9F2xKAIx3lh(H^X9)0t?V7Sh<@2FyR0k=Df$@%47dx`M{qs;sYJkIkI0SkKh@ z#H~;O)h)9f>0p2gp9xvkez%hrXbK+`%jmr|c$6Dv(R=Z;4o=ek{I&b<$s;94n3=%a zQmmD7|7->F3Z1otjQTbwcJP!vj{C7gp(z! zdt*DnJQqGTz}F_1k;ZDIe@=isqx3|qN3*k|Ytw2)Vl^J|T1sDz=kxPOv;7a>X#Ft* zao^%kEF}<%8TRb!q)BG3Md`OE(z+i1wQhW^%9m=h$YEPW;y3;glOA7cdVb#)jfcno z!qI0qBAFg*@yucW%)w9QhfHa%0YX&9%*fzy8Wqg+W;mLQOg$Bq#MlvM{~+P9mqjHgB-)G)_J+A;{LYP*CbOgWd;eq1s{<(%mz4hjsGE)&u4|cV)njRh*6Udwdf_UmCvC)27jfj zPt!R1m>b_S8%};oKoo5$*QO3kSCJ+0JG9VPA>g030U~9Hr5lC3u_4Phru)for+;Rd zI5mB*iFQZ@nRAQ+n$ZvDf*Msu7+*9aacV0BmJ@4lCT1^m&&mI!JmcEnkoyXX&~_V# zVfGxK@bla}VH{64EHoLUR@fd)ZA`7_H)OF`OKOf@z9tb)5aW{ zxmkyAmhALZ6(Y{RvE~{`VZZGC-zAFRfC|`8|2gK5Q1{{$pW~*|KB-Fjt3CnCnhQMC zd8)&>-S^gp!qB$!^&3=3ITlN^(I7Oi5SY%QHR#KK-J&dt2oG_rHp#IGD8 zvupUu`>S3*Fx=+-UJsNK!CLaP9+#A4dXgHKWiGu-=P{YaR(<|M2iYp0=@IrmL99&m z54hqQI2kIrM&UOXFr-V^3o!Kb4efP)=P@XX5+2Seoe6MTO|VvenXQMHS{t&LSAl2^ zo;X|X$Tl0mM#P+@IJ6Q@XA>UH| zLbK5sEXXbgs#0+cK?_csF!os;Ji%;iQ{~v8%Hvs2K$7E00!!_;Zp&H!6f79sDx6M| zzbW}@wmQtbRW|G6>23oXhz_p~wkCc@4$EmT)nNROMU`{CUseUj*x_PSCZzzC7uEjq zv}wGW#iKCTbTzzc&C)42CKz_iEf1a>dV$?oQo52{PP+Id;zCxX?#}ivJ%f|(Mhrx_ z5n>jG?N^h5a&0>C-2P83ls^HvX|l4U?LQ5e?RT(%Tf%-gM8KT7gf}SG?#^SZ`B4JT z)P(;IQ^Al~%8hB?R1FF*p{c%hrb8AyG**p`9}r#fp`qHMw?@D&UN(3+?(;=HKa0{x ziD)l*CmzMSPgS!xUt_#e9H|A{vd$Xp-cH=#+9t-QFPZs(%8yz_1;+}?a&0VRWqi1~ zS76*g9zLWEJspNFQ`gwOaH1s-O~hOD+re?u#!q1?Em8<&DYI`)AY~WdsHLM=vL-tvSB&m_u;Vd6X+<<}RJl|K!fR4)j*YEPr``VI-VcHPr6hqNF7J z_tV(nyUI!=%8oersS(cX(c&eP>}zP9%1*^JRZTvXx;%#9ggL#`JjG6O zcT2}#la(kwJCX(HDod(lC$lsxd;=ozKg|9~DTozm$b-xJ-GZH7GAQ{UHr-2dDt{ma%v zuZ`C9N9rD;^)~vSP8`d=&9%r+bn<)Efz}!)vtzw4FFs+!4bBZ+VkGcM-{kYHe4*eD z(`6qE{7?1N+(R^vVUU-T?^=a}g;O%UfJ-3zyr(Z1q80LLT88Eo!n+;BLS_Zi?-`@mZR%bt5^r&?6{vQl!hB-C~%v(x~!>>czeGHw#ZNc==t2(>6<| zbL5zQ{}5(8KYjjJ6o-Z7E!b(cA}+sQw(BprX@;~kd0iE$3#RK=d%V;*vG%F_G4WSL zh{sefp8)24K75f3dsjggp);L}h;BWh``uPPZ|V9tgn_i{8@BOwwTLyS2cma&`l1n{h^}IuXe5TxMYV|KYMzC0F$@2SZp*p z`rwsuQS4clZmu@w^GMd+EPV+7$hQ?E5@vMr3%ui`bv#S8kfwZ_noB z;B&tkXI?&WjbCt1vY6Xgkh@x2%y2=?z_s9nyTpk?SzRVuZVOinu9PdzFAJLmY7?Gk zoVb}8^0G*MjjUdg6JJuC*~69cvN-Q0nA&|Wzq?4sSNra7`QqYt%AtQX1YxZ@Jn}Cc z^1?hjTuU0vZV&%PtNwOL_+Qp>)+3N^^Zx?rG&35ko0l7}{C|)R*ZIxdng@M9dM3E< z?Ot?WLYlHjw#jf9t^s?N}VMp(v`hv47yvV^dhFka8OaJC0R1WIJ6`N1K24oQ#SQ5*ovd zkDq2{Ki%_mVe{RVC&&>~iJo{hc~tG2rTTR4{lV`)CXpZ6RNLQ(8m$?aL!oS9pI63P zBy^l_HX&Z8u#TeKxc4GS>a!K&w;C0#m#gOC53Y(?Gm4Y3`N%V(28Dfc$Ic=UoX1+V zduE0iqY@9<83Clhb2C-u8&Ewpq1x2-3$|L?@M~#3i)r3PgvE0!k~m88lR*c-^E4lm zgw7w|HXs$dZ`d9~(C>5hy1ZeJ5ypO6J}IOdVC9bc>}FRXR3F_(Xxgx-`>-OzRnmOU z|GG-h8@(R%d1iKo>B;kB3FIp%Io`7xtH3!lCV%G7(@9(K*=qk}H@ zZPlc%pX1cyIsd{$?xBS3FGoOV^y_&V^ko2bS8>%sLNMyQLUB=S|Iw*e>@?(5Zl}b+ z6+z66D)_hOSmF#S__zuWl0v2bkVX+8aoY|4|hJ(9jSxv-@O zBVh6NuGb<#*9$S}nReoh7-EElc0-Q4o8o*`Mo6V34dUz;7=(Db$4XAYW4mVKR5iXE z701;g>p9D-p&`^K394U+8Q$Bu@=uFfx(kaj#s$S@Ov2~q6&|CZ5h-ST+PDntu!&ZJ zp8q3m9A>mTb^;#uIGOEd`2XW*nrvj`h$LW`)n2g=zbe1y;E07Gq6e(EiC~UN@ z4V0NogGHX(Nzq?)V=)nHCG8C}ixI2LQ0cFAo7CWr8KkagF0u_#_MhuW>*SSgCvt@f zZMc15GpJ_ZfPim-F$%L1SYKwF;;OqZnnDvEjvul-AF)Y9)PP5~uVLc=hed|=(Ij`jm zxYjVKS>l#T*vY|gp*?B3?R!j9K|DL||JXVog>Jam1f8iFE#u0WVZafpmW$^ncH)w6t)+E<@TTEp7 zoYq?BcvwWoi4ZZB1*5c2K%XOKKm{EV+8!AT>YNlj06z5G92yn&8a z@a1kP!yidUOdWxqic2G7QU&15BY6Ll-?_9jj0kK`|uhAhY#u%3NtVCN>Q)o%s+%;vr*E zI_Bp^*Gxpy1R$rQW|Kde88$99VlC!cuyA{3FAoRV8e*yoE>MQYijSEG*Z=v4U3x(# zo)xBzmrkg^rQSk!D??E*q@w;xMR-6mWH&DYovxb zclgNMq>fstdga-Yg!!OQ6#oc456@JCG$xuyj&gDc-aA^<6J}(|^Q7>6E>>>Y|nVqLT;KAb|al_hon7^IDz3FI5B!0OqvuhLGUx~bfLnuRDA?1bs| zxL0Uw&QculzVa7K^0QZqS7jNvvgViH%OCR?q6L0FKz`DwH7X7~mPMFm@9?P-YZ1^U zx50P>e$*=n-fm+LALVZ0t|ZX55@c6WGXAh9esBe0nlz9+fcC7*T#M`P)DS+ii&9Sm zR3_8YN*P>ro3D3l$vaOZN1}>Vj$0IGsSg(L9B-IlZY07p#}c)HOuGU_?j~vRQ7s!G zxd}e}3PSOFK3AjB95-}x<8-bh-Zt#aM-?3^DrA7Cu6w@_U%KYhcTK5%jX*N_Gs2DG z;dRv%5EpuG&axz$*pQZdk4N74?6}*y(%2ni-`%ApS-n)JVVygy1Mh1Jxs+k42L|X4 z;cV}=&bu1_oO-9c87sZLSL%e?Oa#yJTUOd^Uft6lJ8}EA%Z=k#G9L*HhJP)ZlQ#HO z_1@BOcwzryDUeO@<7{DzkVG}+5JVA5iGp{X#_kn-A>97@Ov=1iPaF?80%Pt6re6B{m+SaU z4>hi?WZf_azM{p208TDvaaq5nl&()|%!XIBac-4d=U#!=+1Hwk>F@R9Mf`TBmitZ6 z;Dg%|IDX1k?k5H*eC2tm?Am816Y(Omw-2FWm79}|+8|2#;;X9l7{zR@dqNvgZD(qq zj9y{+X!~1mjj_a40jkNo=QM_Wq}r}y}}2rSZE6keY*7fi}H2jI^P zeLVN~T1(##W1^|boXm7L`V!xF)iBqKsa{q67~Ahb2&7XcmyL>2&QN`}(Z|hMl$&ks z76*OnvqF*4d{((hiI5HEl#CVV@ndm#3&j`y>Ukp#u488hht$6xF5Ruk=7`~xv95wY`EwC%L%R25;(5%Um2VmOH&FFy-M6; z=X8Fu%^N@l);JqE#g|myzC8Q|IZ~Mu zDB#@oom(k!Z(U61gZiEX{G%;x{d2SZd+^XVowRtL1acs$%&_QD=gTWkyI1!6IaAsp zs^ze=88_DzT5YH!hmYSFi}{rruUmCtHWixoau1Y)24rv4J!kD4ziLPz zwT6Subut2TH;U(ud&_}bTFxhApa5i2sHtsYcIfJ8U*^E|`6vk7@u zS>qonB>n(($6+yDK0i~Su*GZ6MI_VE#Ka{ol}@Xz)JuLTKAbO7g105dB2D{wZI!Xi z+(#r-K)l`dWj;D`-UyJa3W_W`E%-jT?;$L&HdvkF`Ti)BbM;(23XVQdUOf@bK|C5S zk|&&;iDu6D?ZETNz!zCdY$!}P$^=JEwc||~OUB!qLh29{KIL)sV!neH$ z%w~d`fJ3*j)jv&#+ceZ@+DR(|iog&hy2>s5;Htz~w+&vSZEvr8!!R)bUa>(|c$f4- z@)#f0CBELqrX@!<|A^p(D&VA~dfii?nmrEl;Ds^cPwh30A3nj0x0p#JLJD*uI!ep+ zgx8k7*LeCUSg2ADK4}pNc;|1OmcNBA=;Y4cj<$IxP>@stt-C-KMj$Al?!kl@M#1-! zPF&qk`GdX^{O}kmAmt1JY=2(}?-CcKfz`>V3iUGwg&_;Ye1HJ_x@&>aT>hJboAmB% zXP}sme!FR3r$P$Q2u0OY_}k$*zXFo4lC8Xv>5qAmX0#oyw%jn0Kxjt;g5+DJ$){Gn z0nS&WPy{6H8LI+HN@R}R5PO5Gr>L1JyH%&Oozt{*^ET-vB3>Z-tU`f>4kXy4FcN03 zGy?IcyNk_T92kjKt@WEvgVI6^N3|Iqv!S3Oz#h+EbR@)$tiW*h-sz%W^(s2iv;|YI zOf?D3-U(FM|4lfPT-rSeN&UG!e@tH;?`Aw1Z;I!1saIxh@jR$@2ot|9C7I~68;Rv(^QjmYf$3s}4$o$kxxArBW7C}xVtw&^KPPKUlQWh-2(bR(5& zIApFuReA5#9&B9f(JciM8niR{-@(|J z@-6M+7PL4Q3PDsv?ia;hpHq`LU~fn8e%k}j{R&^BTNzJOeIFA?Tc)_3wbgw-koo+&p!jTPO*GxhCIJx9QpwhTi316Mj z-mS=c2yOFw*Ri5U7>gRf`Ij&8yiaV;A9(x4yK6{sgG9~^TReqC! zjkP9jCiFZbI*;O^)dY)z})1>SA+&r zyrq7Wn=x9sMo^6JzN@rJ)+&E>(H>mdU)wH}+lPE~mu!Jv)vu31oqv3{CbCsZZRG!y z(^~L3Q7CCC%v3ekEEzTw*;wY*wuXZ=HJe8bpr)&dmAwcKyu2p?eA{qSj`qMY2vW?f zT4j!lD z(?zd04^n{89E2|(XSW2CBLZiiAcT7!YW#iRj_tGnGu7$$F+@(iAV>*! z(-9c9Px>3kM!%L=hqNX!u~Cqq4nlwsb^i&(jD=My+!hD=AnL#wQt9eRM5h8Hvt2t= zv9HfXm|u>h!H3}WbL=Ty2QOU^scGn6xXyBIt&2Gt+#f=s2zH&)FdTJ&ejkFB)q{F> zJ6sFogzn14*0(7_LQLXv04atbbX|(~iLrqs##4O(5P033_`cP9ANck65fja-b00+R zjhqH(cA+#>W4Y5Sc!i4Ub>Fk6CQYXc}mW~Wcx>xKGi@{Z@GmYoW+ ztq3c;GL47~1>Rn5UM&K8v5)}a0nt91|N5N;aJ-06rTwFqP}c1-H`VqMDMvGP?J6O9&ZB7jg{+6UVDh{YkVUjoHvj^j(P3bK|csK6Rx(&7Ojivk+uf zbo~U(Qr@wh&|$O&I03V!dw@t-^92AH`vin`Lz1C|cQYa9@vv(0yejK6c@L%shx1}O zFo}PtKW9-lB&&9mMwC>LI#LSG7}Zv@=d?74;jhqE`C5iNKfWcna9~{^tTGe-Y2Mg| z=G4e6hd;FiHeaV=+$oYae{bxc%rd0Fj(bAFEua|&_Nfoz-D`GUfC&<2Ad$NRBtS!h z<=2E*(}@A|P>!fZ>(mXX-5C9PG|BIok!2orvl(Uh47Yu6UysS|t<{DGnm zs-^n3Wc14K$+CXT)T$?QGvq>eoE!X_x%&7rYNx-MN zkar#`@3OMUMoR1yU^x&`mi4md2Gflrj5Bo6g5*|y6N)Z-bQ>czwHouaq~*AQ?l9-7 z2nNji3`Kc>y2R_Rc>Om=9=-d-sCsm`x+^?DJ$U6neb%eqef!5f`~KHroURoXw#dRo z%4Nh0BGHm)#ZG~{qyojSh2c6~802eP!1>E{NZU)gZWxTUULmrrrF#-96(DZhDq-%e z4Hg#vxj%4H%a;PM;NMsh-{|N=i&hs)M!_T%ei0SnBhWQL6Kd+JwjxfyV@s5z_b`)q zr=LS;3NS7UmFXYRh;Xx0(calz_l_@roRr#G#5fz~a!N3S zc>%~2b(m%$g&5fg?Hal%cI!Ir?W@tVw^Vnmi60yAJIejc-RuZE<#)PGaP20@N(8~H zoi!`G9(@YrX!+nw+MZU1d~v->C06=2%L9+FeND_;zc=?&yO9|9gd%u$RybU5%LNNd z!DpSG<(Lk>Q5CoDSiT;8$$?I-nxWnlBz?}Ie9k#qnh{#gDt!4T3ey&y*JZ%JmcKJP z5^z#WEcqTHjr~YjP0~^3DQY>KS*&NJT8K!7MHzqt_ zCv1<2T?rr}x{Xj7_)Qa4ha05L)X}#-U60G&JQn{Cp3L?oXm5nhzVlY~y)y+A!@0f5 zU1yB3SvP*MqiKSzsoF*tm2{iF8xzklkvE+?avwXE{ahGPA?zE|cP;2Y+rVE5lp|!< z>hk7_0981;Y|nv~%e*&sz8qqiKy9=CM9Df>e5}fEh(3SI4!Aylsrhwy2$XjVT5#c6Ttoa`KwU!LlJj0t~EszKGh-%sqat$hA8kaQ83d?~(}v3j8e zjc*A!n&6rns~ZrYHm8ccJO(u-VQ}w>ZI9p=ei6P~N7iw{J5J3nx2#h}T$QV{Q>wTC zl2R(>YSm@Mii&kPEcP>9&R|W=+FRLpL}HB!Yd55mV91n&b<%f3JT+m6=>yBXn&izN zJ0EQ*olcjYXgObX{yA=xo&6aUv0B)u3FS;@xtWU8hJicM@@;#Wq#GEZ}PNEygJ78++A%6DY z*8vggBJ@35Wae$@1)G>WnT%6Z!ywKU|K_6n*g#}g&ydpH$Ha&&ZO7hy#E>}3SUJDf zbi@R7c{OCxLKM<|<~;0mv1I4rQqw()r{zi{!vw?>{p$@FB6^ROAk6*7)kB^rAO9dA z@QdkJ4nd>s$N-$NWs;yJ7*LChGK&7XRzpEWqz+l(U@k48IoFsaGvF_C4HV+z4)4d% z6hpm|;Cu_5*))2t(X95wh>P-3Yx0Cyfw+K(r8?YY8(+*2mu0e`B2>fD;d_-D&8(G91D0&wCzIxCex}U{HiK;F(Zo{> zl%HdWWcNMry8Uwfeoke`CbyAU+cgn0kz;p?(`z~QKd8*bU^Ev7~ghH1_pl2bH1ZG1XUVF=iCl(oT# zwC&c6sjlERnAXIpCGBU+sGBxFUEH!}OtdRtPv}EBT)o+FRc&SkrvG>Xuu`{94b6e= zNBc}p9O2qm6v%@$D*3a5p@VJRocKrcV&+6;{;m4l^9ELw1lRE?;yw$FtJZ%lCXHu` zm#9fSSWsxXM6yw^>;ZV}jCH8_>Rgi_ssk9D8*s|h1^8AgrW;=txTPskk5BdUO420 zX3nX?bxE_P`vELcLVeD6*5MokS~((3Sxw4Wciht3r0Ki40?C>P>{KCjG2qO(S0AIA z?^K%W_8!Ls7arVkJ0MEY|185~@u0`&B$#Bkn6=(Gg2R}^$J^}+dnQTxJ7LBRD;}E> z!l+(|9f{He7vB5{mp@eBN&4rr^1l4a9f_prb2HbM^kfO@$b><-61TBmMWac!#N6Q9@@b<0M`N_i=pJ-&r3%=a@y^SF%lVTAxY2}?u=EjZFhqsF?b8rMtnqvxt#N6I0;PvRJZei=!y*|84ioVyS>A;|CFX`JW|H|gy4vQ@3$glW1An!cTc9ZXjh!O{TO*0)08 zwK@ht>eMZ z6?*ow!1>eguq_=skh+0lby2W#-FN8)@B|Oh}Co2#%JFplDQ}7{szM3TBD)ee|c4E-HTS&@~~O&!DH3N4H%cF`{5Ef=B9LTTl$jM@0h-JcDq)8m+3W90=J5J z1@(UaWgn*fI*wTBU@MZH&Q1vzo2*0og_tUB@fetpW&z7b-HXDCb7}C<(ISRg_!e3o zi+#|f(Pg>5>U{oQ-sBmrjAiM$H?JiO1+9rt$6!H3-8{NmRU$`dK^o@qQx@d;&a(19 zjGb3h6My)pCyj(q5+L*zdZ>mfAVTOJLI*_*9YG)z>1gO3=^YJ76;KpJifHJdpcDlJ z)POV_QY_f=TmBb&&h9yTvG+6IyP0|CJB&ro3gba|ioUNZkob~(Q44}CkhW)QlfMw%9Fr@<0~Q!Jn-qKJZ* zt*j#L2_!65g>^}vpM6)!zQsphA*ZQF$i{8SG6$^lD>u~8pmeGI0j7CJK--^nhgFR*Cdy25tuU&J}f+%=^hDK0oVNASzm*|8-rW?D+S&7a#PxrBuTC3g_R0Cpz2O5{AZkUWww5WU+tDpDY==9m}IMWZDj zeb^F6Zly5QQzpwqAe9^^o(>lh$ZK5wHy97*DZnBVFh7b~jw>poR0e5eC@SZEFT&d|aI6p*of+fSmBP$I z@LJ$dC>jr}U^oFbv}A57$(LvA$_0S~NW!+J(3$d`yukQ_x|?r!^kqigHem<+1|&`; zhfkoeuJec^gW6Lrfr}C+*5N{5%hUB7qp??41exiO*l!R$ooRk!sR@nQCmNi!D@6Z`6$lN797;qr98W%1H{rD`e#opDq!d0 z4;-sk1_QPt$r0%Tawmc1GV>Qt&Cq=ucMWOIc^(Pb%*k1W^D-OIp-Qv-`#?c;zR4k{ z$(Q4n(YGC6d?wnF6sS1;L%^os?U}r7rWW?8PdrIM_S=`0=wQt{E+f;fH3X>h%S5kr ztEAYn^sR=!sDYpF&%2*a`m&g^9^*a z4FuxCtwJB)e?4tr41EBpB+P_H2Y+pUp2Pe6Hf+{sK%A{2Az1z6evXIT$Cs@GW(+yK z6)cCn9J22t6cUEKQ^3Q{!P18}<01cdOdon|)@)@WJ{nwM7StIarX-ogn>8`K5(2zS zQW~GpoivDa9&H{T7VM)NMXrl2vlR!7f*Qs~eqR!h{RBZECSMYOxOVf9anNIyl8QOq zb3UNqd%~4a4A`Ed`2gIj-x&bvbT!5xyam_^AQHVH(2nh~r#teiXxg2i-#dUi)hAGY zuEr?j8*}1j7I^Of(ETW$;uWN=BGn~Tb%~RS+XCAEkSxjRvD^xxZ&&9Y%AA==ORyJxmx`VzNv~2t3{HyFW-6Nji%)8} z+_QO2qcO4U17kcrvP=?uBdK$R1f9-pnO4CusYt+sunN znxV?167!V!7|C0JBv#$v&O;LFQ%uCmToLGEO~p%0U}c4nCX21$rrVek>o{+5jP?<0 z!ePK?0QH(uBSL^&E#T=lLs%MNYh-Fy0MOo?n%$8oq*z8{Jm*~D5YY>)Mo@i0ks6{> z_OP$YRMdpOfVF8JSGhFj?r5Dh)QmO&&3{oGz7L+icjlrWYs1Gib`qHNNbU(jqIX=a zT=fidmI9ZbfNR9G9G$&vQ$$(p-yyTYGe976#L#S=p|<0|_H;qPa$Ik-RY`z`+gjk^ z7X8cgCPBUa*D)jMZzb%kP=lCMNtV=)wTU* z$f4%LNl@&=8s!=0du=N=9Lb%3uV}nJ&2uQ}U}!!@K!irbV7`66+-+K!8oT1aDo)37 zZn9?&ah@-U!#BzsuWO%FO+Y=no<|yW9a){42hERIn2w?Er0H>!stB!PpACgL2~d-d zfJKSbvzMTT!_STcSQCX9>m@&9zl2p4;eVc9W^{TnS)Mb==DLO9SFDKy0oI-75c*9- z^MT3?&6i})YVb-^fc6wRWHMz3a->K-XY45K<+HmOm^5cYrt9rDO-@YDO7I5@m=N>f zFyoIA)AJ*uT%+2bc0tK6oOC>;n1&PcDF}NS;^_urCCp+D03o>_&t{&l^f68dH-!L9 znco$@(8bj2PA}Y&9~A@dccSb`f`mFj=WwYfvgd<9r_#JG!uL;ka>@6Hm~WP-h)u!- z0H&C!*6lHMQc0lZJxSAMlRkp_Stp}8k^n{hijgDkoueLml0vFD{tD#$a0B0lmnsp$ zWl_W@Lcg%p0-<-A+~d-8al-XDbOa9h4G~V~m$GSadxaiY7*@)}EC9)r0Kbe`v4J{>_^g?2xZhZm zKzXz|53k)WUE#Skv@JtHuw2A>Jnp>%!X=!myTK#zC{FY=TenNtx@{WHK_MaVjJoHV zyQPC0%q}&%1%SA)|P zCzltF{RV!KA^;yljd-_IU|tQ)rLJD+8B=R^_R1Hd&vF1RVE8d1B)>^8x*}uX9*0+? z!sCY-r(*p;ThQ(bL_c-$0GI=!4jUp3*UXf zfO1+9!sa14pWry^1)44K$S36(Y+D&Gi`p$;k2bjaija0`z0k0XV_5^_2;`Ymmi#ew zuHUE!M*B_U1q0;4DY%$>{2o>xkbu4=gK^^ z)^=kRcVJZz3+kg*#CmDnZ43@m1gSVw7!PH-mKDCcCHo0< zy$qxj#0W3dr1A3niz(L6sM^tDed%`d&o7aRSk~FsHw*I;vYM$=B&Rcn)tAz4!s8a4 zol?podH4EQ*M9j6#@$rbW*yC~4=Tg=_mOK}YC?YpYRI-Xr|D~5d*9P=QCnEgZ`MbJ9e)A@o}Uvn*s12} zUod$em_Jteu9DT2>-ME}Nhd^nr4ccac&97w#=CwibhU{6`?iSn&gR2={DPVi@ziL6 z$Dg&kP-A|I)%W$zNS`-y`cI(y39aiHrQ7m+`<$PQ&1$o*n!1``n~dO=o^)0zCSI_Yx9j0nY)t-YyXf7AH>>+Ih!47|dHE{J z!p^XTFKc4rPewfbKBrbl*zH$9m)^I+Kc! zu?sW*4;Xpj8k}87Kv)Hb#_GKlX(=3dxxKS%c2D@Za55HsfW;zN(u6WT{QfiN3j#4` zYq`>fAdg}WyiRsY3PmYaf5k06Lrn?Lq-EXd960+J9Hp#_nWrT!jTYm66)*#IV92k` zS?Dilcm$3)(pi?slx<|ZxEXr;FAIh8vX@B+JNMo~3yvkE zF$)RgO0=OF_ah_5K4c| z-Zid?b?!j__Te~Ae)9`_#5AY6oRd0~Is)H!BO>Q5o=Ju=bnIYCliSut(0jQ7uSv7u!!v;?)Yo!QJ&DYa z{(15SC?zR|IpmSNC&$MnEfr^&x`NY*g}&%iwhK(~~5_CRl%yIANCPi%)6SA)&8P#Kbf6!HCI5 zMCu7;`4_uRFd3WcavD>c^im! z>a`#C)P@T2r1bJxD)M{BA@;VRXk01&>3wcXMdEwHE?eAaWU~oQW+X~Qb$9gOhT5w% z-5@0K!me7gFhZVVT6@AFkO=a31G!I>=W@sVaKBla<^HE3Eo)_$J67F^l{&jrg)z)- zsg}F^!cA(|o#`IvO1sa+suv;Z_Pjod@`O1Hgt{U&$H3R(SIPL=ww!fM{L{dk1TUAy zrbU?YvB~Y8^J+_t7HG{eZcd^(LpFgCaW9)Y1Yx%Bs`Wwq(eO+MlR6#qfb7$!@oRFd zV*9nD6y(yItdOwVt2v{JcM`-bI+K6ObL|z_bFK+|wehyhtUTlyq;<3+gmSE2`Yt1O zWO#5SiG4%&RygFSoxUAKlLL1%Qt$@%!n2!FM#!=N-sJYEJk|rSNO&}ru|~m zjA5d3>1|G(c9tnd#6xi2<+GvM%^zqAgV>(d;rX_bQ0lRSjc-SvhmcXfcygMO#gUW! zCko%?9$y2SgZVPMT*vUx+hD0r#d*|aQW3Y`nwL}Y^-lkHRTdxwjc;K-ISrdduDydm zCCLidxpclB4Sj1rhJG~1@L*aA(KeDU8o?L4e`*sSZA~_j3L{ z+@y^^tyXY(!s1a?sII7xtSPk7{{wnje+mY_O8}`Hx809GZdHAB;Dtm2YMEHLS5n+c zZfP{Rqdn;=BO9>%+c?#frn4!gW-S9vN{WogcbU8AAEdz3wHQ>-7UZD0HGEcEtZMy~ z*m52C^=kBC}=Q4AIhqju<+8xvm;_d9Y8c z9>j9k*c8Hy*_$$n%l(LslgPcwe@5%AFGK6=YG5y-ir2vPLV;No=J9I9e9o7!{CE>` zS8|iZz|ut}|6KO3|3rJW(>g{!uFGBgAYNYjFBShAzE*Fs;P24h>_4auWiJstj?s_&kb z7{}v&i^+k{5&gEAybA#vY_LOtq)nM53w&5(qbjOfA-9+xlj5ZY_*Fe{n*tzG&DsYh z0eR}tC(bqQ*@PvvI1CB7W`9{}*VfUG^6jV3T$nE#2~BQfy%Y}CL0CfRDbZJ;JY)^f z2=weSL1NZar4x8`4=at&6TEO{C&oD4-8Ca%NW zUrxmS_F_(&BV90zS1B+@`G6$$(>W2^5t`75>+j9@7$8h6HM;$R^{U zO_@-w1&xD|JOd!doRklj&~E4g2Iny;{-@fFxt5iU>rJkmdS$5qb2i%nax>GcuyJr| zu9NfmabD9eFgL{t=Y=|_+>tAyb^OcsEHDLmtH+RSoLsL%z9G3#aLU|;l)sXhzt*kr znj=%AH^=J0dlsbI9Fe(H?x1R%7|{&aY+Sb}V6Iz=G@%-5OC`Q7vS6C>oRWY_{@9XyfFdgsL8jfHUFql79WgqVxm_w(A#fL2_QB{!(J!0e+x6jz2NtNDIP zvug8mATRlU8q1%aICEbmj&~nxZ*21$OzD{SXzecOI{`@v%0~rQujmX@AXTQY+?ZbF zMlXMx38h>r6lb=S00^IPN&~wbYWon&_aGIrt)Dv4+QiKaZTbVB>w<&L@Wz4oRh^qG z@*7o<Wj5!Kbi{@bG=$I|*Z4fP9AG6vjq{Pv&)+ONR#B z2qf2WZtC>Lg5xJmEQnmp^9o%i+H(PsUIEP4PDe)6tAJ-S7rsOI(6z;`%sKXwdi!9j zXn>i@;;de!P{5@y3*wW?;JH-2H&U0`nG(#Wtw#W4$Q&Ospos4dNEsW2Egx7+0=UAV zm@oK)^es0Es1ggWdad{ZfQ6%(>!P&H@3AV)0_Ns7?pBKOUaAv~g`*_#v3!uvoq(yF z4ztqxleyP58^oEyA)UaVs(R zagC?m1B2zP8g?n15O8Cx4v6u+(cfR3JsW9QDeq)vpq(xrz8`Z|8D{5&`O75SCJEsu z=f?=#m?z(s=!|cg*R&qu@2}vw4c2`r<_pFJha0d0`OcJ{mrl2l-05x|baGYmOMi{;u5!fyf_}Nzi2cuaC_A>5gbhzp zyL6(?`=BiohJkobz=SOBTA$#_g#6!>8}@PFmmD4Hb8OC+IvOG@@O_rZF5L7IH&PqU zCdc!~FHKFm^#>OKr-HeGdulzA(IX(99GJjwsDS)kq3VY7ctGF}3s1of=)O@J6=(sY zR)=yz;HvcB5XQ)Xj4!1rGz+PAzts(plB2ab6fi%V1S!m#o(TZ#@foa+8KD*3W#d&e zQvMGCVaaD+2Xp{D=@qgfq}j{EmoJQduHnU}Fo!{C93#%)l3Z8qMiZbIEd zdt}FDsG~uFnFfdOB*v2$FLrfnpX9@ zcR5|=qr%2^|{OZth?-T|?=|5* zdX}HQ51}9fKFeZW?9yMP#3Pv4 zBW4@9MrAxmY$JouM;t}Fb&dn6CIqYVAK|0jQXc5<@3tIVJe4+mONvoUb@uWYeF-+4 zoh*xQzKouF696q$C|$!s_0}Gr`OU1+KVe4BWhYMRVZ+MGrQD>kk8CE{qfj)@u%F`-3qAYvz&Ih8g}9c@n&F1D%1oE9&zop+6QD! zISuIzVr#7GrMw*yF=6D;cfB!k1#w+ z1Jh|Nh|ZwOnDS{jH7;Z(&xqLBQh2Z?N5*tujMS$sp;?bklBStQO_Pjz1$x} zMo~c8wbm1BP25zU;tps?@JkEYm__jE-Z^m8g=YqxGnsWubJ#{6P22w*pPaYmO(90Q z@qpRsr#J-%Z`?}LwWin*SL>`!oSsL8+_|2gX>M1(UF&e`Hw z*@0X)3cs)j{C0`9Fk#%ta?R#X_dBzV_Rf^#SzEErH^wA5;orvZvu>R%i*{(F)?1i3 z5iTo!qCwTUQVW9KfEtOgMP!aw;omZ`!}zsZh>_^-&m)5>9H~Jl=ikl__p1-?Kb=kCIXm*TW8&SY&*L%H%PI;0&)}`fydCqt0PX> zdsj@5L#Zw43`piWhq1jpKfCh^Kf>-g0-k?<3(pV=ss>FhYNom zC@S%kF1xPGCc=QxXNoMMMTP!H?asTqDjb{x^nVmSo zzWd#sS)5ptU24{y`*FsmTVLl3T!iVpHMm7O_;o(CLTM*N5!BMTP`B~P$9TsEzr$#- zMVy=DWa)Hhah2TP^U{3_w>hAZfLCvw)}C&e;a4vJ`*Vrj&!(wAgZHh7d%(45@a-*(p8#%%N4~%dQ0N;jod5^vy-jDDorf*lc1OclLOF-+ z*W`Hvwj+k({vM&`==)pdGolV__gU_-Aj;0dper)0x`OK2EVq9`q`%r8=k1&LQ*~Dq zg*x}nS|yIE{dBqrU74P|L-}5%_V)a>TjvzT6!&08dO}Gv*KpuLBylw&fB*W6K$g8* zo1IP$U;RDxv9T{Ez25$MGWgl;34-$CXwC%j$A-5KyXAu3fAARk8$Kv20DGB%O6yRM z`09V#ofU0TRecIdy$IBz{`$HD)KkO#kcUaO(C6s68ymf~Ho**^cfKzo@w6Ab*1_ec z>^l#E=R6v>sPZj%5DWU~vF+dOYYbW_bPV;i?j8L6e@*fF5@x}06zxl?F4SaD)`EWB zb&^4K7Jo^={xZ)r_QH)`GUCMOZ@IJziDZr%70%e8QBU=M$mdRNS#8UhG`|jh82_*A z#fK|@gm1p!{g!y`?5#%c^$dJ)UEMl1`-@ced)s^oFAfQsB3AXweKv`Y`ufnRU7V4T zQPzh~=C@xt5$J>;;2v<9K~p2)q1EMwX_A^MiXqF#z0a|)DO#wG<7Eo!mvuo$-K{$O zLEUg+hj^X!uI;cG=(eJ2*9HiZUHs+E4^MHh-6b&tv7t`{Y@WPq4U`FugYkfQ9*uVg z*onK2lBB41-Vy26syTvMQERt8!_B;+OFE`z>+)pTaa~fRv2CD1+i1QZg3ACzVAx*P z^vs+C4LuhY&LZLi~5zH2jNnlLEvDks(VkY}F(zj(3pUKXPQ})%1G9wjt zMDztTB@7J(f=!M7(y<$B^EhUMTtR~)8wm1JYM{Z#xN=E#r_ z2=AX2+otx_M)-$s9UxdCD&S^Yx0e(X`uOiJD;rWf0<({jsu;0nGMNN31n0CW;e6IYPR ziNxuT9!>7z@->l!5yd}m<2kM$dr7dck%$8LhyBN&+LBJ5{9zdTo_5-#L+WK32-Z(B zx-s!`2;dc*Kc=LQdhrbvGk+(4ypOy_rQT5ZtwqRYDM1@UOCmiH56r0Mw)sCuHC_4O z4VGuRT6TYv+SE1JNFzsVnovW~T;pqG{=leYW?b+hz9v_ZM&-QBf24OTDvwU_Q#Y4GL_i7OY#2^1#%RU1$OUe{TiKCcworaP z(6YC8U608a<~YMKQDAD?A%Q>hwxxPL8B_ z!^Mv^!R)RDnR(~V8+uojym(4_tFsy0=b;x5xVsX|!a4~?0oqjy_;FNk1KMV?!U3gW|R2C5MbrB=(=)v-y@|`pgV~7RvO~5pK&(s ze#BkylV4?q-^`$hSL7_Whg-?UB!m-)U1JUZ{`0kxI!NRzDbJs0&A==__usYEBs;$q ztCIPtG#QzuPH1^tL#re-Y2&=0 z2<KJ1?ja)LCgdKFNsWH`HKbJ-bhxEgDN2vB$ERw z>32mcMk?H35ZB@91G9z~`?EK`xhF$keBE>Tbcj^vuzK5@C=EDN3SGk3;{r@B*EPQ6T(Z}Aqp zNwLWdA-cs!mQV7Ar9t&$ClqPj!@z~ZqD!uI>yN28#MTfO{+J+=6OjA#uL2vG24=-A zm|B^^A#Uv?_P$+KJ;H3EUQ45Llvs+WuWcIoXrk`1iPoiOyYb*YEjU z;tIU%w4^|=iYUH^zA$(MxvE16jQuidQ&MJxaeO6y6G`kzzu)cN-2 zOCj8gMU4^l_j8<_=YK+2FSW2dub#as+h8u)Gz39Oc)3`YRx`8mbn+-55w8If>47Q# z5hq?HBxnQCbLmmTIx_Ygwu9}<`#ZwjauE)#Nq!6(#BvpnD~6;jTmkMby)p7Nxy2K) zt??+t^?Ta<2#GF4gNfp<)FPt<>(pKYoqBE`lfB2YrugEh7>@o zTZ`STQO=C)+A#H8Wjq!7$Qr^N62T3X$uaNvp6PIZ*@|}-RsE`tk5wg|!fU^wjA1mT zILJ9{fBo3!GIs1&(yEg&QKep6&s?Z<4As2>@`=gOK!V*n!8~fmcdgbgS4>;pMy%N` zK-$=q%xF8>VUlNW^?bQz8T`?)O?!60*Kqj9T$j|TIGo!z8o0!1QcXp{dvSZTD0{Uv zCm+isA%XH~wC<7@toERa?8nqySiEw#8YH<7I14uMWgYlTG^|2l1&wo-nkQ(8^&67B z18BFTy6;{CfbfNf#!5h*&DgAt_4O)oX#MDsnsw|ssr!_Xb~^PeP9W%F>-fg_JnqI* zo49+k(3oBI=SbHJokwolb2>)vzxN*tFADc%Ef27B4>oHrUWW0->9d2k7=bUf6px}w zlcA+i2IY5PQemBs9P}n4W<4bX?!Ob9HHP;I&Sy1B2iJetK*Pc3%vfd~bA6?A0`++3 zBcA;3P}K6SvdMlDCtK}jLHz`!OWz@fpH(&sb8S4Y@>m+ljw3-UlAsH&EQW&|R+sjl zr_<%ugX;U(fV5-0&3=;YDcys6cU4sOzNFU+TD2_~8h%sKNOJsc8~<8jaQmPBS_(XP zKb&Gi`-iiTVbK{dX{=>F2vk9J(O@Pd zH-n`qr8H323LV!#op%m^JrmzLQG_CKh)?E4F?(zV`)TZBLmp;`!VRM1(~H9(5<4xL zHYBC32+q8OxQ=FxPvi6;^C@9j;OXUhbgYu7fnO6({4J z5fy~CgBHo>|Lr^pWU} zOlc5%nrhKjG(|?-vo5Y@TS)pK=Jtg{ zcU($_29n0g4Hif*$a|d&12Mpxc|3H{g!zOjm2gf@X?BpX(IGny;CkoXC`1axQG}Ye zg&s+Zqevio0!W)64Kqsf(GMF9S9tdx)LAI;e0`6B6>{8?b$g%Zj+17nfWc&O?>Z-N zD!6_JbQ~VIZ;#hokSLH^+6R!*5nLA=n0-8z?Cz-)8^{)Uss48euy*B;Kx(`=F!cJb zT>|jDj%DglzQ{s-LXt)Mu6poco~8p&z>!Av0?MASukEWbR;T(|M^E6pP+@&b&MpGX5qnH&<&&Y*?mMs^-7`3wthF#M zz*Qov!eqKe!adpW)Ri`nC4^{8!@Mv)olHU9L;74SL3;$2=f&!XBtq?0q;{B%204fT zQgUe#UOhm$m*+`i6z!{3mLc>LMX z?kj*9ccd%eQ9@Vvm^L-N$br2J>fUuAunJ64Js6EO{!jILu9y*5D=s47UnfkqrCXiT zp6?8UmbS}*`^T`#T}1M{X*tA_7|~VEmb!H#AU*X!Zg=as!K-w8-sHtm?1WwYCZFZ~u3)=3S5$U!Wp;N>7hHAZ*dy zEBjBUXfjvv?h86k_}aJ?y?ihB^tzc5#KJV z&+{}~iJmTIBf13fiwTc<>K*WSdG}~2Cg-IuMcrMBd+>^lsV8-frcqcIlrbB5_P$oN z6TY!rAb%s?_f&lF_c*T5gd=6u|Hh+K?@OGkOMHVBmMj+_*1MF~M`a8#5~hmdErS!k z68!VlDe+fguY+RW(Xn&U>`ccHZ`Qfk$)?amT2TxlN~C`0>6@Z1!ag5->$aIvtA|02>}ZD+g&LdGVe z^Tbm19j+weGW0Zq&3a$v&N*MNVDsx`caLFjKV~1Q$eyUkeO4h*UmdEk z77ur8UtX?y$e&)tRn*rvP|sg8QCaisHOTfgD))5_{Bl}GbaAUybuCxzQeVlt*qY$V z8&eB~5}r~ATH$~B)t*p1Ut6|Jz2`SrORbESGfe1iBk| zM~>nro8g3z76~uDp4bSjBe=O(*aXtqP)WIY`PU0anMDcaM&ZQ+&CZ+4_)JtN|9;#sXF(||hT~V#{{A|g)v3KHw#J-mYDnE>8#cS( zIm7_738QPwetDlDdh&7qSQbg%GtI(x$;C^GJ^+-BkLlCwA%ChUEY_owWe{hzP1bn3uMUwf#w&2-@*hgxXVs} z{@$d}+TJx7J$<9BmUPc^w9`f+@)@o{W-{0sP9sz6{B9mUs;3@+A_EX2^Nq!M{ma%e z%@=r2gQM*-NW+$O&}YLCH3_a~OsRu9Jnm8xIeg<-phIGd-0nRtWs9B)Mz~}nLJL1!ymPj)_*-J74TMAC z;Hf@bt8~ZH?QwdN?S@qhJKPOqo|Lrbnl{AN$5-bD5$|rX<=U+A=BeLMmbreJpPj=a ze>~L)WT>{MDwYXAP2i)5c$-z1%%lElP-?d=4qR0{^ScedD80B`7@6oxCW zJMn%h(*_^$wNh;Z11SjjDVyvsDI~>LgjU*OQR>~Bfv$dLygD)tq~>JWaR03G0@ovh zwKD|4FA46qf_~;x3m#Xv*YgtvXyh-USV>qNe35AaIV_S0-y{4u3GN#%*3m&T?-9t7 zc;5BJ1?UW8|K5a%OxRcSQQ*@{0`KgDzXD()!M@7$I0r#o9K8IoT!CZog(|M(6|H2Q zQ&PqICo*RqY4E~IZB<36zKL9(>qY`1P@pRu4mn9zrobd$$tNtFVn5Cvc)C1K@!~EN zmcKGD_%J&C=kMh~a6kT%7U^Cj#zJKBHfi&#B4&s|x5O}vHzab&r%+&A$#$?Ad)*M~ zOB7ysjB6(0(Keb|5LtT@zVVC8;h|wK@ZnKrf>uo1o_>Wui>l(jr33Ykw;Q2WlSBOO z&G@gW{muME42F*x+?V&l^I6khQKKD0nZSCD-@xt_&yYL(*l1+MV^9rU3-l+?$eG5; zlSzS$6klX=ih2UW0Zak>AW&;`uV^L_(HCE}7w$hSO5K9W99R8y?itd`A_&R8zR9NY z5DGM1j0C=NI0fgyLM+#qVi@)gnv6#(VqYs~!m*h^b%(4{X{SqN% z7mi-01nPMuAsV7T9e@#3OY{&R4g)N>bBd$Rh)}pG9mmXGVI%rpVF&Y^Sy)=qroe_I zqlm)IG6#@u^qd&xg%}^8&Lwwso=Y-Uk9MQkaU}@O0w9|f#q75hi4ZFYSpGZ4otSX< z^f@~2$xG%JZu6tH-MC6~Vd|VqnZ$*IAOj}VI$mvK0tD@}dkZUKCfNh9Jm z@;X`$vu2AW4VI+e%7CrT(yeZou(mecQnd~6>d$R>C63>MV}I-0NTFpNHE2zmP66ky z)n_sN#kBZ$h^odNKoHhD+E)f|mp|%Gp384k5Xhtea{RDDjiCqexcY_P=jLIopR{o_ zL1g&(bJ?=BDY{4#I9s?^aSZYY@iHmVI(N`$7^;G`nM;ufF2bp(syi`27B z=@a5&Edk>D@a++ex_^|@Z-oVBqMogOId?p~P51(Jtxl^IPRjgPD)2{}Xa4o$A0u`h z!O~Xp=ibbBb(vO@FL}<6<5WCv`l|etz{m~Zk_qjf0&Uu0c=Z{LR5J3AGRVeLZO{UW#zO?=(r(~ObxXJ1s^{ga(J zD>#N?_*%{E8<{CwWMyhu3VL;|GOPxsc*)I7y#1Q!o2@eYYt4qEn~l*G?a_^4ftk_pXbc$QQw3lUe(s%`&k3C z;zz!i?U;Q$oopiEg*ba%Yw1-304iv4>1w4dtIpZI-;S&@p1f4`I(w%2b~57B`#Y{W zq`;W1^Iz}pyYBIgZb{Jw2h>O)mH_OU5-*rF{nB5S-={Qu$JF`3 z=6tu@Q*Rsz7&~1Vw0}Ae@~@u#(1J3aGQna|(&1D5yu-6N_j9MRq6EJG@#&SFxstBN zI5@|p%d~h4IY`|-Oihb*`xEq9{50vta+J~*QS2uf3?(PNUUGq|fz!7cz<*89Za9y~ z&#>h=mezf)FHO*@ouCh$h<)B`Uc6G(_gwuOuT5u2`}q1e8e^8jMO8=?LQwF~ z<(4#50SmSHA!)Iwu~(>{#p|zK%1zmbp+!r1l}C(idG9TPCQn~5#5!vKfB-T6{~bxi zErK{m6j#Xe*@ur~4)2SjG@!aUB&_X7!)1XQK zL!M-vJo%W&xY;=>Dewt=eEZ)LYx=#Gw(}5um|{WtH9(vQG${=^S;?-6Rr)Zz!QI&F0lk&YDU?1_TzElmX=DBAf){$L`$kGr$#g>DJ?S~F&CGfA(myd zpNW%&%4wy063iuNW`2AW<9#WXD2x(?$hK8Do}OUT9&z5NNrVB)WdvCSLh+*!T` z^u)SPNT1cuh+2jgC>M@6LwVqlp$+23l%(cJ!=yPDWisk~OR3C>TvF3Z4_lV!2KADy zv>{V+c8kga48YV}B~qegLD@eD8xn2N)e@8nOFQFdE#gx`1O$&Bmd}P+nAbD+;j?d5 z_=#*dSN(8(T*CRmUie&Jsd7bP#0UXias4>6Qoavbs0A(7GUzkpWJkDKk|4$e4gqo$ zryHzQu3F~9EhtLAqrbn+LY;>(e8ZYCfWUS>+dRBJ+c7H|t@dS8;QQ@Km;k77}= z<@WnXZZhao6mvTb%}frFoX3CM4*Kkjyr3_3Y_xn<){fDN!QAwfo4v0zv`?Ps!lEaiVO zcHWOv{ek~Kce=y4_SSXnea(b&&17CPyK818p|YxLZ(UnPU1aYqrLGx@B$ZHILMrMS zrK0=(_{uWy(87P1F539RMYl*9r!yxBp~+lF@}WLX|qcHPbywg zdfw_Ji!YZ}1h1WaSL$jM7c+{&c}v^?1AEtPwmJ2L!s20Ox3w4Z0_4%~soJsj8xmoP z{G7oNjnJVrr?6=IC;7O7J`|0Z%YP-5ya*dP;9~V+!!oC!Dq;Lcj6Q~6u73ygC-8QG zO9KgLcBh>`i=}plm48{UhSDs)Fm7Aoog*fbA_4*fysH`;fvXFR?)bPQa!cYHzKe8f zc19YGf_}8vG~bmM5OaGb9X8x}-keR13t$8qX-6EcAPt(oGX2iX=4~pJP3ge zffphh8*qf?3pcCA>R=vVlb>xj0aJeq><3fv}}7HKWQ+ojs>&6eQ0)1b5ltxbJX z%yIBLH@zL>vl^ckgE{U*D}4=aV%)s8&^}=R6~F-R>lKL9`YEi#0T%q`B4A6cl`sYr z^OzW-_wRmqC2~2Y5NFBQ5YvP+`RIm1Vo%(pw)zwYpgjJ#y>?uG?BA0Utr=x z8XrrcyhP+7*jk?iI2|@APv)5igCvMqQ>W7HsNLWHc8`;LO3odll&VAR=(Fv@uXjMd zNA1MJL)q-1QEFW%iu8sStIm=azP#}{}YX_q$H3aY< z1sV}c#fr4OK@ZPaRD&AtNw9KcSa7M4+;~8sb$+(9+^0nFIFB&?6Fg6Bi6B}n{P^jv z+Skf7@5!Mq>}@PHx4qg5pQWIO$&3OAk6x<7aC$BgwX$0LpTR!NM(G_vdFa5mLx@WI zm^}j=QAT8Sq$p9IT>sW;Od0Ks?XNfJ^jiaQ;ys@^QGO3#Pm{*o6+0@JJBflchAOv&hHasCDoh#EUZJqa2?j}q&QO$ z5tN|=Epwy1i!^9qQ1Zy*7d5R^q$kf(Yo8)}f`I2QGbgPqXD$F#S)Qj^W*`|5r!3i^ ztOG&bf&tXK?0e8IRpAXjuAd?o4WtIF$m4A7&c!T~|LOKyl4c+)eG-R=Ig5v5e+hlB zby8VUnW}wb3iZm8(U)OZgOgr*VS@i+)l2jH`FO-+MtZKg}tP%n=#_wf~1u+fL4i zEm1j3@rr%375k>*q!s`Eh<{7X_uzTXQ@8ZPLhbh71ogzCHQ9XPFl_Pf4zQ52)6fbw z~hfA=kvnZ#V;NK8V}?8k0Ck=C4F!{&edI(YuPROHN1RK zvH2d}*!h)4$eA0^XWM}y*nu;wkZdKL1@+$ELFkf_#~u2s>joX)#hKtsTbZlRIWx*Y zUM4TPOCj_Ls5q8efC6yio6zdQU^?YlA?(WbM~Y~P2%Zl07SJnJb1a!V-9Pp(`H5Pfl}XtYv7j{fy(UKAso*_SuoU4MH1wGs`W@HuImeGz0^YGSJG% zRj3El_5)O$0z|}uf~Ez$PClEffpJlgClG)N@!1om{gqma>%L5QPV1~~*r4SS;zRjc zBK|c?2}r4u7!tmw{}1@qw|>aN%0pbgteTr%w{A=P>hA#!cwV0#W-yw_ahx)4V?z7y zej_l`VnsQETL%OQ_orZ6hU9U@!Y}6Z&q&P>#^G4>&Eti_M`HU&59ZcgLzejf`>bSP z#o6(N&}EPI?X33K7YK|ivvXd%x>p}B^<=@UJ)NTPA^1?Jcd_$66Z)%l$v+rgNDAwF zWLHZB-hPKgDJ%zDclLju>kwNt#D7mpPSbuYcPi|fE$#cx>5z$=Q17~4LH*B$VV_>) zeC=H%-k-=d^X`KYcJ(RSVPwWr{Ib#uI1>)+)n^2hpy#!2?HOUg*JgVy@VD48p#P*` z3Sw_Rx7I`s`mdU1i;BE9Br+*gY09SXvw6%XeMR5A647L1l z533v^{!`rYqzP(-$|SS_I!~US%&9zrc;lz zzTD;$9aCmaE0wE$o6pd}_Bio`RSh}vC9__`mCs6}=e*D3l6;PpgJj&wj2Fnoh9g?V z+c3qLQ*N%qwjn~jtdo0blJ2JK{z!dfM7~Mo4FLDBhOG;z1$AHrl&PrP5dgPb)9CEfJ;H*arSWO(Y1k5ge+>@NA#e<if^#>sLy9f@G^5)yy>I;V^B8Ptym%vJ{1p zKXzvhg)c9iScp4c<#aT;OcR+|iCacvQkzg*BHA=iK9mzRAaXiEe3}EspK~;VahiM~ zZeG~RA;3U?du^c`H*tE+h`Bs)336Ry8L@T*v6fz!_8QfOqzA;GG7`chrd5Wcd^7c= z?_7*$LI=L44t6xxC%{mnyOwfqEDLTA@;r&KX#i%p=>U`m6Q6vv_33u+f7V?1GSgat zgr^`RYiJf$>YXk8bgOnkqYfpVpvR&^VXsSWezYskA{{kd@GHmrGI;CeZvvd(7>bzY zdA-H3(0Uc}(gY-n$)>XF$r}je+S!G1=BG4Sm|db6DYr^I?SSX;Id9S^M#lAZh-Vr} zbXYuT)1vHtd%XFr8hnZ}WZp~^{??{=m+8%yD4AY%P#Sg0($@ywztHRS~% z6PSN%8Izpw*2}gOm&c)9!c|w9HqjuiY*X&k!+R_0HOxNyHyOt9I4mR2*+0F8nc#)# zH?v3mj{X5+H=}hWaru^=e?i*jed;XpwdZZ=JJ))(PI2jDXVdwNfNf-$TTkM=qz;{x z+r@i`3P!0_U_GACZ<>=4I`wQexz}p?W})%E;30{uN5y^@Oq7S^2?A{JmOb~1sObX- z-=0S(=>YBYX=2%$KdYkYr(<-lU9dTQrIPe6Ao6wL+6}o${)s}{GtrzaZvNol8$+Q( z^YAUpvmB}Hzl$!uZOyJ9B#$QhAq2_R;1rb&&rchGyywHqbt>)OLA*M@6JdfO?(R?i znUiSBZN)$@*W{Bo&|MiIj!o<#MADcu#bXC$|K2lk zearfguqQ`-?+hg~6{1RGRN881+;MtUus-cZWO>^}H%n-ba)Oc$h3=yl7-6CPA+^5m zV|(?&`&gX7M0xu2K(Tj(|zAqygDmlFKFiVb% zzVm*z9li=+6(h@Ky000ucjd8e)oPEw-}EqbaH0GzPy=|GE1YZURq<*QNz)bBGoK7O zVmr->6o;!XnG-SqcS;g(2};gs|Lg-XMj}-EMYKRA@A%)oeCS#w$iW36bg-AWyzumz zQgF4%ds%3By{dF6br?#q19b*)!riy3>STxI+!}ImDQR=~VtF_*}K(wWb0xC|yYW{MiDc5#F;KQFoIhgevDI`|W%>@A+<+ zLtdP3?aw+V>eTSLiEaOwNX{{y_SxD| zXm8S}fE(VoD^c#LeNrk-pSbcFY;>I z23h}kCvF%KByvf+JMHw(QqB0Fwoa}}=mQt(T^?SDROfbN_}5j5dlzT;^89LS`f2Y| z05Cs=MM^ty>P-0f(~NUn0INW{@3YkD?x}U(Y2?&U;5GtU&#blcs!?+NUXD&?H&&0&QnWmmLn-L#NH!e#B1Dy@oKOvS06h z@*FC_h1|W=_cqnMOe2O+<~nO(U!3F~T^)iL_pf!B?M)KLn=k&E*V~w6Wky_YRBs(}+PxOEEJX|L|ca)R9fmi8kjE}1l|?xE#ZI6~jZf>XUDqXmjL znpwiPzO3dQNh6*?>~ruuFl-K z)aA1QcgY2}`9CYxG0A8QLEisi0H0}ouF?1nrLs#2Y+_i07YF+hT^u^xCM*XJokGgE z`_>`XG#5GWzsSWSLHFJPHhpQ$U#fpwm8{B5JbOo7%2co!@%@wutK>Fz1|K~bK(crA(@biH`t%muNlj?tp<5EY#@57R?wz3*2{ zQ-f#o!sRoOo)3d#Vd>8IrBCNHo$ko1qQ4W7=gn;rz2Vva=s`&Aw^wHh%r|XT_Rht) z@Qy^7J(ETMiO01qzb;HHi`@M>!|SN|Q;&DyEQvqVH@j?b=j{uNU+JxfQc)J}A6S1( z`60v_?zsaXQ5qAbmY{=Q_YN@03t4%xkhN#GJ_si3{!=V}o!aSyvcETj*QlA}(9b2qdSlEw|o! z-kVPq?6(igH&_rBmWSNQEY%k)OlBvCKbB= zcShCumnyLtI7T0(lO;t!bNn9SGO4+z*#SwJc;WM%ajbQanLy<8Rk7pPq7?UrD|J+n zSS|mQ#Q&7=j%!P<<7GfEl-xY0AEqFsormlX*R!j7iwB*7$6MqPl=rGg^WjQvx!_tZ z<(5uVPbDZsoLio3%iQ(6AHEFl5`~Zj*wr!I_DBp_Hu~(XcDkYzNKIVIR1G?qvf|<0F_E_=4J%25*dfRQ~3ff7{cyz#y zq^39-zXF84(#wVu&7>zE>8Wwrr52)- z$Q1`y_%5Z5CK^UWD9Mduu#u*ab)I%^lwu@%qTlKC1jsc9v-0kHtDnyl))a04cNEO7Wh#O8>AghMq6Ond3ICm?OakMKJK6Fq8JR3{e0~# z%hO+o6sir4C{0({{lIB#uR7eQv3q8CMu9nGsJePu|8lW*f{BacySPaF4LE4R=ggP^ z5L#%_A>r7!`?>cDVz$cp_*JbS%UCeNQ5{6QywV=|Q?3v!Qtha*J+EKHt3G&@OL?*h z&xOsh$5H^>?NuIoo|JB54=AVmGa1jZy>LD(Y>wu#^HOk*;I_RAxD)Vl`z#1kGq(+} z`yLWUuzgaC)3!CZm$rVB2Ft>EB(Ggy2{C86hN#sfsvB5}bBI++-ab-h*W}|obQF2M z3hJD7jhGAIBlp33d=irtpL%=#fca%&d7C4U@O|yB+9&$qW&c(Nc=K3^Uap0+<^Bvp3mchtrO^pTtv7Nw_*Vr z>~xZoCauMFZd=hh`m@OcM~}c?{;ESJ>E2)=wLha(hyl7f?Pb~=XXsf?t{>iB_ef&s zH~J3r(D>@hdxoHmB1DJ*G{BB&_gb#hA^gUAUV_ufLT|8hw-?(_pC|s2>#zS3rZHi` z`uR9ONbV=_Rm2(Y_2oMc|AA}aDtpK*hy$dZ{uX{#RWA0re=*rd`5;*KAo$8RwYMM+ zz2G}ZsUE5X&@a|#NaHO^UzGkd5}IdmYsR7{-Y2B!7c&-XGK?f>$}{d;?B`+$jb-kxb#r6Yv@hk?N>XE^ ztP#>TKJB10G#&GM{d|Azj6%7^;FJ<+@H`hJXeaUr9xzk|UM7Ux?gk?QLYL{~p@|%}y9f1U@PuTo%`yOpI=`xx8JK760irOVXu1 zuyzMET@D^_SgTXg2ptQm(iKnvU_x;Op4LEQ=WW6iLbBqi<-ek7VUI# zNF^YT8qd{`99ZNo$iK>;J#TiKa9VcdN~(Y0_V9x8-*D|nzZN6E3r*;pT7mnWwg4*2 zCfVj8$%(yRl$m$`MNf3ZyI&f25F)Gcl{Po#s`pW7N_mNwiGVxL+CZM215 zFXd)l;V+yM;BHiJ{KI{pbVr{93)7|p%99$>RuCYdzO1xfYERMIB|E&d(Khg`PYhB~ z8unPIEps4OnRMrOAsEwH|L$B%>S9YSHMqv!)VAvOr48Zd#Uj;AZ_eL5>F2o#2eWI91KFFhb!p6+^f=V zNiI(#mTCG<#T^%WyNjt1@RVUbmtn`MxcdV{xdS|(l*y)PCF3f^zm&EaX@k$o zjo+LBNQm?!{+38|#Bx81?!U2a#o=aV9m`7TM<#{W-CMN5}R55gzKPRN!|HvhJ$;$qQm5%oVy}!ut8)N{VyC*(ZTXr z1$LvcCn7XZkJ!9CYoRkR6MmZWJxyHn;!n4^mGIkwg9wcqPj&w}bW%rTAmaa@v?q^i zv?Nc)sMpkX%oBQhl%;Z64NJ0r$E*h5v82$lZRuFC$c!WQh?2=@HjePv)c;c~TKunJ zQ5k`XXV+&3rDT^^n1xgCR#osK*)JV8r;jCy#rkYK)QIYyjIPSwfoMFt&|_tMQ&Ss*!@hopEG&z}h|IjH7x3@cqg}`Q#;H9061x{H`5XMch(;zivPj2F)M7sTA~r*hqD?jhT^XQ&bjk0749m<>M|>b za=~L0My?-nt)Oj`KpyxF_Wcq3$=Y;Ybb3b@=ymIW+mD!OC~yfk(58Fti&_-@Maa!4 z_ty-gaD*|z%mZW4X7j~6%;FErz5MG8{e?)gfsF1iufE8Iy#Vtr|Ma}k6V@lHX1ke; z<>ZLu;foTD29A-;D8`8xQ)W9Z&FA~%0hJ$@vmz`Icgpz$m@NwKDJ&$JT$zj&zp`R2 zN~C&oN##uV+^%xKk^xw4l(z@ia(3wYz4Zg5v(CN(x5Lev$r4~)r_r+8W{nsFD9Vxm zf}4i^vCk8I)KAX0@g4!AuvbcwVj53h^bci*$q_GJ>Rup&b%b1s^AcMg)M75B%^JvS z*#=gV>$`G+}-ABR@#%U%7RGCU$hI3ya${^PYS#nku600hf8Z`Yisg; z;>U_lnm)%$jTo(O75r4nHbK6^iN*hnPZkbOtnSVG_v&qL6th54cy@epnnSg{eO#{b zDg#nK{#}n;ukoLj{EWqdqE@0Vni1M%c%&$neTCgXiA`eK=V!nP^=zyX@iU*cI*6`F zU}5*|I*^w=Vx4(>IOg8Jpku_T7Lt4$PeWcldCJ(0_m%KA2fxtvw+}{ShQY@zc(a}V zoguO|^L_YKMCB(d_Rh7p@j=SHcM?-8)nOH>2fkTG7=P#LniDPNb8~r~lbSr6%lwP2 zZ^P>ruL;KAu)Hj9+|nc4Je$qZ|B1x{qHiWf{!z#se}i9Q{=ySZ34}Ny>@ZsF(|?AH z#j~}NTtZPaIj7k9r;UAG-~HOy?-&R({2|Gl#stWUz1f~tEs8@C8EUi%tYX5zzcs^d z+O*$0vMzbT$@=A(_B7Cxly3Wvtrnt&u`<&mB|;2skXW)q4*hKz%bZti;Gw}071Ij4 zKkRuwpO7m;#!}Ns3@;SO_iQ0M_eXV)sEpm}B+H(b6IqpVd=HX4-pE1!?;oRo+^D-SKV`tI)p@=Z8p z{CdUpr1krfRN=K>B$=rGN;<1}^7)4zzy1V1>eft1d5ib%`#bg6r^4O=VSkKm%ryOM zPR{vcaC=m~w_~3-;g1st!XMY3OSJ$W&I$WTUJxii|J(R4NZV59g!rR#9^4)5wI@|J zlb%i8P__MqC;h2`P`piU+of<`aFODh7BV!r=feuY zCZh9vN^`_VhJY)LL1B;l(^+kn3%T_Q3Pd31DyA>SsZ_X;Qgpvvkmtiyjg*(LlA;OUeaubMok`1Z;|sHab_?%bjM^19B!Att;O~)k&!#QS{d&H| zIoNTG!&vGGu%s}+USQh@LcjWyV;}XWyLB@70mar!A_5}ENLZ#1~)Z+CuyJ(3$5tVYSF6c(Yqfb}VQ^08sAl4%QBGERyPnyTcp4TpzZr#Mpm6>-7 zZ0YS%3gt12@yL4OcJMvKz0EhtWAOldwNgM){!yfOlD7VM6nR3Ge!UO43*MOOyJs~C zlDkl{^I@J0&H%1cqM9Liv&a|fCouNYInS;8Riab2<2%o2v|WFy`_++l{mrN3pS)&? zSiLbKTIJyk9}^#a@JbwZ9)SpOkEWZ1t z{`0X$>FT4Qn48x@+!(%2NAoR5rfoz|6#sZg?dQODhqFy1a2X)9;L}ZcRWQ%7{7e0o zG12+?TW5bQ_l_#~$_gnf6?$z99{cvXZ#cC1GcOXM@x|}F0k7XmUliKWO9?D=3=C^= zfga;Qg7v5J#^S-Y`Z5%0WvrP4m}}>_P=jysN}oiC zQxkh&o=qlD{ofFt^*|wtQIbFOxq*P{an{2OP#aI$cd&sRr=ICaYII}YD?+P28c{w< z#sLzkMM2dEDP|-u2k!7UXHynrfQtf28Ry+RuE*4#HD9@U{>&>rjns(ypFkm4h+uuH z@Mh}%KIFI>{Xzqpz|_vvOo`T%&P))1{pyNttx{E_>)n0}ZNa-X*LZr!*cKpIn$AiS zxdFrQl&KVGCG>LbLHfpPcx9?{Mh)tLV|1(;(5)TU9tB-XQ->0v{q@HEeEJXPb2`)X ze||DRW1Y0lX4$p-sqm(oN`ZSWMit1C;fQQYAPS7XdZ{32^3CzK*Z$^Q(_B#HLab;OnLEO{R!p#5ou z4>zH^&IU{gMpBBi#&S_y%_~&TNxw-mH>EyLlQBha*+kY|DDLD{uXx$02J>qETQx~P#ObSyj`(nJI_?)`mdqO(^x z+Ltr-rHqEG_6;jB4Aa-AClRCWoW*dBT_NnzLGCQ^ynfd!?^qhsfg(iq3&%u${OS77 znl^Pvn4wxYw&0(cfDn4kCjh z!sH?-06U*yPhF+ZJE$TG<}}F~kZAa4k`-zK*!)H_s3NRGM!Y3DWq)t5l{$>@sF(@5 zVo#>D3pMEzVYlY4Z?&I+rJ^yp< zIO`I{U1CD_51n*m=5)yN@g<3cfU-#BAJjo;?b=&!AWoQZ<0$jW=!3g;AH7O;lqr6!XxBQExQ+*cAV`OSksU;zQ*iIK}nx zI&sLDmRCM!?y$-u5f8@U1u&W**&&NUh5*0{U(DqC0EVg10lhJM-0UFVGEMqa|EOR; zTS$4YSx&)J|AA2FInu*9ue^Y-#zO&{WV3bj*M9l7TBrytTnFNY)?+*=k`P z{NWICQ=xWHp*%?#HaS%|b}AE_2{k&xiYRt%ibrqSwp5N)l1P`yK?AphM8)L8#in4J z=NvqrgFiIU+}jIJnm_hkF8itgHODER7{c8C3|bVl7saOZ3&GnD0HY;nC`8Z*KSn$m zzIkdavH|e89wmqw?_vtWB^cwKXt+SLj5c=>8Ve~;uuX;)awTfpSq|zm0CX5g|MPVo z^1vI;R@_Axg&~7t0OALb`se?f0dCs5Duy3d$b6Xd9OaoF`!kS*dV&gvp=bu8kXvbF1>7$1)m4`u+wCS$0 zIZOQL?PvXr+#Y)G>sUtB@%W~Ze0-&`iV#35*m{2=DBvQg{ZzOdEy#*^_t*|u+Z6hx>v*W21U*F#1q%7h^T32F#Hx=`;SB53eVr}-IWR~!-dd~q5 z=%-5+QRPreHVyBc`LwNiyKEMf_lU{&95yA=ZLpUTpOrR|_r9QV+37Qfx=a?cuM4p{ zbI^|P2REP(-!EM*__%q4Ew%H*xG39?fA7xH4Md4gbg#ZRJvi|2^Hs&qcVFr8b6}U+ zeZ;gYz&BC8p(amWw zEC9>u;F8(5;bS6{CG6wgE~sVsgGt;cM&pJf0~-JsgWpQzha?(F<~R_ro#P?c{C?&L-dW!eM_62{zME8KXw zbv#s!A>(5P4RP!4WHG3@w@+^w`MqMZqA;dOkZU(XUvH(4m0S>^YgsY zDZk(yW}(oV*I{jUzgW`}Q?2=VXUW^w%+4#rV%`hg%qgE+=|fYd>s#KNkvqbzKUfei zp3Xt`-lQyA%zF~e#WCRnwx~v(I_#U{I?1?msPWK)5kGf+I0aPqnqHm*U2NLar*CH7 zyuX6$3$<=s&iP_TOQb9F*s)u;IL3w{#tkXoqiBpZ;%j!Bm2aDUg5;>l$jzY}K${rj zstM@V^O?}c+rlsCM2z2Q_H{%2yw&N70$H%qEEtAg$q3uJOC8$wWT_6_0xmt|{dl#i z`|ED*rU&t2o8VO4?B2y^ zAiCK0%4w+Ne^BWY;u0s9I!bTMCI7IRwGwBv%c-=E>$*v8k43eW@G3u++de2K?p#Eh zbBb=WVAIn-e&gj^3%s}1WPuRA{Hy!wu5I051j;WY`bWdn1H3-8C1fSbl9Ta0a58h( zgmRaskr{!6{SCB7(ZM8KPv6NMA*Is_DhN|c45HL7urF42}mFNX4mkyc=eHS z`(Lv%rCaCePR%>te!O|aVSet?WrKPmoi`%5n+exm`$u4K`zM)v`ajy!WH=w2rPW|) z#9Q_{^nqnnbC+y5PvHq5tXag+qx!yfAR?(G$b=pxxoD#8ZnkQ3{4bPT0St!XUwTz0 zCK^e0RvT1%4H+10RM*hbCag4`(Z6m^P61cDo3HCVHV+6935}&Z1+ouB-6o^5}C|%2U8O7uKNN=vn7|9iT?)WQIH=@&wR^Cg* z{bm$$Fq!EqREae~4gcSk(_EJE8Ax6+vFOdPZ?^$Bhh@kt)#&RhcK0kJa*^M1AIMa{ zd)lSf9VDjnGGvJV_XjSeo6GL!joYt`y2i}N*Q{6UR%!%$|DhQ?2X8Ol3UmGoZlXXC znIU)^sRa&Bq!1A^{nHD8Dl3x&vezojK=S9uixdu7mI8#8fF;(rXOAg-ze#NzB5O^s zRn!EnfOYY~ms;jM^XSkA?4RP(GimOe=IM7j8Dt3CO+rTED?XY@JVc%jYnr0qIiBtn z1kgZGZFMjX10A}#PM4YM+;=~LdrtPcf3q)S)@vQW;q@ik-o@$uEo zX2zR{7RvY%_aIJ;hML7eX9+3yHOQdH1o0LJ^Fw$XpE8oBk5)cG^{o z2R8}iL|?She4@`CD2_Ig2s{4dP_{y`L18FR*2y=<2_kG4$cmb>yd6 z#eBpk#w9<0m3MkMg?7@Cpogj@v?O5`9ZY<4Z5gxA#EOf6HoFKJql5WEv=dTJ;KJH` zQZfUak!f!x{6$dnhGF+%&63uLC%EV6wnu+=EedR6?iO#bD=Bt(e4NkR)0i)R7XX4S zOMH=ps!bP_zwJLQo$g)DLjW1&Q{r7*6qCKmkYc2Bt`yT4@)wMVd&SS!@gvNo?Bb4v zhd2@?a!Fy9upf#zb)FIeWo4v@-M15tBT}a7_b* zM&ei`lC>r(!L{c|5|Xm^#k*cAQA77dg)-73-}oh$=C*mTQim^>I~AQXX0r+1c^-TJ zr()o#%gLCLdzfE+2{@TI+(|dG%>$X1RAUX zOBtaU?fYCr1J3K%PI}{Va=ub&BTR7?kWgCzuB3(B^h-LcKJTn$ivZx?JF4>oxcPM) z3|U3~7}O=$PYse)coOy2rV!QUO5FfVG^~7WuG4UMcAe$W-jG%DTr&977bS^5L+1!Z zz$F3>tn5savXcN?_seFxPc7v;G_ggGfC7mmEEfxg2fo*{3DWNIFcZik}M2e0WmG8F*sPD6OqRI+0tyb%HY57q^}MJ z@JAw^=XJ)<-TTXfhW;I5Sxkds+JR~w6GIsbmgnZlzVYR!i0)BF6@_`hrEcxD3V~nY zR|e-G`T|1>H%m|Gxzkgnzigg=9L@!jEo@e000;^I;ve0)!E%JkrtjG#x@@Um$z%s&8X zlzxqe*NhBdEZ*tlGXoUK6%FnvdncSY&xNXV5Q@RQF4<3`D{)@EX#{>VNdg6?zqo<% zUDp0JTe7%YpLGU&zIDD2p-6??d_YP?he6tG7K7tW=^*6eUXMPB;fT<=MzL4WVUa9` zx71NFcysLP$O?JO zCaXOB>*3vhl44)i=6G) zicLM4cgDF?s4+IRkm+i8U$F;3&S^M!;$A_|JAPK?0V++*G* z(T7RC1!~ID*$w0?G_sUSyHR{eaMFvMRqqH+mLNINnGGxYtN&OvuU~?<-D??{cMmA! z^c1aivOj*LzXX!hz{Er8t{S4(>S7hqlgbFq;1J^-@&hVHCZ0MsN5HUo7LLKwZ7Fp%MytH>A2ArPluHg2 zq(>4~WiqAy#UUF|cr!%Pc*mJ~LeLl0tt)sgK<=!jeo%NmMzW%K)Rz1M-m1%cty-T^Oo6fey!2Xr~9Wvl77TN0k}F( z^u1SR74#J2;W{;v>bMvaCZ3*-!)X=;!}oKB}3{7vzOwteZ5 zY<--AnG{(C^LT;#Oq4nkx&+45S(DU%eL6jZh{>D$=NsnJSfeFR&ilZ2rro-ubQWnq zMBYbQy!qZ_J&X984%H(;=f4rTyoANZs~vFFU8W0SU{!QklBxW-vE?c-0 zvtVAAHQ%mnlk^xGx$Pfb0U z*m!`e(W*~EoSqgaBZ~U0a6cf4?^SY~Fj;uSP+|s!^y3NlQ3kl6s$LU5Qy{x)TVT&p z+^Rz%KY+QZm90;ji={p%LNgQ{?}}E8_>g zq!s>3k(6b-CSoiTC=b@4iOWyl$iuo78F3sbJW1=68-s>0d-IUXGGI*2eq*O(H=)E7 z^gtG?`1Tna9(eVLOZmKSr0%3fJFtmV|S3mjK5v`z{64h9VGRs-ZBWd)Jyo5;mu4%k$RE-Eb zSQbrK@|6)~nC~bY&CXcJ!>V^+RXecu8>Eq6*1C%JTAB9fMz51S0GBZ3Tqrk(gD;Y> zhw!xL*)_wg43E)xhQ$_h;W9kzvOH+v|++QOt^;c#? z3b04q`e$2(HqI{WH=Wd7n_C_>Uz$@AoHx2$P>aADT8-ThA?T#1vMF^brY5j&(m=H` z#_^HH#Re8D-5b{;(Hq@pwUt5ES3V+Gb$f4Fa0J+ae)d$u7#>*v9%xfWwjVRLwaPa< z!Nc`O;R)3NLD;k2F@SyOYQQsj1@Sac)O}7?M?R>-f3$8?ceP$09U%ThV6O5J?X3=i z(Cz+!J)rLPimEo^)ED5Cn+?{+S}r7xHuE?ovw>)rTNY5r@O_sNUgR5fMr0v=uG{%m zP^wyGXLCnWqn$g_$T^dCMGUSeVt?vA*`w%1li~Q2`%m&W?m#0(sKTMtF zTaA(TeCW9SZPDTk8olpMO0hfal|Bn6~IN;*W85Jc4RW$zFB zc>jU>+wVB;wXSoW7uM!2p%qx2xfg4&fg+zbwvO|PQoJf<{h^bOOe@&pRBVvx3uxy@ z15K_(R%7g2E;X|OqDwUo)9f7TeN;-CNR*dU$xY-ojzlgDAx%qC@=4i|!j?8B1;oZ8 zk4%VyF15d>(R$|5>MgB1Zkvs$Lg0^3a5Mv+b)OAz3a?0ZjjNY0$#e4QaKBt|95bOA zF}c?q?e(6@?FzVm4Hu31?j_JBCurPY^A(r`2ahjl&{BY3Tzm8xd1YdKsdI_h7p-!! z-qh*Z`A9x8I->PmV4)G&qMAT_AN#IZz4;>B5B2_O^(snuCnyPV~#Iz(-1?Fn=0X70Q15 zXB}wyG9v=}s_b=;KNSuopI+`;3F1EUP;x(x4t{n&JAqVF|4_nN$@iH?6GLdn=z7Of z^K#SuJI^2qkw5NSSw^$S3g{mg;khQ?mnQ ziQ5SzGqFCUbPkkEbCqEN8bE{4`9gM;AgSzpk}qA2{UBh1xLE9>`g?Sw5&CImH&NCl zEcyar%^_Ih%miV`q(gYUq)WRZH{(E}|6~bsBnqD(0b*x_h>ue7?3uO>c~q#p=Id5M z-pnFYX8u;r_f}XAzB{uXCpNH6c|&ry_XdvBfD5Xt-1CX;vuR^nVb@pLY-$Cme8?4! z&{*?~_-6%u4!|_?m!`BfpSyEYM}q=QT5K*UyQbY1$ey>ax@Zn@3`=CmUR>RSh$ zAx^5>N66Zjea2}tz2&qPY0Q|ijjhu2Pjq@_X{jjE2}3jXcz4nyz@3j)EQOP>%hi;< zdLx|@rp~3CU;TS`^b12c?p^xuUS;()XtkSIm!1eE{?WvZlY0@C^O!s@3N%mnnlJ>R zR>3(^4#ek4SZcEaap^T4x5Tzp2Pp{FX>3F`#&2gRsRLb`D(bg^QER?tG;wIOMZ89g zXKFftn%E>6JBfI)VC2(hV~qCZowW*%&FQnMR~9q|xYy4E0lOAWM5&e(C<4uBFs#G- zH;ufgOr1}TB1u?bA6lcxj-iPS_&CJ(Ij^{5FZ+5tlf+se?O|EXd`H*#BLzzuDGRB} z2VKb&#Aym z^#6{>Yre_G@jJKF^GTlPAs9B9h^7mPYnZ~BLu9|jzHQq=kD?iPqwkqB+TY$14;Xnp z{^@%Ug?p{k^4vVU=E93DJet9Z%<2uF67^6Y>H!bR>roIxWJYy-*y~au17S>0CXN#) z)tdy!kd|XL*m>*xr?K&u9^iK;qs~w|Kz{Z@XE0mp`ELQ4+$r(vQfw0}@`$#tc>3)d zP6M2c75i5u_wZ z8ncjoLtOswoJUhynfZKU6f^x<*F;n+cf>rclE7LnansMlF7(Xzj`zEgNC=)V^J7Cc z3r}Oqww5W&a-3h3=38qo3M1A_ z!@AXX?WvsuT0zE7ub-NHf(`A6Gz_lfv)ua{#_Nn=9f?MFtuQfe*~R3NWKpm8VTRuR zwc0j#uMkv_a(6XD|H)rDGRjz)Ivwp|XIH>{zX!Wf50JQTI9Q)AGXLQ9CwY>iFxi6$ zfra0l6Bb*$Y_Em;flg67>F1J!i)1t`kWoyxo91pl@^PIU5mkR;4{kdbQ1@tXS?i|r z;;r>EV{W72IPIk{S1E~a-?-}aKAoaAyZH1&rgvmld%=*+qJEzT{pNV;k9s7V$Gdim z!+#iOLJfvh*AM$(H*fiBvn-5R?`cbUW_k>CQMJ%EMm|#Hbdfr6i5k_|sXvLtafw&e zGVJ_oZGILOPQ+0*;c`p~cFtx#9A^HaRCnSdg}~s;9uO043Y-tT%_oZ4cgp@kq}>u_e- z+rwZ6_UuU~F&KdD7X(NMcnA;#0RB(*io*D+3o@pUSNTnNJ}8^33($OR@t@fjpQ-=D zfB&apJ$0t{v#(5;?*HJw;nFkTf0u+tMaRU(**;H5OnmV_ve)>etZbi*y!?W|oT6fj zmt~efm5P$;n%cU@*hb)M!0W2|*0y%P#xf8;4BFh%G4PlcO5TUVMXPk#1EG{8WMtH! z{&(%oP@+4Kn2ZFrxKtCWHhCxh=YzwANLWaS7Tz{(S5H%)G71q3t-^lPKg-Jb4b&P0 z(Fw^TQP@G2XyX6Se|I^NgKcmjJsgT+aIg(bJ-SV4)0z7FRlH$yA3t+FV%aNP&3MZ^gdgqwMm~B@uwZmxKO`a}Il3-*L_atKu zu;AoeloGGrM>gP0<@2npSjWqyJ2fO_bIPRHi{ygS2fSNcPsS}dC8-6Z1S$3Hfz;#f z3m6lAEgBUj?HD-?p)5I7)g{TSkk`>~^?wby*1?@6Y$$1`N_F~z{*#sr?*Fl(C+yzu z(V-(V{VA`6%dNmQRma^%0Ql^AEn(CSm%i6GDx>E2Z{>pLN7NFVp4|zfPskM14^N_w z+D=F5o(avr_+`JAVkw$(w&3SJMeN&*3ygZ!RuT;3ma%kAO*AzmkPB2nK1TgjVL z9|n4!<40*T_}$l?^P}IQx3nQ)ueYM0yl0~ZDvaHSC>2qsM?0x9?CMGoM=}`)C}Y(q z9WiSiWatj6r?rVUfhd1Q(NYAi2B@~eXaiZgckkw9K3}CY^b0=nSh8P7HY&LqaYN9@ zn)^VKRaFtj?4(7B4B2&S-;B_I^*!tstiH%?%*UP=D0zGC2DErzy^3pSSX6UGu3r7b zOxXJm<`W3z3`$(d=JtNHO$7W`tp%t;mA70(7F}7HMmcJgdmMg1$IKRJ)gnulzZPJ8g1 zvN2z~{?*?^X^ARo9i?v~lbQ-Rjt)xSxHf&_?n4F`Ew@_-2G(-@eDTjk`STPNHt57dtV1j3fl3}lP&JrC2?@@LP89F%r+{k0Y4K7=aZD2646qM8_ z+xCl_U37-buT?PWE^Kh|^IcvFhE}(2Lfg)l82G|OH$VT(B8%C=#;3XCKPLLKUK17A zHvRsVc9M4e&B>OvJ+OG}=viE><(Ch>uLP1pFJXCq!=fEI(92sBZF?Z7XsGp55BL>a z{<~+s5B_HNvbm*lR2m7s_~g(je+O8l1$kxv6Y2SszIzYl=%P^?S2xVq^x<0=`UE2} zXWBm{s~ZlVF1~9Nz>E>0BB)oG4<9T8ttq|vjk7+3X!HiCq$=nfU^f54yB@BZP02HC zPZ67lv>8~x2w`$u8bu=&FLR#&crOWYs!AVa{1{XFc;jkY1-SDUKwA19lt4la7Ds1)-cn0q`4Dj}RX+oCRA-f6COh z3F-iXb!gZ@+!R0rMJkwG-H}~|B3hMINGpR=6T&S5a@f z`vS&^Qzw#Hc*%w#lz4y}cW0;0TK=$H>Do16dD@^keUaBH)((*UK$R=(Kf-X{JY;+^ z%iv$dAoFs6&?XY4C;p{SF&U~s84D<+wloGe{L*$wLKT89TqISHyl4~8Y%B5Paf9qB z-{oxUy+5e$bH{DsBNu+R;A0j=&BqginY{Ojtr2hW1T=IIqq23Q^_{{AbtMhMmjn?t zI@KEQqaTYu4O_4Ih){be5GCel;+PNPX#NnG_l;^`!TRnKOATLU7~m)jpt_=hU7r~8 zJX7!IwHvNzXs>LRbWm%0)v&CPww8c)(4tFp8O)kd_aMQHS6EZ#mm3P0zpKkwrn#bQ zmg~uZjt9#(8}qyAbF5W10_|2iiu?et-QK!69p){>^yh+Qb1^Q}8s-TgA_9+!C>Ew7 z#$ORBssC|~3w|&)lSnyj?tcH;JJ>XrKqRjtt>kWnEonYkNZU5cd}=Am^u9|O+%vPz zXFZ`=*L*5j4^jnKawDhK{Uk&??yGojui(iD03Tnd8ejvG{sAJ{h&FxL|B z3iu|Ie`qAUAre~*?Nv(_>X9wb4+dAJVDni=42rn$8>(iV~ zw?31j?>)h8Zx@T@w5w{38*0moQ8+)*gC^dN*o^`ujz+t=oBNSg_xd#jIi9b*(SFxC|$2 zt10XL)ccZ5U|P)WMB89>5nj2gZ4Pq;dp>_YcU}o(9PODM)4gte_$_h6Ozk5RoMsxZ zG=|mn^XIBcBPwPFhty6Cu3w&dmWJ07kD{836CCax{)NtXbEj`#Ri7g zyWDhdp-TJG2`|6D2|M3|ioiaU_66*(cG$UrgaQq;fg{rRt7g-HRIZ%)38si_wY0wV zS#cztJoeIt6t(%pW>WS9|7G4QRHeHXL!V%95xn3n=S%ndi{?-A0K`s~v=>eAj%Gh?sI!BJk$`8l^rwfOa!S028WTvo)x;PX@sfINlry=Dy_x|6 zh61GUU<6m9c{1*+Kt;bry9dQ`K>~fJXiYt@pQO?VH$?}xKaBz*LuYX@^MU3^Kh5W= zO#06?R`QI{k`V$c(Mes=2qZ>Wou(@2(W5NPPJqgLh|bKuo!^pO3XL8d%QDErRB8&8 zW7kd?vD1X=Oi0JiRRLkRz=$lZ+KYRUlpH&cvaG9bk)N>lNeojrB|K%@=o z`8>pZ(#f1u5_!kLqRg1QZX&@y-86W;URIN@h_i%uYk3pnOek( zMd^3FD8BL8ZxJpFG0fwRpXM?VJ4*{Q;}m5F|NEJm#GU*$M3%xWnOZh8r;_l6Su$-s znj<5Q^+PO~Da-Aiou9gnjJh4P3J%?R24fF=kG+2_DV?hxdbKZVIUe-&Oq8G3BQA)D z&OEbqoN!m2kWm(dn#i1^mvWu zMI<)?+MF)U9yq@uDmqW5MDYB|_&H^6>eoRy>4cP9eI6Ja9g|Cl`t{P+JTst&AgJEb z(?LO1Us|u5`-F?_xvZfywRVTNFnLsgbZfB3x!}g7;2Y?3sANI=Fz~LqMJmT$JMQJn zW0+@8@zD)<`V#@g1i^b*!i63}uU|S%d;|0)oWefLv%Wktw;41cUXq8t_adQaNlc+p?Uj{+}w%9>0 z+JPTeD4>RXUbm7!=ZUc{m7;CJU(P55UvlUldX~Ja5H7!y3Dc;a`9QJY14?nrL~gGx zb^*OS778%5usDt)=TTBs^nXj^=D?UvZZfkZA+=vXb0^n_tD&GaE-b zZy5%`2Dd`koj!(LPYrl{Mp~>UB#eU869ewpMgK{rS5%y)ct5t&r=rP|d67xbvSTcX zMP2`GW9*ky4@bovA~MrL#Pe;*gL)7JTR1(2{!AYzHCf@v*iaD~MKnh?&?WZoUBjmb zkBl9S#6*e}dtWNOY>dV=;(D9Dy4UFz))n1(Pyt|S9BBBN^O7j4KLgBSiv)e0w{Gl9hX_0yVI)8OetsO)cF7r;M%azne@t zidmk4SdrjTif1pJ=fkg;pGSnxF zbjr=u%h3`MF$$E>pA^64Z#6n;EK4-=aBRJ^(BgklfWJkn%=s1^TmXpCMM>?XA%CBN$x$YCqDz8LEQhvZUEV(CD z5y>4ZiETEc02CtvWwd&%B?QSu9`T>hjotv&3Ln~HT7r(-x9JIw9J)SgbicB!i(in7d9`Ofx4j1zvOQDqJ{+$dydcc^colp|!tP+xPU-Yhc7b^?Z z_aIziNM7h0=)3J*`#nI+*h{LZNW_n?{)QGu$x(+>yh+Rg5aFQWMX$fM$$rZK#t;{J z$@V_+C}~V?j3+M9kt0Nz69TM;;wI(Jdy9FRsb7C$e8l#~r3XDD2ywpyPYH;Lv4$Q)y4vlB<9TmX`jo2x?7l3_XShgg|Fs`S z?jBSx|8*lfk_SUL{kvV~Yhyw42>$nocS@BysXQ5@YRU^1T8g%CJ=Nt^7N{Ogv=d(VZOe#+;(7%e5c1niPi$qnd68DGE*WZFdND2NqJ48+Z zW39SVzK&~JKsS>oy*;s+_*Yea+I=3KGBjrsx?2;$p7bHXkdfxGR{Py@kHS$8ORrp z&P^p!AL3d`MrINI&YeF#;6NSc57V4)^5#>Tyuz#8(C^ooMu5ahx<uR}0@03=K5;TCl}^sU&8 z`3TJ%081;GV-;R02bgcHBQgc?{Q~ICe`d}+b6kv+w(hk38*KkKjFp9PsCkiX3m9kB zVeof4wqthTE7o|Umxlt(HV)i=1;Ko6({k~3Wtp`0WEyE?iF&m>LD(Z54jICJNcjQ0 zc{87`_wiF=Bdqu1tG*$UB|y~Q4?~z|aV#L=6d=(VVDm#{|4$zhfc2d^wQa+XIRl(& z4f)r(G))5%L(RWP`E=YcUwHb7tN6}St3&h}67?p4NpJ1NUmuzbK))dQ>Ce_L$yT#E zYqJ|0YbAyiC2KFveWWfw;_k1<_zt`yo4X@V#YF7L^cFNICReTXHveus4J;@(-Qe2s z6b=JC4aR>4rG1_=n$W}mp(0abkae!No2Bb&*dz{d;rP zdX(tBPPRZ1tecYV0ZXa*4$C0(F2F!{KS=STh4;`5(a>ngA)D4d1AsG)eU)tYV#koZ0_X@X;R?|`q=s`B+(wx{ph0{ z{5Qi0?Wkhjz*vZTPUL6Bt1urnfZ-yXb?f+zE+K?s_wnQ)5&td(En~?}e`yV5l{)c( z>yU4On&7E=U5J9wukUBHJ9QZW8302I>w9P-+0xxe#ihoxlVTfax%d8*#@DD;uJ4qe z{?+VFZ|=>N9vHY#EP0$SFU9WdK53lf|rG)DL zPNJxU?TE=JS{ioWe3d5GK{m`};C-0wE+3YekZ&;CXD8o0Sg-H*7y`Qe)Vn%@&=6?JA z2Q9hxXXxRdAp9BjUvB&`b;@bvs=~=VMc~Z%?aVvz_qI1~9e;QZOU<(u>V?_*%76r0 zzhk++z5F-JuJz}r3}58)RboQhFPDoOTATX(*3stVW!xp5d6TgrRMvDnGZZ3sFv`BjjL| zit32(@m#$fTSHt!14cKbpnK(pUiM`-$;;Va428mt)f4sL4&$oIm`WwKC=O$J<5<{Z zY6lsk03)Qb(%{o@`KoP9UDTS1jYg3}7!%aixSb{^TghBGjwGLG7j#$`35S6$L)q0t)gP4Vi__&E_o`f zljw;k*|z|3P$C+c<{I)3orFRNqQogaT)Mu9Bg*X=Q#WR*$?l^VYu}&-ga%>+L+%a5 z!rml(d@yg9pEFF*!;vyB}S0b7P-*$dKZMk;{0FRS8CID)wGhS?@vChnH=|$!EudP&n)tj(E+1}I1WpLV=hfD z$%X25dd{~09^FN9!zA(|+H)#o1~qcxU#rgL6p}^T$sc~^aA=|Pf3=iPfQ%3`HfU6p z6p~f%Fu>??ypd97Frz@PdN;Nk(73!;9mBV4-o=Q#=s8PR3tJoVC`l$-G;cHX1WP?8 z+W@Gf7b0Y-S;ib%d$tCHy$02Z1&y_;eFh_zx80;EQOch8^X#I)y&GnRX`i0?;i%_m zWwxBZDlqJa{gOwFD{pf!9H4AXx=d6b)}hVUSkqQ^O9zea;(|DY9jG)yk-9#PtYM zBtym;s+a9eZENvsD=`raZ(?9nMk1P(+1{4xJuO!_FpY_soe3R{8sqAy3Ik=-5MeOo z866pw!#?ZaLN5DjW80D|lok>Xyk2qmm<0~;EJH^_C@>xR#5j1-(;yMwKQZGx4fU8@*(nHWUHQxqh5)&jC;_9lxVz9?9 zy(c&FOV6I()gGRH2m;xsS%8&OqnJB*NA8n=Rp&?+OyelH0ve;N8W{RfFo zA*T&j0wK;QWCR!W;wjWeNQ!B||G-Gt{K{}A(H7?Fgo4z-pNdBoiqc@>nql9X3$ST~ zv`z1lJ4=1I+#-3>!=Gf;cCCig-wx;Igb^Vq;*a#nS#g|oW(pAYSL_(Vob^1@DQ*D$ zHzA<1U^Jyw#>(3p9#4fg6nu}DK~?;@|A|)eiX^a&IvyF@)*HY6g-0~k41EKCG_Q+i zmE=B!!XMm=lTvzhq%S<~BJ@h#_uhD3 z>KtM39t8$pRMpsNfmMc_^aR^1m>50fqr;eIr%38DH}wh>JXH)lZztaV>pieZ+u^%2 z9s{nS3qIkhyLv0B+`AE|M1mz#?WiNOuQD2v7nn z@!FH>p@4J=V2x2k%eFppl!OA9xtp|6c>0vxm@+R7{fSf|e=6@4AuL_m7tE? z102`Q`LCHVebzJ-++KF9tS_+Xr-5$2@KwRL?v$mq;uGSUeSyB61*p`|?z*WE@g;!s zVsC2ihjF1RCE}Aej z5KH~aOt-s%VZKT~E&lo}oAJ|q+~Ep8au6~%XEx-p>&x?VQ7L%MJjVbB7Wv~yngReH zfdX6K^Btpa7Mu|J_uhI;mAfQXR6}BY?iOX>sTKP2*s{(8Tb~hdjUD>f8Vm)LN-goOoA%O=FxsFKV*QzF>Bm zCK>)FdUa8Zv{jtZ;f8)~UCZ?MsvN83_m-05?_s@Ns6oHuL0qb}V)sGPr>LD?nk*|; zR?@0n416wQE#Mw&ZwlNGImmM15%0ez=Y0|Jajq3+=DKNn(dir54-)i^YK7aJ99jWh zoQb>&AKoN_WT0c6)ZX=;E{2@;kMCCK-exMyUKKb?1&6zJuc7^b1XhvoF&2U^hJQXe zJ76Cs{xq@DMG36g-upd@m0$b(GyK?+=<0XG7-?L_S;?a*yUK39v%vV^E$hEoKQ=~) zWADX+SM-t6a5al-SDq?5%W~tW)e^2j08oh5HY=6e5*hfZ@Ub>V1<=;#j zB%Kwh0G*TWlGeup0l*ph<~izlPJ>uAkk0H6d2T}xlKPb(z(RO=xgH^z zT1!~U+;y?{w`d-?-!60=W-+}7ef)*i1^+>tdn;?_yX-V~qdJcC)M>}jV=P*v@A?ZK zaK0IdqnT18Tl&^6j{+EEK5Oy>S^Ve8e-3AY0Dtf?QKxZ{#?TCi5>+2i9Acvhp+T!q zmIZy5kC`kHNz}D9A^ZTwkFKMvXcd_e*6*VC!VcrU>EELxLV^vDblwK`Nk(SyFWl(b z`4?kUYxVekOl-*~JYwq_a5W3|!5SBc<9?NrU;}8ljq0T!vI!<{Vtz zz9V{Rt@i97qb%;=Ll%8*CBdnCVLXJPVuV3dgx*>7774u6s`}SWjJw#lokYH(dA>3# z{-Rc4#Eh){HiCBRS83&TO1dzBi+5NLNY4h9+lDgZ>Le!O z*fHA&^-5?M0mG<7+V`?KvHQ<4#Bo%beDiVO!f_t7>A)4mpF*( zu#11I_K4>>`VkNT5YYHSMCTa7jpY(TKd6FA8X8KPW=k^IgCNvWscZz!{`81(smwj7 z0S;(@`H_)DwTlJX?5Vo8i6LBNTnwe(4$Atf$lQ9;B%tiBJqx|FA9!WUJmDiScr5*2 zqFgnf)cZubV)Tj8xvCg{8v^^l+F_d_R#H1k<-vkD2b!rjTd_~Pf_+P_Wj3E`DAM3^ zE%bX81Pi1>xmT&-86?tOA%2qd30U7+pel}l0ts?uE6{7>7M6HunaP)$cIUJP*En+? zqfr@b)Zy6<_LPLt3ueU0Y`Rh4JwpfufWNW11daq1VkN1tK;(iW%Rxw*yx3NQxb3L38y6{5TJPIR5ca z6>qnJV3rq?3m{l90;~I1y?`c4OT0<|<4kF9hax`)@OJ2_uy{FnXS=Cm^Xpz30fhr9sE8B6Nock3&3o0V6}c z_yp80+Ju~lHLsPA(h)`DXd1_NI7eePEUBJ4X!3skh(^hs!kBGG!`%1WH5NTe%EJ$g z2Q?UTFSlCl)*FY=D&-kl{i9W%pnWPM>X1OgFl`KnVDsrO8gK=-S$mV zvdg`&AK0-Ny|7ox?Wv=<&#>T1z2};}EK;lIRzIgcp=QPus2{g2nL0TH_~D9g_JkWD z3%)>o>v)v>n}9|b4<3vsElIGLN2m-2OX2wN3$>8=qSQZi0>GGuEks&n%&dskEfU$5 zRPO!nofLHr=&^%T{X!5`7mOWE$%>Ti_1B>l^ras1G)6u3axs=nlB|Ym91DQhP2n_r zT)9f(Zu3kDm>y6ock)&SJBomY1_>235tnADSw5<3h=jG;4e(Pm0ddtEqM44ZM$7!69QTra``vLz?KWZ+uEU;TU( zbnU3SeDuu}Ze-*#nq|X%C-1xz`v_qjzxIKR#3|4KQ;dNWgT%YXtbghS=181>cYsyt zwMW~t|^mjLV^TH}T1S9N5)O*0MA&;r|bO&~TM>odU z6+WgX=_-e)7g^N8pPF|NF2@)!P0gRkWLR@SIO&)f5q?i#GBDWB8^rl6_id|v>bEjo zq8Iwy*%3xF0Xg8}y@m_{02QyHsO$aIK+Veer+ny%{t6nwXDW&1=>uB&Wd_MF+oPzB zUu3gA)*$j|X->J%;6#C?@0Ub;E8v?oQe)}T=)ZD)a^My(mVNb8IKAA-WhHoAM393x zAh+!^71CI>VK}4|FL79+n20c;nQwL(J*T z3cQ`Rb#=-=+o#^=U$B#b&=BhoNaRIN@bHxT-FXGdf72E^S(phtV4%3`9ST(k`@28j zbgDB;RvD>`XDhv04kOXJ3_5%`9ILFJlsa-Vi8sKOcwVK?2l*Q=JpGkbr@!e5M7bd2c26`i(J9p7*t`&{{w0f)7 z1ODZ$B$PBPLcoZVsm&hnR4W|u6n5SwnhSP%Ti@|1lub{Jw2TT>(?f3}SCK|8NoyKm zOXKQo|9Y2BHl4|LsPA75b!4ht#V!D^o$pL;j0z2kdS&bX%re_1*5VD_8?#(HRr_=9 zj-_lCvz$~m6uvTC$fC5Gu$fkQ*o_W_hgCh{Ugeb6kzIL}8D*c#*8YSrec!Y7-%-Xf zflHMtKQ+jJzIgDY102Gb|FBqF^3Q1Co8rZy@pR@+k1nk^+s6&>Gbl-^iVi1p|bK*ddk`)1888BX?*q%+MyV} zQarI6ZhZRY-yeAf!O^-WF>H&)?{tc0sY`UJD`q_CTr!kUq}FUzBlIa&AB0QjA1ozN zR}A|Y%zOEuL<>J8MimR}rW@i3?`ALD1M8)`7x>d~@IW5XChw%VF;7MkWT^nxWm$+D zEZf`iUHIw$JAL|RhFbiakH7z$%#IlR{hM3=($Tfxliizwll_Ur?N>3k^xhGxvXsl2 zgDCOfk>{FRxr3kjBEnL=*Z(GjDW$EQBV-JnH}su1YY%$f)`mD;-45L??D+yV6$S$% zRyQnqn(3SrSvKmg`*!8si6W;{AFb9twhb&Kti5NYq{)9- zj7x)PQOk+_#fbiJZOIvYzM{U?Mi{1BIE4*Myhd;T<)FGow&}^2%S#p8*pIdJ<(7(j zK(&*ijiR~MY-^i$48HD;{q)79$GiDJGCX%C{?Spt{okE$pG9}TW%RyXk$+ej^xhoz zm3&FGJ0r5C&!7(Y$a*30{J_bQ{ww~`5Gw<(?VB*SPSJn#H0u`&mhPGcBC^4?YF*zB zkw4t#H<>=C!xSZ%)yV^$1sB!(OYOGBqGMPWbv#WTxUR^A#;N)F5)0(LV8 z)#C@k-`T(?BiYoSv(5YsQp)&j=dW)A0fu~bvUNQANc$;%`_D1zaTv|E)Sakp=MA<3EuUxMz zQKZLHv2x+d@L)sa@WiTD_$SoDiAHY!_xM?YgTG4}`cahJ758~{Q}y|(WOZ63EgHD! z-x+JmS|Qj9MI)67UWk#g>5*pfmtGSn5&-{yx|j@r0`39;g!uoTE*1mFkCpGdviNW1 zRnY&^#lGEQZW0oiDyV7izRzc8KL1M>zlogv{`)7;2_6$e41M}Pa$(QsY3Ui6Sr36Q zvKS$l2rn@S%p;||qOuB*U0YWl4)mZUA%VX{wjmkGh>ft-eQ(}cG~fntL@X_^wmX*h z9t#UGsQ<(Kg4)pX$23|p5+T;{i8euJIE_c|;?Z%+%GV)HAPt$&z0VU{jGbiCMu5XF z{{Z{#Oa)(H0)P@LY@G`^Bni&Qy#8QM7(1G53Ue=Z6v1>7{P>z^L^z}(nn=QGG&0`m z)R8Lkm(%WQE2}G$BfmsKoGl+eRff{Bqh@TI%?1!lG$&+=yWA&hmIz4jEx*^8tJJ0` zFu637O{`~)p-;3gSEqm>hy3Q)_Kjaz?0);A`Ex8A%wok$ABKpFrzKPBb|vvp0=wLW zG5UgCEp@%tpIbDM5kq;^W|^(xTZdqc$i=ZpW5iwIag+Tj}@8W!Vh0Y4dMU~92{IW1%PVHB?@ z?gg@V8kR+P<+g3Tn8Z^)f>IK@N3GxOt&m7iNq`72ihy-|J6Xhrc2GcC36=)EhZ`Fd zD77|a10{~~?qxtT33u$1-#)6ayTJiKq{Pivs3@Hy(|lu9=ZPf1nv7m$>cp1oJcIm& zjJhj7q<+7U#NLfF!Bk%)%C0;RdCx*f5UZ47G&4d^x!gU$UG$#JkF;P*<_Az*Z`<&m z5jHR*`bCUM;XZ+XS|t#Aktwlz|M~UHs@wzXN+iK(w+OU?eefe`y2A2U9uK{l`$@9j zd}4|CRSwOutKb6@ex)O_3==`#=1>z-!GyWGN;*VPa$%Q=79$}|9oj5EFp@}@v>6kc zQCDfsvL7V#n@umjZ*1|+j z%#2GWZ~y3|_Igm&yRSoHC54pb>Q zuJ4}ypt%SH**>@K+;cLu3F?rpWEpI9dE8e|s4dG`GSJpd(F!;yuYss3|2lmjfJv{+ zz=z!v*1U2OroR3)Fqaxi7zOni_E#Jylr(>W_Q9K}!5`)Ma_;Uaa=< z$rMrf&e)>p?KytuJ3-Y>cGA4mYL0(kr7fCQx$b|1+RLZI9m#A zr4$ZEAxvDIFAD%5UcG@W<21j2;+uKg%{}5buOgC?HKD~$3WQP+V2oZSBwwZqxL591 zU9JMZZ#HOe?H&H#4VKcw@GbNCC>wVGeft?u0UnD2xyo}ur*V(PUu>naQ2?R%RVpC) z2r3dsT?+7%i6-Gh5(xj;pKWMb#MLoCfu3qeJP@5e`E`xvAKmB!(jQppTeP1D{d>we zHz4bZv+jtIWHt@hgxvq|!3Q%o!@?*q(|%o?RsE#oV>C#fG*uxAKQ1@>6hqLC&PSuq zpAZw~=&W>OCDzLIw29|Jt`qB2tGT(`$5Hp+IPA)(^iGNwv)v9ndX!Deb==BoTC4Mg z*X{4bCy@P+=+@pmgr#Rf0oI7E4k1>>?o?^}9X%axlMQ|IT#)!_=OQK?Ng_1m7uc8VB)s0LKzm0v;>-<)+6 zH*ePDZc|3Q;2xzOszqt-;Klv0PjN_OSdrMU!BY?24fr+fnn-2%C2%Q~knSC?qm^rd z>sz3}G+UYMSrADfR7-!yC-O`v^HBS6o*1pxa!KHPFme0IgQO41=!H5HE>P8Pr6~3RV9&G+@%nu0!!5oU;25CO?9mu# zRzvn96YW=DmJGlzeU!tg)>HM*zZHiA(0uiIM^nVZK-TnLi7LM{eu;wNuR`SH(=$Ko zV!C>IHT$HBf1zFlHR>w;Ue=FAlaun}e3RX#J69e%ys}(PiKq!H5|4P)r}1}Ovv7Iy z7T!}gfY>AkTMj#49n=)(E_w{yZYgNtXc%pPKu)Z`UTSv)(MF1{&aJzf6I0-$*PgKk zhI{HyLfsrtcHSbfvj+;_D_kk=Ipc9HIak#ed6aN3pb-E}3YEK~E4a+g^CtV_&UZ6_ zYsqa5xL@*vFjh}!_1fQWh4P8Y?frqrjn&(w*(T^Z{;6S+?E z{xh1;Ue~e(4Ex;15j6VW4=*-n#XnuBeh?j?pu3}K?Kb3tf`dX?D)}=+CCmHUPjnbi zfsd^N)dmzSB}M|9<9)l2*YC-H{GGtSXXO41itq{ja9WJ2nUs^O>GP|W;7Vwq)3PvY z#uI803~xd7?;yvGVBYpaiuWSW#6!#kRBPYVc&0Tnt(JLIEeLBD8QmFJN=f1`5=tg2 z*$KY)3jzkzMeE+g)&zond|+*-0~;tk-aEwHcaD*$6Sk%T;yy-B>4*C{c(1$z668Pr zO)G#5F^wz-2yQ*`8w=Gliw_D>iHRZ-ofC7|rz@iJb}I;K;(p4yWYCNYLDV4?TsZO!Zji-?*veL@_}_SVVVXzV%B z4e;q$+BA2%TrTvg8~Xhyb7TQtEeTrMX2=Xu*v6X0IMP!MYGTi0bVrTpvNA{}*fP|D ztQO(|L{dAkNevLg$txgTkI*;$&`}EcfH7^3Uk@+n)9>{_(HwVf&|DH{y>UdYzIlot zmpmT9#Z)9p!f{|+6vz!P@D*kdm<2pjF$f>~m<{JmlZII#mW9h=GiA4*dxB*lb2-0m z(sGA$qhIC*y5t)36@;Jw&xsuUQ3Y{{0fUv|MPPkTI`HhcjH{dLSN)(}q&n>Qj^oa= z4F&+(EbG-1wiC?QwmeALlnbNeI=KTc|@f%up&)YsqLKN~(5lBizzjt@KGS-imYVb4O_@Pt|2=a z$vt-iaS5fUPv)Nq6bmk91>kbqcS|Mq@@;DTGGG#9pwxE^8IUXRH4Q5XS{(K*+q;X8 z!kVjSTER2Yu{Jo)HJYAaBdhTZ#E&mM9!V>=3LID<;UOy%+si*1sgUTdK(SQv)I9yD z1zgwgORuHdWYbw5gRSsrPf&D!P6p%YlR|n?nM>1>UxnizL7X7A<23Hs4{%zE3LcLN zQ(Kbv!NtFViXXAmq_EVW$V%S}`aC9c=sry>eg-4KfY2U9%|U>496;X9w^S{vCM~}- z8CA&xwr&?qB20nwVd#R?y9~HoZkoLK*SW8Dt1}F129)ZD?$^KEEpla1>>teKr(wxo zOE;Bfw?o#_Hb-Wehf-s+bB+s0(0~Jfp&1$*-648-YTg&C5X?mzQ(O1`Pe~{$Hw0CG zqSSOUmRXgm^(#mP1u2ujfwgA1mkUivxyab-0f(=kz)%4cjg|5n-e(u=Bxxu4y-<30shicPv@1Bva-l6sxso46UU&rvj}w1*+u6|~9fKoE zv8G>m<-#SwwsHoN#6LEW0i}1>39(oo8)jMC_EnPSIqh{XE_J7lzO7d7MtQW`$0xR! zgG$DTshd^329hk+h=lFvzoy@kEL}Kx$6PT>o>9#DV9n)^|xz`W1_8OI~>sObS8WD6Ua5Mfu9jwD?AW(0Al1d z%*i}xavSzwjAX$RXI1cVfTd3zQF=!~@q`70X3(=pB|;na1Bee=2zD<`5DZVue!~)c z09g%+a7Z-O;JeeCE0VDvi+bO`^iugC6cuSKA72mMCU0C;@{Kd@hZ7z(YYik#9ut$8 z_R{WC@~!rsDh;RZjk1l8zQm6QtF)iEH3gz8Gsz$wx4@m~F$17#T98(hN6)hym|rMk zLiO9OjXaYWs)x$BwF8C1HATXoNUn3)7fH4b21HceQW)cgL%^gGBb<{#R=Bdvv}mz8 zV4k`5X8ZPg?1)$D=lZkMf~9Uu&zs>Zl(j*pu(8UTa6LZ`d}cp#$FwV(#GVQ_MgWaV zfFUWNU#G_mqx^`aXdwI7Z()axcHQqynZM92YOnnU?CItVzaaO8vAyXjqQdCMb>-tGegC9Uuu7KW{ zX*mU7)&A-DAdu^KGI1IrGbh7?uR+>(I$#Jrj$x3bsXAHGq(5grjTKzvmrnf@7u30FljS7$mNmb0mZd?7j3#gBlPQGliB ze64EbOI%i6-*}Mm%?B1ZmzXq%TSd) z-C71P56yCq*Yb1KNX!;E17z5R^H!*y-QJV?O?)Ah6tx(~T_Gh- zf1s$_e057tNp0)h_qTqCovFh42NloYlyi)@n_WNrCKTcDF_j4|Ndl~=G;Uw$OdH_I zsNJn3jyJR8`=SYp9Joqb&v&{69GbUica_p8GL=c#Bovhca0P7Vy<0L)-UJ;fC@}3* z5ZY{?%szAwnl1!&)3SI%S-31{>nFAvCzduD_V0SHI7>X7WIvpq*wstE)c~zi@?Pzy z8q732;5!&sULj!=JCdv=8;alYg2XNP0P&yDyzs-OWL+v65SilsFu#_2?9ZupyZ;r2 zD@TY&Wr9{6Rzn+B2Un(8kKR=G9aSfh9UOi0MgEc$swYN*DF@|#EWn3x)FmwVg~me? z_U%@x1k`qcf!Hxl=chgpAn=Uyfyplh4%q%t>8+Qmo9y$uDl`siFpZz23dN_7?ytrc zJ$JmfjlSPO5wq0Hb4&dNGjV^c@Z%_~?$U8(DzS-H zkK<2z@ug7TH)Y3FW=x)qtTt{_lT4_vf&G&5xZozR=DIMnY}!2!9b;de7oG)?HC!Am3>y2s@T>h`@Z~P&?AHDa=%xsN_%W4v=US5L zU-O53qq#cw^Uu}fAQ9-11a|WHn5HbkDSYq=M9SPp%BQKMbd3nF)2bO>_K?$0Lo2ea ztODs4Ti_5PQVvsZ(R3TW^ zU7er(k&XjlHHhG{-Q?k55D7a%|Cm7t45b#Zr$L+0R#q6(8k^!Z8QwSzz2Xhp{lz`v z1=bIfDUXflQC(ZqUwm}*Jsc{#kMi;>gK8n|j8dWFKS3sy3-y z&lIfgcs~~tp2Zq+R{YRJL_v7*5$21#y3_BX!i_x*Qu}61T7YaNj7z5D)-SB)&mSKz zUIDU((6~ZvOM~A(|4SHZIc`!PB12b1!EGcl0I@0!k8u4YIgEC;`;n*&p`DJ|84Opu z#1VlkrD60?II2liO%sg}Bnnlo?0EmmZbzGLN5hG6@Mevtg}qOIt+QBagou)d!h$QI zFkosE$9W$C8WW)R>W?I99ODyhb6RpWI_jN~xUqq6oZ{PRnHu3UL(^o$L>^9BWwHta zq~YW!G!`P~(R#PUTq!rE2<%}y^-*(lhhLvHoJEtdBz+`Jf|r-ER8r5~c5+m(_!=9@ zEihltBhtm1HFHebj|WrRq^=H9JMRFR^8>7hnDWw)Qg?q~)rJ&&|gr>TLq z$*&kV7mZmfxq#cAAQXjsvYe^ajfo0ejrtZvZdgmBmFqEaENv`M9h-?;S;ap_lDJr| zgHkXZPEjdWP0eww`j(^rb*3sja*63(1US`VK{AKLw(%RVAyr#|$8wGs!esM8afQfr zg?OCwJwvxe>Rn+&b>RFxHci|K{4F=mhSj zMhPE=?XRR2(#dljPf)>#sTZQHA+%rAvi!UE34{^SFZ9QIoN!ag&f@Cuf{Wt^uT$-( z_*lFz%tnl2>Hbg~TsB7{*)b(TUe2PgZssUz- z4V1I2olQ zv~iF+d(JX0UbX~95sL7cN;P&pMJzKnn1N;+GQ!*lT0D*>Dq!a)GBvmv%C#&rLQuS3 z%LHli3gO49z}{~^dkTJ#utN-#T-%!R=}%Zup+~?pcq1*FX6egP%sCAj-^BYmjs< zhYfa&PJlM-^CzP?0qD3FgVBFk7?4!dcSc?!QSRZU%zKn2mUX(*5ql|6`etS#bAZ!$ zrlE>Kh=W)c3&?7sh>m;RSoakf(YxthTyzGU$@sL-K|2}#mV%bHiy6F1B6aWAg*NUp z>;HqvdM_wo08SnEiY>m^Ajm2uyPMdCOA`r0a{32&d#DU{X3si}7n0%gG+-Rwoi_nV`M2^0nd? z{SN(0OPilU8s-F^*nV#p7x^Z8#DjiSC`Bx}27@*(xBPzmIk62+*o>5(%QId>OjAG0 zLH^~JT9$QfCcE4#?syV{7`^@O7zcw*)XTpZhbp~TG9Cojeq+B&EFnDL&m=@LwyB$; zKNE;J;%#Zc=Gr4!gEZn^WC)3D#G3d1ldZBNPo~1yVeQ7L)<_tb))ZA$^CRgf(&*;WRPFb!4QRMwybyVrlDmjW z?ocz~D|u6kypw;sbOgtUbBwjGhNKxv{HFkd2(VH#P=y($B!8y`wm0jOQeO?y>!}q5}3@(KKCLt4^yc$C0{CTFkSZsAmp4{#SM2SjQ z^U`<6HqUT=Ojd0X95)ZA7I;p?ej}_WCbHkml@P&@Ovoyj{;u}BQh>7T3C$}@bCIkVm-{&jIE^2gA2yP? z@vCfS4PI}1jD}Yo{aq3zI>%_gr^7*=c*x(Ct7xvJK+C(&!SF*7VxAu;E7qrDQU3O2 zGVg<#A#@uhA3NE(8$!!6aF9L!guQkP;oFNjuJM?DXtGgASaElGM>%19bqn*Mlb=qdTvW6>6x9xeBmPQ!A#fi?6nz z9^HnFDHwvuLILTabxPV*V$NpBf7M93B+?ko??b7yw>`hRc~B$)m(ik~Vu(94WZs4} z3tYI;@A}V4YiNf*zoC1BojN8ZA!j5RCrvyGQo-~Vhyr|gkOIMAbB(xR>w{&t7HDgj z-pZ9`hM$2gjUnb4yr2m_BYu{QV6=M1$V>B|LTwpE|J#-_;?}3$BCnLoOIh@YAQ(v| z>$GnoXxmzyTcwep?6hWfGbYbf)(l!jsxMWEcE~n4ADGVn+?+oLYXyw*$D|7+ObMi% zxNxCBx(G!HU=V&l{q~ub01~2Ro}Vq~Snrchg~b!5w1tGr!K_#zDr_@T$<`&6&4r8f zT)OBDZ~KEzY~5UJJ6&vlO6-SJYPlyOSjTTgzv_ri0&)t}zq-CxCF`s&Oh2tYWKa+! zT5!V_g`vP)sD2tOXug!sx{T{MPFk5>XaXjqWg)|{mZE=3>T06IuK7Q8vE8(+BfVT2 z;$|X;=v^HsepmQUEm*U^$2EgWyQM7CT+%;Lt|U?_3k6bXq3Vv^$WDd0QYn?4DnNFn z*6TxsaLfd5Ws(*-+Ch*dR*H6AkYP|K_cT!g!E$7%N(-cF;z(JMzAL24!!D}(r;Sc@ z>c3jy&FSiYGely1VFX_$4h*}mFX3|-l0ghtEP#ZG0cy|F3h~ks>SSXpbjkpOR^FyV zsPLweq46GGhOW${^9G+A069ebR7s>alF^Eb(RrdF`gL2!?u^9HPxS5#aw!6g*Fe%g z(tUYzQtIp99ii)9q8GlVpCF?sr^R;V(@>Dkyd{-Ng$0>fPB^8hW@WKQ|WMzEBZ<_`vwbwAO^G2i6Y`75Pq2gQuPY?1s=7>ytbo59x3 zXuZi0?y-cK<4xa>o4hR58#nwov-<8b*-+Bh5K;q~%M4vBGv1uBP|p&3Vrg{O(iGw@ z`ioe~z+2_Y7?gg-e?)MBpS4)NL|8=0xP<9|J~3DZ`dia7!Wb|WlX`chrHzkJV60a6 zhPn7(8D!nez13sEmMPNoIQ5Eg^(IEURx7(VS@GFcO7>Q;Po-GhEe&5wWjMwt1?hES zAk^kAzj5lAtKs0@s%$qSRS_aOhq%UaTqHLfW&Ss^u7!Vfr~I z7OoGZU@d-+o_4@rbLq#Om*BiXe0XASRQ^CUh!5#f5#d7Fc4zambIBtWn3VHB1M6iU zIL1H!f=i9CX$&Y~s9eQ}D$!2X^yy(p#}uqrKEj?3$#8@hgyVln>B2vU-C#MeJvx}$v@G+U?W$>s z%CxH$>K=)YvS*GTuP-b#Azmy1K1K}FJWFAy^=3* zLg+J3askIRO_zTE;Qt1LmwpH3Ss%@DgHc=!lk|O^Z$bwJZ5!U27!PXjPy4(aVCjTE zAC1#4K|hmsr|~k-B{}tuy!F_CAv*4j^rtA8@en_9)zE~`k}vnRp$V-e)?`geO~{b# z7y(L;gpkh2FyNHtS`4f@Wlb!jt&n)v`S9EG>WeL~__uJSN9WjedIdb`g|Vm*j%bDl zfHo>a@!DDb0mnKnKM{&jjK?OakfT6pdkon8%v#^yF@3;}oGc=*4HP{fM02y+D+Cq; ziwmsPw>|2f^hhFFWgT+$DCq=ot@+B{5+Lp}G&_<>XMzp67G8K;PI$zh5_GyTI^i*= z`hD}^cg|FceKQngrN3XwhPgyAfLN&&QD;T5n#sfoE=>xq7Z zo{!OXu*g#RqCAjq5f!wh4%T<|@ILpbL>uyHV&qbXbo+pR*i?^;) zX$M^upEV8>w-4*f{*VX6kQd!$%?~v}-fJ7Rrs6v?ieZcHSu#1&kkq08xIzTP$H4xw zyRq@3;2x^=SBPrDQrxJ5qKPF_r36t@H}mUochNoR*9?5b79~7#PCxOLs=iPA>Uv@3 zZ}sFG9&>v9N9v}8a%#BjO~T1WmC63O2P@5BE?o z5cwUBWmWPk_{R4)rM%!9^qegb_kmLd#&z3YzSGQ+QQR|A=dU$vK3KGdQBf;MVCF6V zyt(9uYm&XW9^h12te1l?v%>gMynbHnKdpo`%m-UT$52a2vKM};vm;r^{QRg1owo7) zURdw!MEKeiEqC9e^Q^xr$bXQ8G{j&Y;v@gr*T00lnYX!P5Q8ryFFx3MOv}qnXJtBy z^v+lp&KUGS82+U={Y!CWQ*8d;b5`(av!x`C15%v>3RmmCu&Og?7+S|u3BR6xty3E<<)mRws)Vsh4N&MVr z_T{6pLYc?1J?xx*_sDO^JD#f>O2AU;bvzx*VyNuy3az9(g*bgs!?pzho6e{uZaug@}kQ2b3H zRKVkXV$aORf04i5#+_6NoOE3uKjv&>@w@-q$|K5A(nnosBWMfacjeA`)#v#4t2*9R zeKjMrzmY@or1R`8?0Pk9>?_+*92+5@(n)};=jkCkY1ja#S&+aDE?jXkV1KXZOLdz^ zpHu7N;m?sLJgMOA^lN%IqCMHa*pABipBzrNSK;EP8^eijOKF$?(Zb2BOqrz)UHnNE z5!!Tr1lerv8w8jM(V3&w&R>X;RWOC{7T$kC6&znQ+FuGLrt-JR1#_GGdCpBxOC(r-%r4i$A0#Z1j6_#RU(abN3p?^0RcrWGA zJ)N!n#}h_^0%sO7;jwS|si>5Y1C50O!JstlSb=Ay8YC6$_Y`9{xO7G7)fV>!ctE4+ z5BW0dK!UDn!1zw#|&$J(tGF|edZY{>gKB9p3v3T)hjNW-GQrSxS z&6eywsEFvXgBF<1UEQMm=*_|I%d@c;UUJ3!V`-hYY7iR!-ibJC5SHefVED@O#&T2~ zvv=oX6uS{k=aA^1|F`lcxv9K&|NqKcDJvZ3`#*;C|E;|5YG{@OS>kRC>CZQn_q5)l zMt24Fj{}kRqU~~-Mg8<(=??h9@PDh z%dkK}%m72e%8J$el`z|5%u#8!k1bst%m@{zwXc~XjV7%M@_&T~4S!@}ktqntS_25c zbAib2hX!rc?=a0WaA?QC;FR$ceCPltNM>4M)6cX#*1nXMz-rQkzif2ZaBM1n?J@aK zGpf#Z-Fw&Ytw6fKnQ7Pa->ZTVR7$fgNeD#z_rd#nLf(I@0IQlq3NYkXNufAAWmd=@ zKP|EL#R8s?d?^b=B|;ywKv&Pckuk7>jk1)gGOQ*$a}rlRH?@J&$g>TTD~0H{7Fug$ zk(kwhqivlaBk|wu17PJ>uc)j3JR}+@7`gk#f1g$9YwjYpaoI!b$7%- z(dTWGFE5uKOjpy=MLz%e0t2RK{=}Cbq-sl)6ZY)<i;0j$HqRP}BZ2Ewc7} z`nN<~PN||)t0)KZGpBVls|O;&MCHR!wusmOX@%)$zUXwI0>ejSB4DZgL7?K=RRi<9DiX!rG_y`X;XYJUaix1dy zk4vuE!~|eWI>6*yGB_8L`iP((Rn201D7O$Y&L}G|f~KF<%O3>;iz!U@r|?syIhl!( z(cCj0XfWM#R`|UAuns{7-)}dQBNJ3=eJvNRIS-1)h?8}qANUds)P^Wi86NH*wU2qo ziCLJ;Zqz5`4FNVm^*#o6P?BEZ=8Q!XvcX0J#bYw^cg_)-&1}#DF}3fp?w)y?tjcK# zRdGs9H6~Wk1AT)oCrdH^xb%|9xu_0WN^k9ixEr&e@tTA0wDFn{dQ{@nIFjU_psr-Q ze1YwYG+tXie~jLfpfh{K*^I8k@gbt}U>-x7t@`N1tCd*ieBN34()z31LM%Acpy^^c znf*h6r3VRT>)u zsRV8d0>%B=E(}cWB)-+gU8<6EZH$0#m0dF6Ow zFaS5rPp+K;3sHdRM*uQQH(Kob@#6U$!yw53!KTuRC130B?{AB0aK&_4-V^aWuL#x` zDz)(d(J?ndo#J>}9QThq5f@CjddXh5Bbk6X_48Az0r-gP>v96A1}ZyDohj8GfiL;y zYiMKdx_3sBBo`1xxLF+q9!wiR6mTHg+{stXJ64X#$*Y?+$3Fq?vaJyZm% z>5Kt7f>Gh=8h~o0t1vJLBU?jH(mvJc=hGaG_%cmOEKQp7QSpHB9fY&4gD+gn5OL!t zrjTkPbtQbTH}oWqTy|S=Qc=KGtEs%Fe&yr&VU!hGlisJVeMl|me z`E~f>7-~oBbtW?2q_B`BWUvJSMki@lM&Zf>F16aAC-r&Xt?|5!4Rc14UHfVM0 zN*=QYs!N`<`$6Jz8owrELO+nsEBaVa>&u%2)+c8_6jZDxChth6kxnAWwrn$k8&O0{ zol|7m7da^JH|fI%TqMz5I<(NU1Yc~l#%HEGYm~T5vhZP*OTH`)jHw12)3+bTj3QLA z)dlOXvJAhEsPe`~W(u%j)WTZRW0N>y*#=|PJ}t!DBo0ExFsA$@zUBcBgW2e%j;7*i zqN1fKTwH0u;h5HOejNaRCz}iBd|JQEMEgK#(`2IK8I&O?5i%9DKoO4dSNg|!g#;kf z2gX)_YBki{W*!HhuIV~-VhV1nEii7es_mvC5gF7UBlT<@-9PA2>!D^o;REn!;rqXH z7A;2F+A%(?cNEhJFeYQ9qAwnB$~T;O=$cg%&nrZdZ7`qdPFolL%=KIL#o}|m$s$B7 z1|nm0r^WD=;o|WR>$gT@eo~kHf~wATY!s{RLPp$`m(yaPwmd%=q-*)TP#nWj zZ}s_M-|)thhcO{GbX5x2SA{+alC`t*-d^Ou)3HrFjYUUpIX-iJ;GF3yY zDDBR(R?5M$NYYC*s@i@glHF>YOFLQGR^$if!`tCut4888{ppa1KzE5%fTB8X`6!Ls z?>oA+Qa(wAkOS@t`W9Y9?sQzgASXiH(NN$#t?abg(rHwcEA&J})|m0Xi&?v{Vg3h` zIjY`F#5DAqP7@+i#tE|hSsiE1k8D*k`z|!ttVlO7j3p5w>2Xt#`2AK9W>0@%G(s)d*N~3^(kP`#H{QNitnh z?w|Qyhqe8DZs=d|?>Y?4w~xYhKdxr4_b?O`ZDz_njgZ%M=m8A-Y3Re3Binbxv_LWse99z26f)H#knX=;R0oT3r|B9NtjHvRz3i zH-EY-^gW@5ED>Uf3@1xufAG0Rd8IYma)NrU@4l3>D(HJiXfTdJN?Q^Iij z8v#RgiNQ*P`DrZ5$zlRzqwF67%fwjG)*Sug@4 znqszeRlM~R zoDVM2Bi+d2{B4ObDKYwqmKazADPCfT;5QPx`ZIsBQ!a-$yb#hHOJgE?#y~Xhg>p5D zctW1<`Zr*E>f8`*O8J)iu;06h=#>d)WIbMqT{haeGd6L=m9Cn`@FD!7#X04nO$4Lk zq3lcjP02qoVy5PSV1S;u&4J&~V43fB_=we3h78XjOXVcU=38uwLW255tPGeybj05-F>nFn_Z*l@+Eu z0EW;hB2wWv8_!6bcr3Ast3*hlc_1Si00*!=wee(CaAC-b?{QX>HFJ%xPP&dw+Pwuz zkfZN~$eRwzLo@GKpL4Q2d%3qGc#sRUK19DB3BId$8}kd43j`&f#`8C){9_7$AEy<| zfuV?SAWnJ;Yq(>T+&0Ib-j3jmeR=g;@E|sj@f^bYA;wGIvI%c+h`QSYr5hLk^9ZI> zS9(o{oAc(WmyBq=!+`&$v+fL&;{$+ud3;{!rVPAUzrUu+1p5(wwD>6sIz7{K8Tw{J ze4=1~X-E?Z=(uNUyMlZKb5{*R7@g7S^SAl50g^a&QMdFEz=6r0fuTUo5@ULZVos%djxUU=+8w0c z{CZI$CqR&@CNJ`jbT~Ni$~m$X0h zU_YEeY=zoORDjmw#0Z1QUf_telC0h}6@NcAVtz9t0e_oIkU9|^nIwqE zpeXNkQNsc?qfphvRsF_s{nTDg&uURvFxVemm%K|vf@IeiOxA>|daVNV$3Sv$7WFYu z;VU?U)YSgS>z~be{8cNG->_x3u!*_$pG3p;ViQ5B0UHuI5f6+@pWI zR^!-Qt;u95b1Q-m85<7!$0?~)5!^1AzJA>F&#=7o>zmG7=@TK~Z=CE@z?=IL6__)r zwh%UYQzk=+0=n_shzn4LUND=4JFj4i$0DWfA|=AEP4{`*gkd@y71`@Qg-YspOewnUSLwENAQu@^+iY61DN|oT@SPCw6zPeH&Ef zYekk?h9qQf@Y;AJQFv{!B$0g6q?U6x3$Bz#5>a&(;!d60#B^037~htz+(n4*2;A-P zfxV4ac>9fve1Zq;iX{eNn_CxI8E`Z<$8Zal+a0wuhm#D0WYo<(5OQ>l@9T~w)IIHx zu7et;XCYOe@7H?%rK}xA?bUSdjds;}Xco0Kc%T6?8W{$fZc#fj7>K>L1<49dVh3Kc zrglzr7cswf4|Fd|PUsmD?)6-(-%#w`4$eV$7gZ_dJR$3S7Sdv>qbr=yY+?!$$G;!Q zgH|9+ezp+a1{x9RowVBzfUM`OJ*O!sao`TS_rLcl;Ue$z>EDmnQrG_J%>3JrxNh1X zeZ7HuS9t&ZR0voCg?8p4zs`sL4(74mE!MM!!cgt=J|LbwqI3zG)~}$y!(_o7+^WxO zZiNo%F7~k07rl<}ua2ua8n4f1Y0w)l&*uRr{~6Fn`|TTQ9_{4=Dg~3mb#KfApMr_a zj)ADsXAWhsrb#%z1l3)YswJRdMyTy}=)1R`{RO8(W}m26Z};{t4f)#-dsyqTqIvF0 zR2cF4z1*V*?8yRcKA52gJtQii<-FFH5-iQn@WNFAidBd9Dx%EZ}(z&cLv~G<4;lfzT)Ro*8xV zsnqftH~5D3P70NOy&hZa{cLJHR&UVd3)SU9YLa8dC5#0@^D$gViisQ4T;szB8xSx^ zoO?aUk{X~UJn*FZ^NvwI;r__qPoKdJpU>*3eZAWFX+W=LYr7IBUhF&;wgU~Sj5e3@ z-N6oA3DQX5sQhxsbqAYpr26-hG1&-U(4($qA?z{2&6&eCg|*JFgpYen?) zw#WLnzt%zZrU2Q(Y3IV}_b$X#XwcY#AZe;V5i0OrO{2V5BjI+n#W7ewi1Edq%4g%* zu*0#hDzh&Sn-2Edg1=M#iEQ(=6bQh8nEt(oH9NSdE-1Y)5Uv9xj;BqzzQ~Lxlb9;; z`VHT#jp{y_(Jr$u&NO>zKc(w=1O2>t-rGjo*(O5S0=tvDWq4X>`l(p z{B&Rgf=!NaXA6}tzo$+O<>mpC<33rw5Q%s&K%zR|u>5%`bsk^P5|O>6g&mKbU3&Mz zzy(S1dYP+~Bt+@}=uHg8++SRM0SFAixo|V{g+DNdlv{OP6D+iX6ol!KgI--zvK;M| z&4!4O%i>2qJO!MC(pQt?ZSU_)CezjMGEaWv6rH~k?YgR6_84F7C}jSV1=!m`{~KQ- zHCY&Wi)UctnTQFb5nGM!7&JojWb?e_ngkP1uBuUuqp#N0ia@8FrEe2+;YT{cMR>u!z!P^=C$Y%hvPp&(w?YvUk(e_SL(qwE@njyh$JyBaI%#@d^ z5vaiD!hi2%E$iIeYlp4aV7vk1=EeUIV^I!!6R$zzQf2Fe0*Cuz(EpZp43l@A*>+$3 zYSAzcBu9e+!=`fycA?2@o-4kb#~>{GUMbbc57b_iiEr7Zj&1fZtBYG5$}$gLd(X|0U_9R?_qdWFS zg8iQ7Y;U~Sd|m|PCH?fJ>NEKH^S_BR5zp^w70-sLe({U0WsXylzWhBURIBfKusr$u z>bM@8yf=OsP_)q)?f!i!8m~;*H(zx4XF=2O=ixxb2$2{~bcAl0dRO*Ee;Mbc+TuAe zdWT5-AV;3AHx+PEKMYQGc{CY1@B$z|zUcKn`B45RW$h}u#s0-jx(e(8NxuKN-b9C0mfa9Mt|lE zI8sG~9R+b*4~HGBcM%R=UU2~4)ChH$1OY_&`5!VBiKB`z)IlQ!;031obT^eZXYv|) z2-*SqdFx;pC6or0={9RxcyclG4EkyNqyFQxZ}JAj|8Yo|qK{Lsseq)}v$Qu~f6@B< zdYs-ebwVd?;hWJqJxyo&H3hf6P6{(Xo?9l=5439<^p}JW-rEqg9tzM{U2W$w4$3_! zi5n9Qej>zEvKB>!-w7XlcsC`ICo;9i&h$juKD40s`*p9mpG4#`1`&yH?bn};&GHxd zkFc|b$E@{3ps-geSX1aclh^~C1cjC^CX#9-lQ*A>%P2}wJlPaKiPRcVVL3s&nPD;V zSt~+a31@V!#yUH0bJ}`4BrrmuGA&{!l{Poq`cEv~a^P9DrlehWDsxA3gq{$coN2}V zH?<5zRSF&|rk)y+X*{>wb|xbV@3eakH~9U9dwsHJ;stwN7*~5 z%EX^#)`e1biQF9WVFtw?2_f(j{NB5w``K8PuFQoUQ=ZvY z_iya4s`IM@Mc^W45}Y7Biky*5?t7=3&!|Hfibg^sG7&b+KUms~gt{JD6}$<-0)CNH ztmr5j&Xz%vHX`$-0BSeUPrpxhr{x%gqGL!;Q%#W)tMX8R*hI+~xUi|u>i&`fH`Ku2 z)Za|;3FN5Dr(jSF{dx^ou@f1DiPia`I9v&f5`g?PTT4EXq9_T+s6h|2`KxI_Z7ma; zg1it*%QRV^PhJKe>i4HIssZ3<`lOnf8O-1v$*x(}| zbxYe3By{snRZg@!&&+S=6O09s@mYFa3Q{^DD#P^Z=FyqO=hJE|Ka@{dGrKukfjh1} zM&fYi9UMi36A~K^D*IHW2)fQkjOfDH{3t+vcl?_A=v+W8&>SuZ{GR2nZ4M?1r5z0U z&YwvFOh%5Jy4=gSp?%J$!!~O+h*H2e)mX|cupk(u5yut+>dH~=S?hkwrf!~*VE}pw zl>if6#RH~X2ZA%|oW%BXBTNdh+3Rky^B!HEf?D-irKw0j+q6vLOZHdeQmffq=^dy; z@Q+uF8J3TWctj14|CKo2cRydJ$2@QR1)&z>YmA^3`9wEj5}6sKuzk(27QvbGPlJMq z{h)K5Un?xOGmYNJ<29*vn|TC(fMP?GVb~V)cDMV0!I4n_pWeM5(TC!^0bXYaV$cv1 zry{7C6d%h2u#nm&P(Hr4fk}ENk~8H(^B+-V_OU8%X6~b>nzdVr&Xd0JPB&sa350Lw z5l6okgpwfQT4?CRMDBaOBzpI59mbJ?eWlpr`}Y zc>?6NO*8y#l-QHhj>q{~NxiDc%+R@o^s^t^3;WLt|8+@*7eBt-Tgj)x{Lw*Lb0Fsc z(AjMh*?J2$bUzIhS0ks!&|M)@BsxM;&PfDH%+a|*tQZT1BY@CV?o2h7s#h>=9Ed8l z-`(v)ckb%bK>z$(e3yY41Q=zE;>;>luz(DmHWGPGv(`b-06jf-2D7@^Kw03DuR=ND zkYFTf!b~Q;TWTbmvqqVSzyLQh5!dn_09*SI$1+A4Eor&VyuA6I{T+cexoryCsD=h{ zQIY{ewh?~_H$TCkj|g3qJy8{Zw6JShB)ci5thZAnI`x=TQXq@}QcptG!H53Y%m$xg z-e8e!DUm@wYkEAlzz4Grito^|f7tbqAR^sY6juP0CRi12ib-=QcLH8(byz4QBo;C( z@_!Z3GpZI6lW~5e_-euUu^PQAv;ON}RBhvXiK_q^rXRtqr}k09g!BPVG~jA$L>-zU zz}?HYmFV12;syfk^_TZKeUQ#O{1hM*v8DFgVAS$XM&ar>2@wMlG(a(ae_}UAfzdDA zhs*p1Uz~Ji`Dn<5@npOoG$NchJP^!&TExk?tLl0BH8|Rb0ERjVl#qH%`lUCdIU)E( zTVo?U!Y33-Y-~V`IBh<|L-{m%Q^=owFkS8p>zms(2Lwd2Nq1WD1VfAi#p8lp?~;n4 zu1ChVrKUw9@DN1i&P|^TX?2jXzeSUCKVwofoMPkdOXmS51Jj6E9a)rX0kh;2E_3V~ zXpP4Waaj}Jl_ez%Kwsc4I~Pqw0!)e16c3!g8-}k&p*48V?$XP>y;YbAG+ld?eZq9i zq}^Nd4=1f(KAwuOGYJS`!U8m~h4~l-2B_^dj_YGB7NUZw(x6P&7}7B;>5TyE4jaJ& zP8Mbt=~06&bw-clZartg_!$44UPpwiGQ8T2 z8B{=7_Utu}YU_$hzRcF0A(Rw#TKKT$eg8rC(MuC zKoYt3>mr%#h4se}|32aPZPw3s%6U;A2nFk5{`c;OAJ$E&I{UnyUAxbuz0k`9;E3q( z3F`ZF5D`3r+{AP}fNw;RK>$_STG>ezi%AUnI!cmjX)R@@G?TYd@+E>8)%Q%P9cv=@ zTCZ^QPWQ#^bJu&f{2IoD9(^KW$GQl5CCxV|RSnRT4TEJ8bHHtwt~>F5u$k}Dws~<) zIjFb^QprR4^hG|NV@y%|gJCgCyfUdA?}wYeT>B09_GcOcW{eNF}akl;P=@ zsh;jTS$3+s+0uQ%W$%iSa;%`0AMAVjWkioVZ;3$PoJh^#1xSA?@(Q1tOHmQiTY7?J z5a7){(|P~)#u0v6r@rOp+g~pTkrGcL*0QbpfHX!VnCG3WKQT^|{(IUbMUlP1QT2C< zkHELmsXa&2E%1TZMW~sSb9qOC@tvP%uMd4bW$7>P7Bb0Z{E`nc{MK|tByqj=)A*2X z1O>f{bI~dY0N)^CKrTo4BIQ99=X{M4109C=;>7$=Nr!^n9J9#~8LmjV)LPebIvW_{ z$PEeObu($QC`&QKfDJK%Zm!x7fNawzO;cK7-G%&n;_*-r(~RS~$C3VfP^T&m;pa70u)A^TK?5pNXNqgltx zCr?cCO5r9wNSWLD)^Xtd6d3<`PyGqlr~>;P$(CseWL#mPFOiaSxAZ;i4)^50ga1Ia z6VP@^Ah6KLZ(exH-%oP5xT69r<(|tt11(=@)#`j2`9HU|)9)}Vn#7``A1)|Trrp4u zMj-A87SlR%stN*o?%^h^Q3Bct2cXd`WBwS)ml`NA^xSvqLK~pCYn*=PA=G}iTKgN( zE@M?2P1=(bKm#~K9YE+NnjcP}zec!aa`L%J%!f7s4no!Y6tFlQz1WW=;wK|+0r@z8 zScv3Y{|vN3kGRR3cwo$EHG6E{!WfM?F&ypkBvO~w#Tr+sl3LP9D4^C_bakNwFu;w9 ztPd20aDI{SxU44X2F6!~5`(hIDFp{#5J zw9ycj7cZ_uTpzrWjzG3x6!^Xm*|-o}*9%56Kg9h2B<>mQ3p&(Iu1(%UEnZKmom9r; zRu0#el%y=G9I`J?lQw9Y8-K)6OJGhJY5Muh#R>EhlX{k*F5rSgG8_HV_5P=X`j%Ex zI593Mnj{DXxL?sg0^_(_XSpRz1>{sD3>|I!RO=<2@G5QQ%4|BgJ}ArS%wl+ZxgUfF@Lj0u8I#!NDG@|;54Wm$?7?8N>>Qrz!M zSgw+03&Fmb4wOnSz+GE{N$uO-3*RJj6T`BMA+$OboHs3qi^|gjF%nOEki)=hbN) z?yPr0p4MyT!e~!{MpF#pz!B^~K#t(RNW4vKaFX<(UOHngz8dsbMS}xP|GSd&PBr;6 zkYtKO_~r|>4svyQ6o?7+e1_|}I0n)4nSNOq4Eymc3u7{o?|9=S=R8=on89Hsgr316 zZ0Iv$F=Zign+J(+_c#Y5(I8WTRWt$c98w>xRQvKn6aqxBF}c0HaPd)fn7xTye&!~m z;%p=cQXs_rjnSmVOC0QiMi^bqF5UjbxK2^_crPNj3y|wKQ9_sc3iAlrTWBHzgBuIc zxPg*?GTArpsm<3yNgR48!sR{+0$J+W%BZ*)T~wQcoO)zSk1aGM@LnJYXpc_gMkjq5 ziAzyVK-%KN(R_!1TyP5=3oY838cccg(rZ7|&z;FVID~#)cL{DBmxUrh$+;>2)O26; z)MNCuSn#_eOCA}k_lC#LP1dx4PI7U_aa!d$uSU=?*)~7H`-_mzw=t&(X7$ui1lf=# zC(3F?#6X}jOhO1eG8)?ZjB1(ESuV!5AS|p^E})v}uCfb4;H%CCJ^kYyFPXtR5`%#q zR$}IODp)Qh4UTOy&+wo5lP^DwK<&Xk1<6W-fr^9mMHp1dOM1Qrq8X`t1C6ZTY&U&Q zA-}u^y@yJ3QTv0na~3I`FbPM~*6zMbDOG0{Kt?GVmJ^PMT#j29N)^}+i&}7A2-tZ7Bt=?_E#68H1KoWQ5IoMM&tpN}C(mel z4pPUMp@%uer8EjXMLN&sPn-Yqq-F#3eqwRq;hu>2+02RYoXBCjC}1ZtXR6=_?K(jS*vUEH-^*LJh@0Oet8zeo6f1(g!(%h~dSxGRtw~zH3x3LfFadS?^WmuF{9!wEDMQ zZSx%GQR2&ti?L*FHYqwxB4Mfj0&911X#H(JS|XM-uHnUkin*m>##OO@9e56Yiio7o zahJ+-*x{}QixVh%&K-W-bNZLiKEB-^Kq^$?e+L_D$v97+?Mm)TD9Mk&SfDXYMF?~+ zKYY!l@-}#=$auJ|9B-&9Oi&Y!@!W1Vu(uTsVw0;ZG9p_x78&doi@})Hq*vc2Up@fk z)Jwi|xE)tyEa3p=SOXO(IJY_@jbEi-L?SVY;!!=1}+T#^e-|a3ISeVGrU=r!VQoXR~HdXB^di5sV)A`Pv+%~tagnfL3|FeU|GAC%VB@;O#eDn6 z-t-`_{jV?Nk^G~Gf(x=uUoJrh`|8A)1@D^4N0Z+N>HQTnW(S`RJ9d}0E!+ah<2AAi zF>z~URn-Dje)J!TCDpQfrbK$^X_zf{ZqUd5s3MI+j75O^`+fhu>H{_qa5W@4vg*IC zS|8bjM{6HHJ&O5EL~6_Z@jmP~zxjAF|7Z4pOxmNXAEnrR1QbGL7WozaE=tHDb?q0U z^APg?77k^cqLx|}9cv8M`Selz7f^Dg%GGg|g(k}N+c9ef2OFuc=`*;i>mIK@xVJ9HE_A}Xy!bd#T=<0RP9@J~zM0*Q8FX}7eLU!n_|&|9G~rd)=2w%ePnjHw z9;GthRy9bx=w9yxS!wOr@XZ*R(U)*xNWh>iGQkEOY~6OEDm_m>Y7)~ zSP>euIH`vFPOac@m!U(^5fsUBLuTMqD*3yz%IBzaWP9m;`AS16N0rp}@fV?!w~iYU zb88QOo=t;(L_1u5OZYMC7%qLIQFjw*_@MQlqPtpwSo46A+Sam2%qIic%cY@1um5_H z(Z46VK5oro)%rN_<)4kN^l^`onwOD54*~|?ti7neQLkB;QSxT`-R7yYs#CiW1oS8X8NI2Rh3hAy5K4I)qU|<2^;u_ zs*m%jec!}&x8|>MUY!?Adf!%N|4{nvf*h1X&Gz`NAX56)%UPj++{>QE1uirx>AR-|n=dOfS z)H5=Z)3g#Pb#ktQHZ_=PcxUDoS``<@a=v}JQq>gVAdppOV!Xa;d+?x2K$PQdc%o+x z8VfE2Oh2MG7-D;%$TW`{dQK_#9F?^BBRq2?LK%Dh*?$FWw5mE#oy1pNnr+m%ArmM~ zKJ3UStgN6-kmi=QV{AJzAIib9pHF%o z{XdH9|1dGw*tqzFM0Mqq{|!H1E3R|%@(WUuisK5)%AZ!?ib|@ZE9>eT%4(V(H@394 zHMd8!b-k?U=nd)af1TZj4;&co1`Uo6jl63FjR8{=v!0XlI1mE`1ugT352~AU+ja}P zc~eug?9_*B6l7m!c1}NizQ~$lpr)juqo8A>=7(Y2MK}9nO^;R-<#^HYB!3u201z0I zN<)dD;)T&5hcY!87M-s|(O6O%J3Ii2aZ00QSCB{JXG^uR2Eo-L7XAn$%IIS-%_wXRA;QXgh(n{q7PEpE~IWS25yK+; z=(@2iYY739!jCN}QD$`^A5q=1rYB`UNyaF}om_{_1dM(dU11@aEapoI6%Y4qCQZp^mc6nQbF_**OERq3 z2(WzSANE-{HLk+r4#)16SjJ}JpoL;;xO-4!%xbKX*u|va(w%vTeDQr?&4>dH~V_L zl?hC64UYxWUb?<}Z~vZkura;NUr$hyVkWyyWxg(%t3onh?9fy6d`2DeZ5hQAmp;A(2V|gbujMg+yId;_TQ5|~`1)R@ zSoTvowi*@g&r2)*XXB`_BHS`_Okm_r2eGPl5|i^GBXa=&gZ1zK`mWjW+-Xbz)pncL zzzoWZS$blT?aH3lq>x+qw`C&bl_klmqmI&F5Q?n6hoKeVUo?Mz;hEpu#%^&pYGc`P z1g?BR_?Ojr2J<_QQ%9&~^a#q@8z-Uhvc)|>KnEq=M*jk+R{J=HRc{SI$%|1`89DP9 z&!(D6c54svyTiBfVE`{mM(3L}Y-WpSsg(x0@9$I5`1TF=L^b548_B5}%kq$0&83*6 z3T4tDBvD%(rqmXuk%u~&h{wQ!Pqb4{Dr*$7x)F2Y;KF3|s^x^TO2ANbF3Vuitr|%{ z>RHM%w<+ypLJjRea@gxqYXnh1tK~M^i$E|AL!Tauqy7EE@qpa;=a5qsNuD0YZX}&q zG5fW4(kw=rK9lt!`i&*sSMKzvq8nQ^dGuu%uvAh^96wr@L_aBy9*q%pMuUtDkMgy7 zOLmVLa_@Eh$SkSh)`NfTkGzO?iZgTMmf}xl6l-VF`oXrm=N>D&54Qmv*$!g|WwCb)GJA868s-_J9=y!OfNsmqGeSri@x0Bapr{uk@6m@bN5Om*U=2wT-h1hyIj9Hqc^w31j$81D!MfgZ@~G zIYE@@8fScod2I@z;jp2}R3>ts#zVHcF=9uo^|zT2IZ42~;p;$zQ{QA6I@vW{{hk9? zXEBX4nAA?hw%kfaIGY9(aN+#mLf1TX#{wE9cHkjH@xk|=Y;GL;?4O!Wf&Vb*7va^2 zO|VPo4 z4n)6NA3cmQ^IGi}zUYH;d^hUotN+{>77S~KQ6jyR{@y=fC_HOR`mgap8tT~_^7)6D zOvEOXJ-@Sdv?Z2veV%n(en@c2##=qlR#RxFSmq2k=TO{vvRO5uP2Auy1NXg6bH8&N zNt-k1vQ7nN6!cEfVVEJ)M9Q$e=BjzVNcwgzduzO`}^s!dQq?mn067q|#?eDn_1-l4e zMOPyHuFVq;-PTB*L@){T_vmUqy1?kwlEsb4#+w7459(YtKffF+M9L4=hD~|#GSA!- zbW1-~EtjM10qo`tx}K50CP+B{Tu5=#y5k)9Ie)d}e(qC;*6yiqJEwwtS-qip3~UhR zCW^2P>W&WPa^EWMdy}gqt)0;dO+wijPbdGWyp~19Zif8X0^Xo{^^Y+8UrMQU(2Dz8cSMQS{n2=6|lXwXS~~dDR~;AM9$o4i&bjj}0C0ZTmR-=h6Jd z%$69*76!C9V}gS>V>ljrUJ%U&a2#8uni3_&WE&lKLytPNI9)>|09K?9-<#>}3A8MK zw;JaP_Jd+*$l{oE^bG#pp2+>oXkT1|&>Ryf2faUPi}}fyVR9%M=uM=cNFqj2dOYC1 z1^(VnyA|_z!buTuj98D?d)IA6u6chv8?23Ryef>Yt#?+4jYN`x3j%*W9m@8FMbQoY6FB=$Q{;S*meIhTx zEhhPs5If3(Trf8O4Cd%c4h1OWiek^Z!tb&u@?xKKs?lLAJwh&Gs`wsrwaKelm^f>4 z7gPcaD9%V1Q`e4rWdcDvAy3Y7{7gSG3uIxWzQ%h)5-8*W8u{d07s~p3&apJF^s+IT%9X%0Y<-NlJprzg}OvXZ}%7rcH`{9iAgt zoFm+uBf640oP{Y4b*fZ@7zru-`Hv(otr!*} zl!SDJz$81@yJF<`y+B-IhCEpw z)e6U#nQPD&qqQH*>YAuK@yKn=fQjS=v^KPg^r4s|9rZ{mv@I>AJ|ARGF1AvX@vBI* zG4|wh_5&gAe_XDf4Ds56iJotBr4uX)Vt~`xEaY|;Ef8;*=&{h_9p@3Wwi}wh!`PC2 zpWK3hZie~OTq=!V^0rnEO;7&P8K%-$oxDM*bk3|6BJ}oWa;>}G!H-9AMHv)A097)R zx=x|iY2Z4qvqy5!+n3jG`%cI?yL>1}w>~K^q9|{o)RnKy3{-r=T9Fx%jFSXUgcnIk zMwF9!Xcd{)6ro&#Od^vb3bFyeUMA`YB4CwMZm1FBFK-gaa$oVb$G@?o{GH0MS7Px?M_A++W0?xTIyz=HjJU4cD?xVtaNp(wN zW>e%3oW#RCEeajPZ`oIe+0P`&SCU4-FgXJq;ra4nZ(dZ1zl8~v)C%ZUWI9w-WMRN< z!aB_KUE0;l<{~3Q%w(n_iYdHo+tuFNS_u&?Xasn=flYcw zE#M}PbPfPJ0cbG*xv|7HYmIMvVhSOvR3(obUNfh0O9q=l<(a{0eG8c-_?(WZPy425 zaZyfER64vl(>T+Kg@Sjb=}k%NHwE%HF*zQs+83~W2hA%w6xG$nK6qy#+ldgLl!+Jb&Dm zEywEE!vl6RS0+NZnXnWPvE-<}-?hShIX~VN@sd~EjEZtTgh2>JqhVHeZ%C7!P_y8n zj3>atvqUZ1@*Yr4t4G^0u1yowY2K9VzogVL$LKL+_I8e$!@pfPrFGz5R12=PsI*rm zD)#)~W&VsId&u)c2b_t2w^}Mk<=YfcR%e;xi`I!|nwdmBhaUc&=eLKOReuPZSH<(M z74bLrp6GPIVErSuy(6yV3@5qT?eTFpNUlQxpC+QVCib(+ml{M4Q$cASNR6BEF*4AP1sVRYaIS3W`M zBF1r4n{tBAodLgFWu`%K+Q0$$ek0Y;bMhe$t1O7jx+b^(^~sGdezj%jkT0chz$ySN z)@IAGt~ftQM^DeyxI0L{J0KhZF3Kx^GS_dD1@M)Ps;&VwQ6v($Zazc}i-|N(U^|`N zKxY4N4zjpEl9Cn9OAm)Asljn<;{if|6k1p6q=;2=D88SB@mgt1#ktb})DzR*tlyB> z+A&v+_iF@<@ZL{8om><{E{vc2c%2(j#%6;wN4+x{Cd#|TTK?=f5Y?q+8B|)tC zS=8&PY+yzV4?5beW;jH_cR*q@sY+$z|v+$sF_nXI8p@wEu!`eWGDdlrx%8W^>*QMn*K3xIM zS+vMf6f$f1#@)qKvBAIqu&&exxamy?@KT9g+?x52A#Uk3pd`M)V13?zW~Mu2D)FWm zV3r0opIS2anKqbNKsLF;PF7pl*M#;dDZfr5q`^BZ05hR^`p!xi7AU4R)bbkAKrcj! zfYaN|!{XN?N&&BbC^J3i{4FRq77 zfiH_UM@iEFOgfGQfKV8JZq{zpBi<&T{H*109jLUwrmr0{HrDT$aoSV2{R1)M)#-3U zK|5IiN=sGZ;4Z0GjbA8OMiDf3R*HUtZUrpq^)cqesjo=6-u4equVsrBPg#HoBI*Nopv{!??D}pCAl7;YGBTk+J%4-uh0}k41L!Zsmfv_#pz_a`kHp*$+9@6~Aw6{G^kFt2b&rq~^Ec z6#Dj8)LiUa!PeCpN%m;~Ez$(E?t7VH`G07QBC0T{G0a1#O%>uGKc8fA3+sH zKbI>0JD?%2$t2bauE5X^SJcU*|Ci!=s(L&YXMDXGlBSY-ciDbtMluCS<)>6{TQ@J1 zsqq0>(;}H!P>GeJUyqfQjktI5qs?CLn?!feyvE;Y+a-)fygwiaC?5l7mEqj#+FA$M z>Ur5%L5q<5u7!*IizMJ-0iNk{TqmrsTc|pIlkmZsp!j;g!&C`m;HPtF44f0V^F3|UMO9?esk z0l7oE&Kh0=I*GH|ML8!)@>x46x}xrgyx4~{S?}fi#9W7Zg*c`om1FnTtj=M(HMv5x zM_T8b*$vMgsghiRWK(H*wR3@ggKAVD7cnOsCR8PshFiYja~2M}wl4ABKTEOD_9-b@ zLpn)En9A0j=>LMFverzIVV<`lQpBp=-5W|R%Afsx+-~-yHtVk-expCMs&d(Rfcc%Q z=LDJVZmp!<@KYF{`Lg=OL!nNh<2tL_Ux5StE00 zq(1*WVRqz36EuI{6B&;8nD+!qiBIf+7%tkA=M(CTqt!8#SQnpG2=Xi45bTVAW4rwh z4Pjg9If{#=y_5H`A8v4$u@blUC;)~rzR;??8p3v_k;}tDR&?*^;%?C((S&pg4!b;S zVNNs(#Gkn~xyapHECTmz7FDxWRwQ8FtS-W1;!M6gVqEtRu+b&12PR(e4UnIjyGgJ- zS-0T7AKfg5g(A?M%Nq46IxDVH@(!Tz^876ETqxBH`$}7WV{Df?EXYEBs=R0?#fW+o zW2}^fA|+iJMPm~D7>nM(XAT-({=1s&E+zgoBcwa$Z8U)~B;ujEWQ;xfZy_z5W&0Ba z{N$!sSK1zK8^!>6*+)OxR;-JM{P6Zd^s6?@H%Z-Xh;>r zda4??`jnPHksk4SPr}OXMw?ZXc*yHd$9<}f92imIiBB+VJXjL-FxzZN18vz#VTTXC z77tH@M-(VCGTm*4v49f_LM@1*q@t<-P_S6%2wD)W;0=-|#tMj2pIdMKu=}V3bwjNF ziY-~z!1XG@`7Rlx61=L4<-Kd+;0UF?p24Jhn(IxWxX4~Gi3=Li%JA;dyt5NYD_U%k z{Q5g4rLQiI^(F)yCMY*A(?l$5K0y)vE#5c`LIvgIKs|ub_FC$DL=2C*-8NJ-OT|Oh zA0sU~jBWl{&HT!`W5eW&q`VZ{$P?JjV@y)I@BA(;c3FqiAK__HTqZA!jv>XgVz1kJ z9=8Dy<~BSj?nMk03qnJCb{yLyJ9gBA0BHw0fwA{^`) z$nndjy(IGIUil&_0Hu0R$;>>WG^u$)JW4Z$?2Zsv+<%bQ_T;U>(1Cg*dQ)igcE6O4 zfiq{)rrJ9Pl4MZ~SglX{)}(0+%zUTmc9X}`}4Q=vdP@GwYnwI9X5CJ%4*7TV{@Xd{4Tui zTXT9g7c}kfTtD_;#&W*H|J4Y#o3l;b9mJ57TZpT{xjUZbpIA?vdV&WiJ|6k_-Kt8a z_0Tp0XSZ7_5?hkeOgXEq-kuC$%FnkO_jpY&voOZhGGhCNVB=@+x=ZedA3st}ji=MaXVIY$lYCTm-P$j9#MTm1%D^g$u_;$y&ZHf)rIq#1;7?@fo}>nwUdI_=)Hh*Gq{ zBCOLPlu78z{%3cqd(kB1m+Q-dl-(aA)ADaX{V^ths;zi+o?ZIG6&hTh_?T}Y3udru z`C|%|S9pyYkP=?K}C$kN58wI3x||(fd@i^6j5o$>e>~6-M9LrBMq!bpK+g87BHT&kM8El= zV0UPB+e{NM*g;G8(|Oon@;p^|&MW%N40yGn;`MEHCpI0guab<#jGyA z#YsCz1?s_H*7+FTd-7Ri<4An&NqN?zwSIE>>0fVHQF|Xd*GG;(n)TIMy%hQw?eqL&>q`?D)F?f0@`d1RAMe)^N|O{NCT zkb!daXsXo3ECv31=>n$5exoC~#H{aYi*JR$+Uo4L)z7R-;k%5_-!4~`Gv~`OuHAj; z^px|>x8tsozBu(VMf;D*bq{n7?!se4e=}6oti8K4Qw8vq_ok{%^zUl@?XfIlztV?4 zNlMxxy4s3mVxM5XJawMp%(Yl3BZ;x7`QG}K3cq0ay%stfD(Nx+A@`-PrxTFNe9(sAB4bh4`s1m zFcU^#uNl(Z*17&B@bG)jOQATZL)ZlA_&qYNtkjM;mFd4<$y8wwag1M$f!AgVtij`z zQx>TYoN`IxENA*EwCzfla281J;s8W(;Ipo1E{HUt$9^BgU54xkzs^UvGLpdF+VA^B zD9?<+l2tAq(A^;koCSPgG+)24NF>@_k z27Q2+wgDg|9PRDt5%X;d90VJDFY_1Y7gM<*SUODHe(RgBd{}{9%WA8-TwCeI50SiPohqokyL+xDOc*oM=4Hv zX&y6afm~@3dTFsSX%ayE0SQ)rT*hQvM#xNzQhI#IL&k{yW+HyjDSh$Y|5jW(o85Ak z*|**(cF`TSlS!^rEw3FIl-!P0U* z*Imn^bND0~)V*ZYnQ|G88NOJ{s`-h< zBZUDOGwsAYZHbSXQySO2w3;98rCq0%(cxEQN?%HzZj-t8b|5<9t3qZlj6znoqj8M` zuRETn&lx21Fi&r7LXR_8hOJ3|>)haH<+C{k3j_vcjyIn%HxQ5&)uGQ$!cl1F8Ipw< zN60c#n3J$^mjZ68)cMAojFfaZI|gs?6R(4sdI-wGOpeh96D0qaAzQZ*gbC6E!Hho3 z4BoP^n9`_4;&zgVZJg_pU=9ZhubT$8T~NQqytEFvZ}WC{$n46@(PRrzp_;#mpAD}* zT4r}jgFKYHWNj=?twZ^RUt%Z<7;P(r%`9T19aTu2M0Xk(xscMg`=xC~NTW_??4o4G zUe?#CPL|B!6sNmL+4Jw)40dXsDZTo}Y#?BJ^Xxufo4H7E@P}kEDZK1{zSWtew2_8& zgiI}x!i5PpQKV%AVdCQb==kkIcJM1GgR$x!SW9YAv)i0Rp6OBd~s&P(?NU$;?9 zJK$180A+mz^{dwc9*I&yF~&%Y!B3UiqfppEDC7HJ|LmUOh3X_6(?$m?8DIqlIdGH7;o}SmF-I-3#ZcKA*22+2aMrk+& zZ%YXkB7Ma2Gwi2hJUWQtnq-c5feo3mmsf%ke!8nMNA^1VG3iSTb}(y&MRr?(brKg% zxxiz&GHcq#YaWkMf}?yc8x8ZLwgc_=Ej*I(R{zQH+PCViF-Ob2ebU(ZkV!VW+DV#j ziAdFR!C14znXpS1$ISbufb(yd?|)&849v{x?k@B!eyl~dH##^!SjBRM!&+n=q&(sh zv|=iBvB@aUyYhBJp|PXxSh^w`k#H@e))7IN%yUOjgUqBWh4(V!5LpsWxYH9E z+Ey7o`$14oJ896Bb}ox)#ZSiFTfT|%Xvc&5TZ`#WmQ!x#D_aTEc7;cFcRk)(N}njW zoz9|H8Ez`FXJiRVf5mc_AM@BKKF!1|C7F;q$a0f%N%PD)V?#Ub`$b&7iN>{r)|JH~ zCy#S)4g;}?m#~cDD$e2S%#rv-_P3jnV#QpZHTQ97G<&Y4MZT1>4fy6to>>IrapD!` z!C)@LJo_$7tVMA_ue11)pM%$mlPN0WKQr+MD`Ga*g^8?8nBpR{pe!Hx;$c3K7wt}w z#m2N@His-0{J)C+vwGBOQ(8)z741;$6`a>?Q<_a__Pl7#`^u9iMpn z?p@#1hv^yR{*2Dasy6Fklg0Lh^)15ewQ)4~qbp&nX8-8h>bIv&pU=)`hK{ceuda=w zZ+n1IyC$o*6@#(FbGo&$8FRF{Rs;MfuB~b`brh*2tDdGko{W`DLD~fHXypi+T^mOc z?^JVrtl!P`q}D<8U~>d3jVol24rwi9}0}EGV0)` zM-hN{O5ymw2yEj+=I2Z#qF)I+!(`NEJn);=^xsrK4E*Z;Z4MxCO>#ooU8lv;jVE+2 zg4Y2pXbLU6d~lI*XG=4v;Q7Mn`-q?~!xI?IuT6t75Nc|EbKtS}5|b>a&DjljwHkB` z9;oQ1a&h4Lfp8~`BV)zNop&smx!aoly9YI;Y}*wMA3qOmKjYf95mLa8^$y2kkcC4@ zKt-?6gXw|Se|CnH-3=|n)Hes}ZwjJkmmym;3@!o$?V=EFz?P6qrs7Ny3@m}^jZw(R zg4gcb@hooPWpqVEa>+q3wJ5%jltXrrg^Cl?16V>{UZuksg$qX<3`LnSWWQtJn;8n& z8Nx&MD~ov;8`Hzx>?86qRxn+h$Lc-4l4TZ(#gLPwN8(lHWbymIz|MjDv)L>Dpf=oK zm||ToflT^j0plZ?h7!mLUX0i!r^6*wm~(13u7y7qL%S8+8yb5ms!oL)L5ER=;V7kQ z8hJ8N9>%ND?1Jb#AT;z~KN5_>aX*;7T|}s%a}1WI?7ZrTn+m(po;h@ z1e@_Z6f`MUoN)s1U;8i0P2nnJT%`97!pKjHs(kRTDVoyU!hcZ(z=CJ$OGLs6F|Bj75p5pb$1NhhHp&nGlPm!@oC^9TQ)Yy< zboeQ>`M&yY*<98>m2==tWpG= zQJr@{!x<@s{p&izgXM7%*~{zstD^%R$)^9kQGnY}sQX|Z#*d*AN6nnJ>@ZP_PF-Cu zmSo`fHeL0B!g`D&1$8$%oPE<%g<%}$3+R;z@sOhD*z}OgEwXmZ&|*&HiApjxQJ?L# zzXkx=j9y}W22WX{V#kW$P~y>^b7C(e%(rR;=}$E{B3s0%48u!AdKhCV**%Vqh|!JZ zn3cIY+pm>yN-C3}VE>25OkZqc@7OF1^OpE1Ow4Lq9}8A<>>tFNjURo7H%&IK%ycI% z-OK;aw=s4&S5F?+mzN2bfA8l!-f-#Q;jIT2TM-$Op4r zT?ld(6j;Cbk%@uwUPmj{KAdN}FLfYk9xsA+Zqh-kawuFCCfHeCzc5ojm?VZ@Y4?$c zJc@W^76&yJy-i2ROKf(6kT_x`Ed1mhy}V3J%^eVlARGdDJdEeWzJ*G=3hBn|K_F28IAF+EbN*pi^Itwa$vI8nVgjq0!Vk}4tfq?;K&ekb3S#8>mRJm0kzNbz{7Rh$_W#S zftRh^+F}o^N|)wuVIYq+#LP==ClBwRKxF01eucjx4sN|O>-ieb5ilD$jiwlSPgtx%8$zB=7e9a}amXplPBl#C%2LA?t_KFzy> zf{@Ww4rmr&qE*(d{=0>lYUcY9>h-Y07He}yC@?^%)%ykOT8v+{47PwWLFeD&!w-~* zr|U>a;eT}n!w(D2vi=G0{@r1Gt4C8EdzAK-Nb=lMENbs8JIMj0EHOB*%@T|wu|Hrd zj8uzhGs-Gj-d7+0)^r-uQrlts@L=ra($A@LDR!us&~{G$qihgX!@kB+@fECrL$#A+ zqu9!MY-Rc7?laIV-o~hP5e^@^=ZM49fxq`XRFZCs5Oev{!t{|&EFe}{L0@thuQ2mM zxqZK+R8Il0yhw>j3 z@qYHSim>R6tltkg!>$j=)WpSfuWo6;uR3h*o|p|GS4q1o5)jL+B&Z{!KRXr^iTCFo zvzD$C;|@lD{D$Zl^);MqN|vQl`0*1{O-My2`lvhm7RCba`g(tUK=u3P7#-XJaDQ<7 z&38#3({6Thxy%UsKB`_^f?(^(*S8teWZN=Corc05%o~*kgqbOPiWPVk8t?N_=C#-Z zQ;NtU5|=Ue@%eG6 z+0s7Ns3ZF0KEQlLc4z$9E>7-COs?TrUg?9(Mth~-=5o>8^>+q)1J@tIM{o+b_mBIk ziFI%urKwbj7f`KWmE5p7$l^8telLEqOP}~liuU#2FGm0UUi*(&udXIKeeqea6!uTt z!7K)Bk>Kv)yZBi5rF~<5xRxu?{;fG1_fC{>osp6g8`6QJ%H8MCL?;~;1WJrXv0%Aj zk9z76%%#Nyln2W#X;j+CH zPr?$?q8;U1V3;}_3n$hk?U+2uIZtX(mwG?Wx*X~wE|50Ml=g0k+%GZiT~FF+V)Aph zP@WSW$x(%puG{(U;#@m+UlQCpt?Y|mIwlrcuNS$kYdtWwkddSh%rDeaiqq_8iRAPM zT6yVHG?C1u?tBpreczM$?rU0YK}7qdDb>fw^o5(`5FIlWm1ke=I+oJ61zb>i9A%<9#!~aW3j4WCcpD8JXC2Hr)goEin z1CMv^JJ#Fb+f^ANGMM?H)H+eLtbS}?(su+x+8bh5SQ3=ny^*~(15S_clEt0MfhFXE z<_dfQenNP4qKQAg@@eN7$b0QKAXf6OE)Yb z-6Ab6A>G|BB_R?bsp8Thv4o`H(%mSH3(_Hoq(6j3rI9ocalQO!?wfnx?VL9|bLPyM z`F_sxc^;uqgG&Dv&H(BWc;lB5`&$3JhN@Q+;vCXpD&umQKYrA z08wQ^4YekD9VNz4pddJv7ve=#0apm^n#;z?RICh$416F_KKyyCf^ zCM-DUuIo~8hI-`_qL4e1KaJ`dX1f|RnQzdb8G;Lc{%(n4h$2)AmxH%?5rPuhY87QI zck~ccpREbA8!NIL6@P5^$VKb=isGGj!&p{NQleTAJ~_eiNk*PS8+f-3*bR>_^nP)i zU&8Jat37-FYnR@$bC8jh4c*Giy2R|I1s7d;`&W6%5c?SF%_m_>*prdFRh_ciZsw-u zB{7Tv2OTA7%B<7f=K|;HcgAy=XLYJ#0kMbbh*r0Bb%;3X>O5OExBD`D^WleUg>N+D zB$L^>m>tw!+0*1DYg2eC6et@*h3j3P@^r>CoD`}@y!amQ)So?|qOu%{G6+==Q5~`B zr3-5ctEdaR8W?4mGYMazr0OREmR@lN*F0^;0wJ~qUXq91~LWfGK2m1I6hbTg+>=2^;<@&YOK?2~H&S`@&96sp$gRV%_gJhuZqC$Jc4;62zH;TJME-%n(?c65%Vj>0qF@3f>1u~g(7Z@`<4f{u60!PPFABSYmXa zJ&Fd5d)iBz+rh*ZXM5X<-(GBADp*2nv>e(o-_6{FAeeivQ~v8rQ;8O8)-c&>`>(5F zt*a==!1Fu<;CKca#c{a>Ks5Zu5aZ?f1Ph=)Wu0Uq~?^%TZmGXlwQ`=KEY-IlWE%LMfl@ zn{N?s#)0mM&%GsIfv#kbHK7qI&SbG8IOCx1KL}>SfoLxK7+*B!b-fK@hsY8w=oM_t z+l4;#3~YZbZTkYIl^$GdZoeWIdH$%F-e#*f2HPRKU+IbB=UEIr-YOjs)O;K0Q+(W1 ztAwzxUw$=t=Eh#D7{?&g_wK)e!MP#hDQf&=hcmkh$egtVdBxhj`R_+B84RMxIpTUr z@T7XVT-(zs)&@s*8I6yeXV^>PuAXQ@!6iU$N|<{@7%XMyK+qUG0{xj}DIWw;nlj#* zZ3jrm;Ri2tX1uTT79-C%%kO*lD7|QA_3*Q}p;Gf4`0fBJB@R*|Cm->#Oc+cY*1k#W zW*+CDYM=sW0W;xV86FIf;5bS1m@NJAWX4KyH-2oDvq$>(SaNTbI5A5hPL5jojd(B2 zEXAC&ez44NaJ{)*x|!u)25>S3XF|m-iOb72PjC9ZgFEyLi@ZMe2Bg>`$c*0Y8a`eB zGu?hKXimr0vKKJAgdIjq$s7PL9*uqjx=rtK9PGglBb;~;@~i)WsX5=bs+5Niecd1D zR58An7aCEK_@L|yc&T7r$*mXxnC&IZjEYZG_l;o>?cpBptQiOhn7@DJGCj5-$*~J_ zqQPvY01PuL%k@(3K3Sr8v^VjCSJ5IIr(ws{`&Jxo`xvNvKVFv7GsOvx;fFr6)n4@)ZQsqWPb zX;}8=da9;MQ!4SXDjURbE#8#@B=QaZ;0a)GZz)s)pg}D}AOY%9zIfIS>H7c-Kdqp; zTE(@fX}>yFw7P28!$#=8d$H(`fAeFXmI4Pn#)8P2HL3#BmWE9QX2DK3S4$dJ$^JrU zrlYSwN2k>jXfx| zm{@HD`1avZrFG#*AkT4q7fpzqUHZh8!Ha^tlprcxE+kAtx1TIhI)9Ee0{Heyj~qeA z)MTJ(zVj=R9}5#FO&BBKQNH$73!s-=M6loIiJ}hy3ZE5=K4|!@p^LxL6@e}-uX6&Q z!ll>i%;paFz{M&soxfn?p3Q@gpY6n3tA5Nm|6Nc!+|Y_#-f>*SEA))tT+=Up>v!14 zhOAb*9(Us%Tne{siJWaU+GNB7cLy3F2jdorCqzZd?J zA5u^s+z!8}-CXs^N#-PUxtM;jeX$+>?sEhU47xgBP7>ds1;*?f->AVn4Hf|YqIJ7^ zz+%asii(I+Tg_A>Ug!Gkg&Z*;Zmc~X^N{Rure4h_?M!m_45W_1fDc8*ss7*%XVWhr zRYKm2ai`+BgIWM%Hb*D@rAZvhKmchG?_F7ZPhSA7G-f@;yG}+&8U4^riG$m-(|X8* zTf~JZ)S8Cr(X{h1oc3Gd>cV`Z1{JN@w~w%jNPv6a;?}hezST_tfYhpQj1e0<8a5BQ zcRuM_rl+3R;5TWJWz_6uu71bqkx+2ckteE0;RO`sM={7nlzemM{Aq!}HS&Yz181cr zFY#l3(Qt`MWq;)aP`_C>buuaT$yn@^RQC!re+{gRIhA+}xQKS-v@QVe&Tig;!e*ga zjYo1vgkOHp5$|hm+D;taHfrPG%lO*0#4cV@?W8H09Zo*R{Z8(({dd&rt)*oiCrfXDaK`UBja|=HJh5Xg(WD z?Ydd|GsVn&kR_bO|L*wA*NHJj_us$M58jDTawg{MF=)mWa@n^0ywL>qmU5s-&&?Nm~B-Gd4_K_$EK|`$mw-vwZbwn`O9G~MlJ+DjPg3%y8- zgRR&zN2Zo)=Ki+%6y=L*km+YC`sHI|dOqjO=_^qy6gw~~CvjXuY=CRY-&<GpvB;)$A7}Im}*@4{hx1B1=YaJW_2W*wIQPs6>zB%;n+KmP*$^4J5{f5%tkdPWB77}JA^!7cOxGy zItysGKW8#wER$ZFh<0TVQ=LG_CXN4D=F2*8*9XAD>+DNmQF5ZB$&^6VFEHU_0BVQ= zk=F&Ow`ikZe=sE&nwcf92hlx2DS6GJCBx4fO4Tz8L?4ZbhCO; z;0Sa}c%7roVnTWX3X;UCQ=Yxo&p)QlX-WAaK~+^R$A?k`%XOu7VhX1+G)-irRTU1)y}a^+5`#sbfwa8&HGnGO+XR{(h}i^Kyhs$TaKn7vg3L+ zMP;E3rdaaj-%(k$^XVV&&D{jK@KldItPCt#;|a63>DimV8CC(MsE>$}?>0;LQqPsD zjR%r)tV-ECeK=pqFzCI1pGJ{Ka6daA|7S)7{1^$6Hp<;EA|d$~*(j$bzK_$tpEU_J zSGJzgzZ+0vqLLNf_q~O#VX8TP72X9<9V*(X@&Rp%j2Zw9F&dEq)PgZl?cCuu2fNQ3 zQlh+hzuG&!%wN>R&X2U)BBK-rWmHIA601yO`VK?MXb>3bj}XBEH5(b+U{twWut8RJJfXMkw!fjX7+?ZsNoXG%rJXMo zhY>rTTpevNmuU6v_#79o;Bhj`)*amm44BdyK`B+>K;?*qc>Dk;t((D&VFU;#V4ZrO z7pMqvN$^p{>PF>Gz`|NvX<5A^-uak6eU2TrQ)@Ajc)w0}dvLg_jEebuasF4Ad|lyR zELp_r7IzsQ(!tbJp2KN3{xhvwCzYU+mh8E%je@qO2I#j;eSUm*rTD{xWS*wS9`pib zvUfNoSq77~1~+10zNC<#LNh|N3_aU8sq?yr%5Q)b6OziGdg9~IsY#iLe_#FTRIMw; z3%q2nB zv8U*$#ruT;H|8{(%cn}tzvY(HCAN{U(4EEefMpQ~8|RTxX#fKmAIe*f zlF($J9eh`U>lW6814@|)im3|${aXC9c36alMt=pv&aLaUew@97+_Yl$U2TuCzL?8j zdsua=ZdzxktPw+alcD~aPLNbm{VM2_>uv>Nw!hzTBRj2wf@JI%Lev;LLNj4h7*jOn^cm1Dfk8M-0 zBLNcE7EseqldcPil>9a-KMi5N_wDt#k1)Dbo38^NB}JNPzw#fVP3bd1nqDywM}kCu zbseMM#sqJ<^|Lv2x`ZTfwrW^ZK0_4MCyySLki<&hQ=tj7i-K4rp>Zt}d1G<3iJJgB z)1CM!Bqe?jgG3>!x4d=cWir0tM6O6S+wVFH*CUG!ev1 z1O(p~KQLoK4A8%=%`(T#tsa)ji4T3eH}lw-u>n&z<(1$Fz|a_&Ey}w&fMD& zJoW{trPk#fq(zLvEQwjQGaP5tG;A$kJ^=L2?3B-puj^V^@?Gdt)MsuqjM?|y;67KV z?!4rQ#vmo7It8yPp}BnO9rj(HJVjPm3sSBw%S$E&OawDy!J?UbWK!A9_flC0C|FMT zTkbBH&+yPgW0{S0)yM@#$hHmd3z4~qazqKz5lNEN_ZabfB(f6DCMn_j-?9=j={z(* z^Z+npJ|PNO5Y=dl9CZ^t;_Yb>^GyFKihr1b6BXj+9M0&UXnM>I$~tI8j(KLUCQ|bc zaBS-Q_4P%<8R-89-7)+?o07|uNl1VYmzj)Nz=CT!Qua*d`ec)w1+xwqA`arAjFNlP zO1FrV{~vUhC4VnSzGdDMvy-?IV8?{j&A2$m;pKJ8^<|J-RZE$G<^ABQro zD-)_Y)nS+E4(3FSyp{oa6mRo&TxX_qXQT3Hklw@AyvR8#!AU-4L4=W}ioVy%=d&%X z4-b*#X%gGJXW!rrsv2~^J+jUZ@ z4}y;QGU)+J4#fwo)22Yc2_5##ljO-)9N>`M=_vN!5bExeU|v0PMPQ#vF(0* z<^x6X9#g3@sOucL8@^;U|F-bY+L0heq+@geWn z{EO-W=;x^E{Z|N7mEp79GIu$1M)jIZtXh$!?YAV`8}GDn3nmqn=BX5>-ZySMB{0(v zo?Mi~aUWT=i96UGNElKmTMXzv6*T2=X~m~AV?B82QLE%2TO=0pAXv}!hRKWz{Viec zZE-;HE+LH0w_Oim8H|9UL7f=nVuDd^uoI7qk~i&++G~1dv`^-+sYz1x!yBnUu>Y~S z`N2SnpAbO5aWdlRw(@*f3*a3Ua=A}Mb*kg`!&_?V6=Ux=?z=8#6eTC+ukPNib8=zi z3w?ds3W+7BQrQM>*MFfTi6ePd_n$*iHWD>%xgjq7VmZDIp!ygl7Xzu-EJC}SdUO>; z?OTArG-No0V0`HCgW?7|W=wzas-Wv^SD*F*SuRTjK?2ONL@?{3AK6p^a+(A5Lv8rv zMKb(Bkeo+dTk9b)GEW5LK0Bf#Azdv7VO zYdGqlz!a!4Q{n#T=f*?SRH~3pU*5dxI}Q;BLOusLZ6Q{HG4ITJaNoBMdX{U$acc z4TR9JRkj0qjTf3;ER)Mrj$v5H?HOdwqrLGT7m1R*o8ayk%qJEnYhLy**&9TIU7E6~PGRwHN6 zNAfiJncV%*T(kb;zDB%Zg1~}t7BQyuq7PG` zxN<0v!wXw()h3*G)AONV+Re^xg%(zV>5)xb>cQpul701o2%5r_V|CZzU5Srg zJ4jpk*TWa;;i@Jo%O8{kFZtIXfTfR~Ab%wUN)%L~iNOj%uy)nw&#E!iC$jguKek6@E@&3Q$CdU=I zx$ys!o60YzuyR1PeUmLgdh+VK&HvRUI#>U%760i&=VQvmvZM1Wa+BxHKdes31G_+7 ztDvO+=@OHJA3ph?F3~$GPa`b^SNKe~IK8ASsmMI1x~A4EzoAj9yrs1*Maw2F&Jt5q zS=XQ4lz>ZYAFdo88!zs7HWXX&ZYc51d?skX!E)JviE)h)JaI*C7HhJC$<`Tz7Y@TS zmTo(ljBchd(jfk@BPeM%4kL*?%Ba_lPe|rbA*GM9t2KLYJU56KEun zPyyZ6ubfmn+ybVZXiW&UB%|SMC{QYyq}Y(RS|hAM8ev66mj=9#(oBF+XrDSN8xvyk zseXH(W5Mu521;f_>Go@$#+8OJtQK9nI61AXvYcW8W4(hNT&Ub@2mKPxOgHy$4GnV=LyWnbPq@)~*u&JYqQO(vf_QJ8m zejf5*dN@lYmTA7J`DCt3bS>HH4tC-Wp;r0XPuX^mzc{}Cy>S(M1M;MyvpymH!dH_< z8m|&MK06-HqYi4@il@FAf$n9EBWvaMxl?`qb*ov;d~1_(o$>DM(!c!8D6?%BA@fl3 z=wEuAH%pnJ#4o0^>d+yP zkrFcQk-LRz2rnTZ_PdA-jDcD(69a&Ukm`_ptIio(t}Do!@-T3R*H=anKVU6B;%buP z&pV5bpTD^~K*k0C0V4a0|Mi}XFI8F~HbZ^KDSd4?pGX_b=anC>DB4A|cMtNeFd=I1mtrta=O zk}>Z9mr{yOc;GbRTcOdn3}xK;G|wL{mnZ){76tqK9`}YacG~T=&1#F)URj@UzAtNh zE;10g^mWVi1siwog_RYLG0~Cc7TMThK7DH|?pUy^jUq3w&Z_}4me(EiS6cVvwC=vL zEF>52CG!vNlaTiF>5SH?MQxWBO2!;eqTYhJIjQhuXQkd!_j%vR4^a3iPaciWnoFyv z0Zj#>WRkcF8DDXiX6a`?@)NhkXlPAiZE4(9&sEEYo-NZRlR8$BzpIMwNurO3Sa=?X7Up*3diP5MNx&THA2Q)O!AcQhL{CV)EC8Ol`Y-|3o<PAl5*^Fk~N|X;*!7L=Vaxb}m(=#i`KxK^$?J5N_F!~N(b?V2Km0HPZOwoD8 zL)@z+#6UkWq7Ke7o2EJ=)!&gnQ*C!dAs)~}5V36ku|_l99eGx%Leywv#`A0MLOQ?V z=xt2-OBm^+SuZmLt>+V81XhvLSqw7fQr!5cM=2lmB7^=PDS2=u*zx8 z=0np9zZusTNIEO@)g&GoCU9Fmc%W$d87WD2!)R3mrShQAl_^~*R$G7Sr5oZo9op*l zUH0#fX6k7wL_I_zjFY^0I`4`{kxR!v40^Gy^+joXWh9`0q3oVq0XMZ5!BBtGN6*(Q zKb{1Su5Z@@(j8-PB#|m-rUASn*TOGHQ>~K|^WyI*sb+;{ZjjhAmrXYsZ({=R$>YKD17Z-$v*bu@9A-$37M?YP~E`T1d2{ zkY*~ckSkD9BAE3t2bVwe#*$07n~M*|C$T4DZKf~8n`M8tT$Tc4oXG2MHQGwgKmk11gg`Ob(%-4_O2^B8Zckxw0Y?VK~Bek8P6<#@Fg`> z-VlmYB1A2A<_!H>P1shO`oaw{0T3m=R8aB$R>#w8mXA)NZztK4b+8WJs}b`~*sn#L zF+)oo63LqPzet`b1T6&u2vf8>|7%MLVLx=>n;K`KUrZA|+Iz_&y{dc#u%r*|vMsDx z{Lrx9GiuzR)}OrNd}pV#dWPR6c_>jOy*I{>oLV1znR-8~5jIVgBV*}MiY2j1KP4LQ zB}o@%LQk8zll_r>N$%7wt!i}Bs;qvYj9r~kMKziMmsUk(^|SR>8H*~yTv*a+4!Kk7 zijiV^myvc7`{g4Jx5^s&sLMn0MC|j1UC--Wek=@bd>rjSe629eiC~r8dL%SLM%8XS zs1Y`Qtw(V!JWio!UDZFiR7j9!i4`?ko=>xiefzKSKZ53sfGCF5O}Nzg@`ZN*IkhSh zGPVlU{Uyow77Z#f%goI6wGsATO`QnVi``6h4;B^4na%6+H)kI%18|0qd6>OV>gyczvC=I3mb7kCsvIAwMVkW|uzizn<*;tUKB}mrnDm(!b!m@D|^8 zRWX02%dPL1A64l54nt!Yi2s(Bo?ebV{v>FZsKBMoXcDdEBW^~LsvQn75cEBK6-b|l z(ad!W+?JpE0zNts?GjX@nWq&(1)O(CRoFm<5~CqrP>JmaQ$kS4@zYO&5VQ9|Yh;#l zaz?g4wtRX2V^@#yMsnV&&5e=D4(W<0p@}#wrnZ8fpq) z9hc*r@}ln}u|~&A{L-N2MYd)c-Cjm9^$n2jGOeC@>?WDo?~jPiUmbi2ET~Ld3aU&SC;9o&C-UsF+Uh`6;N3WUb3>8kb}%c}w~U_E&V?v65`( z6Yn_eIUJH7O}8ds{^*Rpyb}{?70wc;eH)ihC+xA8fZ12DRqX_fANUC41ZB|?%&2(6 z)q+@F+FoaxlOkBG+VB$ydwQKCnAhoCTTODqC_e}7MHXAHWqk5H0io^|oOgHW+`Xnc z)$Fx4`zu=0P#@H+DED!km;&&Ol?q+~Kj_Thq)1}PhbrX;T?rReY*-pzK_P=kc6@r( zCcOqW(pOu%VjlD;Cgcys?1`;Cb8c!cJSvEh>8G|4xAY@%(>OpkfH^$FWi*ke3#z-T zwauy`vn|fQd!HIl3(CRfms$>LL*>Guq^0k!_;y!#tbX;N-Me68udf<|LU;TVw0~>~ zq{=%Za3JmR2!~NILkdwN(~QRyF|K79TuOPd;o0FU5e>|$F*(-qx=xyUQh$h9!CuCG z+6jl7M%kmPh41BW?`V`)r2A*5>MK|oGby1q>^)lmSpedw2*&>k1;(Po!iAo6C6a?f zYJW*P*yi<)XFSc#exI9Ha6J?90NZ5t>~)w?fNoS)c$B}pceax4p@7mArzjf5Z|#Z; z&`u7jwBHX6_mO4NVuXybKpKC?9d-t{BqY&)Cl<6~j=N={{{ z@GX5L|c{7PVtpL)-QjOiP4FOdc0FqC0tdO z&&`DtMi9WeouL@7iuut}N8142h@edS8XWucCS>Vf_oROc(bCc&aTFwEG3oDd=81M} zpS3oufX%2wG&o91os@h|ZJ>x5mdx3iabcIV%R zROSAzG+8pvV@oZ%X8U(m;QM%7>+k4^rRXX42JnA{o8-+4It?ddaVw$_PF4sv2+!aX zt3~b|pj_})hXtM72*O-9$JDXRHi9f}m<7t+b73qLa8nQ|3Ld+cF@F1kJt_BNG{m4; zxV9!)KroS7wv#-!{;bOo4feXo*IH>Z}d)&EfaM)9=``)hvX*Pza5dC%8QiYV3+I|aL( zf{+|Sl*9|Rj#vD^t3C495kQnh`Ild`Ovo#Euk&8u^Zxhk#uFeR4B*;BjpOJtluo+X zZb}KW=wNFW!ZJ%f?9BI6KD0MKyRdmE11f*iUiplE;k4>oJ3v0$%^?PFssl{^$lRoW zboTV5dqsRTXrA$eZjC+uCsboWktAeVrc+{n*v@8j(RED8nNTM{N$i;Gq1e9wCjSW0 zph+NeEa&tI3}gx-56CIu$Y&6FN%jX0deonG4mI*_BaywyWMcXbucEsGAp-z`DLIxa zJ)e4K>z{{@F5UpWji~4%aSH|tM>rX=@2gLNtaN{z-9XIlTjr^zG=nF5x|z)*?RNVB z#nrzOI1!J5T$h}O8vxt>pj0pZS2-4nsTJAzM;yguFg#;7~M;7erDpU}!?YUoN^kt&!c%voq-wSw?UW{aL4%x9! z_Bixr*Go_b0K3WXh!g?30MO1vOL_gMjobCbq>(JX!loAi*G-sG!G(5KG2F3$2o zUpV+hU%Tj3xnUi6wL2SGURN?a)R8%TFhwArc2P;!z72Zyk&H|l#DW=aM*+7YrXT%} zHGFd^ZR2%00sNilFRa-)s6f}h6He}tL66gngV4z{p~xBEW^!^IP=mSNT9Vm&0Rnw` z4|@WY?MZ|FeLsK(hV6K5k~p6q1GMXDeFo-bn4Os?ZW4iv7wgmFB59lGv?Ko->sXxovF{ALa57MIGurT5LfY2H*Ypr}D@ z9$-2KSI3X=y#Q&qDb##j zti^&19I2SNR)Ax0Q{f;bYA{mlzuf6QG88z>O1roSgr#2gZ(g`_^W(_<(k9h$z0A1? z&u;{wQax91r|FK2lzw6jKcX#cWilNA!KwWt{^STP{Y;j^Xtp?ugK5sqBSbD*IM@h8KN1W z-+l78yYr)wHiI5NXL+j=%yhM-{1~7FMp~b|CGA-LbfyaI34Ah zGqB)M-kNU!-l9%%=%k%YJ>p{e8HkJpJA-&8r-2urujHU%uRz75 z-D0(H@0&zBWcep(QGOi}MhaM}k6cP7?K!Mv_yhax&in4gAoAuFkMFZ?%`*`s?dOfa z=RtDT=Jlq@NPO`@iJxQM>NXRxt-3wawtuhTaPM{Gx6ZnLryXdf?(#C}8?hK9gWu;R zzQY~Da{6Y98V`)G5h>81JmUi<%mL!qhyw+hcz5`w^0O0u#Tp|0*m`m}U;jBVnuQt(zjg(6uY84`z9boOJ^gCayB_rSMD69ldO2|V3Fc<_ zSR#tImiw#$I3!ulY!W_gmQU}&faR^ey{-G-sGcs}`B$ky0P!0Wnb65Pw0k3)9duU^ z5fR!xvE}pT=dD{=m(F_*DV+!um_iL2mP%oM^v&wddHotR<{LaF656o#BkkY$a03*6 ze9mU7snE3!B>}7+zkH9h2M>XOe}mxJMbz-`&~>x$rwF-k13jYPmrn9#YJxx1 z<|#CZ=A3Qz$L;1n>Hq%R;W@vJ8_B-)$S3n6!vA}v|H=I!5R>UA_Vo4(BhZ)j%c?ip zhN_zs(stZ7j7@Lg^ZlE*q|Zlg{(%SlNO}Y9TL1HE2L8@;d0dd`WNTE6EJp?~f4gbLIAlkk>g}}sKvz`Qi%9gG<>MSlLTyYx2=I&L zl)9nTna47c5~mPKx(ttx-e7)+H+_&~Oyj@79F8mj@6Fdj6-{xcKYDmUp8F|SE2%d~ zzMmn-i{NgL+KhDm>%H?-|4P|$1ay2)va%43E?A|9)2mvAhFcfaJ8x))0FlTPQG*4% zoU?jWWSl!Fh1qlV&oT7JlTw{B053lR)U$Kc$&=$oH+sqbS&*(CN$(lOxx+r!2z4|t zxk;u`Gpk`~nMCa6|752F{wdh=1E-F1;@dQb^mcnGYe zUrzN*Ze^$~;0Bp!$!RA4(r@)BgfM1Oh`K}ERZ0x|C-Z%6JH`j+R_QIst~k~EkWJ1G z(CPRcHhBO-20y@Xy%+b!M4LWUy8WDz+ly$b_M1^X%ANsF1T>WJ={9tVJLsl$;4LF= zOt{-^&XB`tQ6{xkKXw`>&pBv8gJwckK`O76s@UCpk72WRxQQms*!C68L_sk~R0AbE z72PIZR$U5{%J(qSPEFR!S&mGv)gPv*Ul3!y^VH~9l;f}OIWG26vo(5;=4;VdGT;A# zn;2?}W^B&rX{4UxMAR@EFyeyva>UUMW(&Y6dt^QDd3X-AU+$-tuu$doY!|idAd4!q zL&JNjW+IhD5OmobP&lJUU)lViiV~{0+ssy`l$bC)gjxT*00eiJ447(}4xz-{p1N(* z24ln+yiGA`&sU>o3IqPbLv~!fto3B<=W9~maSY+&H&pO&!5FmYTtxr?f8Nx zUssC4?awi4{X1XIi7-Ai${$37M|)QWT416V84^hs*j}q$6$vPkJiIpvwEpS11hM4p z!xmxI5;)3pD@XC(X#;0ja}mp*Q%_kb^^80J8G3=Pr7v@74R$SYR9Yf2>M<0?Wq-LB zkT0DYEay$0asLWtA;gsSH~!kcJfkiAEeU&T6pq(96GKaZSe`#23CSlzSlHknG^5?II~m3sBXUe#lrF5wpkOXXuz?&smkG`qunqh@Pm4RL z_{FXnn3Eu0+`nM{-cnm3p~UE>*Y&4)8wLn zuGef&-%+1cuBtcDs7|_aMl064GE7DzY=fKmp9>v;4Wh^eh>sb*Za=a9F7AJs6<)p2 z$WidhUc#4Nlr;a0ce{R=qwgU;c=8E)UKm?<%lI4k#A=VpLOqHW$$WUEA zuQd?XU$ywPGbOoHlXHZG6R+xQo89>Ui?udY zpx#K+6BOfG85W|dc%vcweQQR_D9nqkm0=d!DI5TL`s*(BDf#&jV;+zs*ya46(V{yi z>%OgK9p;)%)}U%%P}ES}t>wtnE7WXt*S95cwLi7|TPj!LV>a0h(Vq}$6tDK%+$l?_eG=A!3+=+3NxIlL$W)$0*8)y$?cPkAZF9~Kk0@8C z*~t`czmUcU>QI3q%hl;tuxY(y-6X4#Svvbih54R!)?yH6oeoGsp zI{~x^FzssE91G{|VMZbtIz;cPin#@U8lHLlEi3IIX;I~irBml4MT-ekJF88e%Y%5%B#vXr2F*}ZJVewxT3 zqR5Gh9_etZDK)#eCkf5t!7hD(%fJ*v4 zPQG3=WK0}6U2C+e^zCz{R=|G^o=b}ck3Re;=IxWxS^(E)mJF$c4{22Vo7d3-5O6nB zxF-&NrAzcjk_8<%eSSvvo_>Q7uD}kip!EZeo zMKOpXGY&R0PC3Mf!HtMWce+5$Oj#Kw4q`}7EmaBeonav39}mzNl!L7V*c9QmH$!%c z4nf6s`24qvx8Uq^y#ClaWiyy^N}I0xpY|pOA*>OSsA}%f_j88cZORB8=sTqex;PPL zG@|*@+(-UZj40h-DY)zFS7WIjheyHukJFuDEERDS9swIUjoaJ=n>9@+l9(=u0(0Z1 ziZ{4V)yc}t0o_c|bUqk^$p*4@QWOOyDPjr6;)0<#vTY=2H69}Fz597z^vNNRbLyu^ zxRz)Riu4e0t&(7C<~=MCTH6QLnl~=v_*Q3qjm2HYhQ)LCxC@AW%!F+(>67xUA=y}o zO5a`A8FcU#*h-M#OQ^!G24vo|+3^f<_i&oRf1^o@N(d)Gje74Mk61 zCX3xZRPmba64-z1b6XV>NK!E)mxgVS54=@ADpmN#ppothmg9rrC}j%&XQjOMe4}ot z4Kijy-Ufg&quTPyb6N=??JPB$?Aukc8sqMoePE@1|7Jm6WYF-n$22rcI7GyC7%Y{| zbrj*f@MHt0 zG?Vj&GmJ(@|A(uyjEbrc-}MwVLkux=4Bg$OLpKcFIHZ6`w}1mkhk&$zbc1v%4k1V> z-NMj_q6ny1dMLYb`_i?;&tT% zb^&sjvERk0!P~idj`K`pW^lXX5s{CACz9LV3$6kHhALAkzAFX`9CL@MSUFu9;mXuJ z+hzc5Hi(C6ETg;2VVo9J*1~WG*(wNDbIC`6hyb@DJFt{AGA3&H(uPG4^H_uPth+J7 zo`DTwSox_1nDeqtXBX@un9BmM=PZ4}TyEWoHRJwv>%K%WPXjRXqq-!fO4rR?ahaKY z3_&#PCJ~|n!SHBVlvRe%o#fk(s6G%)ECKA z-i_Mp;_7ZGH~%fehKSP|%=3HoY}?(tAA0V}2x1oMt4 zYkM-QGs?Rs-{{rKE+5Fzm0z%8g?VM#W4-GRmjOq3P=3A;$5kltd=F~d!0Uj-j{)QR zGqf9!cE3u{(d^s(`907z!ZZH+Em4mxfjIf-pl(*Hz$!u2hnQfARV8i{uo6Q8-Kt@& z$lXbQI_)16%RNBhA3m*gK$~7ZDtw6Z4=Y!`uZ+?`Ccr+6SozrVVD5b4Sq~nUNhL5A zBvXF3_v$%v<<7Qtnx~hy#W3})P^OYz1Ir%4lXC`kbgRwjTLDeW=#-zeXbx9xO|JTU z`b?LYL$5q%i+9=xXDb36nP;d(@A>L(H;<*9ddGC32}e=B4WV8%8)1U3kW(kZR2)UP z^AS^D3+{2!ef>d0=5mvRC&%YSRqX+<^Tq^l?AupjYn}J*bUjp5ApVAN9Sq~SuJ;$G zCf44wF)FlaGnv@C$uwgv386GoE@BbgBELzZ2l0s$+7p^s^&EM=n8K!&?uY&#`Dt)R zs2}Q-<&AbVe|`3i<-?b1Z9Jgr5F5DUKZ}WQk%WE}W}%SD3c#_DMU ziE$eIg(j>^3LD|pOe)zE&A>+{9Y=NrpwxxXo@~vJHgFFcsw+4hG6j4(>AzPo;{cF% z`|g9b&7LNp`Nc#mn``-mfASY5x(5_yJah5f753VqV$}mW#^+3VUke0><+T7cU%<$m zf36DkTPayl5lDqu+x5NH53NdZ z!=1rOn@(lS9zQ^oxNGONV?#-pz%wEjFD|SCEsKBnDBC|StSG$I4e%HHM2zo^Ts$se zFqQ#tJgwL(J3RHvSdV|knWT|T#KO#h4M&f4DTWke|K^P>(h;&(yw6`*oi_~3f*Kkn zFPsk>;W`~B$D;TnJ!D57Y|t5N*4(l9b(hncpAMx$ApLwX9BQ)|6=hckmaUr{b1P7* zC$Ml46$SV|?tSOh&awr-unjLF^jcK)X8;aS@sb4XS|6*wT#bk^G zT3~Jn>J&mS=ocUG;$6V(6U6M?C@yWl5z2#=f?`@dM z(nnV?Q3k<_fWwBn=qJzYJfz$NTQr`%lDQhs0I{ou4bYqg&ph=jh7or^eTZt+`yC~3 zhweoMWBJlhVG9IeMR!4ath=B*^l`)2*n3)VdF-9V_E z@vq6VRbYjV=Azigs-az+!7pvZjSjOk52r#4JszMQaT~me?&EV7@lf!~er2FQ00<8N zUMgA*_V#zqK!4A^a{k7zTj!m54P4XlJfLR!pX;iUU;qDK-P_Pf69WvJ2Y31K6dW}{uVGcW2d1CwUprhaV2M#EAEa) zt)4eA<3{UP{PK3IU(U^%c3D*u4|xWu@16J4A&C@V31R@^D09&WGc{v=N4jz_WH{L9 zkHFD7^Bj7zucVy!k=1+lgl#iNrx#MD1to-%3Rlns$nGw3Hd~6;`xA zo*Rn;yeV%H@!0HEmlj;9zEp1xoQvj)iQ21(+x`dTc2Xi@R<;BL3mew0sH&2%Yz7a& zeE~N3ow@n;@>xBpmnlRT!SX}NZC+3RghijDLsb&R!KK~9147mI9jArOpstrEe}hZr zTzqt9^VvvjrP=F0X=RiOanFAup_ctBwSD`O;Wzl0(k;|_8t?lu%4Lx^?=}sIC1_@= zkMCHpsP+8)bql_-Ip|4l`$J{Lr=-Iydx~^uMeD!~R0wU!dUNknVCkk{l<9fVa8JnB zU~^oy2FIf_+N(w0fzEfdl4o9x=qvkYIQZY4$CZ=uL$Kl~{8N|X_fg-&c5I%m0Noz< z^0ZHazgtG!2569r{hm1BAGs!K(@`wy`nr9OTZ*Uz!>16CMwBxyM?Gawn)x_yqRr3l zeH@p|&I@kYfxHTfLDX*-xmb$jPzT?iRU>vWIJTXDPWqkYj>dniwCx>m3Q~CTqbeH* z8D`eE&p$+|S1ky3d%tw4fNJ%8tZ5wC39$6!E12~Tk(2xoNS~rP_O)+EkWS@adyJ+G z4`~I0nVp_pWtLM;pHo3w-`LEk-k*om(>E|QQpn#BY3MKD;Aw_#Y&B*hZ5tV%daErO zoQ!5=tw%eYgc6|Fzt}ZJ=D19>>Q$5?hWK<0OgIAj*bNoX0xIEV00#548&~|(yPP8R z|L7T_gPi>3`^&f)miLd%UwLVWsJX8yQd6YZrgwK^60Dok-fhop`oCRi-VH*B_*e% zdPir3iKjULsbVR`ph^;Mq`>SO*?U=LRdr2m1c(BbPl{}nV7_Z!(be7KP}e^&82&I; ztOW^&BRSaEs5^V7XJ)lt%+1#V5lxirt?OfA9x$)cx4V1K77mXhfpoFZ^^wt5=5`7) z1n~R*^-rJUKYs&&ZVu)Cvdu_IIyRYHK?uHVC<)$q{Zt^MxRDA%F$CVNh(}sdeTq%f z?Qo+>6$q=jOg5@gvxcfDdCobP(%%uHT6zpJaN z7q!8=oJi1#%5&*@ilTLsj7C#Gc7lcUvw@W1Zgtd<6$Fb~Tb5vbIuywDSOhU`ZTjwI z&m8gEX*psZ!A?vQ^tA1>#bdfT`hJqWiqffQX=y#LcOpvJl=YR!UvJ|{`dZW3RH0@a z@ZkvOu=NJr)ByA*Qn?*hN|M~iz+h4d{3?*2w^-odb|k_!_3@3JeM-od z^E!uw1Br|mGFPp?@$_~jS?BwR-F1G-#n}KgI*!F+VeZX>hkDwTHLwz_96$DvdW7CK zN#pzE{S4{>xNUh>kKQoEy;+@B?o6`{Af@`b-=5>W=}A)5uB}vYMG|{eStN5*H4t=9 zTc{~&-n4N_hYe!~*P*_3+Cbe&EZnU62$AY-FW`v;WqxJ+fi!|TNPO!0PKp9Dx$QMJ zDI>>lao{(7-#>;e7CFGC6_uG;6-bWed?8~t*i9+FfM0E9NFHd=OgDguVzSpaH zpT_IUFxVKM4U=RQ=2rKR8$7k}z%k9Yi>B}KU;8^G0w%OVPb&Em_A}DU7>PT`wa)x{_`0g}=WB+4B|Sm!NX|p$ z|KvoILv0S(xxd8uqo04m-)-*c?=tW_ZMQ!r12L>PH%d61ilu(yVe$_sGOw33kd2iyMu4tW+WvOer-gIj*2hos3F= zC1XhlaEVuw%X5jAPc4`tuNA{(F}5kT3?1jyv81d4Hpgovm*13P@hu zO=Jk08n@zEYEBk}q{~ppLf+`=pMEuf`0%*1ScXtQPM(LWHf@l~X$!K)_&%b*qbwm2 z3UqicA%seG{}D#*7V0ZW>e$TyAdsYEjK&sLZ52UXCr4uivog5tRC>s!9CF&zJv3c` z|8%EJJ4_*T`zX@wJ}lW%dkjaV9`H52_}$qiHp-7H;>t+uU1+CKN_G6RO5o&CI|a6Y z7Nr7lF7)O*DF&}suif$@_KTw};lx+S1x1)jm(43s=<>4imyh!NH8$P)!}C6epxwEs zTKnu@wG|t)B9x`)^X>1XwYs^AAYal-14>y^dQ|`z@+j*m;tVIR%p8}hq|45ERu9p{ zTLk8tLM9Z(TN;yUb0fcLlTp84h4;oGFjNnIKk_o9?oV?c_}1b-E9gLUUis{zqj+5d z06n^}sMoe^q=DBl{h0zbKv&2sA$KFS`%)Fd5$QL^0E4Mi#3E8CtuR=v$!zuAehUKa zPtoG3W3W*=5(0sCJ|YR#BHzvJZoWQ1YPwd=r}ELabsJTg7bs0N23}Qq{^5DS{(JdN zPgO_3Yx~tlPbfMoTfx#QWm30=p9+AIL#(Al)%^bP3=qGuqdx;}XGF3MT`v6z!v3Zt$D%G1s0tNTep>u(q92-{iZxpO z*oGs?OxF)b@|$yCJB3RcC>;%q+-MlFm^0WKZlt%dI?B&d8NZ$Sd9_I<1ppFh>Ko*m zR$!B83x2O}PI4UEg`(`p`q%s3JrfIJg+sJ`qWU_-MEh?F%O~b|jBE1y(yv0iYnB@J z5SO93aff{R6DzUqg2*tZp!(7>1E80tAHXB_{4i>3Wu8(WZb^3fx$Q?R{CK9v`tyCu zUd6YHKUbyyei1d7dx(I&z9kmJEX7Uz#?e!CyKg>6KD+NE+1 zo`FPQcHm!z)%s7${x{i48wi(2j;(LblVowR?ws92)D!hhgQ5uun<_vay564dX1L?W z)4f`QZ{ttTK4r70?ragx*q1K+r&PO!-F`18R_i zV2xwmYVc|_pR#4Ph-TIa(Rn<$b!_~Dx&)J;uIBKazDuU{Whjy4Ij_*<+F@$W_0S8% z-At}?Y{G-TU!5kb9Z^g{DPgWcrf{vnB&i30L($u0(kA8E1~} zKkyA0#*Fz-3bS&Yy6B`wK4@M-rFg7Jo)Z+vB0lpb_#Qo zzo+)M@8=ri!gS>|d$-8)7-s|5?^6UwU!1EYoT#2_U3@$7Jl)OfeNi+s`n?A&_d?b; zty&ZT+;{x<51T>J{`C42gihdb@-+-Xfe?*VpP8J7^s=^y5L? z*KhKgbOq3JKriH(5ae zPghy9t$`H1I@m+)A2pt?VDbzUGyV|xv0acK1uzRw3JtI=Z|sEcfUcfQ?A27G!g3cB2Bt?POEL=d8wv4jG5=6783)>A?cEs zq&FvC56V^O{nN>gT-7|no~r9Pf=!>73pJJl1N?o553S#0LtT2<$#5aGy2$LXM1f$J z-fvFtb8VPpT{%!-=$M)qf>T;ESnDqEO71b2V$P+_;{eoSFe(SBNCHLy1d1Z{RvA~Z zs_c9WpD)4MoDS)SB9>uUu&k_?6I^FH*2oZ-hY5FLw6ZzT@*a5(UVPk=cu@EPKm?s6 z{xh?JH)l%bG1MbnP!B)|Z_A0q05LzoE?)U|DUqQ+lE_R2){!o0ff)~E0%$L9ZHEA1 z{V`ygh*9g@b3|r-fp&I1z<`UKlTlY{LFTa}(EUTUYHSYIdSQrveh3PX?^VQKq>Hjr zp~H|LuTkDEPg4kD{dt%Qk~VSM2dUH-koWtOoEyx7vYXZ@E>JEuXJHnNoynfVr*HerF;P6D)e-6?xY+%)c|5 zqxPQClTsH20uBBKp7bw^dX(kRn`}c9L5Gx}d%`1FV{Gg3^z#$pO|WSiXA*TBfV=J4 zk0O{`Lk>?v$+Te3fPaw$G(UHpR3l#{xCRbVV8bXwrVLW$|!S!Xo(+=nit6xRJ(hlmwtFBLG#0> zhYi^UZLSV;goH$d+a-92$V`MUU~>t`fh4JpRW|GsWJgzWOH|n?K?}SnB?s!$r|Laz zp+9u%q2_!RA$;z5fEkA9+YKx!K}uSleE5Oey)RR7&s&=;U~Wy%Yr?=+GE~u{NHC&4 znV>|HVb^pcd+)Pr60wYK&eR+%Za>p9m}DDa;yF!lGU3L01|A$JT)7Y zM3kkh!{hd}kCpOGsM3(|nht%x(5$8dyVfJ`qRZ_QBK1Ziv*U@?|S9OsWxyOmdcmR)o- zrUdc3^R_+7>8X`lL=mgKiluqitH?I#3Jzx!P$jT~_E$L7`4jVVQ0WMy6b&R`%Z=o@ zKrLu?nJmPMcvn*YqN{K(xBe8Hj6x75B3@uGDuj)uc`YKt%J)%-9Fc(hxmJ8%f!Y7GpG`VP@G9VF5=@#wKs18kkMOfgX^9I@;fCFZW>gHm)a0KOTZ0$ zC^Hhw0RVSzroH&`G=Kbh=t{XnsG+3PBmF=~qx-eS?|EV>tdx#jhxd7i zboO9j4zoEm9}ZT_3B1jC>%?Y%EbQY(@EyNDFzh+c)vUfek#MqDZ1FG%yR>Eho=J zg%s7%2a?SZBjBUCjbajW#_4vIznWgnJ{_IfBylwy=hzy$ORiO|9AeSyZ|UFNO4c&x z9Ym`DLEyOWOA*V*;=D{zTv!Sy5=;wtF_hX)+x`MWn5j=pZ}nxFe*3$ViF}|!*uZP1 zo=)NcXE{gbG$f!IES;_H_elG>E(Q4p{0b@fV2gMO?|;IV^tfrt?L$*{$!xDu&WZl? z#-G^?rJ~Wvsi5ih?LSTIS^Avi;J_`&Bl`|3UdxY^K_-jTuSj7X5k+_)KhMi0&ggFm!RXvmEMl%?}N3B0*gA7?3% zxIeJZo_J0Gd{*D@?f^Yb@0nTeAueso)+6DyeakxdO0+wdmqVaYUhHX{dz~`(XR=-btIfn-UpCf>~;t=O#OE$eif@8g<7)iwbOT^Yj3pF^wTFz>)q-#Jyp=mZse; z(mM|0X_255Gh(*6HQhFUO6Xj3lqs$rWypjBT4zx6J9hyHCP zL)q>D6x=WXW%UOb&rXtLZR5Lud_zDs_dVPpXUlK)(ff%!VoMb4d)jjory^h`wJ`gQJfJ(q zN0+Kcab>%ys*)Vr+y7b)!naRi6FD;ZIh^XqxXbo3-+KRf%`PtmpAF1tNVN67{@CW^l8^rQVpJolJBxaYH^kK~G@^6Q8$&u%DzePRVwS<~)i0W+o9gAOFlfUop#kB)~Lg z9Z?P<#n}^mIf^LzfOzS|@JokDg8CO_m&(w_`dQBp_6uS>Dfc_rZ?C4eMj(d@Hm23nWxZ}Gs}Oz7x}hRY9}Chc@(Uo;tt^f!G9 zY%k`@_X(1xTTK2Z?&n6`g>(SSDtPcvn&av{rEvh|=X3>PTHFcs!}BVcj%~+7t}&n} zj;iW2hR?2x<#xNlD;UFz{z23?Q@?Ney*givEcNn}<$qkF9)6a+1~lJWKVxu7ef8ak z1LSLRB`GP%HV0yQ{^s=Kk8r^C1$%!?3yFovE(CckRBcH|=HZ!(Z}obR`_S}qE*$ol zKAXYC`}S|fJaCKCA@>rn)P%a~-_M$7Bz59DabKpPYZqtpr5KLyM2}4aG7EYi(|f-> zk~jG&_s~+~A9J)1cUs3!B0N*X^WBMOCHaCEb!EW#)lJG1z&3G9*8OwYJD2apfo$gh zu{MJLW_T+aq~Oqi`kdiiCep~aFr1Qc?uK6e$)G5rAfi#LR#h>IKFBhy9v)U} zdSEr5QzA8{wh$CtntNLQG|-a$i2%>;2cmzt*q)xMcxHO0J5V}-P`I;t0&G(v=o-CV zDosr2x`~mYi3N4)%I(-srz?@)HbSCfRJKiP;C&diilxXhTFu-isCPrP z(1{&(E<*t%rLKbhASf;1h#ZV?KvSqe+tLS}q&%G&oB;iew1n0AH!!-vbFRm-S60)a zw_a!$ViTxt`4897+;%$ylKkm>Tmt8+AnRue0-aEcXkM#J$+17mdPAOA;nT&lD?wB7 zu9_A+!a<(Ht)IXu68lJ`yLt}1xX1m4_uG;%wdRVtPe!SkN1UM+Q+Uu9$z+!lZW6{e-{Rlgq+gopu@ivh7Dp{Eks<7OL)@M8gy zo=PEF_Dkv>EybQXnX~FkRF|zsfl7|9U)}560xnTXGZ*4vBk0H! z7aS|wGVluuWYmmoFf@0z`vPJ(iH^{^9^whWP-~HY#pZa=pKwqDPVsYz3HhHEgm&7l zlX#j2LiMJ_h~jOHC_KNX-)EuwrFKUD+O9)VX!{ThnGzCj$-YfvS-m+D9OjOez^Z`@ zKe!JgB+Z8qF9#=UX}QJxsoN6bd4lF#nxt?2@oYvsW4;ya8`(YvQiLLFk z%eeu3oK4W`Uo+plTA6_T_-8)<2!H?W*qSYHbPBD;iDqMXZ>mK>49Tsg{cd#24**fx zz3k*Ac(Cvso>=|_$b>EjA)*RMhnJXvNSpwG)od}V7MYsh%s7qSCHJz?V3C_vyV;Bo zze&hn3pS4Jw#Q74(~O{-4ITmeP{fLF)aa@+qxEuK$lh^*X(T=+%FP(}u;*!s$Fu{; zF(`^xZ7>n~>huNdFfG@c{#FsfTqxxW+)tt8SYEmL6u-(UI%^$hZ}(f;yK1-~+{x`p ztz(xI9vX5cvr!;@N1}^Ga$Sz~8Yu`)0RE! z{rc#HY?jyH@SJD zViv~;N#pZa!?+7@S`q*u7g;nSqHVpz`t;y2|>sPgQbY@!n0N!WXhW5_g z6^hctDpZEZte9O*4o9BlTo)WTd4({|Pyn2&3;V(y7; zhCdMK;i9vUPYy_@czWV4ZSa-~dy{j(q$s7YTf-s&MB$peA9G0g3L>@2sIY3^t@4U* z4ol24LPQuz7})|W(_KBIqAecrL)^`u?$|x@^*_@5cd-&%OfF}SeaI2S2 z;!P9nWzln`Aqs7@Om{{r)ENOo4nsk2H!<~hl`KeU4t5D&glGmS-*Bd3- zVsMQy76?P-_NC(EyZXBE&v26NhcT+9!pBbIIOeJGe2D*R zB7cGwz?Zt)MnS%3h|^e?kxRJ+ZSBoyM#+6(k)ci38S2i6nyL6lih1CBv){nVnA_!%PbPbG5b2PtS;KhQ%RR#%il8;F4z#@Rl~&!3tf<4 z?PcqBGJV5f3Kn)FxRi_w8FF^`=Fb*-*YtMU5Dzh{cL)m;(%I6|x$Ks$ibB2M!)idJ#07J*}k8A6%Q?IxPvA+AN zU$*D_^(rJkGcstNamx!IS&HkfG2IMlCD@sdia6h22WMBZ-E@p9HO?3cjATF*NDPew z7eDR4@b3n^d9u8ea1?S%`sIbFL(U4Vceobt3xzBHAY+PB9K$}bIDZ$ZuEpxaO2Z@( z(m!@4^zY@M1%Mbj_nUcUEgw(z4PlCLir_29Ap za?tnvy4c7SQX(Y!Kix$CyVKBjlo?c1#1sB%5?MeV%&Fz;1O{2HCB^`46A2cPyo zdUY3(VawfuhtxZk-QPF92|l1Vq#);$II4P8v8wCL!!yi%ViIjfBEDfrz>m~Jt~360 z5&c~-`OE%10Bf?Nk0tUnB`doR0S_3T-+lGz>N!StCRCJe&xhHI8zfU zlgfK7_8Z+ur;P)<&5@XhUeg`?8N-04{gqsN-?)`u%At?>05xV{i0BCPK7ZLMJac6h zctfp=sjsEh4`)7Sd-MsWGeZuWho=pfjLjUE-Gdm9y{dg|sZBQghphZS(9!Llg%ECH zMf(?~5=N2@rwXvsH=?5keP@P$FT4xZaE5ezx)$=C*G_)KMZmpM`dZirrAuofXEM>{ z^CwPt4!n2zUKh}s?5}DChnq;a+xh)&69VvIL2LMn7yv`i{MW8pTYbqfK9hs`v-jG~ zwIkYuH?Dkt#8N%REgX)08v#I``^+tGn4e45TZnP?RlyAal;<~d7geXX_nYl_3Tu&u z)3LWG>(nS%-RVdbYTx0cV5}fYLn%nZsjX zM%kfBp}25g09)lD9O*>E=bR(#k;8XyfzL08Ph*bW^$b$79~?s?5N{@s6!^W`6*NR5 z$P_0~@V{m6r8!*9hP=!kc5mKhL3sKi(lAE}e+-I2 z^4%34OyrF0Fc3S=5&O6x_Jv0L(oFncLUD}eS^DFx@D_1aj7VjQNF!8|)?Csa8gt`& z|9uE>aOPiio_86O6sMJzHkX!vBCWhAeVbPDeR< z2_+F0FQZ2*N>wMfd?ClgHON(d?9?pIhXncOD5Nba+{+PW!B7x&^0Y1}KBE=U{8~`^ zM5!rAei=z5qp#fiM7djqobwl)-C{5qx{mGQwF5Yc|}2kSK%%PhK?{c zpkNAd8p=y72j*%wQIyb2VfqoFO)dh27Tk;zeqqK5IYsiQL6&g9jmy_F(uFe%B8Qg} zO^u8IE+T)3W)i2QFisdwwqXHxDpR$z_yi>n-CCZi|Old)S!$AqWqvQX>3lcc3%0{(EcJnc6wEZL zWQII|k3{6+M7lH_zac*_3he9M-NmS?9$eVuIEhZvUsrfc8fv_gCQ9!PlcD#1n6Jd= zDMqbBbbtgBvh+RgE_%g#1LtahIr*GID9v6oZ|XX>?opkabt65V`u!81{h&T(48@3* z_<{@|PNwax9A|63-@K(_Z+?SNIe1ETY~wo+(%W4|-LQnzd3WJI&&mOwKc*sm*8^Q@rwo9{#q zcc71^H3MhNYFK3m1^o#HI-m2+o}1!#3MH(`pU>JH``Z@fqFZgj5ubplLke<+@MVPi zE4c^Pxk_EZ5&V|!TISO0fS7|^Fkau%Ndj=;=dPj#AaK?jhZ=7~{FXJ>__A(|tGjU4 z5j;7*M;(@9sgrKy2xq<30sb+$C4r#@TyG1YLYU&9D(WVKVM=kl*oQr~E#*L|yCN&G zq3?Nff9=g7=>r)TW!$9o*5m23!`x*Rl%%99hC=C6M`^_}e&KRL*gtEvEfwFg%>R`j z@vS>k2oE%Ly{}=I_{Gwa>PARu<6?${?2b_AfCx%shPHJeth?ncbsnYggxez&S&Z^- zL9{kjOZt~7JJ?h`smy)hd)AF zayF)9@0Y{MR9ffUJQZLR*S*ds z;c7q4-sfjP0I5TdO2@}^`7s=iUYpFUmz>Q}rUBX7I)q!y@>~xB;glA<&frGeAowd@zo4&+OU8cP;H{){`Qd@2+uDk zq|WLqD;1FbC105b!(DS2-mld}QEmU%CL-imVdy%IsHTAczNKEX{(;R?-6#KP@$Tjj zXO5(MUz^cWPId&P$AeeHO+GmrCOJxX*OUewHt(E-a>i`(Ot|PeG6h`Ob9Qy<(Yo+7 zPZmA3v{1kj^|~Y@TiAE;@$7*UsS_Ngto~kj@bEdBq6aQJDsTweO0zxIo!OLxzaonv@eYJm_ zbn-DUx51=}j_Gq55V5sg6*(1_p0hJL{xz4~rX&IvG0ru->+qMbIZLfRkAOsg`{wKH zUprRT49LWDSlwS9dGT&5R#QeU>%|?EOW}YW1v?=cq_%-Vmr|UwaOz*~va?MvRNmk} zeYUB|yEf9DtFZ#>(jR^=*+(qQGUYFS%YH8S2C-LaxG@rYzdZdi66r%>^PZgc1$?KNL5WZOwDgIo{j-N}I~LyecIWr+<)Zj<;lcP%?7mJhj8m7ZVxc%A0Ya zd+R~vi;4`@4#8a_HJ0&2Wy1efmczEj?#M_=>@^UTsgcL%^hSFuDz)sgX;P@x@D2$t zmrqtz4L-#0EQ@!tX}Nv~+B9;c=yNMKUf?)E_ z>is@G%>I@VC^hi;f_iL?u?urs{1AD>5Q?xSxE{0|k}`xbDcxX0DmPtir?625t8kKT zh8QUia@I*Ap4iy#aj|3GDAuLyY}v1^%~KuETQZ8$I=P(7M%4{b)R+3TI8ny1oX^5f zpLg<#mF{hZpAYtmw>X^8a$K-&3D-KfKeMIKjF!BsdLjR?FRDxps{A@$NmTLK=dUrB zA=^_7w1I!_k)a@YjC%r@uw)O^Y=L+V#~nLm!&iT8SNmXNdB49r`FJ~=V zwW`Xkn2h#WQ7$t9Rgodv&>q1KGEK+u9LKEu+QwEpxsIWc@l&$7sg3Quqwh7j{iltU z1Sl0P0}BT?|0`ma!@nzW`b7T^eN1vgAAk7&>0_n5SX_|)|Io)KYU&l?7PynH|LEhL zMIF~xF9r7Vo9^TAmNS8LCHO%W3NjQp<=l&h8o$~DCa=qOQW|}*VZ>S zx885>;CJ_SbZ93z8fF`jBFvPbbjueV``_M6U%sh<)t?R(2$a9P*XC6=tn-~l$l*T6o&nYe@9l7^9t$ddY72A56%_9%XH%3ON@5lFVtGpXw25n*z($ zLhubfL@lzI^0hIq(}w!mQyW`KpSlxqW}F;*b+xC2+QWuQrOb!bs{MygG~jaXbRZ;r zm=zm>A_IwGdMBTAxJ#FoR}Kr}tb`hRMw%MOP%F)PF8qw$dv1BW#soUUEZfj%PFxyD zY{r>A?=sk&yFWfALe%3kb3(4cW=^;sd9)g|;SPl++PRfnKB}?ijul|ET|;pW3d@TH z#KS24@h*vqd%^-Gb?qBpZetD;)NxymG+V{Q{|@8QC|7+h8yFa2YZdfC80&Fb9qHi+ zrT0Ep^Sa8c+4_!xC(pyg6kk%>N-la&5b515^(JDd7bX?dkweBgF~yf-eI)5yzHRuh z%rY#BXD7#nwX};^p>i0>#bji9XL|rt)ez1#!OZB@ECK(m;TKnC`#<)xp8g7T41o1R zm-1fOpJ(eH9j|JhlS<%Dzz|d6y|32vW9Zib zT>xy@v}H1EGP*42bA(U%JuHrtyF_W=L)~pL)IgCU(ata%W+v_IU+W(=nRhhzv%Z}C zrifY`p)%>UW>Vew8BU>KGW?hHfNJ#bZy5g7zlQkjtMuA|5-ps0M3bF|3_O10?=%?C zltc1}k47N`Xa~4fv+#K`MCoxK^6|;zbAVZ|Z}h#0X@hyg8|U?Z-O1v?S{-e!B)2%R zAV7)J2Vw!$MIwCU-6s_w_eCHR66*`kWd?1x1E_wbvH;gC+ZOhQg<|00lyK+YxeLtL zE_E#d@~Sl+ls4HT6okwzl~f%!E*)6@fVQs!K&aGtxn%2^6s!{44kuOC4Z-+8lZ&^C zmt&7?B=08~x{;X614O*rqlXksms~CiC5}jJ?}=0~aU_(=C6=fA&;e!kbb&yroa|L| z1`caTC!#%zNKb^eN-ADhtH;P~X@HxXO|V;3odQCdrS^9$S6*Zz0MNRk5vm&{tb0|Nq>7E%ufFDt@D#vo~ z5Sndp{GpYFgKZMfL;c!6TrVlzq1XpWC@jc7N+6c=_i}RRkV;dECuT=uQDk<8*hEZu zNTCKvVS}FAieLSGx?wA>@;b}YjHfh2GxGfjDVe6YD&}t(9h3E%mdI~SJb%aaOp`1Z z@_cH#n$m4Nt(7m=5p#aFwETuX-l3TQK0ui$(TmOwrwPyOf+&V5J zbdMLSy;kE7yshi}w`~;o5=GNNS1RIupO_(3{ZLf7`bbx;c-=hE>VaXvGO1pZloJjE z0vCZv{@5+yes!8_68T^h%L$Y4PcjLqTH@yAdOgc+%Vj-J_`|QpVvlbY(-fVbOpMpJ zo>O8VtI&B64Ut}9VRDxk{$uFZNW(Aspq_{jiHPa6=7Spf2%QTHS4-dyN#xonrFWy- zm$aS0)c9tCQG2TO3m3XhMqG5PNmfsv75pIA)f*}lmhoZZCH$oW$mIx8w}%fEM2vX>gI3|4!>G1-!#Rh8>>2Xl&kEa*Q&JZsbuAh+hEM5W zSoa)%_FcN(+vB@QX-f_Sjnr3Qz^!|9ZgB2fz8D>o!PuawlNfAVQJ}KEu=cDMW-L!Y37X?yWXx<%to%HzSCm7XBa9m4!<~J9%KT4Ol(DE0R zbV8={*NTX(7ng6S0Gm6Ma)r{$7da?e(2?F^?y+NBbyn<{n(w#u2mj9Jls_V`U!*E^ zjTFw^ZHYK!-RYV{&M`K9!j8vmBxK$`AC1ZlfS^2u#LMJhnnO| z--_kL5;Mk2Da70I14zaYd85w0uR&5s_8^q=wyZNk0lh)QQUib?Pyu&FLZ3|p{gw-B zpO?vWH@WQbu0Y7>X~*03B}kL1N?=7P(WV7eciEBPYq?l=9{Z_KKgvY)S`0Buve4xZ z046?BYSmANE5TjL?lO^1=~E!^%^COy%1q!^Ks(}uE@2PgKWHrM+mhx4Gg?ubyc&rewE65K5+ ziPJRSm~^O-71UGINMihh@x*57*=thth(V-+Iq!o1l>5vdazKECKU3aZ}U);ao zyf|x}wVw0r&)%Sj#rRpPq!^}9ojx#`4Btazz0yj%aEujmCA%vDOW7ZIU6X(DNpH9_ zI&A%6Bt4gQ%)Oj`=~MZn`J9xCoPYvGP>@1GJ(i24%!sVqXwkwCj0mHONG3zxI1|81 zKYKzt!_4JCw(#s$brATxGle}jW$F$PyOqLvLP<=sTRBfkrMrG4cB2z#d>pu>5I3IT zojprl5=ng=;7=F?bFfA*O{K??q)k1H(Eg>8-!C!@egbdGF!%$IOCb=00+%H?>WE}U zyu@umXA0Lq=xMO{!u6NGKk*~ra|X*0K1+W9Q{hFrC6-gUxo17l0(|3Q8FG}+kR^7|@YS59-a1GMWrvW97sIjEl4c7lP}|H?F&$r*rm&@x+F5nG($c&D4k9ph* zXlAM$6#hOS&jTvk0z zN(a!VyXL=1*#g3E0+LF23i3i3kFsc8Kyd2m6vGX6fWmSrkCDjh*!g^~t7OY6Or!?I zP5xZu6HU;ghy0PWYkn!vrcxem{35#0LcLH8$je%Ppz$2ciA0O^S}_vxtfk7Sk^DL@ zWDnePj<)o%uou67Cx`lwoA6eoZc~hdSp<>UR-X~jsZvXQx|q-OOCwRfHDy(Q+-?lR z6s)+tUdU~oXO!b=^m6TrUh6yEu~PqSeQPn13u8Kz&Pp!@4p*wCbOgnTP&a z$IG3aA6HODo9+7?9DpYGL*y{~18yrfjbCdXf>^Fy)ij74!#@BGQAzoPTAab(gU6L( zl|v`exWWvd<&IxEthH9ElMrdVrjOgn(`Hx9U&jLu!ys3wB0C4al>Wd?vP|6^==K(G zOof2UG^qYVk~F%27E#Shz_Z6?tvEE2cQ;XfX=;7L!i3LC{FVNi?Wr2E?nyk!ol!+k zznVoK(InZTKNs0vXQ0G|pxmQJ$eN0J5jV2kCaD`OQl3p)?G@HKO4sBj4bP2h{VfZQ zgcPl+J(KoPJ?zz7@9vt+Vl#k!^IBY)TH>NWrn~LR)}S##$>hd} zlWiUzq^VR}TXvJQhI0jWBVX=+Xjg9#!)Nx*8cfq7xsF`LozfTV2#EPuL#%6)gNL=9 zO{eYHYd-1D4YT&{7vM&~h{u7%0|6Y@%CqZ&vSMLcb*2Zviw}CroDQ|SQU)@pwj{Pf zXVEenE&NDLr+7jk6Wrp#=W4F!Z~o1d{tIY#zR)Ov)_ZsZ?fx z&oj0KN?+<|xkx8>ENh7GBnLHqcIf;Z+}SA7Lq^_NC09m7Hz)7vJt2Q(fqADbMQ$bA zI?4?5{-Ssf;4i~*c=Um^ep5KqP*M@V=D}~Q6M9yOxkS`ep;$ZOOFn_>OV5&ZSzTS5 zOkK;_2kj>OJ3Zi)abE^@DNqXsXavhKOO@E7I>P>RZ3Bi}I7g|&_ttf7mjCrx*_1VP zQ$19E$EXY{;saydd(~ zOSuVBj~Uz9>b~Faw|SFm8w`H1pv~TteOIbn_tY;jfd0;Ha`s?Z&H|Fho4m9<;6M3_ zYy@vXR|VTNDi1>`*yUd{`rIA=}cUk{U2L1n0|`@wjQLeGY87 z)yi_MR%;@WM39O0S(%)HLTwr?eHy1l-z{K9C$3oQk{&n4E>k64QZ?-jlX`3Gw=t%L z^yY_$@m!D8ny_R9b{+OaM!eKxsdBPeW_viKp}uMU3w>Qdk7aFB``e}#_D1PoFfHB^ zr!?|DWJGnYk}iUD=&?x9C+O`xC<_Wi24$&^VwZAg?5HQ4UpA2zOkRqOJs=;pk7~Om z`oSoe)KF^TLo0~9VTz70^)ZrkGZ#luBYbHLU40JS51C?uC?rN1pDs@#8$N!iN5IBj zM=n!&xc9%lh-zebtB>HP0?-sHGqM~uu0((tU*Z!Ua_wD?oDMX~1vuZ-lZQQvTBr3N zmZkEKM=WxItZ{cJEkEm%X&O{WU=L6w_L28V|0#Z9#-=Xx>*zANoA!2_SKy)sYJ`Sp znecHj&x~a2Ezb9A$vskZpY7~k2p|n*;W?b9xUm>9I?SW;`Cj1yHN}{mZ6}mUn4Q?Y zy9?yWeOa{AKKNXzL?vka8o0qu*8SISa}0{+0chd`SRIq@pHN`THd8K)KHLJX2s2jbBu^*S>S`om+y%XAwI(^gNjKlLt<0DaKj^8M5`NMCj1bS*lcE8rzzQTUNb6{e*mHps{M0Q28SmsM@ zG3SZAkgp5&+8ihlEo!x@@A(ePm-f)z&-z`_j z1saO`!*5vSyl(jEY(~OCyE|5O?Oi{3bnru50(?mgJdP22AOT?o4(vn^EtNY<8y~dP zZaz2g2^|3QH2*UF z`71Q!%{c4#@YSJn-*sQf-}X-zp3M5<76jfj?T>HlQ2&j4=e_`Yv5Dy|N``@8BTm1&AW$v=q514iGa-&T|0=i%`Z&^wgxfsi!dCdLlsO8hA2 zupT=AJ|uUL`~}kbqd`33`?AL6eXLi!yjC<={os`3J=Ng5%g}$I=`-L@IzaFJcj3Xf zlsDi}6$i%vk6P^3z8ZS66IF`GAB0_l?U%+Ti)Du3DXxFVDHoEr|CN2eeEIK^c))S1 z>MHKvOdtx~>mbtSw2nb5vaweQF(kzoPz?67Og-)TDt^>V)E)luk+smGc&tQo9fpI% z(@8e0rG2OL0IlX1(WCp-tWXl+&Bap7@ zcn!iSX}g=7Li^S#5wp4i;^qdeU{^7jZbF1kihhFxT|!CO_~UDU!|(@Q_n-ny#Fwe_ z-oD41QdR-TT{FefpZ;=O75Wyv`*y|oFU7f-3seHhujd_fWfZfb;MQ;+$`DqzaAfT# zXZWC*-n6o+<-s~8r1(1N3zpyleGg4zklxom=xlCAgjuT!&oSFK~6MX5=2pYlia!UZ>Z^aP4Sh5j#p1R zuuPn)NX1?-2{+ptAbLeW>OjhsCp@uB9Owl|wyj9xqo{=th?cAIzB|ZpYV_{ia0J_; z%iGqXSf0GuPa1ms3(Iu|?JO>rEAQCUa%pjr#pixCBQY~!VIeP1U zZ1o_ctq=wj?I2`z3buf<-1~?Sqf;06RzzZZ7WQ7JE)@QdA;{bPjC(c7KK+Z9*xbE!HRw1#2~ddZFhX&zbECAh2E=>EF0I6iGn!=jD7p&SJ@}Q zs}|jKoMftZF%);*<`|u{SynKlk?6aud`*R|Quu4(i*l#&8y6T@SJ}2#nar{7xT>;emPHXEtR(pi&t&6FiA%D|-XIW5q`Q2t zE6z+E+lQ>x>E(97s~>Mw=wM+`xo5VXRCkJzU^qJtbXVUjtT2Q%LgjngF-*Pq2_27A z=U#+?6J_I!$u1KO7q!n)7hY!c zTNE+Zs^OtB6+!oy|6n}V%TUttX-MwLGLT(XAVB3LYA&3Qgt+UxXc7rej+%E$2qeh-1iabreVsn{G^4F988FnV3e{o`kjpT8EmdzbI(^(q2` zBHq|rwUaK-2FfnoJ^e0`2IF^Xg#J9`jBFnLg@(pdOuwt@4q3{-cRj$U%`~$1TpX~ zD}ueumpL-8&D|7du?SOzLBVd(9g^L4-&{ z>uq&pwGJitO{ZI`T}GA1D7@pIkzuf_xJr)sy*nf`+`ZA03yyu22XUYHeTotKG);XN z0v!h!kicO?g_7&n>AZf;lTI`5N|_u)fd*qYxNZM9b46Sba1k!fwLH11c?h1D*`4M~ z7{)QtP~KZ{{aR%*Lom)xzA%saHpvim<%v9io(cW=6%!tYt(eL)AbuB6lb$HLvO2a^ zt#(q8B~9Q{mZFx%V}=`0klep=PIP{lX>mNHISYSpLeRa&BCi%AU!OKoH3ybL#odCS zWi6j<4f^PIxO0HX7xVOf1YHd3)c}-fo6}G?6g2EFcV3L4xdW9`?6V(hm2*0?mRi=6 zn5nzRfrez2=WQ_{kw5_Okt4TarLgHjl?e{G27~e^=*we5t?RO1mssQ_~7@(x;|R%=FbzI*cko(1Fe?tMznj-h36h;PEOm-un9hiar8Ki^t3eEWIi83`Gz zXB;b%ff9;ehAf(zI*he5k*^ zh&@+fHRSq!1bR%Lj)B2V0-fg#J^8}T=UN5`TuA;&Vq3hreEap(Q6YzdBTsl}i?%Op zpDh&k?iYs~Pt1}1%iYbFZL%WjxQE_a3DXDcWY4Jj!b&l*6R#6e^;fWo5gl3R3dYn` zEfy@)H#ePlF-3EJVVgnr8%c4x6M?snIJI)RWWhk4QCHaH4{q&R025$&JSoV8It(32 zzhBaF5cl^IWPVpG$N~nvR}rck?|w{nqZ5T_f4ZmrWev4{U|b%k1?-jPah zp(ZOg4(O)@|9!wwj!nVZwjoZ)&Pd*F0U`u*ctMVV%HhEkiMEQbSXbfKf<$jL?m5DU zV#LVO89{j(OLCOrcXK)H@ipW4svqpsGhqoJTm10Nx7rqY8P_xozI+{Sf9 zDp^CPF6Ji&!ol=O4Wztlbn?4u-VO9NS0j`sld+gps5YU#e#?wBMzj@2xikSOc>0a0 z--$fjZ3pdhOjKfY#`E(f?}^K&V}NvC&2 zNOHjE@sD_E#Qf9!b4PTjfQ&5))e3-G5!7Aer0W!%g2_n)pLEjRs)@<}*fbOe4z8Ac zh?FZ|4zV0xQn~-pa>8JiLYP)n6hJ4C9{re$e}YTc_rK$vg5;y5fW>3&+4Q9 zFMa$wO|n8)f)*wPF_nVLf}93I*^xr@d!Pjq1@B&IjuuWQl*~hnv@nSIHu?Y1$FhFK zoHL9vrlA_9ayqhdMR&?BX;)oHm#+0HZzqvxZSa$lYd_OTbh^2n-GK*GY)>rY>Nq`9|%mk&pD-1Rv1ozDZ zzZn9xwkUU;+}_nw9-2}9GNbY({Vrh!K~g_5&;8A;0jMwvMyNdN4Z?xEK&7 z-361bsQSzzI0-Tx<)Gm;>O?0N-4^qwj${Q$5EHzatgOc5#GI{ba;pW5|*_sPvZazbu?Hl zm3lZ(2Cll-y9*Kk&=2pZ-$H4SWg@&CrJf=|<01w(+CbzF4J`cREUQdlS2}bYr9uh; ziqmFK?w%YmZJXJhfKBFvbfe|%+Oj{u-kQnjxu0?h&{)G)WX(^G9=luGnDSv(tWwM) zZj0P_nU1{>hUNKN@2Xq7^xG7t%9@^PnL8BQpM0pC5~|;m8EOmJ(zj`@0?VA*$dcOM zGM42cfG!=~olihY0E-0h{7m@4gQ2n)>a60GU>=|qbt-+GKorb!5}kLFoWGw$Y9@T@ zz?`6Y!N`)P!~FKsgC08QKXvmO53m^$CU7dWIchqm9meAO5TXv-f*R}A7cmfxDR|Wd zVb8Zlik`xaLo&ev>aesk&WT9({CPuitPU;lZ}N&k*NOdh$-}Fobwx`-2{=e9u8$ct zuXT&Tn2F$MmzAlg>0#|8O6PBy@26cZ$CV6lIrx*=go}1Rx~<^-_@+O&^3d3&+)Hm1 zF|YM>=?tXPs^xM=;>Jz#6Gy36XZH1b);ah)Z)e;;LN)v64V4D2!75my(BqVL{*4`) z;jFt#DBpcm#!}}bgyExR6y@snvUn;UBsqbi zH?hzCaNCv4<(#ZcAPVmZvRfSyGE6ZZg+6T_^@(Po4R* z!t1_M(odKXMZ{wW{j;lwxFO#1Hmr5V6cqL_$#agO~<>8uxG6KVR)6O_Gf!7 zI1smA|DS-UQ@C!WE?c`g*o`Ta2o}H>Un#P@Ug9(ZqHY?yAC)kZEN}si&uDz49Js-X zRp7m8qXdD3_1xMlYwsZ@Yh@?}6TC5NRaf>*Y-=HmmQ6W7m;YB+@hw^!ASjJ<5G0|Q z5R|3XPha-vN5;+6zduULYDCKzi~sy!LfNDPnt2$f?K&2zWCnB zv1!vO;U9gq?75OB_ZydvYYTaI@qZn=-2U4FfscON7M82(4j#$5(Zm1yRknFge>T5v zaF1SnPYFYp>~QR+a>;HS{mGlFj?)_<&)F1?|Fd{xsx8Ns`DpeHD5U4-4IS$fCLwhK z>+Kk-lI(vDZysrOP2X=7UTLKbl=E`z5Vn12z1jaCU;lP=@>*$sYCr_Poa_65L7URS z8uDJ*>UaMfwDoL@?iCbI29JEo8*=yVWfpDAd@-DEJCgl&fK~Rb^qaDmZ>^jPMx=w# zKg~z?m9Po?Lv_nz@7c$#%c?-j+~c=X%4gFnU`e5F0?6KJoQZ&*B&2ZQM}fuHOHN&e>7h4M`@LKiXVaoDBV5~MdQ43q zvQzNFXD5zBH;q3E_s)p_MM&~2a~|%r8>z%j7tB5A;Y<#gjXL?@_;uckV?p?oMj(VP z9Qc1#wEuq?w0Nr8|NntOiA_O<68V$=2L?U-zbe|F|NX;Y!y_W2qGL4G{x=Mof=f+H z&&bS5h|P`2F8Ci9G(N8kQ&L$~T~J>4vZnD>Q~m4bO>OPPEnNW}Z+lYT5PaYD_xFDA z8W`yq8uuKXZ2b7itwVCEeP+pJVY&13cZaX5wZwIswXO7xUF+>FkOt_``1n}+O!MsY zzyH+s@(ws*sKHm9z)KkYYC_c%Q9Xi+SKlzGX;n=T#K9OBZ$yTa7H=798b_ zw$*d&i-dzMMho;>kaSwqCw2qiCl9XP#fo?`y`9FZ$CQ#&liH28*4)Pa_B}Avlv#=( zp|BM~Bh(|JNf`>_3t9ce-bR~7p+r6B%P=r&6#@VPN5iSu3GH9UI$S@ezPtETfbWfh zzd@i-x^GdMaF{gApr{@((#IqC$oSiBW59yhk5ST|vJVzRO@Ywd7=QC~GC3RDR@6fS zg2S)Za-4-${nKA+-6^vF+#&45({p{}l@DnTCy_uSH&oc7Nb0K?ZQUm9N-%JG9Aqdyi8#L=`a;si0q;QWkWW5AA5p%idRikn`a15;=G z`3~kJl>(wwM@^mcVPs^R`P#l&#lTfbWf{pQc`^;kRdEa`iwoPwRPvJha4sUDf!Lc} z&W-#Y;{7+98RpS=UEcB!+}W=34Zr_LBj6%-GmI_6odF#Crv^h&!!5FFUq-@Oo>D2) zM1Ut+{O*On3@3m+GW*)rppLA9JFkm6LV5IM+D z+cT}>yXw48H%;ojepVEbBE#BmJlLnj<58AHjD&#Sl6( zUTQdNsF7Mvi9(Eg1#_w1jeQ}1uV00Ed1~RoV8z=s@^eIJMi_w6QW{Fx*Zbx#l8fR2 zPblR*?Wuu@{Y|% z!Vi1FY?1jb)*ZhJzVn#BAGz0cxupY2mgfA+NiG+Jbf2;D`?yB-fy}}8OC~)Ym}Mp(D5i!a!juL=shU zIO$Yg zhyf%f{omg$YJ%!Vs-8i9QN?2B>ZTRrG#PL?`50)ewToLQqs=$rcscMt%~xSR!Po#> zxbk2?Z1AWF@wL9TxM%aG%Z73>DKhg7g#rN5`s{F`T0x399*65bB1T55>U;gvF&-J&ufu7|!vng?%|NDRdXaE(sXu8e|Kktq>y%%*e^7~&^ z%i3GpLrxpDI()YUHJos1>Oq(M^BM{Q>}f`-trP{bV0hyg2)M!vwt7tmFn1||4CoMe!lsj&_;y9&>(w^n2cy?9Le-B*His3~ zk4|Wdq17!i?$X$cjL0+J><^Jc?-+P8QsxXjW)fvM4dPHxZqiuR<6! zJ%}cDR&nJHs=dkgPS%@~Ji{GsQV)f)Uu4w~DZ(Qm6>~N8_-U0ddv6Fkp{}gq)Q1C- zo`lHj3EXJ1qQdN$*Rg@?8Tg?IF7;4NRPlwTJoMfZ0u%vMmon_aqvdzyvA;jP4;Qzu z%ABw{;nzVEPuuXp6uFq+20DLt3=$4!;Ag`f(oBP|6QcG`Ln)SqEixhR*HLD#Mvb%M zuJ=oig(#ewZp^6eSmM%`pE@A%T%2_6L@KYxqFPrrLbZp+azIw#_UrI@Dn!hNs6IUY zhV2i!h0dYBPvAVeEZ^SIr@3a*1Q;dQHM#$G`^H0y92|2{QpmC1bGqk$*S(k3F+0p( z&*jYb+=iC;8~*;KSm=$q2N(C}RFC9$9fjvWOaxTWQj%-H8)fWI)$drg6yXhr$rju7N$i>V}g_Xu%A!wb7XWG`HzD_B(mvq)T zKGi8aB~5m6H{hMSY4D<^WF|-aUc|-1bwq@BtlRyDI>CSoA?llO`+-Hj()``f*D*)R zk8Cy(`Jp0bZh;HC*5LoXfW?DI`q5yeg+X@&^xZ$5I5pLo7`75oKU<<&!qm<&l9+p%cL zg?#9F*7L_;GwVVddUe(DRpOkB{Je{J&Wq`TJAj-lo@_*ler+Q>zR5+IZ^>;gA7pVKBbM8-nMNnaw3bV}(i17Qn ziwg=_;=1>IG}!JqvHJD~_asGUy5$1goEyBC++Uiw-1)eu75eMrQS!`-@BhqE9|bsn z#oO5WlA69%fh+|z=&0_eExuU{qX}8{v_ENN0Z|NRWcn7%^v9!XYx6&P%*O=%n7`>e z=eWmUuu18!cX2ju^(h{s;>g!oD5u%$htjj94vFkwm0lJT6kdI)+&>GPE-?lqDn^b$^slK>^jeJIB5-m@f$mtvsQE^6ZBc|>!=Q`t zgK%=gPTdw4{mp>j(5o~@>pNVjIZpXO&Y97!PtIJ6GNXIlBAP5zGmhgJEZ9m(A^y62 zu5dptH|$3!1^njq?{RJy9=()^;0PF))0-{+&|jeHrNa4(CLOLo67%7TguI+*XCI<- zt;9x2wZ45qBXlFH7K3Ysu?^=b04omsCnY0WhBgR%RvP7Ts&?EPEv}_dsS|fsFm50x z?j2*Qf@12A;hW?Lwph3+btfQ(OPN&kd;I$eZQ1ijL(d^gX%c_Zu%zeW1fD?(O6c9=i62h{1asM(2v}i%oCn| zzbmsLC+&EF?21-V@@cG&CqG1p1cj^cs+~_rudworju@l^i7?)Gb&Hsb&X{%)<6ynL zcJsD^Q-C*Prr<3w%>B6xtCH5QThl?xbLZqnTV!xy$k{45d`@*9;xgs>Qbo_#{VWTe zg>#kx!KYB;mXg`$>DJe9J5@Bn=o_%iZ5nF>_%ri zZYXoWy1>{fBvVI+7X=6&s=0u9ZMHzkD~zWkj|LQ@>wdo&9klXym83@IF`_~6Lq5Nn zgpJs|A8rh}ZC+D+vTdZ!bE^OsPfjRlxY@0wk*>6%s$!Tm->;j2Go)mLxA6Cc!&S5l z;9XZCd(GnI(!pE~D(S1~oE=`ESo5a#!X*BTTWJ6oQM585m&%MT7Obg^| z9l-I?QoTKZk>m$e;6J|B(&%***B*`kk1cbSrNo^u$-S0q55xL9%hO5>%# zSJ)~E+3JhCm{@3SI9lF!0tb~=<(7D3!=9a3a3V7K>BIf?lY)hCFN0!obprog{UJTe zEjt~F4-p~re}V0?=6iV_*v$)xtG?kRBaD7w{%WW+v%*T(()y*T0MWYUxT&IwPWzZ2 zZ1BiQxl3M4A(}6`oJ^O^DIz6Iw=AeSkTIuf?@^RGTa;{VV8BI6*L||EoO@oYF%|S6 z17ENsIGL=i#xfuyiQN35NJ>!<@aYXrrc#m1Ty;&%GYiAs`%S#} z5Z~QT$u}09zk6sUx~9@tOF9^X$i`aiL@c;3?4PhWon%&Objc@uaQ@X9>rkrUbqX@> zYKt6eG4b8gAm(=A&O{9t@U2fYMsPG8i^pt>&DZ*5@}|xYw>h+ICwuS{5`2 zi~}4#+r(ztDreX5m?oxKNCvq&USA(l!5|TJoo|%Oj>vH*pKHUc%I{dofXUlWd{|a= zjW_};7I|tKyA9$^1=dSi%6HqrsG{Uqjmkewv06>w1o04XGU2uIJD=L_;CFr2&&Bm# zZH+aXlm*rYflp{T3?oW~@=6E#sq>`quM;q2P|CctHaAghJ+XU2^TJb!W5nH3r6K z!T`0E?3^wUrR|V?+rxhhz=)wK@2FK z@eX}7N#qH3D42azfTG-rtooyWn?x=sN!mHYbR_QTE%XF4@@7>IZ_tx!`{sd%fT%5} z2^vI(?4N}y3oV04kfR?G+xFqHL-lPh4Er9Hs(4;9q$e<;9TrI7;ipM!m*;-=Y9a}gfTPJ$EnXHE+JzK zNTkvyz|jY0+1vHCj*rg)ptc)73F&?9(Ma1f^ihxdi7pqp1E)Cxm@R0jP<|ti?O#v_ zSs{uh*EAMZSom-t8mei=m+xj5$EEE$1)FTdBPVVofh^H)=%spi#2Fr4vX|7s7!TPe zZNwOkKS|onyfH|+yWi_QG>|G8B32N9fEOPhcd$ z>c&0{E(O|HVPL@kG>0&qhAHn}5GP{MwP8`-Zm|SkJJ-1gB}|Av%v)hyuyt5+;s$7O z3pU<>@AA#hPC(J$rGe+zcgW9`__pG&v!N`@)KqWr`vWg2XJ`sR*#=r1gh_kCN7p;_ z*TkzMZfX4wmkbWHY_v@Cx4Ue=lO&IFX#cBWL)`|)a+3F?-WMQ?l7yadR?hg^y#hEM zg75cndRlXd1B=^FfGWb(d~I!ajl+B;J2^E6d642WHXCZLCoC{4nMQ3(_U zlM=uG+n?(HOFB?kcgl|KRlxz&-x7%{)L$2=PJwzOnB*at5wWJ@4H)x$=f;9)y#YPs zTAdwC5;3IRY>Z|Eb4`8aDEhHsm&6-N%)tLZbFQ0QeG;2m_&5Clb+9I@x~2uJq&IwI zMtspmtl1uvNMEfQA=b#et;YtfWTw8uq}F{Wew@ayR-?zMKYo?ZFQvK`xg5@|JO^$a z0=Jj@RVe{!)jay^I2#VgRo{3}0HjR}x?Vw7mo^Lpz}`h5819Sjw-xZ!cB$%)$wAe! z-H+_HY3i$FsN6xtH&nks@-;{?gT&5gJ56bmex~ z8=BZ2xjGP}&h7lX_ao^GD+-+5_JSMnCU@n7j?uyE51^78TbB~_a3TOAuF;@gs=03& zBG<_A2OlQB!q7*Dp+^bc%MC0)i@c9GX?~8ALBvsDnin1J!eEv08AC)7?RETIx#!(& zA$$fRlj?*9Q}{vgQ1t!AV1V_#!2`yyJ+6O8YUpo#p{wj(WOv^GGARQ6eR==6hPjF) z_=+QMLx;-!1`=dl>@_30)Tw%SGv&bz+^Nkyca_8qZDNJ$cVjmEHvRYQ$%(J5#IjAh zktHhTp#deA&eo21)?elI~oCxB>-^_7hm%@vlMD1`p^oxE!PL)4Pllk+>PLSBxNKy*28_M) z@JR`z1RRmbgmVv|4Y`>|(-<&ve zm!l#}d3!$Ux!^ah!bFScx&_mp1vgPD0|+UNxcP>ALU_jugU_wZ@OLs4q84Cy{cDEe z7rqYR5iZ%%F+qr23k-z6InzJ=^=3tn%%8?cJM2H!kxv4~du2JdHu@dJijM z5g%*Y5X@GetZUsW34au$C&`r(i>Qrto0wqZr3DgD>p3m>qWdXweMq@N2&UUzCLOH%{o(D-?-)wn*gKfW*tl` zJpoIdOIzcDkYa`(2M zppjM~FNE&A1cl*?E}@@RVy<%l4@HW(Va*Cnx3Weh#R3%g9C^9#FY?-~u6Vs$UfXyXzMrSyL_cQVPuS2~c%DeG{M2iQm@+NtqI*%_A~{q9 zj5QI(`<~dSu=rXQ4=0u`Kca#LsX8=_hMbiQrrl!5JVprC##)7b-5Dai-`F8ihqD^a zg6I9U3o@-ZwWnDz@a4Jh_KtKZ_$CVs1w>af{@x>Fo|?jA$zN^YBka{bcTBs; zeGiGH<{2I}_mM{T*wV|vF%6JTl13M8v|Lm@%CZ|;dt`Z zaap`~vw{9OSL9zq76&CTdE$4qX>y5kODL#*d=5N}tz%fB{FZM|Dn~ErmFx6?VW;SK z>>&m!gh%@K=Wa~>7K_T=exv}>QH?&U`8Ib?N+!0qBz{N@v_kfB67`)D!qk*Eh{i&H z40h26+ zM@f1y40DqMBqi2Nrmr=n?&isGJrjmcC6kCOQXrO`=1iK9aOu;G=qDef z!Sq2Gr6^j%uq#<-XLbSUzeC_4+D~^^k2ew7tW)Y&v0&!6W%PxeREFvl1@O87=D*P~ zcNvo0L`U6}+J1KhY42a58NGYWa~7}XN`x$G7j6{fKGxT5^E!f11AaW$mpeb37s0S2 z4)X@wH0VL}NGmcakN1fJdv5IeW7)p1lkW+ERrMf(WM~(Yy+Sh$FrEMo+8*;Dp)YaP z97V$O3MmvNwAqNlT0a6ZoXE{_CzKmWR^G;+RWikYd2`+JJRbSdQ!YVy?rFdjtWSeP zfx@A6k)zd^6!F=ZI5M5<|G8L-5EO|_Y$_F7&JsLuo-DACe&K0reA|z*=6%zY|DN8Q zE7&S;T0_%vk7LQsgxZ04MhWjVpp0e!NoIs6Km@v3wB(7PG|h02{4tioPy$KW*BYf; zyZZccr7c&*WcxRFA6a!}YzN**8sq!b^hLeX64AqP;C&}IUlGWL$|$PKK#&0Q)~hZ$ z4OD~k#hfe;1|}Cf9nM^D+OnVW{>fkkAaT9e`%weAaNLQ`WwTuo*_RYqf^vLYtpR&y z7>XU=dCVgH{ZDin9c3(ZMqhfE7{FXl9ss&`ly{%m3FxKdjDjf`WZwsKm~!(| z`#eI6P=}6x-4g2Pp8oV}J-=lQWdDy9EP4*~fP5(i=I*;dBnFdiOLfz9E4B-jH?&c5 zKj|0-t9XMO)QJVlPny=NooE^&*@{+4-7ipZPHiIEbuL&05 zN1%*pUPSmN87B5VUE@IW&ht@2+fuq*MeMxwJy5SHN2EfNU}{SEua| zpVA-t{N4r8PE5NG0juC;0VUs^I(_(om}A#5okS;(FRfJ18%o@$uMsvJl>cuXu{b|F zW@W)%$Vv9z5JQ3>j6G7aHW0)01~3=65Wd{`IZX!*iTif=K7qHhGB}11O1`Vr1{Tv zip1atl|KGz=V`jV)Xj}u;Q8Udy`Rmh4xs3m#)&O70)Q!c!;k>%tifp@K0Bv=WLbPa z{L!P^@X`2&`PPR9ET}QXJrcXf-_ebf(qP}KPe06TvB@k_> z`AKdQ$SjG?JBD-Kdf=3%<)ZqE&k`VKq@scz$qa&ycUpoHw@UQxAHdZ(O;Mee4QnLC zuRfh--_I#0`|GgUa&`C~OrTYGT3JK{txo($8sA5K<#TXRd$u7-&$vs=Ps>3L`WKkW zE@C9G+(aieO_$=+kHBp|ZKl=2LaZEuOdQRj#&?rTmr$4mTVxknL?dk*T)78Y6loZB zGl(oZ3!>$iX?9e?9%6R?v51YaOH(U~F*r+t9f(kE8X{Q4*4QfF! z9n$H1w^2&_r7s$;hhY!B%ORLXmlMx3rhdjGr5%Xl)lz4$!iO9fb1m&)a}YQF?m=*5 z3tIz7K|DjXLGHDRRKiyZ7~AZFzA!A+_z2HZVmOd=;tR*{_#H+L zB2s;G+kWtU;6CJk5%!jCZ3PXxb`k=?-8HyV+}j2%6nAd}#l3jZ1oz@r+}$ZogS!;h zHc)|L#if#N?)&}l?&tZkf5AGAIo8aqHFI9))vhh_T^YOgqq@)prv@2Q+Tj&Wx-h|# zFbTzfRJ1hdA~cr~tD=GrIPlCAZa$BYfnD*b^P|*amcNNCS^BYQKk$UmR3sO&$Ii?k zSb$|Y{{Ihy8i@XDJaYdp4C+rIHMyIs`jMOm05mMNr-x!+#TA_$mKd@gKSAkc7+B)s zm6D^dA2$D3Jg%&2$p;nfxI)~3K^)I>c_uhHBNTgpLTJz+E)<==fO^r3M3$77tWF z2B!KHSMimiFSRWf_EZ6l_yfi|?AzYKlAe&JE%!T~Jvn!#VPNpWVLvX3U1!p8ikSQhyP*%C( zWE$Fmbf|C)!cbqvSoP^xMXNAT*<&Tue1itl$UHDQu#y;`y2CJtib8T_12f^ zAanL*_M+7C!CNWC(E(7Iml>IpESZEMO!#-r-Pl#1UaN-tk|U5pT)y0d>151kMt6?y zmDHiGZTUSyI{zBf-=VPw8G7Ze~0akBNVF;f~(DMeVbX4%nM zTQIN!53gVGriqAY0exz!7q5RC8k>r_Wd}}vw>NN%S(W%V_hrJDYc7NQ#)pf!!OmY1 zXb4w-hvl5lVWi~t48xr!f3jz{^!jm;vGq*b<0furQj|11%$`%vdCkwc?7iEPu;$>Z z3ZtJ~`r%-)eele6L_6iPtp-UJBF};>t{=wk50D*VmQrzex3u2+cK+4B#%#o@p-1ye zECa^nbjGU{vPDTVmn-iyVIOyE;26^TpSusml}p$|O<@+}`g@@}NiMV&7c-3(Gk`9c zjjL-9x24^O!2e%{#IommVYf1WEj^}pxIX>ZcyMt>E*Q2k^*>zyf8}et;SS8jx0XTg zAF}c^I%{Or9pw5@GJhZE4zeV|$&)#6a_!ClLFB8T%%JYupnX=!VXqg{*6P9$VpU$ z^V~6&T8p_W>@7lwuh>;K2k|dCY!Ya0Z4E2kRXUVAC>!GjN_700tP_&eq3ZgDD( zQeR*ZoYMQY8R?DDq^f%KLBSq90I7JzzfJT)SU(u9aH7JhgD?Ui78X3q=*o@ z*pw&6;1DRO8r*Bl^qD48P$2^1%4kcM`$|gV^(o^zExE7zoT_MPcwFh`lKhXoXLXs8 z=On_@3&|HeT%YLdn>GsRc26*k70z5w_6auvVqG>pxZnFR@7=KR*2;5lUWG^3@g7uqyz-!WmIys9Jmx#) z!B(a$^-xOSTmr&7$w=QM@RO#6!F9H-8S717r5j}o?^dg43xVJ8R}@b+eqp;%&cRrh zLP_;E1L8966r;!j7y-`aVTiBcJ@1x2CQSz>2vS#*QQOd+XzFPWqNa2X0us>i~Yk< zSC1T!fr%n0Don=ul}(erN}9Nju!uzmC$H0e;6K=J%OrX&cUj~~PgJ_GDbq%!>F*$; z`*v)L`tHCcn!5&u!8$wfWVe*KqAn4Q)N(7+OqqA&X|hB|?RuT`Of_33^F66A10N!Z zB2G8;fqpIg3cXeqvDh^IyvV*PmGU(?F88jgkY6oM3<9t4O_dy1wo;fhS`<`i=QAnPgE@xO!VSx2q zE=Xed+xyzO>yeMIQ5u<5+`!+V(4wW+!?*JS-R~4)-jVgKsRV30=+QF;2Q!lz>W4XT z8#CJQYL36PaJTO5`l#UKuP8{j(zcW`L|-ztBDr+D_1$l;FD!F-UwVWoKLHXj!zngB zzfJZa@GG01eD+T6v!xO5ATZ}oOdVst!F+-RbxU>iny4?cI!xcYm!N z{@}ckAAR$2o1>4hxPN^g`I|3)tBuk%it(A9pe^o%^_`u` zolQ=I<(3;ZkH<@ffM-5T(ZgFi-oHNT-IhKr_{{1zw$vKkP?AEx?0PNti8B?XZ@%zu za+gyAO=8aU!*6~VxX$uIlmfk%&{ME{zK&GUd68=I6!2lX=Rtz5HLX3f{9$TkePd<*{Z44(+UDMd z=9lmL2P>bByndYiJUhQQR9Zh7y&H+rvAf**hyme?OA*m_cy*U%PLB(}5|0G)wby6VI{KnqU|ZYXlFQfXfxjL9+TqEl9Qt1}qhgoMN_>?~qgY{$+j- zs;%~M2^rBKz+=)V;E!~$PhyIwR4<7LU-8@xhYgl0Md0|I&oMV1)oP;%G(G+{_sGOH z<2D<~sK|c-6XAaPzDi^>8beIaDBu6PC(t|=4|5?t`$8#VE31J0g|2la*CmZf&rV@b zM@&^!Qp_6QZbck~J$_w|Hf9PyY}xTB30D7LcmYLL0L+FU>~HQiMD2xJusg47>- z>0Tj33aqQ$gy44YV#1ZP*PFYHi{@_}>L9px@8LQa9TnIh?$GDt~)IpS=qpMV&c%0~B0_CLO z#!d11e4I(eioxq6|1G!qKfdcWVF#dv>%|=kPoa@!9<&<)8W?9k-Ef!3OdV-oOEVlGG^^E;xF^x&J~osL{f#+pU;ET@oR%iDw&>6)e%q2}*C<0+>DP?Y z5xn)vyG%^!^bdf>2Mzd9ar7Oyr&Z*Z4LN#l-g^uReuTN}dX*>_j=Fx*dMJeC{&O}; z)6c0lK>i2oBDhR{>uSaO8PCE83#KGVF(gGIH29bvN$LrC0?bE)$>NY;GT0!#F)0qk zn_?_7X}Jz9fcv3_C4iwAxN=1T`gn-=Bfcshp;BtE5>QCE)$w%bx=B=9#`qB2ReZ%- z?AR5A#)AtZ@eXXo7^?{&%BT@8*^feb*6Ls`1OPuD<|Y!0=vF-F1w0vzl&_n4coM7V z*v~gNF5|U+dd+k_sd`zpyLgyxZ`s}y&DRdz_)RL59W>Nt_zyGaK=%NSI}J6#)ZU5yJJ4wVU|YY9}&5T!i_18O?> z=0fVjk196q@4ebJcyEss;&)(9buRe$U|Rq|9CP+uL!5!j1{+o#dZL=8y*Y}?_ls;< ziXm~{n$y?3dDp-`+1mc%>>hE@5t?3(m%^}JF{8(KjmRcFonQ;kL zt&i_v@lO{9>-8D6oKm4a77EKWq|LObj)HiS`KX0Z;Cd5f+g#$zv zjKG=Ice=)vU|)~A(!g8FV#wiJsoAWkmE!n93W`dv0z`*oVO5$!@E3CN+gt(PP+F{Y z#v@tZ3)y1pj1rvrh?G%{`rlmEnpf5q-zR|>JyEvb*R`2MjywftUx-D)S|^04QwLb< zp0fUaN4zbb`=RD|d+sWmnDBcDH&~%&Bm%IIchkL~wU4U}eM|#Ibda!rftaF{&va*6 z`+Wtp1_Ln`pAM<3092Vv+Ze#xA~xfaqlRY|w9SI$U1ZTPvnhk2A9uh+oF_Bj6)5 zZU)Pa)2}adHa){ANulxP57;U(bu<%7n@%TP0zbC&5FiW3stahbXA+5Da*@pD_&7*n z-=G(yKfq{lKV>D0);46oV)@W2TE$)x%Nexw66bcr&Pc?Q+T~MVi>+h9_D`a!RRD=0 z-E_d%1-SN|=%Ww%KzXW`fJ-N1@t?-C)_>hRXK`{$6q54D!p(R6+R}){rRH-6TE{P3 zhA||*4vDW4edxy37FUYVyh<-#=gz)!4Et_88j%Hi_nEP*p}oSmF{F;j6t3lxyjV+7 zdlf9E2Z(fi)Bh8+4X-qxj5m~T?wH1Ixmh4CnVu}z%JGp$-d&{^+h zQ>(CCBu*QKu<5nX4+Rui8E$d8s?rzEk+IW7ZE~yzL$xOKuiXp?jnBDqCvZZq&4X-q zcs`_k9#92LKOr6Pe>xBPD$g7*fOxGsbNXl!$Q~9fY%o}V^I>x=_ zaPC{CsdnH7i<))>m+Nbvv1T~qewV(|EmnyO?}6&)oZ}l;dWZY-gSK3&PF*XVXv1VWPdncgyi!H{CKVNm>|p2E>I(=iFj-p8N+-mq+TLO5uzOfJLU#6J_Cc#kUzP>EYjf|6UZ}2c;OmOs}i7EBJek>#I(KpOd7?8 zk_$(9V92wObt&UE2a7r`Z@Ka(t43NMzjG9oTUHKH9IGO!a0O^5ki#ayN@tOFu<(l* zuyIZlJ1zt)Y-RB^Bpc~M%;_$__^9vFV9~@LvL9%~9RQ4p0B60h@>j3;=$R^Ms4A^R zz7JqLQ({?<931kqMf;hn#hM4iQhUb6o5nJA;aEw7DB%7C>vo=Gd>fQH6y)k1oUq_E ze|w1l+fj=eoEQskdtipFlIOGZ1J-&G!;Eua=9oJcEEF-pbBb|@O~^^;=}L%?^-1PI zmW~S-X6U~grLX?ZtYJ%&^gSX(3bFpoGj=pOp~AB&Kmcy^h~+GN1DmJMSe_Z>-fo(Z zwI8Z-8`>9;d@zxqP9wmA8>gWZXblDlqo~x7I`S1t9N+-MXJ*B;FC?%d;T3OPqY-lA z0h%0aj|aJ)H8b3RlIeMpP1$|U{onjBO{LC_n#{%_R|AlMnd3|XGxc~T6m8@=?VZ#N z8o9hJ{dG9+^=D{fDg)wAU)zL(&FF66nR-sl@F-fTu*Wse2Zkc8*fR7b-cY;=&xJ;* zr--2LSSm&6yd+sUH-Hmg8BRaGjkpnGgX-d|O0D{tVlp3!D6);kq%42osv`ywZQ*nr zLm*#ID3$D}a(LxagdBUo z17xTO1v#?WI~Fw*I4v7O@iZ~_wO;IXnVB5vhSKt2K#h%q<8 zn>*atJbOPTIa?NN!4uqw7oPts^5RWw5h}a68-~0`9%qRQK(Tb$tAHl94V$JCaaECl z0hJ{HN^})1X%p>P6&{>VOw`hU`K`r9^iT(tX8=6!jgEk)^6q^#UtqZL^P(un3U$wh z;$JxU3@twnLgzZdIiz#k%=szA;`@9W`$vspja^y;`F`>>(L%wOhgA;>Jz3i%-^H;> zR(W)Jitd3z_?mzSAf|`3i3$qfKQef_z&lY2Rcxk#MR2c?2*W@;Jmtvt^v70@em32M6YQgI+sqzG2 zNoi4Q5tNtWQcuq`ajW@7d!HPOfh&;!s)gPSbO)rEO7JN2=f^5N1n`07gcOU5ffk z6AA1CNWSW{j_{WpHX8K-|BS(Dh{82UM;H#k>1&&yhv4-=2xb!Sw`*uvy|D0Ny!1aET$<0R0u#vbh+R(dA?!lW^IPo!tmBn zef~m40tcZ?hu zAHdWZ_Ad({-hyrWH*&5eP^$c7UvQ7(_Vg8AuY+_KQ88$ofH;wr{5M1O(IE5bRu^+7 z@x|8ELkT&c8Em5qOq7oqjRf>scB1NgV2pELj~iagiWUsT5FkGM48q1E1N_~Zp*R~T zJ|=#V0G%-cwg@*txqS~VfRcY^zcJ2n(KcW)&2f7^_BmjHK%1-p)n36s*7~V~lva{6 zW=jSFAnL>1J84qfNg&fgoh=T8Xohk|jsRA!lRsfaT74SS`jL?21w-{Od4i{~>A8fu zgkc~Z=_1H#aqArzHak7Iu0-V7*Rt?|2Jqn$3J9KBY(@YfFh!uIZO`!%hV}7stz3h7 z!I((%B>vC?LEqH6@S#SYBt)FBo0Ri8p(ZODXzB(iez?204F1r!5&=hXoQ>)41E>(Q zt!IE&NfYPAv!G%U2wERytr^aNRRvq42zs|r+w8Rik*a@(6FmI61sWXtp&d3&i6$X3 zC%mLL`yH<~xxDn6X^}K&LwT#&zrP}lX)D2W!xp~ffJg@ZMRxtg>StPrFq4rxUmIG$ z!q;VqVgfeHVnI ztYKHLxo<*nc4`Ow2>Mwpg&8TX_Vml-_|~4Wa1+xLO+j4%HtC*P;n&{Bcsu>XJeZX_ zgIV1I?2FkC9fyQVDF8s*6bQYhsryZK!5J_5)AeKQ(AxaicIA63038CClX&ombWyNb zN9wT368d@iH;wRb-^99kMZm+-z)o2}L{bd+m9S5iQeeXg(stc95^f@Z>PEE#*mS?T zHh#@X{)&V7-dFJBK}lN7e2B9EO8KgM)iPwJ3q)diGR3gfKWB`NpAh3A0F_6K&f}<3fbKhN`?35B8dOq2BhUkB6aXje9Ia_%S;~h0;l}eKGjtcyU;7GAq5SYH%qe5h-mlYR zvY?+VPcMEI0&&qA)jL%N#MqC7SGqoFTpm~9Ljl+IL{#WC##0@#50~UR1|+&T6+5dO zL%^)lQ`J!Pi1x+9H*I=>G)IUyZgB|bj439;9wD`GJo##c_{~4={fLuuYaq*0B`ik&Y$GXpU&79k56Ua z-aLNmJ3jpk{{x|=#SP_xSM1w6Wm3@7pIsC7Nj3slDlY*_XQxUFw@GKnAl>6Ph_9-r zr^VXW-UDBL~!89dgOVd*32( zju3cR@LFGI$0_BP!PA{#=7pM~zeB{oN?7(bihv${>*H3~p?oRvlz+s1`-D<{pIZQz z9$JozZW3%ydkaVZ)=RU&Kn(x>t`=dyZl8!?a8i2F(cSaA=|4HSia@$d{DOz<7@nYT z_s(YnT#49m`{4G|GnnzzhFN4I<* zKL2AW&L&h&24H1P8=(v#F%le-cBTm=Gt|=PGk}t~EMsG)SPWz*5{tI)4OS;&NLrqm zyFd=WZ61YlF1~C#q@em36H`ffb9HaP`qb)ozK``v1?7Og@!YYG`Ee|@fuNeFm>@2j|BNP%_~U4_@-%SVF|4phKA z54EAtU^;oKHp-23YC)coWdI9f^4c()X;_Fn+7o1k8-`U5qtY%y`X#0 zK$Wzx^-eDl%UsNi=GV<(4YEOyN&u2tR8it61z@lF+D-50k9xH0fs!}BvYKa4kFR`o z>9~wAg=7q=vD{aVxyu)gh~6I*HzEK2{+^iR)N%a|uk0!}P=BZanq)`fwk`<)RLOM# zX_Jo;jd?@W|8X>dGPgUJB_jEvh zm(77OIaRWA_q8zd*b3ob-Iajwn?Z{WE*!c5daib-S6tU;=wcwTdQSb=u6aB5sIZ>G zM``}UgxYCw7u#YHdR`Y)0x=KIBVtWy8*&NO5`i=YRa`wlAJ?VnNfM2493mG-mQg2? zSgu%WHad+Wwpv&ayHZoEs!JtH?J-lTmek)~K2+mZ;boxwWnXJO{@rr299TfVBj05k zRZa|%1uRUpoZ{|AkU#lW*GaAynW}zH1*5-aMx#nQ2V4xmsHx#!!?no|HgH%U^C#KUKdU6=$JU^%_RBDWj{Cg6f=l&HIu z@0LR2S1RCIM3uSrQB;jB*wl>gMZsjvKKnpR5%o;jw~bCvWcmDG+DTdZfbOsLD!PcH zY7!U}lt9n4bs%sZm9f9+X>kxIk~tbaWBYO=H>Z8=>fS-BE-)a!ROW6A30aK0)i!U# zQOAmZjJZn@BBrG~8s_B5+lCN22~Xnbqn=0LIGaac<=%Jn*3jktQsM0qqyl9cMZd;m z`HY?ybdv%sJT+s*mNX9Ls2QVV>Umd)f%B zDSha1mcq9G)q03VJi+d2AxY_afwSwb3ekA5pMr`SE=iX9OJQ zJbZg9hU;3w)=zlmT@_c`2Y(}5BK&}q^#jOb5;`}NWePxJsAsvMu(s2G9Hvd31^CH0 z6jMilJH=SxKhRO-Z@q@`xI;bE#Jf?%$j|NDCZlvBFt)4T)>mxCskl%KJobm|!>&Bh zpCz#%Mk0^G?t`Mf`~?7U9ofv|aYs41&f~aR;5}d{RzL1^qIG1VkZ9)U?}M4BG=Zpj za-0UWxd}hAgjpW`G*Budd1lP9qErq7K}gXzPDv+ukB%6m zZ#fb`&$9dXe4bxfWR*h9P^utccQdai-NPr8^3>AN&#*YBdUtMAe zEbtMkU8KK$_>U5X!8N-jkzg=6kSP`hmKUaLcO%auRL7x8+=ySrt7lQVR;QDxS=MI0 z##4*p?`RdyB`+5XClh!9sQM+Ms)?vHo#j@Cd|dZ{pCLqip&*_!!%-XIq9S?ymxljc$-L|3bf>tD}C{tR|tIbzGjQ_)(l}*p; zn+d>#`Kl{j$VAWcn?(- zkBQph;?9Q6E7~J*sn5n9b3yr|d0zO?<@U=g-fq&~0r21dMf$EvsWT`8DAs2lJ1Ey9 zy*~*!Fsa*x-pfUQV!}_t{2>keiPL|Zbw4v7bZ|v2S0wot4dFuQRm2ev(iEdKb}d(B zUIMl`bEokf(N2_IXs;m^jK0k+tsA~3jA;4^6vQ$6I3IAIFZH*0AjH`5_C~`%J?nOp zO7SwsgBE>k4-+=?4Ck0@VKT01Cnh^Po~yK2X1{|~js5{PauvKz>_=#l0nXbe^njwx zHLq`@JlZXgC%GRWvksp;y5^JDStbfiS(Kd;GRE4HX^7O=wSm+rbceA;--KAu;kb;U z;{1a8*lHFepF+iX8v^WlX`AM7!JDlG4oZu0!HAv{!<{5 zRIphK=ZeH5-{YTlGg#tn@Snkx1$Zph&C>n+_vFP2qk=g z0xxWCzXk2%F|K~wqUAsT?sBe70PGr!qaRTVMPcPOuNCW(-g}Tb)%DvauiL@^k;zA0 z&tfzM%ZxpiZKIIE#KiZ)c-`p0!rHhT^xsM@b@E881+A=TVSxNXGp#0-Q7Xaq6xB;I z>Q}EtLosmb?&66)7`3+^jejbQ1&Uj(m^7?(A~HHincrCmc5Scg1XBV?f1i~msO>=& zgll9Q$CFSv$t^}Dv!T4da}K05>ww^39>9l-X~y{nY*mlRB9Uq99J2Wuv@TK^ti*Km zk@+VX%cV%&O#-@)>rm)50v$&-Fvj;lS@yA`4yMm5KYk#^+jb;HQm6a^#SAP6;Hig4 zVrn;>vNi*CCaJf#VJR8)QTmJ()(el zVw=-&#WzJ;_h<*Do-BG$!POA-aL%qR7*34xr*^1qbWnSr$XB=c;4tNH)93GeaG3Ur zw%}?_%P$fTLs5}JAOH_H(*mHE9XO#C1fNP~M9;=+0}W9^uLT7q|!N`RVMf?T}ca z4%dkeiaK@kPYKo^*3Donj(*l8Z z-=5d#(+qs)>MP!?7Bpna#Pc(tX?<2ue8oTv2dZ7`0x^nuCBpRCi7F&Y`Ub25G?%Yc z6^-`QxpE#%qszv1i!9{D;-x1beduzIx0>V%f6)WBnz{X=gQdrHQhQM((-sv-RcP9w zv!RX6yw40vQ;SUCfb$vGP(hnlFw2^kJ>pQ?47^1N7Dj+8Xr+}D{v-^*Z+-ow)D0+L zw6v~k6{7fpfLi*9>Yoqp%c@#An@M(T%@xBXYpj?T57YDCu1&;8V8-+ay)r;|p8d!P zG(+WW?n@@NDpmdPO0Sq-?!wQJRv~+@e@%~Yc?)=Sa0|WHuy}8PjY85(&Z;SyC#*V_ zQK?dnI7GA3I~x84(Uk+UqW82uH92!4phQO9+0Q`uj3REo2#*8`&+&c{@s!>tS%w}@E6J)MI63f_y#O0A52Sm$Cof8d zm&ofCjg%G@l|BuN=gcE+w2+8Da~N94$;56$$o^jm5cPiGyCT338^`q)e3E(OUSUF~ zV9Sgv@{Xv9$AmOf)yhJ`XX^*3`76%c366X59ceW|{Nq>{`N_pBu4ddL{N`ZIT+wfimNI?GM9hVVM)o7bIT35V~OQ~ zk^%w@to=ngP1gkA_cUbW2+N)a@4S9Q2z#gu&humw!9A&fkT3kmQt{s<;`|gW?=xK5 zZiF{s@T7ff$Tgn?Nx)=9%y6oFKbEAPzwuA@2yWVFofMr{8Lzv?9s#rQRuLyXs|1wr z@pw}oIY?M+M_fM&NUS8Zo9Gv3dKe$ubs%cZVkDs6kr)OQmbqs6b!{Oc$LdEaWLg`PLhxFl=Id5~(Nq{%<>R92iM(|P* z`u$5Oy%M?#oam;R)V~-7ow8M<7XQriLFbBS=X6qJK?fg0?kfFt4XIi-g3Z>~?32K4 zNAY{^Ea^-7WWLOev=b8GDoAwv5=0M+!G8Uwkt~M>fCHDw$~@+?pkqm<{`UztuRlj& zF5+E`jFpONDv}LAjRv;U3Y>T4T&}a|Lkqj1(YY{`qg$-wWo~-3%_FLUqjkakoRjy7 zMyc*Xl#0;HaW8WE+;2%KkJbrFMG8Wcg3jOC-R2S$ma%Ca161qF;;3G@Nm#A$y7*4S z%IoQJs&RqOoFZ~wa_12U#8mn!6dLSmsOyV#!#IBqF5|kDF&MnQ^Irxnynams&8v%U zxMhhR2~32fVD>ZsrR&@QCT(|bILORdIaca=;SH6h2rVh;og8qs8r;W(b@kBaQ8wO9 zIb@QoX<3X)n$}Q(%1C4=(01Oax{81q5$A1U*Fn!hs8916p-E4 z(tIK#y%?zn%(uQ_CfsaeM)dsJd_^_*Y8)Fb_3jlmS-QkeyAslXfby*L1qnw3aaJNh zC4O!a1pp;Hn|UA9W!k0*Q~_a_3kBq>r0n7FZzF!QCV--By4|$CA@z5(S(Nlwqbrjt`FuvEe0%{ z!p>;J5Ilr77R1%-L1DOV-p#Vgle7ul@WC*slJD-Igp9PWwb-}xjumrdi3C;ld&nnQ z|4#Z)J@Ik2)c&MO7Tw?B8g0K{q!UQ~22Rvs!Si(7VXqbI?2LbdfFE>}zuREBGn`%9u7tei5tOD4V_=W8 z{E?P7%o#Wh+7m3}N)hFmerXhrr0YMoW;mv8+MCFUNGxdJhp&e;g9Q(ee(oo4r)HiY*FHG}#Xtvw!y z(bcj`j<{MAOv(#^}Io!4G3(3pt7;os6C z20q47qW5}`pmrNV66|VUVC6)u5Y~{8%B6%aBhOGsf?X%_d{-tRRr+$!n_J#wo?C$wQqO_pMcZ+vfMZwl^G ztoNtKBF+z3U&3i$PMUkUh2@n>6+}{eCQFNb1y0kER`bkqNcJz!6#jkbJ9y*$k{yz| z@l|j!hz)H=cjB--&sd}e(YwXMgP|P>m5zYlUBbWfD_&m={N=v;$7*j!VV&U9w|@%> z{Va7%-;_U~60#>ncThh?;kH`Zd z+lSppsZh2U)i32TB7D(I5b<+mc$>Lf>(Srv4{wFLU7wUK9bVr;Ni9FoNHYB$_=4^0 zHPECZDJ~$u*mb|dYBfW}DY(dVqQb>Wn56z9u%FY$hL4)sD^I2-G87NRfnrkT;3R8uGZ2B zr9TaE6wT((75~q)8jsLJf0t+!lFh9U+g64t-&e2$nCa~M5` zsM->T^u6b|Rg8~7TWyYU@}{sRe)`VMPuF5IIKk=~k z(i7p{;y)IPHhhT7y3fsprH3;E>+X^+X46; z_5Mn4=e7vlw>#WkLcBwQXsCvftL{QCg8ewOVi`S~LmvO$T;aYV9F}AN5f7&O%Xnzz z+}yj|d8Z+~gLt3?$i+dG5f6yy0TDN*qb({@op1t^7v-#$QxeUftp*OYO^F_JWW4dS zX5uI{RgImXwcb;;7O+;0N`$tkkh83Q+3|O-zZnieA%Wcxzzb9{CV;m72= z%mtkxsUQun(>LGTE{$Xyd_PllZ~7uH-h82J@=ZlES3bS~dzfus0YGJBdN#3@$iq>m zhn@)lc2z0T?9ms-lugpwRgptxKKZdc8}2-tWwTvrb{b|Ho-+hv{oj>X`gPN^>;#AP zn&>*^?a>_9)MLdHfhu(;wnA69-%+c!p!j`*2hSTb2xQ|)S$!8=`XIX}?OQ34!qJpC z9laLnxoP*+bGAB7syi1|Z2$E-nh)yb)ZqVH=={IQctp9^u3xcwAyq^4k;yJk=Wb^8X{_^8@srKF_cLW&tV+9?1Cm2BRX~ zvR6P#e0&m`%1ZU##(@v0!Qy7ER@I6M5+fzC`;U;3@IgpCr6TS!(iYvfGfcJer@Y?xVb8)kO*j@pCN-+|PMi>U0p!xDZ zwiA5_y_0}rK&wI6^J%7id~<10cPbW>PpLD@E0Xqnk8xs8#MjvNNcL$>+Lt;SHHd9i=aw~WQkMCtY%!sxOoWAyU6zIFXyBnQbX@te80RbeK-R!EfIlj z3^sF0X_eM{A~G)`%M}U2RoG~Q8&9_ILfbl9U))|G&rZ5X&!H+t` z;UM8&E8y&gKf4@OmHD^OHIk$f96kjIt9geZB(`#uHXwpRQG0c_bIt2)ioqaIdQnYB z^nE@0rmdUR(-g83-zYFVD0b>*^6ZQ$W^!nlSuVMR$@x#NZ`N?{&jYC^f4*XznA-e# zOxHQy7DZKxV=rs`gMJ>MId}V&{5#7KdLx$D1@LT{PT8zqLFVwBBEcC{;A{y6fWEFW z_j83!J}LT$*Kpa}Ns{EWXj<@BoiMDump_?R8zXh?l)?2MfeLNmcYmjyO@4pupq|LU z9zi2vglu4zE;-iFS^sTC7+4awoMj}@h33sWTss=zh%(xRhl7Mry;J4NXg-{uk)|O) z5+tN`AeP6FL5Y<~!h+3fA0=kcgrOX3V55Hx*cN3YJztl8CV2h~L#7Ck!{zvi-Vb_y zz5wc>>JfOMY6(v)=eT{}CQ9#Vr>0k!>?r(H|0IUxH6dr7jaN8@E)+)uwg5J+L4X^) zK)A7Xggm;>qc&P&GyiOWg=|`+iOKDm0F)r_;l>UD&8Xzybs6ybsJbjgBGFQ=Mzy`f z{RT`^>d%UFG|srYsGTRxLyey@X*g*tAfJ)PL1t2BZcq?`-CWMebfPvwVK@^IF|$7^ z8m+!TMG?rqJY^IO=y0GnOcThcRFV~&X`6VP9e9BBA85isUFi>UR3m9u^$ zr1)%VtXTUThZksh;p|wF5rAS~k{-mpfi-rOYA1%gCR$$hTw6^}dfmHRXKoh*lJFx2 zBAEoln0rm}0w`P94&2n3f{c9g(y^GY?d;xdO3=;J$U@QeTX|n4|B#Z1jQenH45;BB zyOmLn3-8cbYG7VmjZ$+4SDZjbGDx;%eHo32PHk%9O&jrVn|qCp4YRE`zLB@OFiq5l zI#;txJa6%yeFe19UNC3#Jh!(uZSeoB`w3sn9o#!P;cE6B$Z)L2^2g2DNXGN?ug%WR z4_X4oKehn>FkVVt&p(9_&d7idl?F>57eyg&rN_1IeB_9|z6fu>ed?BD_iQIs!{@`XoLSevH}Ap*%g_1_zIB4kS7}dzZO^FEu?Pn`-gg!ZfMn}7d%4cO z%cH{+M29y_M=xXlZXc6NVO7$!h-q09<%NlMecYbw9Pl+RivdS}5=eCv?P`uEn&!DBQV3`rLsX$p^{+2y7lSxmRrT3Er?0*0Zw zn_!=r>wzMfrq+(UE&@weB8ls>GV5N?-A{wBG*CZuResmJ-6!nvmsyg`7sHb|Q~FS} z0cvdp9QZeWmuUJwOr7U9+}|6nKQjh1>R|LXdWjmnBu4MOlf)>|C3-KTcZTR9dX$I~ zosc0SYKS0uMhPMz_y$2TInM9AIe)-@x7S|Jv-Wk}_m64CK7MkmH&;}7zY60L#Zx~% z^98;YjKJj1e|%~7tt{L5a+ZEXmW5jPHsbXdp6lI<6~6iyKX_TC62H$iV3tw;PB89c zP3yWW4=mUY8e4YU+VswYSfM4Lc$o28k;l$8w=! zfIo~;|2{`nYGbNNCI5W3TjM4|feAFrg<(h&$YBu8PMz^*+A#Nv&@(DnIoUznGWzhs0ks7P!FfNDcp`g3;qUK#1~cIc*oB6~hHB*HLm4B1gcm^& zXPbE0(0Jvgcro{+pj!EbDh8IfT67OXP2V~7y|XtXw)G(ObE5@8O~}=^%`Ed{S&9-> zw;pSy=oHL>(0uVOf}|eu)=EtAe?G@gZlP!Ql5Y1TJ%VC_Eu+}BFt46amlwDt%6Phl zByvf}n-T|1CXgrTOp!FYPw7YGS`eckmQV{aDj^qy` ziDE5Dt`6jroAtDr(n^(WVkOrkfBH1sXIh$PP1Rf0+aM2hCv-Bhk^lSxZX{K=3p zyp-ad02VQ!7VypZRFvTbkJ9qZP?3m#)|0J{$(UNo(4I}YV!|>=fE9n6KO6!57aP@6 z$sSto;QQX6E79%r%-1d;3tW*!j{(t}7?A8GRJl5d%W&r9KIj{w=4e%VABqtvQY7$@ zK*lo?jx)SxNW-)|VNluq0)kG`!jjMQ1ZaY1UUcsSrh`X2!ibXn!Hy9AT0 z6Y7C`Xgqj1o-({Hl%>#%sV0LC^T-x8Qw!MscQ=#K(&JNB9=%^IJ+HZ96}l}erSH~2 zVP*BA%d3m@rY*2s98U?I2=;fu>j3Q1_!G6(_`=^wk&RN|qH4$XI#BVGFqRi>1Qv`_ zMbKn!>T_KHj1<-nqEWJt2e^%zUfuHCbrkqk|+0k2tf%uozEp zd;@c#05YeHi%1b+bE}H`2I8bX25tgfe{5By=-s6{rm!Gm%q|8}t{!j-L4Mh_+*eGm z9s*&&EP8&~`cj3pESXgkNr6`dMN6r74(b>t+qx&(m?Jvd>>$Kf9fSru43$Em1P z8cb4JYgU{^kEGu91E}Hbg97a|n663Bb|xh7!z@Lso7=Ls+oZuM++Bi&R^<#Nl5zrq zv$n~P#~Y?M6SOk{W)kz&NCo%!`TcC0h~8Ir*qsuox=TgYEu)kzcC%$}(;F}y&Q8pl zPmSgXSY_lbP7j@YW*AIP?F)2W1M>PWU0_9-XAad|U@0w?%T~lH8Bt(EQgC>u8oba0 z-(l8YQghie$y%vYU4jp>sVtQw#gQ{f+(h;Q68h;czCUXOHL%YKQ=;09(0#q>nQ@)z z>}Z5zt_AKD1BY)9jeSMYEi7kx$w%wDYgQci$7>P=0;KCIKET76PxJ1SM6J#2K;0z! zVHn4s6{g{^?sws>RKDqExIvEG_E+R8A8WO;-Fxbm-w;I(H>3^=tqluNL|rE1WXTE< z*n4i^RF6_(aRA`+i25p#`}Smjy{g-M0%?fu`&9L{r$Z|O!=Ml&_`ZVGR2%L5SnyZcR2DCt5@i2#cw(2MWH^mwkX==`^)-6IV;)Fxy~-o*5?aLMZz zw-dR}e}i)NtHmSV+4NWE_=1@MqJ{(@P(WW2Ad;AU*{f05@}!T=WW>Q2U^ZCc~zWy+2QEQ9&_ z8K}@4RB`!Wlc5g3b^y%r083Q;M~mBp0gB_Dx^5)skNp)KEAN@0yn5ZV23|ND>vx`F zt&|`I>t|Yu3D+qsfoOfR#MF1>!!JVDA_ z!9xcpk_n*tBm5yt2#{>mlWZ+vMveb^3cEp#%m1+PHURW5M|CsA7zJBca4$jk=i(#d z{|VwqC^@p2ftT0M9M>9uiRf`DZL)8iel!w1e#EwPW6}f-mU$2NYWDZ zjs&|Z$+c{$JE@hs4lPUm>N0MD+SsKj50f?84K$*X7~Jql4;BT6{y^SP0>Y0Lt$f!a zBOXEFUryGS0xqj(2Bxq2U-M@oc~H3^Uy#uEPjDw>D=+?_qfcw<=E&gAy!|KP?JuRk zyeK^rreEJ=L-E7gEzv0@L*k@?ST*u5>P9;oWW6C1!+IREEkbr&(d5(*xVbn$qn1YB zioWV$M(#h0oe{|uJKmFJ?fzc2`#im$efVSNB;N9Pl?rVnezbKPxu=%ZhVku9zfKhr z*$LR#lptV)45LBt9KI{c_uQKM%30f9KhppE3b?-j7$tV2{kEAZw(7ozr*#??w)gpL z43XiJSz!nD&pL09Ed>r%B*G6k0HQ~Z-B4ge%PF5n0u;{yP81_9A-J&m9_>H-zMJ#v zdVt0fMnnexaUdw)b7U|5;?qVcz_pOfJquvw#&Zpqn{nIu32#4QR1zF=CAbgiD@4o)L{}@V2ectSIp7&*QgAEWZ z{W1m=n;z~Hn{|uL>Qh>L*GKnhm0yIux%d?Zz-Aege$E{~;wTlfY-l+#TL7%z3{G+! z34Q?+g92&QUoWj`=h87%(;jy==J@`}^UU3dgYSJm9}=(*krjT!*TdkDuS#JHGO$Aw5%^ z=*5hJt#ozpWQE&Y_!e>l7jffIAA=pF8hN~I#qd`=7jLNip%`!FR&C*}*|anh^=%2$ zwu+NqY<~j-%cAc5{rj3Y*3ve%N+#GgMz`Kc)i;(zWK6>Sg)fq&*kkyJDFB0-jp5D}O_hG2l!BQuHiqS1gYb)Z+(&;CL2xRf{bTY4W23;@G z8J38};9T73#A_);bwYo)BAHDD)KjUND=;Djwc0lcqX%a%m~V4Y$sg-7O<-Tb_%xo& zCi!oxh|`85ptsj_G|LxXC0?_fQ|ku&$KuP*DkrEVYGYzTqFszB>y%(*5Oz`9ib*JP zT3R9&-uv*3k5OqifSCkD#bI1G=;&`Aud5PAr|_t=e)0L#LU4KWKsC5laopiovuUq? zzsg7j5)Wa57vYJRU5@c@wle4%!4O5J9agHW&`kw^>VS-za4;sppIFYFD+v1V{4|RR zruG7xp5+T0Yx-|Y2+iKP`?iKxXDfnQo0T(FlK=9Na&ivm1Citw>I|1nBHBFOp)qRZ zl-PXYuNC+qVwQargsP}NNzEHqk0Bc4?yrs|+W0+N4yW%+896X$s#bfTr%%Rv>!FFy znDP0wDedC?1dRzR>*gof0Qp|gZqD0GxooTOu#QshJXV3d zV|GiQRD>mfB7z@PB{)4lHQVla@H?KFbWBaOM(LyT{1cU1wjG^FI(CL`HKCkC8Z8D% zH!Yo#T0c>(J@bN(TKqw@LxPeES#Xg)ve!c691YR=Pz}3RILYWCQ>(N zx_3UXp9egYK0e;67ud)&gV=L8fvGf>>>%In?&3g@l198`-gE&fffTNTTOYa_zq)tq z!XGawmV_+bzPVBs`nNQYn|rCBY5MjTQ|?3}`ciztbv$dM|C|g6mZ4VVuVT6GeM3n} zPWrN@AY&nVcUd389GA7p(xkKPZ@0>_5w7)Lx2&;tPz}<6j2j&FSN5w-)!2SJ(nta{ zmQH9HsYCD2;X%ggp{<}9rL(+W<9CTIW1mC4uH{87S{^js;SjdQKz{8)q-nhOx`}f1 zFvshIjW5`4L{$1s4C9~!Uk>?IIezHjw zHuh#95&-eEuEF7eEcCwa?*GOzyf6`p;V!g7KU#E8+>ff?O#5dI1bl_n>lyx_D!)Q-(O#%ZFJw5 z95*M8-A)zuSXMF8IZV!rh(kbtK9tc3n6Vz(9UT#)279pYys+v8C_U9Jo~2&1rT z9L>!n9)40xa|t7MLB)ep4RZf1?XCaz$P*#MQCiKF65G1>zp2eFOHX5}I#_z4a6W-J zL^OE@Tye^J}wLy09}2Nm{)ZCgsv4uqCJNLB_Y$DTxp(<@9(K*pq|lp zQlocy3vdZR`gMyRvvQe)M44w9th;#VRo5O83(f*sS;B&A)!+QkH-y6yPDd82`WU?O zN|$W6ZiCeXvcDr&ZWxsG1Me=#6uhv6531o8ZE$eTBcrwuyIJF3(54`*N<2wHA}F&b zStC~OyC!_JXg&~a(SsO!fCka}Mzca;kyshAvV)x$EkB5s-cwqIbSn$}OS=H6fZdAT z?!bsAh^O0bA{~fpMIPE#fW!!k-I5RMf#bU)$3TBJAYK#uN_W?(f{?RUQUs~lQ zAG_bPBJ^^sizP$$%clK3s00R+ik9S5cW;;Zl)iMsc27DMbUZYzGubsW_wgdo`GK|m zY^ZV2>g!bH<>Xm_RlZ*~Di>sGxy|c!!y9QEMZRHE`DV0pkS-?o0ub447#|MIwDky? zbFo*$59lUX1RGd%AOBwPwU&H|6|MsYb2z&TkSH}&B|BCCG`t?Vn^tq7rcwGs|n17L)F z`^#5&;grp591+xh6m|O{v8;K4=>6;HVcoyQh+4nXzvhd~Rgkhsa54don{}(gilDOul&@nE^w$)_4p$6| zSLBGPGSPwPkKKeEx4#8X78Sp@AiO~wXCMZ)f>}Egsk-Es6b6bRKGZ!;jQ8>kQ}`MY!5?@;c5((m^=zZnwU!*j~xP6~E1(+s{hr4x9giU0HG1-&;)_B6cC@CJWv zsKI6HVqIR9Xpn?!33R;tO^f>%K68Qi26Qyd-bF7fqrtQfivkb3pz+WSBp`;zTFmq= zPM|1UUg;f1@n)l|Jg;gd%DQHt+1SfI@k3wd-I5XD=b`EEt;0V$DZ*&58h?8@8L<#% zb2#HA!^qoc>V3Rd>0sJhZuH>QxEudN-8G|E89N|)_*K%e4Tmpm)oJ6kG2-}e!{Fr@ zQw4P`mbhXkjlu`KCN8l-T>_~fA2J|%IxT;9fA09`qFyxc9x{*RfSCPfCXW11Mh!&m zsN+Eb)h#Znn2H}OLt}SAU8w9!;Nv`SQszXEWLT!c2qxc~+HqnbZ)R}WQg_v1Xtk24z8>v>G}U5G(`sFxNA9;95`&}QiW*h%3( zrsl_b-;BPW=F4p}2?!3#1`WeYl5rnS{sDjMB9`wP2bpDKFk7SvaTQvJyw^E=rE@~s zu|G7pzSEw9_0jNjAuqiB&rgIJUJ2`>nEt|GDZ&GU?Q(uIL;{}ZH3c!piNN;9a-<&U9*#q~(kB5)LU>2r zNzHF-h73yqp_AAa;=F7M`3W;Tv1&4|#qV$a6+u^3`B; z8c!$s)_oj^7E^aLQ)f0i@7_jzhf7+ny`b5#uPySl1 z*Zl|E$4Q9!f6I44M7ci1HUJ6ki9E(x@Msqt9PmYgAGTIS4+ngOXQkwxh*?sq^yUb^ z_|PSt%ef-;An!!QNu0oJcRU+_j-bk zQidsr8xfxt$jILyQKI^$WL~!%p;v98mkAN5@6g*kCT%4a=nB-=Y$A4c)_ZGV@IKFA za^65_Qvdz&z+#@^ooQ^+8meiF zTqLwIB+9YJkbL&%P%|FOxBU5L0`yBu25F1lXK0hp{6W*9S?!pghA^YQBvd&TD6JUL zrRT7;#Zh%(g0y)_rny_bmDhsRV|wcVOY4w)Yd`U1;ZEy#k1@2#+wcXO?6W!U{JrSu zH2+Rx=bxTyIMa0}f#+wW2@6l_Q%t(^?fVz(d(VysJ4>J~$5b=-X6WyKvb_J<^1crO zA^)AypNPn4%x)8$vw;}avvjz~ceu6$DRU7kI)cFGbY_4NHG`u$%5ixU&8OqYrxUk7 z0isw$CmTD`onw<|9p$tgRqS(o;mndC17 zsC>S)c{5z5xM{}{t!&wE#@TZoTgfC6J?J9hsIYE#HP@1}-W=vH-(=FDMPMk?kWhKa zha(T*wthi&+Mf*=Fk<3BD0e5FNDAHaF}mC{W(tsZ-|3dOwRv5G&RaeZ!wnR}h-f`C zrJu2`6$ICukrEb6DpT4C7J}RW$-#oCn)P?B!pHy$&;#}G^3QZ5g>)l~1oI(5X4Q9~ zd&bt41|TRyr1%FbP$&W;isf4-o0@Y{ zss*~|J_s{3hIOArLn0!ylV_=xuHz!)J7e4($C$9sCiKP-kF-x1rp!;FNyYM^M<>4C zav5K^y&Rpn^Ok&6K_r)ylL$<@hb6W^GOK?wVVP@bVu|q^Dw9qjC&0$~^HAo0f)OYG znMX(szN*Vs=;Udg5{l}MJaSY8$I>Be^J02LrA0tdK?rx5&%>*og|Qo~4k#teVfx?7j}pk3kKT z^x6o?4Ky`ab5;;}GRfhXOI|1-y117taB>VT>G7~76l2jE^RsQ+XM4qkQ zD?SM>`)^J*eewEoektnD?C0Be!G`;b_;`dyMztt}&MV6P2)8jQe%b#$C;K-Y%Qu;; z5_V_=LI>Myq@(52p(QG(KvCM!B#M}yybV}T6~%(}3N_hF+8q@UH_>dn1&)5deR-MW zbXY^5_tMp04Q$L?apME>!W}5(0_Ti7?`&dMHnUc_Tvk4jKi8?h%|)q1hXzC`ug8=_eW4?kTSNcYB#(Z zpG{Mg%EbtX720Iji@C|mbsQca3*I~EwoR2G#C!qQeH4FUhG%(d33;9gFOW$FF+Gl{ zAWGp2v{LGC>(g0}C@k7!koQ4Y;EIi}Wt8pv{NCnyhz@s?AUH}a20G|=ZKnO5N7OgV zRlA5@`+$YxjiN#V`){XB)sdxj+%CCpSBZW1rRr@A8Xez)T|768;g-X4kC4lbMqR%7 zqV_p@flgq8w}$JVDdX zEdBRza=I+VTof&7_g92YK2K@MZx6*QzPmZd3RMKEhe|_FVA<*I8q|gOj z$DPneg^S4F34NstUY(^sK+A1YpT!oe6hI3BQ&F0(&yy-d#}cuMcjT0>zg!MX(wRiQ zvppd&;vLmLL&{3B=|_td+=D6;ea?t!*eAQ<{$-WTKCwwXRNbz#_8At2d7loPXp&RG z`jn!r+JYuD?|e&r7{qC58}^ruuu{CpFf+K_+Ao#4wTRe5$Swc$kN6q!4b#*@8Sr^i|Ex=N^ltw0j{>iI^(C1S5MV1*$c2iDr2O_)01vU# zmp0Rq)L$%q=M4vR5?*;9SDL0T(>(>*3&j>%$XvQ-H*kVNfzW5<-W5y-3i8*gmD1-= zo}QSijjmpFISlA+_lw^!E%xOd_<84lASiblNo|}0N6ZtlCklb`3~z+;OM0Bz7d)J9 z$oRKgk~d_0M5#e6%VG3C`LZmN$|5A(TItQ>Y+vc$LTFP>Hf|0zLzz%6i`p7qel-OH z%|LlJ;hj51>wgub1CeaR5Hy5W87<+66cNN3*7_!DrlPcxL`9_k$P&!Xa&@B?s#O1q z8E8$#HCRHGUl`aJyF9#aY+!3vTR-;5@0RUPzxsPJA{GvA0%$jdK#N^u1ZE1v>;>86QL%Apq@+t06|-n?BDqA8LFDt3^g1DG9F(`mkC*vzJEX!OHMd49 zA506;tH8v+B_c51Ycwb_TVr@CG;}A*NxPA}%wAJ841Omb) zk4UeTd`HP*ND9vdveiN_u+>@WpmO5!$^0_(`ARy*qHmx@^no%JSy9gbApIko)_f{{ELOfF;L|f2Ky$+aWL0XlCXu79CXpzTB`2P zF?>vrB$O~)rmDs=f{_Vx#4vAdkRhW9-o#Q-XapS(4>!eATQ%ss^Y@i+&jAaa1m911 z)bSajG1{46Ce8GW3qzKD9~(z>rHr>q5Y((FkNRcuaW%0khilqT%5L{#vU$&bQWlFx z@TxjoY^=KAPL~^^)C+8T?#yDyVu4>}Q!sd&f`KkBjEID6qo?yT@4S;a0PvO}j;Z?(Rj>FE#7b!W!kb9(`DAH@>%OK~(cUZhfcA9x}RU}4*?)+;x1??5GtaQPH-a4-mMM_7wzj1{q zHVBg4d*DK7WUeQ2leXYHD2RrM(X+F2Z?e)zhbpH^iOiy-#rglM=*ze zaOz;1pSj()RH_`Mb!a)(j6(35GH=gbrfhy-|EZJp9&R@xpZpfCt$eNXZ!6*s(;wP* zF`6(+rj$;e$|gz*`)ljCVQv_yU?W*Xv1we7cGAu}_efLbx6O??lF}5~_hN3o!CK9W zU0!ce&906;5rdcB>^>l`FkB9yu*_dUE9OBD7t7t7|F*vvU+YZ=-$l8^pKBAUjPr~4 z_GJ(g-wrUeJ75F;*-jok(`46N8vP7;C4`J`yy}ngG?zr_+FC~&>J(anO+x*<>PxKK zKQ5>rw@%EaXHFz_#T+)M}dXS zWnw|*`}71;JJEMGwf4EZ=>ath{j{kD@)jhS#g)vKzuKyx(Xsg&c~3>Vdx;&BPK$YR zc4Ngx*+FAjyk=Z`JrsfCZ(=`DrZY`JXmi1GeX+$eW8|)nLh|j)uz4i6xgNguATKDQ z6?_cc7M#JE+&+@1bl*nh)aO@P#~qZrenifVa(81=4=c@6smBQhu5&rRKayW*xay^D zYev0xlkP{TuV0PIXe4}KmkeO$jK;|5;sa`I{!`-(cS|zr;bLdw`$9iuYW~}HNKc_U z;b#dyNo3WjtD%=2Z**Vlb3>V?6q8u-7U8}l9Pf+d_ye1ckc=0RKD?OLEsMOX`MTRu zW$P#0HU%sU^N{3>g)E~S11jQdftd`wK55(6R%?H$$oxo8E& z{}5X!l1pil9rdUxZKh?qg|*Gx8BFNA7S+fjCg4hQ-IQ+s@tw+RC@QXjZKBrrqYOM@^5MwNk5{gi|WIEqZZ~ zr%w&|UWtxYNb9HF=}=dk_)Ym>5gI>(ye08Chi~a{;|u>JjZ{hpMZ4xo;-{os+3j-) z=kWl|{E5UtuoXC zx8D&y{iy%TCvE3u4|$H`|Jgj9AZP%&n2Q)Y8DmWP|FwB+L8O!kVo(VrGMSv(3|n4N zSrtZ4MNTEgQ4B{4vanOa+^Rcncwa+UFcn!b65iAtK-S(f`hF}MoF`T=h->0trwRs- ze;!p?SzTM-_=4Zu+TQuPy8@yM7U6s`+k_NhnGbokbWyo?b^YfrVTE3>*zAnT?u7?A znoBUL#j!dD&Z9TahzF69)|>osIurYp>@lxckKS+PEl(9RZybSdB3d%x$`$)e>hVeD zEoN*0dZvb8EGHaKc4!S^(2B!Zg`!GySHOkr@|xc++l7lNZQ7GwjBVzTXa3Bi{dIQG z&_Ke@Z&BgddC2h6H<>n7jXJ+SgtLfy-}jTq3KU$`!`{T#?3kx{gY;i@+*d^-?{@t6 zF0O4kfjpRue=2NZbB2(iQR(Z4oQk{g0_|es)W6gj?**2u^q$ks_Kd5-Du5kuO?Gv| zgaxy#w>4N^fIKH;)d|u>~%FWM^2<{j0{Q zTC9RoEOw&ArAfLgNx0W)s`cH+hpSS*QmAflqO|;QM0A2T@jkF08cgmsB}5o23QuNY z60qZl%aiPPcr|E2>|SmV?01SwY5@B(5@~J;JP&feZ#{$7Bn$dlO(Eu5K(zc3woE3H zGKI>!BA*0V~?0~bpUxu%UJr!;7Ns7v*pzEM-Gjq#~XSnQb% ze^6wINa(M7sMC)ZR=%Ll_@Z2AB5s58jL}vZFWNa9x0gXkQ^%b+S?Q|d2Q&3yh0e+D z5z3UrGLQd5>60q1JI;qNCxErso9H!Hue}d1zDynE|7Q@`b^6SJM27hBdxa0lIqV3i z1a5~2g1Y>gZ%y?(pOhD#`;5K$^@LWF=tH-cu%FZ23Z>mgit%v~hCPHSn@ z_BoCx4jyWn&W3(QY9^>K>6oQj%&Lkonibv&dK-!VhMFfJkWYT9zD2_tFyPwwk-J$G z0qf3H>;mpQnH4CKj7lf=-C0c$S4rcxODAaSz9x+ZaU!DP3z@h8#)GehXhp&yF5{Cb z!;UX@w$)tiX#4$irk6k7pZx^9tRdhkD2_y7C}eAgtZhH$(9R;VA{BJCqfO1(f1^pc zvBTq=kAb1m{D0>V?a{CGL~q*=m!9t?tO%lth1;XdscyYZZLLqw$$7!}*tp=d>9Sk1 zT)#!S7#~=jSaJiq^a}V%;a&4`t|B6tQ<#=3_%TqX%ptP7iFst=7q--B-|zk|7GCzL z)b{&({&dnT&<|~mC+a-AX#9)19h2LF`4p7i1gzQk*p3rzCJCi-1rWV5QuqeqigF10 zq!3t9l!e%R9vwc_tN}bC1((Sp##OyX1FNX&-E}1UIs2MfpUB30aL)(-)ZM+*P1YPFtG6DZGa4w;ROXY)kkY^!cAd;porCk4 zOF2m4vzCoc)2^x(Fp=)crYTdS8lDQknGLNsLTCyUyThl9#)FD{$jYxqwxHixtkGoj zoi=M#S@NB##y%5@Y(*KOwvq!>66de7G*=vj85y&q<6p22+;i=BHJfo7^A%js;YUk= zcBW#9LMerfqCCpV&HOuCwlI?4gbCu0B+k5HleRHQFb_u!hB3=ioYu7x!uY10GWHH2o4n?Lt#RUcClxwS?wKe=A*^)BntOQjb;zVPvvHpK6G_; z4-C^7pzpA7q4g`>cIJXVrZ?vl8pjxJ_Vu|eF=8EvL)`}r16amR4looWg%4SD?E%5@ z0IRAs&q@dq*p( z?{9Q-L$#jITi-y=MSjw5H>3Yb9tZaB#eKt+a>u8TKA{>kXf+DbOWtO(&|z8%B^LDk z8jAK)fC;QAPuLOIg4QkJet~x=Y zIZ4z-U&tQ>dwA=+SPDK$3W2Xg&lZHqInIY!qjz~y05$L*3#R@ms!;lNaV4~Yesk$u zlplsn&?L4m#7jZft?v_q(C7K)JKEeRj8f<3aU<&p(Q~ZoBDvB2+f~B(hbeDeTUoT@ zYw*(Qvqpd<;5K+)d0kT@gv(OyC%r&;?Kk$R<06TO+nWN5s@)F$b_wKP4{Y2i%O?pqw<&O^BbR&P4D-&F%!40cO$=mC1rI%71n z2tx*SAF3vVK9E#mJy+($fm7zq50hMNH6oFFEOkioGDczDkPyzg81hl`)*+5~gt5oj zgZdru&Mgo*Q~WxAJeiIPI*4o^e7};9=v$W#YQdR3;n7gHQazf46B?&`0T`J(KPj-L zU*rBb?R)`3VxT8vFQf-G3N729oX*95appPaJ}DB?=e5( z`kpIhB3Zm^EU+!sdBh@)?t(0To@y24$zgg+L;?&s&RSydrA_rY)lmV%L9#o_$F!L( z!{j5u0QV)I6Sv6(UFLaC=9E)9JxY|5F;cZi({#@~j2;Z}m+y2Dn$vQivCWZk1EFAf z4;TZ$b*LCEKt~}u@&p8;rLKgq#$$n}+O%pWAaF&(WlAn-MdW&}xLQ>Lp}NkQSP0Y< zWc_{zB!QF_OOzaV4~F@H?|gd_|kFqDHEE%x?VR$6x?eq-_FP zW8x931ngm{JkI&OOY*Bo$0s{1s|(Rwt-u_yUG<_E^cM)O8$kf_HX$^@ws~LhSx5DW zIt{ku-HdW=31|K!N#hVEUtk^+^13zupU&;o6qnNas#GNq=%z-l&8v>0;6hVdg)o6i=WSK5# zfdDLDJ~1Ta)EKnD*l>KOA%~Gk>XtjGkpHT3p_fwkis*Wr(zuT@q?i`fn?;_I2f8Xu zUJSd3tvH{{w{=c;aw2oN%2`ZQ{^Y{G*JaWc=FjvB%qd~ckprg#av;a${tAHUgq&ds zy33aM(890{pr!x2oU9C96^^fF*o zp_1U`I)k*@=i!a@C5?@JjZNWom5K=M)LJM3f6MeCN#{7R8BAU`xpXYYPc0YH4du}d zk}duWQdp*8OhC~9g%yo8{H~x1miQz0MgcVY<|bg#OAKq2v*&rfC<%(Kgd{OH+1k2t z9^WHw7iCAgo%Ym4@>H2OQodAdxiM@SSDunGMW3Y9l7RC`F(3~Nu;5&+YIdI*Xf;O* z{=&%0RX+a7i?mRO>5w&sre%ESqx>aBqZ+1ip{~qEUQg9iyWIG41dvByfix7cL|Q4I zX47D8Wj$9YJDkXdC9hDuq^5`Zysur^t7V#*=5GV#Wl2OZOGPWzfE!KX2?u&vo=>WR z$YY7Uaw?itpM&P9^tkGdEA$ArTLGPe#*>5Qt%^D)a`rnBUGAlg7q(r0gvu_6>NxHD6Wq>Y9|Q-Ohd8>!*nYh?H(K&Xzqu!oW8Ls7M*?(8JFV zBAvAUjr4wAz5bJ{r!_28IT3^IN>mRcy1>G^+^zYe=@3@!29Cf6<~qs*kD_4m=a;#G z55Xj+$3R%sWAkbPOkEb_@S33(9|4W{v#c*;{|hc0Q!-ylgz>md(b1ZVXz9eKlbVLCgR@s;l&pq<@e2A z8Kc5EW4jWgbq8aNY~y>Zd2ST7ua$=C!of5sYx{|jkf-k*lV1K+qljq|LC-->zC&(O zfB(fZ)DSG0C6ME}f2dVOCc9p@JF*RJqh7LQ$fn>R013CgK2CE3=+J?vsPp*%Q4Iip1hKeOg0RWBEHG6$CzwsguDIA*nGK@)sfKrdA&oOhp35_6p(1tGh;S; z@$dz8>^trR25#)M?42b>in9Etv^f+n&!j$htasvvK!IM6TD*=g`)By`gn{L$g`v_X z*w3SRy@tN&8&E!G{6a3pctYlo)6*4qrN;W{b^$bV)JG5u)2cb!t9^gDpsb_HV2)xg z%%Je~A;kp1H=vK?&6JM35kN?AD<5uJwNKa1OF*Y>WqJnrRhgQ7KFhYhfI#mVYi?d= zT(|7`2c5qkM=f5zzYiKe)P$l}=GecS-ud!UZ(U&mM5<5U4YgSHS>~!pn9Lx6c3RnD zy-8RNm-p^3tI{u@MgX_i!5p(|zwT@z9&GC9t`nv5m=4v>Mgo7Mi#f=2O>3^ z5<0S@h+e&B_mlMi%N~1{9`1!FX-*xulmod zNz)*PBHdB%z|0a~Esk(j4>oO@Lyp`?->mei#ciPn@k@HUkK;g$c!0`iIW|jEL~Pn@ zU3HJ2$joRjXhSgMS)(Z2(fp+*JMV;E9gsG-Pdf`jz(K`-1p|}QsR_rMRbn7c<>^`! z>@z($S8O{!Yy4F4%M-Cp2=4pI`kE;6J(4$&d{;GOHq2p}^5p{(Pt|?tzCbc8P|2~Q zsyVNY4Wvx6zb&!X{p`Df_sE;U?}N`8x1Y_L8JT&InkarKVhx%1Jo>0JfQL!UeRy{8 z@$RA5AQ~yM+y~e0d~j4qSjZ7Mg8V$x(LFZ%(7v5@{G9B#QTVHzn6|8y#UI}MluIwN zM+OFqD4k}+r2Gp7TD zQ}_&+4x6sSs`hpe5Xb}MOztabtvPn_w(rkRVt>Il4mb_+yKS4vvG&ZZr)o{7wv}g? z>{`s5vpDf1k#Z0x%A0!jE=&TH_49WW1raGW(kcvq!qu0)E_=Q|;yXngn#Nl_6!*9qj2b){|srn`B=g6^VuL~bHaQwpiuhIJ#1_C$i zDz$+M_NVR0wxx$48R;2s>qTQWceBIa_{V2k&p<0*)|gQ@CLk~#a;+yvc=kS5F9gJ0 z+?`$yMEwB}5|?9xk@)E;E}NSd_=<1Gj^D~yQN+@>l1pUp$(DQ(bT z`JKLW;Zg5>u~_UhcI+XfDxkhe^1B`@UE$33qign=QONKAQ`lPuH2KDl`p-573u1H) z7#$KLq+@glI671qDWP7{Fj)7SL&zqxesKdY(X52?~MfSnYxN&a;5X$sZ4xBA~Fz7nZ^m^+~ZhzCQNnP{22 z-sE)LV&tDGtr5y5!nxc{Ne|!BB|`4Ae0Gnco?+Indyf#uLsLKBYqJVq^2 zT9%UJj3?@*gK4djakLDK|MR|w2u^?_8S!M>G)I1|q-PGz{blo5Z7%|kgX zMXm2BOI`Dtw%;Igyk2L|q-ViK-w|QgPNJ99?JGi64<^!t%r4ZOndzzG>rz-sm^f6X zYI3aj{r;h&?j=AqaxeC_!>~s4g=0oQ5TyH#IFPOQ-%TmxdqvCF_5s?HA3yp@fA##s zR1Z7*qtb_em@8BI9KO~XV&tNo#-Dv9dr62@O~uvT)P0I2X2?@f)ELIHG~K`7bDnowD{JXW5M%&RDFa3bLIe?vAP$hlLEGy% za-gG6DujvYO*$+FpND-S+2*xgNZTZ-BU;(SvCcx*av;|r?fv7BMPlm}8S=hq@m3Rz zg>qqzAz-MbzE_)&BtQgp8ESeT^)3TC332~%)w00hYQ?#xN5+ME?V0zVW2sDEn==Gg zzqZPkCtbn5lI%34m2J+ok(#;{T*;<+K$f_@$l=nmto*mFQ{(<2y*f_j^cMb*fI(X5 z*|~*OXKY0a=G^2>1KK|Mm8U00IC|k_uT~`rzs1I(*w6Vab@i=2gxE;U0`hHQlUITT zk1*8Q|8WcofLN3<#LKa@6cE)AbK6{O&2HBGQI@{cg04zLAbv*RZphvKk|4O&)2~m` zm;9dHmV^hs6aEOxZ=tnYDV)9gYbC_ED9e^lMj2$xcYne!2_j&WG7!sf8VpY1ZMP%3 z2-J&YPpI3wKv^n&0=o>i2-@Q{FB}atz2Zz?Lh+ivYuj$Ch5d3Pwuc0}hb*AM2#X?| z-Dcm%XH5Inuk-dElr(y8rxBvTblOIx?0Z|idx4NoH z(?|j0`KFfD56w#kf?hiizyEk!aMNJsfy*||q<58S9|}8QOrtpouz@d>Hx-M zyAcEZcFl|BZ|Fa-%~JnLZw8jMt5J{Yc9!X6arm!2?3xf>RJKv{6{xY~z4m!1G&ioX((dW>aJa zYjc#%<{$n|ymn+2Gg4goPu!@<6S2MDWzsX>#?KOp} z1HSJnUh@4&1#_|tr#pA>=J}+kx1ai^BPqaeY@m6VV`KhajF^zsSSDKumJ~DsK)npL z?gVY-V7yjoek#5E%tJW`wN`lCgk{hHnr<~RpY`Drl&N%0ZEKeSas2{JuY=7|P2yCV zNyYCstaos=MFoTKA_oorXE}BZ9BguxOb||^Myl`DZlI-7TubcbFq5=v$a8PYJ9F9+ zVYCto1ruGb*B(`tX@7o;_^py0GA3aj=>C&j_L%xkV}sg?ICmz=ICO_D&6kIc){ADa z6G4Q&9jC)gCvzf9x4qrtQMqBEJyKkPeEa)+(F%)x4Wpty;X%*0r0kJp*v3x zu8V#?sD@Tb!$6?5=%1!h(g$RiaB$9Nmr*s#$W1CVCH;|O7a&FQ_EAt@RLDbIPf{u9 ze;nYf5T>jmbU+NT8_?0TCOu&$_@LctiFGYN&3Ox-xNHEi^_d&b!qJt$lx6%zcF&Dv z)oZVVPF2MO$K_TUgIFQ$#p<_92VdhrmK#)4tcJD8SOQv!nFM0|8U$yzm8sYmphCTH zowPkasAjT%n)D&;4=lXr_}Aj;RpIo7xELqohIHb_MH3zh9xx^C!P5|U0-1qCfwEyB z?;o=3j)pv5?$dLYNsf4R<7EhI^})^uC&zZ-+(B5e<<(ZoF4cl&JkX<&K<&(_p0rVe z*c1fl{v{M@-`3^dswQ?W%hG=s4A5<`Cc0+)A->iCv#s`gb-;l>y=|>}#)$*7Ws#vi zW}$#VJEt7(Ekp`O9(JMZyXG}$-bBenS_`GZKEobP49K;v+<9(f76TI&)4qH92QM%2 z+YJa0<6iPw=Q~ZPfm@aEQDHdc>{+&Tf8ykJ9n^6upW}iK^3bsXb2==y^>ho)OVF+) zr(ycv6{J@F<%u!>a>y9v{*Shq*=zoxH$q;2QXC6+6mnr*lBkiG7;9m!-V{)l7x)m_ zBl0@;Q)<}Cp7WRVE)Nu>9ep5nqoEVKS-1QUs(l4%HZva`2A7_FxwD zQ4jh%q0UjWoq1Gp#L9O9B1EWr=<%b?QE*75kGHyqJfza(<|L6w!$M!K&-6T(_(Cn^ ztJH4u+Qjk9(&;?IQs_%+^-i-F1)0*y^fU*sEI+Bl`M5H6o``6~(P<$PFjab%Dj^9P zS4vSJ21IJabQs|6zvS?;foNzK>YdN-vrr(eKx?(Yln4dF_Wkc-+Bua1>1UaQf`E~w zU#c1f0|=1FI?|CCB#3$HJHq$7-X6{IciytsZmyI6eRFvU_{fW0qHoQBq`#tI1K#=3 z`Yv9Kc8qHj4xnz|siIK@*5-qYzz})eZr6lbacaRj`>yRbh?kUWEHSwn8?}W4!nVFy zZqrCW;Nkeam?+T8*UI*k)0e|an zeTDeVVjo7vz~71p(4@(+=H1zYDdgT>|0->Ps-B71Ik`y_2LuS48(dhegIObEY*emOv#y zg2hau-%T@fe@4Hez(VqvKK+2r?BPGR)bCF7-T?)_zseVy$oF@e?>|Ni#1iwL1*kRQ z-*p)V;SC6;(S>V^7 zi;K<$d?3Bt=@3lN2H(sV@tqM#A2jrvB9}}G-JTJRV-icW6idw)yDbt!$QP!2DCTb| z>MKRBEFoSKBn+xIN^!^hPv?RObB{ni=RyfzaRb#_N`9J#PCV9*6@}(wp!46uUHPGU zhQjMaQ7i_;MUZsX4hgH4>fbb;0r9?`fkv!L>8E0~;^BXoWUGL4dPX7pb!omM9!6rc z+{{@S5uHOBEo=m%F(8Z;L+aI2^@4jdk72hw!GC<|m5ue&KGhohOREU9c8#DGv zx^fP&+%!wXCB8gl3_~Yb*FLKr-lH1+<{TL?nMM@aP8>_3IjuQPg}L{kSHx}lCY zUGD`!-P%3JR9}vzS#c1IK~$wH2uL;M{IHdfZ<4MJnjByJlN^}v*^I~9%Ib<%IoVLt zrc}8}zu}xkD;jHVC2D#zpGlOkOxp}@-qU$HZhiGK!n4R$qgUm-H!rFU4$k9rvM5m~N`2er4Q?o9#XzikxCV+!p znK&Qu{ObPW!{jokpvXB+FF;_pNEj+~1gE8?G2yP=Bk{enF)hN`>xY0uC`5PNkAIqL z{bX=zU(l*+tv|UMS{GH%- z4stVa-tk?x)Gu47lg22{%loQP`@Ux5!*v=pUHO<@rI=~JzYV@Ued6(@ShPz9l$F%E z+*0B7MQ)!h0B)^>XuydV`s8gmF}yV7-L(L-vpu(Icb^OVH0{$65_p$*%fBh$2&LCw z<0r}<;@l9NTI^LYETS6@pJ5H?KcPwK357r$wb*&BdW1E+_3%n?>2T3oTfxa6LOs|K z>c4IU-c#eTi!kmDJ{zz#dDcW&X+ZY z`?I6kmZE<_w8OpVyCfC06sb?Y{0;Zy+-%?}Au?CKhwyjee<@k%GY6HDfw}texWx3c-d9fBWu`(Rkb9OjOSbXG=5vNc>!AxQt znK;T6pFHcO%aLyxVR*5g)5S@+6@+zKDbed^N3EqeB>VNo!yPvh>P>a)JA zCFSsy2;2%wRA^!}tS?1bIlJNb3b&roT1mvK`5Z`TwBsoa*iu>FGMBkO`@Vf1_d*`u zZ}o);Nny@>Guk^0efsgI$X_p`?-TPbl{)9)3bacLuPhXbW@K8V=0AgAVZi?zczZDQ zFy#LL-YD%6rqchHzPfGc`~TKg8-i#UXjqA7QVxFSJ;Igq-tDm&sJnvxBC=`Z|hX(;({2rurlX5)HN{5u@-TE>unN2TdIz-(@8HhY# z%0S?%;xifkO`go+S_bKK0T|b^mvNQhRglQ35J(QTLR{;KYkwG^0#+{1af}fv3 zQNp`=G(*GZ%Ed_Dfh^-f2D37LKFVTm@XaR=vPG_6GAWv$&f#bEA}|g&&U$lIcyFWD zbjEUZA2rp|53d* zMZ6Dm>0*&im;7nhlDwFe^`?%O5e#89b+k)w1``7F&(DRcRdP|}^1mT$N2RNuU|iQj zr0!1Yx>fG}tR51z?g^`X*pDZ~cQU-yEcyT3u2_-TlG5!?BXG*zpyo|QK6;7t2 zk4YoS@R*|3)jCTeTp244#Dp^$6^0A5&LR#56VM97Wl@R(0Skrhwni9YsRx^|qpqa^ zBQ1tZ0-PDB*vMk$nvur-Cop0T#LhJ`yI51>!eG-dP(M`Sp~&^Alx;PI4a$uF=}1Gj zzBvTu%5wPNJXd6tBr4{!zTM~*A8w+O@H26SW;aQnG0CNWP8*?C-SnuC-5QZgjaGPR zRrKLKbwS4E$X?M0Z!EF&#u?pFlL>nfzlD=x&JqS&mk~ydb$cDm7FT=5dfYFauhhTZ z2O>2R6S~4j$qBH>lNlTCAFxfWPdM--ut%H|mzS2TvXx}n__s#^rC&AwN)L!u{lfTu zRIJ$;TCt>L3Qk-mYr6*PXPOta?d)x+aWqKQi4ST2<3OPCrg_hHI~v+1)NK!NRA^C& zM=*?9w~kChz*NO8=vs3Z5kZ+bydm%!mH=jc7t=Oa48@XXSP(c+$NZBqahjmyEPA(1 zenJ41Xa39PuRb|=U;6~s)xJtoG5F({RH1`x!%{aPugVLNe+dNr~0sl_IRd`?Leb&6i^6J6b( z%QD2@uKopnq-`fwBpRe3&2p!6=D$1NK8XTif{oi%1@tImI6a;3YMq&s!!!B+Zn)=z zvk02|{Y&Pjzk6$JBG!J0PevfnSASky$tuQ3H_Ym+LR#PNWuvkgabLu;eQP_Xcia^e zbbjQB(g)o*^{S6I%}oY_UsB-axOn=g11e5hm;K3m&zH|OiBI-!czjK5(QVNM(O+LN z-Wp*iupFeQt5U*X>_{F(lW+)-BX56zla!^l{T*%kNyMxBMN7NM9)-wqJES|+6acZz zlYR$;KV@qDuEu<@A~^C>)x6tN@_6w9?e_$tl@L5Npc#cY&s!S>r46N0Q?0{r5c35;;NTPcvZf%}C zqg3e`gPf$=t8r0+-V$N(NZ~q3f zj8*NG_Ms3t`MRAISTZ|jm!D^j!Z!ZF?=(ZPBND7`Pf(w1%Gm72b0X;Wj1(odR_Ccc zZ6EKNngXV})#UED(o`4`G1S_6{J7+8v!|6RxU%1q;B!Co97Rlcsl1+>GxA86)#oEA zh}_WZA$B=0>ftzDPdX~2G_t~yeS?F>c4;;rX}b>4SCH!RioQn6xx9O;`m2BH#ttkd zC<7Ny=$J1`;rp&5rm|pE+K860;7dPW^w_%mQ2p&Aofc1%hm#VLyxdMNTcbxPbHZ^- zMPpO>cFP;Cw*5t+-XS!m6xA}DI>8e1Ri;EmiVwUG_4D?P!#&nBS&<$(RvEoS#Oi^i ze9z9?OZb8vr$;R{x9I4HKID`LIL3{$793eysUzIkd9;LwUN}GLxMFh*!NY{cmZ0 zy7TkXh{ao)R-08?-QP4chR`emKE7=4&CRVjY@gY(#Xk72^wMO+!!?JY=w22FMd_Yt z!^vY1gZZFVm{J}m5>Ggvz7!KE)u(&%r_M@rH0!+ZbQ|hu|7KhaK*l&fTzrSX$ z*oi-EojHD@r9`dSdh~Cw}W4 zjdJpFGj)ZpT&{6eqv42RspzN5Z|F#+i-n97( zMgD5qq*qs|(nw^4vb~62ant$5YDD~8w{yGoU53yzjmPJIE8VrKDNfgt?tqnvq2Ze9 zv$TJvIAVq92wUe-O;Fc@aU;txd{hyF&=CE&Bbqe;By<#j7x8d@EWA`?AJZH2XvCm{ z83ZySO3o)#DJpBm6R||$X(I5r+Ogdrwv+W*o3Jsc2Jv;A=fDnh+uDox)8})t;M96d z;}-7H95ebdNLo4BI?XM};N}z%Al5xsb`uk0783% zO38V=>Mg?hT*kJ;O#-K=VMyu7sbQ9z3@Z>Vo`d;-$D$fttuYy_m5L^@m*NAT?AbzqC! zK6oa2m!zwdycyEetYVr1Ynyq>l;EzXA12Fvbow|$|5Q}?Ks3((cd`a+inDS$Kj6+U zZ~8C~epaLSmJI6D1>#B_(j5G|@I~F$MM<3{7fN(<;}QYy@U4x@&jqcv0(k#yr+6&L zo7xtWt*%9P#AYf9XNLnqR!(*OMzayI`BZr#fqD<8H9k|Th*HWsBON#+X`lWsPfS-U z!R8O_lw(RuY!>nYdi23$@h=K=3`~lrBmjzgz<-LX2GYB~QI=5G+Wj{L*O2sRF!kc<}61Nd>ubV4)hlQ+}UZP3Fwe zLRH^qMboW=j)N{|14$%#DmnxU<2vnr2kE^7A1~s5*On}h?zG&<&q{0zWTnO`>a!*9fu>=kER^20 z4Ax^(hy7A+v5)4$Knmtq#3QYi=0Ka?l_`yNI|U`L0_ty09r^X)+HTnR6;{a?yx=Tahfw})FfbXhSyZy-<;c7el|A03tBHTgsc9)yx^GI+TNRr^7bZP%-QneD!JHtErc0T;N`>wn4ulm2SG0=P=9_d zPJKoJJUvne@-#G%EfEMUuQu{6|Na}Uo}~6u+*V4WaqLOgr`#^#6I{;5PGziRVjLNP0*#pwV5K8w*fh}sjoO)m5{ z7B-_70_TtH+(`qb3orh?t`uvkzMngE)L(9^QY2FG}waYn87+efV1^<7oI+txXgI zF-TKM=7zEaDBQIq-8f*I+Z7hTNbaE6jvkGiUgjbmr4DjWaDSoBp)1d z)~qb`Ck{+B|0{oR9?(`bMgGYJD$p}d#zJQ%hJk~ZdI5adIw1G~)IX4zJO)w{mf^!b z4qKYw=dL+)eEFe#;*I*-q^!|O%mfdQ%XR}lH}Jf`fP+CkwP*+nx-OT zNorP1ZEg*+JZRwXdh>77w=0+4a;{A0mkpgx6bf5UC}#oPXmFqC)Q>MNx=;&*#9$J! zsQ^t)OJ}xqbAZ}(=4aC^=l(0w*J*)kMXh^1E*@>7WU%O+*JhNbgT0~eBzm}ACndp< z!GZTwNVD_CW0)KYg1i0f$IICf6XcECZCAezbda8*X-Qx`~W~yL6&z4 zns8K%=&%!);2bq5;|;B%i?toL`SqJO7BP*7TsMEeu5`QmB1Uq!7BV-CfMmOiqseo| z*an7x*nnq_1X0#dvrKmEtJeqMM69SbRIu;k$Lw;zOhR84J7ie?E_r31Y8lR7HtJf2 z5~YAD7eP;As7?kKbh|-g8rH}u($t~=?R`LZ9#So9774Z3mu&#w0rEfF@fLunr(X4( zt%U!bmup_C#6A{9UFv6D*2f3aQ>@0GfijO*zdsAzXCzgf@iIyQ!4vQApP434y)C_Q z4q6k{$4;$K?od?TmcUl0W70m{d)CPi;CHC8cDRohz`|ewpLEhdbKb#P*!26i6+myP zg1L+*S-?UB%8!a!mT@X1oDo0M?nXYr2~2CaCFI z7O)(7#pL`m=NZHa@g*P|5WpMc&b~CjHo%E?j?XtcPk_1h?yiyt!~3hmYfS!^ zM#vaV*$VkK9TYoXkWEpQU_00ZGE=D-@8aVsxA}7c>geZpU#?ZO0?OySyB?Kx^7ps0 zW33B?cJ5`riyI(!`z<}!z_&HN*T=7ucz0E@@jTTL;GySv!1_?^GE2rn z*|>5qWxsmPZ;6l6n3%bxSh@MyZ|i*s;@*!<{vV~LJ-e@0ce4s-jPb#){l1+StXg(& zKHXah(^V~Cl`|JT_57;A5z}RwZ)?vtGWEVC#inG1>sQ=3pq2quOm08Dg_&k)^*i4> zc|Lm~$@;l7O^rnwKks@gK&N6-b3oE8{b#mSj$Z~ z1AZKvlQmvYemT9H{rBUN$-V)@A&1Q4(_K1h99R~j(!R*PL1a8H}keo=T}Cz?Z*B|6>q>d4^1@v+JN6P{yrDYq-l-?iHJT1D*arZQ}uUz#oqMwzu8*91D$_A#_dz(e&$3$ zVo&VqRrK{uNL0=T@81KUvI9ih9~%9mH>=++vO^i)t=pzgpME-9{qHQtfB*S^o`e3t z@PF^Vh*IOC4Z%{DiCFZ1)sEoR2{^V21*zc>PlK3#uf8izSA zG5f;IRb>AE18QZh-4v;BZm=hWv0{#?GQ!(?a;pltC4*Ytfl}Kk4M#j2jiQ+ z`sFdcGp%L7F<}6bfc9-|E8XDunmUZG0;KW%aT+C^P}Ke5gjLZtOfmHJPd2+pY!Znz ziEfh9S(17qRUij`ZSB^D-;(YOpv0&t)%M(>;MR_7tySNl6#W4m{!W(ys~U)c^|6AJ zlUT2&FRzw!+>4OzW4*C5YP511iunzNi-ul7r3CouFe1l4FSdR3#c>S5K3B1`b=x&T za}7WTg;Et`Pb2IC3`;0%hc=*Gn-S9)ckXHIy-w z!6dgA^oU@#ghV;5_gHH3)QGQL+no}}v%YOw8YRI?pOwYi2^}HG>rbM8G0QMkH1~ad z^Orq9lL|qFjv!*RT}NRT@2CT`$uD*8Ah->%+VPo%R7j@lvs(fmeViaLm|B!1v?z#6 zyh+gT^GgVvS|Ib9!N@edr0erUc8&uLh~mk_9}qh7Pyox;-hu)C9KF1DrrbN(a|lAv zRQhe2q?Pe!R(G(LO_7`ARhiJQES7Hmlr2J~hL*fugS>f4t+#OW&Ld6g`tQNa2Syh3 zgM#MvgqFR3Frw;oUr_E4jWI+|;hgF^0YrU>t+3G%b-V+@u+e#DFo2 zGLB6$)$Qh;7icoG7)xj;9Q$;NEzJz|L#sou6EQJ@O$Zj$8hoQX^bo! zF`#L^5alf)4u z?h_r8XV0i-+G6|2=MXk`@*#v%6_-CDCay-nro^tjd7dod)jWvcO|!|8r8{g+{8th> zH}00I!QtX`SW!*k%iWw^NNSkRGZZv#X6)XUcd9xW^q-=rG&lu$0CCd z>Bz|*pWr_yEOBkQ!^(+r%Xi4;a0b~Q zvl0I$WA{9XpWbO8+F>jb$`w9Xex9Q>#Q7hC`C>9(WrSzr?W&}WWcM|TED7?~<|D?X z`NkZzaa77BeM1Mxq*~k7(}TvvBNha^@n2AuhVX^=V4;5wR~}lba6UU zOiX8dSLU7+Be@`n<4K+u(DP$=1hs7^l|bC9$P2tG+kQ2myGY|8n9qI?m2RZR;6((# zV}4EjR7AQ*g9aqyy>@1Lnv|kZ6<%|yGZjUXy!P2E!-Zvo<3#i!Z441BGfTgPE$AOZ zpQF^dM##`4`w@X^Qgof0E3@$l8ih#DI)>!3|DNl@@yf-~;@w%38g#Vb-cTN5eC^mF zdLKKMf_Pcs_NHgh?89`nu3kLYzGwU}=8{edisLV8N4{Z`zPQ7*jO|Cf&NWpteA*u( zZRCy!%c-mHa{A;ElUMhSFgcz94HDajff+)Ka_Z3z5x~%dEh7JF$3H7dl1H*{Y%n2% zr9tWM$19xGSEwIl0DlxpFOMnLt9{_FN8jU|;7_1HJTvQi#cIY%&a6rlxQ*>EPaoy%F)=2zfmknn7*G45!(Rkn_E^4r4-JoG}NyT9vOv{@KrpN5+KM^)Nct2ao zck1#b>XfA{Su%7UpqgL)tmRZp1o_tSNOyVLmS=}Lq(AfEFvfy`jAwSkuP~x`f*lK^jRH(2?ITN|4IenmA4tV_gwT@fxubSl)=3Yj%MWqw2 zRxLC+#ZbRRL6!oF=JoF96xWNWHjKZ!h!4IRITNf&2K%0eWt``*7P4jF5T6plJ77pR zq$U=8V518-Ha@xmj|aXi=0X3wr+{et?pL!3MBN(r2$2qmhefdGZqscbSru!}T=HOB z9tn&rF-nqY@_jVI+O*5aYdGtR+5JP7J2argzf6o!#(B{?L?@Yh>%gLPH}M0#e4+DK zxn@?8Rd!$1KAJ210s19acE*Mc3HmEpd=a;wVhV{cQ#mW4pC+%qZpU*pb96O7;y+oQ zViiV^p{LrM)gxREg2WM(KkD4dDh+}SbiR^&0Xtw{PEe$83d{j3DB|@HHD0{ z{W6N__wZsG6J#XPb+FjppvES7q~y7OT%6+kF&-9j-s3t`jdK?mSr|Hp*L<%2O9P_4 z=ps-lFl$8`S{mHTX}_#s#gsb@OQQlw<|-WW^602tDzT@#n|`LiBl>C7EuwH$C!+0O zk9B=+;j5WQoq>FLvuuB*-Mwo6l^&~Yw zcIPfL__KcMjC(AUjD22?^`^Ds?-R89J+Y+W{lFt)GH9%&n{a1B+-=O}1_2RD-Y`Maje{$#C);`SdjM+3t zsjb_z`iqXBzoy9mNb`f+5wfE;Ba)Xdn`87C22__YHQ?IZJutLHOwttg&Ms04r?()y zxotLAR;RD<+xWuJ6fQjH_WX48f~=VJylh~uQAp+GTd75w@%Ai#R#Y?-Jcqx7A?0&I zaSI{%`#f2++!wuCo`E8^7nP=y5r1TP&oXKw-)ip-40kb7=kc4J_JOw;U>e6|>yke% zWW#WUlg6-}Uwv1Js4ErjR~<_{&JU7IJ93533|irfQk%R1n?_^^TPxQ(gAS$cF5yk* z5QJI;_hR1PSaI~pt{+$d`;9yb@x1w&Fw!%Y^Og+{D@g|X;}2b>KYzVh5g~I%i_bG5 zeKpED)pJ#lAU_cyh_hg(AAf&_-RzWv%a*u;c`>Ca-?$tPeg)vEpW-{t-H?H~AmUVu zg#Q|@xv|(?u-a9u*p< zjKf2_ki)C)Q`kS}=PmF`-A097rjE=gx!vj~xN{75*qDcd!mmpxZF4!eeOvJG;&GsN;hkzx-C61hWGsET7BtgIg|VBnkZ1_vi6cQ#DO8n| zxa=>Y=kbp(brA2l6Q^o-00pw_pS|iX6RB>An#CEZPfXGqOwtx3;xubiV9)47OPOE! zGJh>)_Nx*^Ub6km$0BF349v3QB49SmyY=Z;jtDu2D{_p03$a7^<2M>H97Lo*UghYL zKZxYICC{)`9VP3DXeQGWcX;#rCCDIN-OCzAiZ=@sePizU{g=dr~XKF)Jrp zDW?`FXU;0eS$X2EREn%HRU1ytH*?l^l^c!}8ORov4D2HiRfAb1l%l1>tPV2alrl-L zg^vi3k=$g3hgH0_V7X7snkpumUkWr!6V&R6;)hmRzY4Ve&T9R4#9~F^INp)dYCZ?g z@N10cr^gax_a?RZjSxF*4CJQYSa{3a$J+da_EO-d3$@gO|X$qSaia0~ zs21&I_GYWAjk|JGAFQ-aJc34yUw84kAHhk(LL}xiETI4VSh3?Au#)cvDSr6cN@wjK zYP!$DKKEWUvfCSVVsZ8DW5n@0bTx{BV5|Gy7qRv#;DbZMEPN{j$%LckWJOpK#74d! zTc5;Jkw8ossB36=Oen&W)!a})@qx{aw;+^*IQR*0f+lvmHtbVdqe?L ziMC4HbZE-ek3|GuW~*lf)>UN3QovwyJkha1J(U5{=VPyP+3L*3;i-W$3xR-~bBjMI z4v=@@>S-W_T-(dPR2c7`7#z+5IQ(}~%e0|TXM?bmSNCVT8Ektqyx5aj(M4{8t=02N z`pGaG1yoCPY#=ztk#6pc?%oj@CZ>nhhSSbC^h2FWj%ZZYV5KEX0zU=$|Ce~H5hI7QMKdI71HYmq$jAB90qK{0O52S*u-^VIA$JZvMr<&RC|O*g ziP9$AlE@VPWiHJc!3O*-*=92Ll5HTs`9oa#nQ;%uIt*imo1$Pk;FS-h+if1L6 z%$k=g#?kmZ=iO+eA_#W@y8qRH=DR{MhtcZFsd`pU$UX$>rzk%Mg8b3k<+K=xAU)Kh zdNAS1o0w8gN)2|oQ-x`jAoF4g_hXcu;*=Bam83DGg!Xz`1IqE0wJ2+{O;l5aAUnd+ zrmTd5%0B`h%vs(m$*Lixr*3LUuYv0If}cT9`Pnf7tV%`my!P|(>qPKwH|nSUWw#=? z9gbvtXp%ojZem_*U=9(>uUK8pp!tSuzHzs~MM#b&_oGDcy!olZ`orhSD&6xp_e&5G z3m1~L2+lr`sgO;_uz}$DsAtngrf~SbpHM;Tt3v9R;!TqzE1Tihm5~vVh0OI2O33#{ z89(q+3K!Nv|7>*rbk7wJB93fa6G{+HvzDg>RKC~K`g#U7g1FCJEWKxmaJ_<*GW%G+ z`->n9mm6noHE7ugWT}(AwsHTmH{ItszZydBnQqxQ)CPHrbMlm?)e5zU)9E=ub=g}O z2J;sd>O9XEW7EO$ShKrLz;{~_BlfOu6x9d@C0*KAm4tH)HvYpN#jH<=qx#p@eI7KD zHRB@fx&DZ*z-8@`a*YJJI?{c1jJHKpbOXk>3Uj%ff~wzOca!ts9?8_I!FJ)IMBmr( z+32k7M1D#yJeQ|tpC>o%SKXH`E$kGy^21+;_+Ettqx5i!$+)Q7Qla8bP?Jq&e9#E9 z?IPRfGPp?yh+`5}_xq@6=MRBb&2V*2dvL+ymQ{Nl;B`*{5ygc>Q5v|Vok|g^ zS^ad)uT4?c{+TGX*!`t(KSh}LHs~$D2kbbDauGg8P{$~+r4GoCl*ONDclW8<-*m;N zQ%@ccqOUzk{upI%q*mybl3>SzV8RWAk6Xvh6g;4)e67*}qWpUyeZ5N&HJGXvoO>IA zJMiSZsokycXvxQgyYB%|*ob?Myp9C85>Ng%@(Cj7D;jw!5M1aNccVMMf(BUCU1Yn7 z@kek0yRJ^=YASSv_&u}EXr#z|Y;*=W7*x}S8GIoh>&bnUIV{Mdvt2{%`F+7Y8#FRa zRh9PXjL%dEu>(Agzsws^R#DrAspG3Hocvf70#k}=C#=c!U29fp8x*=F7iLXEFXZ5x!3!%Pt?v%c~=Dm)R5z zkc(>aRxEKAV(pJeI;l!;Z;SgzUVFRm;`9A);P>LG8lvx|mDvv_vYPj!rPN)diR8@l z=$@xAq}i^jQsMjxO-SogV){mx-lr(|7+AEk0J4V;<|@ z55IcN_*u`agQ~vSbytqd3;az|MK`PmkKk-P>=S5(e!-8-ipzW2@Aw8RMyKC)Qyzd9 z;d;Q5%yku|7)0&;H|%9vxb`uFs1&P$G*v{#`WKwb_r@aeod}@+j&YIV?YV8253c@=WGbb- zoA&7%Y47XE>)%&V;;W*%9|OPhu9Xv(EfkPG&nF+Nic$G7sBm;wLuO6Y(i0&O`uE`(OLWzh3|V literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/file_to_overwrite.txt b/tests/acceptance/filesForUpload/file_to_overwrite.txt new file mode 100644 index 000000000..f45c3ef31 --- /dev/null +++ b/tests/acceptance/filesForUpload/file_to_overwrite.txt @@ -0,0 +1 @@ +BLABLABLA diff --git a/tests/acceptance/filesForUpload/lorem-big.txt b/tests/acceptance/filesForUpload/lorem-big.txt new file mode 100644 index 000000000..7c3df7df5 --- /dev/null +++ b/tests/acceptance/filesForUpload/lorem-big.txt @@ -0,0 +1,93 @@ +a big lorem file + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/lorem.txt b/tests/acceptance/filesForUpload/lorem.txt new file mode 100644 index 000000000..9d3b7a654 --- /dev/null +++ b/tests/acceptance/filesForUpload/lorem.txt @@ -0,0 +1,7 @@ +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/new-'single'quotes.txt b/tests/acceptance/filesForUpload/new-'single'quotes.txt new file mode 100644 index 000000000..603f2187a --- /dev/null +++ b/tests/acceptance/filesForUpload/new-'single'quotes.txt @@ -0,0 +1,9 @@ +a file with a single quote i its name, but different to the original one + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/new-data.tar.gz b/tests/acceptance/filesForUpload/new-data.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..05c74a335f044fcf0cb12dc0b83e979222ee8765 GIT binary patch literal 4361 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 D{Bx!& literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/new-data.zip b/tests/acceptance/filesForUpload/new-data.zip new file mode 100644 index 0000000000000000000000000000000000000000..d79780eada8fd501a1ffa6fa36f28b7e4f49ca89 GIT binary patch literal 4919 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 L#?>%SeYyJ=;ik{^ literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/new-lorem-big.txt b/tests/acceptance/filesForUpload/new-lorem-big.txt new file mode 100644 index 000000000..2c1c631e0 --- /dev/null +++ b/tests/acceptance/filesForUpload/new-lorem-big.txt @@ -0,0 +1,93 @@ +a big lorem file but different to the original one + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/new-lorem.txt b/tests/acceptance/filesForUpload/new-lorem.txt new file mode 100644 index 000000000..b935db537 --- /dev/null +++ b/tests/acceptance/filesForUpload/new-lorem.txt @@ -0,0 +1,9 @@ +lorem file that is different from the original one + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/new-strängé filename (duplicate #2 &).txt b/tests/acceptance/filesForUpload/new-strängé filename (duplicate #2 &).txt new file mode 100644 index 000000000..ac6c70ca5 --- /dev/null +++ b/tests/acceptance/filesForUpload/new-strängé filename (duplicate #2 &).txt @@ -0,0 +1,9 @@ +a file with funny characters in the file name but different from the original one + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/simple.odt b/tests/acceptance/filesForUpload/simple.odt new file mode 100644 index 0000000000000000000000000000000000000000..c6b8b94bfcb06775f50836bbf19269a86f183ad7 GIT binary patch literal 8708 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 XR#ia1IuZf^#8)ryRiygfxo-Uz3;oRz literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/simple.pdf b/tests/acceptance/filesForUpload/simple.pdf new file mode 100644 index 0000000000000000000000000000000000000000..305e543db831e1302dd04f3dbccfe6ef4990e80b GIT binary patch literal 9622 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 J`FjfF{{s=$D7pXu literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/strängé filename (duplicate #2 &).txt b/tests/acceptance/filesForUpload/strängé filename (duplicate #2 &).txt new file mode 100644 index 000000000..a771bf79e --- /dev/null +++ b/tests/acceptance/filesForUpload/strängé filename (duplicate #2 &).txt @@ -0,0 +1,9 @@ +a file with funny characters in the file name + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/testavatar.jpg b/tests/acceptance/filesForUpload/testavatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fb9e54042cb1605da599a957e6527271a54cc0c7 GIT binary patch literal 53092 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

O+-oswwjsJY$PBMqPYy>fS;BcQ7EFBE&taU;(E@ zt=m1EyA^hFj_TS|F5SUp8SY4JvRqE+EB}zMjEZbYUS|!+P;5-GPFF3QVdqP!>um3b zV*oOq{Wo^q=mKY~ui?s_{eN3F5m>EXBmvlej z-|(gBWjP~~g4E&RuJQf?P@`aVFmpp4*g$<_?5#rvk6{uie1V;!$Ytr{Z##h;a91}y zTof@jD(vtbI1J{Bj|w7%RbTs*xOr2f3urvzt|N z=tfLm@}!rF0$`tWOC51jMx%Teej)y0=CBkBOgz~ERq`%5(1@qw?0CjGcq!tlCX(lv z$mS?Pcv^C1kxecMyIRxAtfI6_V#dCj^9J7W1TK>mPzr04Wl zYw!0}$d0x%62OLT>n4+lIU`AKB9rXI#aI)-_r<253lF9%g$o8I$%LvORN*F+y<#g_ z*Y*-r{H*|IW1x{Co)BjW@spZU`9-Y`G5kTcT_d@mkTd&nx0I`^HY6GQDVlKp25VCi z^Gyf>R6nRoU|V0$G>toROl2~-Zh`YP4i339+tq|4o@%xMJOCBl`2^FX4zo*iXAEXm z=WF%e0sDH%5BZg6xka`%pUzRKmz^>GSR=XAQ7_)K-KEc>Y*` zNCJa&j@H|zGIat|E`*N>3zP+uhadgFn1)Jee5EetvT=iH$D8=HU=L@wOV;&amq~4X zH7&~1JUfA0Vz>_)a(OS4nM|_l`bv3q889C|PKAS_0|U>j&qG9sNOiSUQGdp+wM4-n z!TGw`coTIa0kgVuvxIIJn;75nu`L%#UOMF?d4`lvZfM;Y%3PftNGtw!efW2lzs3It Den>1t literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/testavatar.png b/tests/acceptance/filesForUpload/testavatar.png new file mode 100644 index 0000000000000000000000000000000000000000..bd9c3cb1e0dfa471abcdc9b9f71ee9bfe1b002ee GIT binary patch literal 35323 zcmeEtch9 zPy+++9?y0DgZJb4z(wu(?X_3l>t1U|sj0}5+@-w>0)a^0y_L}bfo@NNK({^-5deS5 zM#;l~Z^W)|^*ul!s)x8QJlo6>;3^P}r>vf*ri+cIkGZ=wNXp8^!ushuCv#hC4Qq2N zKexZuq99Pzr*|?^TE5debDQzT8|jSa-4EZnE$F(jYh)Lsf8*rdg?jolvTl+-^&$-W zkB?iXmE=M7k@G7eGw_4{Vmt%r1-=s5s~=V@@A}?xC&Op*X6LO8AJ%w0(x3IItSfg% zVABn)Ih7~;_B6E5(k?GR@o%H^qrEac_DY2pE(3xGZ6hKh9|5w0hB7Xd@Nhp=tG3}D z3`GB+0{6Ql;cZ9UuS0lvbhz)HlK=N_Kn?$=mjAEr@WTQKuYx)Rl&5v|NBb8%(2&HA&^Fz0)8%zstPSb+pNiICZ{e!_m`hyen(4xpW z*PuAtXC@Ai7fp`S$K2~7>KVF~kK!3&s3~|3kBLLA( z8RDGE^*1fAgSN0x9&4tJcS8gqeiqyT^oFB?gb?yy#`SK+L?cXsF$?maCX~r5l{3M) zM9a%(Rr#u%Z|dA0OBtebt2`fr9AtnHmSiUw5k%`X9;Yt+`dKVMw<6c@>S2j;Vu5SS zXU;dZ7gc%r7nwyLwm-xaPGveZ_7Q`&q;MZA+bz@&;xDJmA;PR&zu)2$Ni`2oImZ0m(7)T+Xhyn_?q zyc$(?y0IJV8g4IJ;eF_U^ug|U!5(ckJU0P2KoQM9pzM5uZU{Wk$45>ob)+*vl~$2<#1iHY2Q(KvFjXhu-+pyZOww0C4dtV zGjrN??p@6I`2F-{_h3F?mwvN*7R=%AbB{;9UG70nq))8zLm6;ZN12Myu6)#yusi6N z?UNFD&?m=eSpNc%c_AsncwPGCm;i)+0Jx;&O%&q3#XCF2rIezYy{)Vigiq zAyX!!nJMa2gNv*LY2DElO)9NYFMYICz)t(eU9`<*OqcKQ{acbQxL82PCwA0KK6?%3 z`4kF0Na-m~@69`J5uv$SFhkVedp0GvvcK*^rSlDmrz|XEoNP!z zED;RAqs;NV;@z`xwTi6;@=1Cc{w}3~@^HfL@pe-Fre^-?6X#KU(2u9M2MdH<@nTY) z=r8!Mn{qn`4;381SquEU-g$eke=Ud})hi@L$Wdr%Ny}u~=S zyBegmY}CgOJ!A&-cHRxoTX*jxLar+xw9@xsOs*$;V9h)&=^l&R5`x1DjNJZ{4Y|jo z4bP{NHu(N(sDr2laB}DA?bQSE>KI!icEm4hJrg%JOMYb1_m+@$#cY5zhJ++8t#MTe z*<(wXm)(jh}9Tuv{N%b_?r?+cWd$iIF2c$fMma<-56G%1=hUR|B)F zlFEsZ4yaOQTlb_(XNAP5TWxjf?SR`C6BV00p*dzd;_p5hP8yM?aGKHRD*H*^cICLh z6NGedXfN5^Onz@&k5tuyk)-g0P+Wk)hT^kp4Z^h`?3fdIwWC&W*G(et?{)zK)!ci! zE`r=8YSf?;*zB($W6xkxYU2`A)9q`cVxWzlL`-U-_NldcP8cau8v>tjqSM4M~>a zc?X?Ly1d47X_CnPvMG zfLXgSY_S$*ep+39F2|IG( zbjRtCdS1kTLVKvYrP57Xh5r{D7=(HV*h)>n=_5qF(E9*A6s}wHJxZAzT%xC^1z-C_ zLSlklJpV-)u2fd=Auw=lIZav{h9^k{l!{Pcdm*D3NjA0#(#*!OQ8LGqY$5xVXxZHE zPRl4fQ$(>vKrRnFg%gD856T0;NYpS1No7Y3N}Y1tq?w`YW8v9?slQVAM{QpYrP%%~ zelTdt5e~}Y(*%-+8a{d1F5u(!j1NS@_Qc%Ec*zdh@2oXX#+lZA-J87_8kaow7YwOK ztt6>;o?2Ks@>zn0#3=y9(KmfFnB+t^=h(7QlyN4D@Ui~)PJY1H^Z5ZWp^9;j(9XxP z`-OO+V4ymL7R@B4;7Jwgy}6iv|424h_q%AFnaU;)D{cH{1C-?aTit}q>_7uohJYb( zxS|YRC|k_DI``TW>ZR#&va}bNGVQI%n#+B5w#nWnjjlpd4bhEJpE(nw6F*sJi}X{1 zs^#why0sS3SkEKSzK?vPvR~tCL{&EL%I~V%&22Ow#F3~+_Jb2-7k?MF!+vrsc~ znP>juKw9^xr$%Q#Yn;+f%hK;y;eAN@nON0OUfc^3mWpU`Kz3(|Twd*$*{ zjH#YV76`?XmY=73){P;|;wEMs{C|T20`g zsBm$q0RK*}7Mc6A{Yb5=&v9^JNPBmpNzC zKdY!l(?!P1w+voEKCA#DN@`VP?`*xil=k$Q9=F=c#?*P{Y#M62V%KMki5GhFP-M`BxfXj{Rj8X|jY~}pg zCRViHqAU8w@7ZqTuCoxk{Ibb}mkuz2n&rp<1tWoc^Sn((lN>|oq_!k z_XZ})VMvkYnX^i38&`~plY{~y{cVow45{toP?M*CTlvocw^1=|N{N7s>`UZd1Zq7c znOO)W{pRH9dfBn?%XCj71ACD-AF|-W&dux@Ge~dw1ix;0Er&ZUHhq8%lz@{J-7wOk z=v9PD;c;@<DuPK{CO?g7*5-3@4nNLQs_ z6#)GjM<|68|6L4Y8fmRM_!q$cBpIq8mXm;2me>nczN?q>G< zluCN?Y1zrs)1}c(LKj9QmnWk7SM`cWFUN>iKX|lN4mlBdlzR|1I23wV$wtP6`ZLn{b5%xNC@W(Djp?d=&N3Ih#L`ofEwved5 zr`c7+ZN86iYH-Zj2^Bsu?HhK9Pdap~FSyzbN z(3T5Ksp0=!pcN?Q*VVy-2~7CXvOLYH<)++_AVC9R7B)v z4+?at=mKCX?*rif)eYL7%3YPB1;1;2cdl})kLTLa6F*0Hx#*z|aK`6~a(L8)IN)-( z-ic71<*f?;niVz4v<0z!s>NSaoYmhK|5N!3KAY^P%X-Zgy6VwM9k)E%7#;8-t1;}v zym8>7ps2(fqVNYE?mIXw;&SKkbivXl(5NApk26gyXJoJ(QtO;+x+9X3h)7Kbml`ML zRqJ@wzU23z2?}1-1=-d-AjNWVSD%Ul{4%HOu|AkL++!`VVk_Jf459Wxj z!S5cu>>j9YxZ(#(Oioc7BA7tCl`@Zkhb%msLz+TwBIOA@?YgQ9{g8&8Pel2@4H!3M zWH9I@uCMQh3paG2YBfCYZ-eZjaK;;19|+7UXV{OmpJ(i>(PF{$H8-5u3H$G96f&3)+@O9}ChZ&bJ(!!>nNb5fG13dLbVk z<0fxr>;QA|=(nchi$f@x*2pb21zfCF7ML{(x>p70a=zwa)IIe2bA&ke>g|40ojjVi z%AYX!+NBG>EZR*_SN1>%&x20Nki)pib5~AX-D|J+O-R zok|kFbVaLnMH=teL`ibKN%`nua|JF*cI3-bQAR?jgU1eyYO;yuxscd&6>P z7=!g@w)3E$cRzkXW+#bmgX-x1X~a8`z9R5?C$90cd;#2lcy`%CdG+g{_a7D_Yy+a* z?z=%+J~b9@<^9@qrouvfxC7|ZhlituXv|kTAp#Jwq{H)I_}N>GdvMR`26MSQSPZnt zNRG=BTq!ZF$tiu+d)N5NE9-*46Lq8`h#)$gDM>nKcDd8d{k~=n3&ywIf_0&{vjRQm zy3_vH@jIsKyOYc%XJter>}qU3OI0QV)m@%hYl1*pZE<5I4E6a$G%6-_X(xZPZ?V0p zAwm`vpvL))ZfM~JY(}k)QU>B7aR~a};DQ^iN$=I2PS4fZ&HyfE&U@duO&W)(ka~5V zCvyI0J)70`W_|-p5P6gM44-klgQ)#Z9EJ=nprzq6i|NfP=&koYo^SrE!=0gvu8dyH z>s?2rnan(**Z$Z?)&DMDw60(yY3p={>>Mg&RO=|k!(=Ani+Mv{2kouD<{l69L*;i~ z{W+mT8b@qscyxr(mw`cYK{md=lN=zB_Jg2!Fx0DU*n?`cER6ML_$J=WiT742(MvhH zPI(WEk@`n1c=&##3nMk3Qb~$$lUp21u>q$y>gwDt`uOzl^X5nmoxX@~*UI_17p3{c z7*Iz(bPO-3%C(6Yj8mE$YPlwALu6%9{|f~bC~C8m zQp(`AEz)_8D|ol79#bdC$|%8|(>|9EC2%8$Bc5FFuUpIpqi*!HXKGSw72@}6@50qx z7(~C=I2$EA`p|_rqKCr975P9?IOIwJ@J0q!{ayCzos{NC-#ZX;Q5idp+x$qEIK>_c zslI(jBe8WJb>qj{a@rK}JOjhN5el-Q-pw?@azq9nhhBV0*Sj@4%jvoPi!%XRY`VWW zLzN;(XgXiVQv3BNUekkE1Qew5??%$h0+rmcUTRt}FOTC7Go5>}Nim;U!0L5kc#Z6o zBpINS*cq_*<(D^?%lm`zr$&S#S@0jWXkW(Ji6p{okvrJ zk17JeA9{j=?}@L63?5c(MvW_akS+{UJ=gZD4DyHiX`F;AJ<##v_8z1X7}+$}H0 zuPwz6YvOY2{o0pDx<{g!v|^55d)DjFiBHw5gj_Z%uqD>qJy^e^p<#lgs$;rHdi1kwne|m#GX%;)H;WmB6Dpqe} zuUk~)UszPZCKm>G=IDl+9YgV1_v+~!B+id=AnqGSbWqqR1<_C@E?s{AqmnJ6LAYda z`;Pi|&5z=mhM9W0)3)15V%18o6FJ1IoMpS)Rc=ShYX5Yoi5u+h;^G{HX7PQ12OEKD zkIyevCBAtSuz^Fxal~Dbg@=PqS*g=TSn=Z$=yaXC8~Nwh=N}3L{LPZ>IC5jkYZKO@ z)H%KC2$QyT=1}f)g7WEUnMC3%2HV{ZzgdwSkp6`a|JqvgOPpHoz@!&4YmeWPPw$9( z!)=p3#97bxZ`$`g!nb62^s{?GWtn%_S*uk0;)(XVT0o={o0$4bY!eBY77Xv-2}9f7 zzrfxk(8Wzjl9cL@DmP8ji9W~5#Hg;3j)EO~fs!Z>^uUtlhZ^DuXjUm>@sveNM*qx# zBn6!$BaW|acDU|Q-pT_Q&cv`5w?$u0j1Pt4Xd?HM;~zvq!S(y7@E~RKAV<{gU17fzDMJr?m&WBMa-Zh7Hx5%%8C3WfUd%PE1V9g)*zaex{=*VuI;}O? zMeA*c2HN9~`(!eWbp2<^=4U0Iy|)MyGo7qpW3{R)F3<_s7<$=mKo(`)LcR*8`(e`H zoukL=4?Bd+$&Rn{Z8P=@z0Rl(E8_~ieunD;l*1t+bUC@DWXxIt&3QpT;o|jWMDVVM z+A@vt=`_glm7i<(VxH#F!|G*jL$D8iYmG10^Tiw+dY8^A!)DKh{#qnNH^i{Plh^Q$ zO@Go6ig_%71ZZ&*asN{Gw1QpkV9HrKWyh#sOIw`I@T{if% zSVDowI(aJ`uxYR$&X|B81!20dGqo-P_dsn{Q;7yd$mIwuqW#fkSi%OzIoo;R;uSDq zD6X#e*CO!}mVhMot7ba!7{z!T&wtU}+?CEBiEO=qvNzfw=a&j5F-mM@?K^bPOYwvW zjto<+4&x^O9PZ3`l1SPWsxqcfOcel@e{cXCt>TnGHRCl4cD1>E9U7e3TR0s1bmkAlM-nCk|U~V}yb8+0c=!6Mu5ND<@l=f!p%$wXS-hv9pc5%(C{@087)pK5=HN znC)T}m9*BhM_^|K#GDLXfe;YeuZEf0{e-L|Amp=Gs9mz#g3RtklNR#BI>nRI#K?JZ zPhJ3`e+q^Ku?(E4&bu^Q@RXGce{|#_W92gH6&iB}DhG~qSNiqV)f?P3ofej=NJd5> zmS)#mZejSNImWVh$v|EG{Q8;Vn@gkHCFP8Q^CR73oK}Ei-#0Yo1fAe!WGotJqnU3W zi*|K+!dr}eb5i`QJ$C#?F%^7?+j2vM!;cQ-T^Q{Oxee3b!1b8YmxOfhN@8*8IO@p4 zvgxyjS=C00>#hmYY;Qd*{w}=0sxe8Yqh{TG4nJ|*J!w0;#(Fl0y3)xj7c@5Wzoz$% zN$tB4*=Qpgzg}u?_uO6QtO7>>R7`H9E(e>K^HLhkCTc2X^E6#G;(KO*owcHfh}@@x z7*wsdJnFA;MlE!E8C9=nKXo-_j~RQZ&-e(>b;m#8>MB=3;&gK~?{R$NpPyq!3}lkZ zI9{r229-y!Ca}r8G~HRu_N(`g24YU$11wr65oEuTqI%T|$g4+i9wQd{5fY2NY90-| zhF1m(mUZZqb((ymdEo5z5j2(x*q_=3;LhTHx{zc%E~_!S`Rk!iCn=LVWLca@*&1I| z3mk(K7qqGT%S&6?>6K7C-d2nJ;h}AJ7Q}| z(|d$ZhqV<)9zhawfy6O*ef67vngXd@{A^~qzKZSAm#_E~+{g>sQUoj=oAkbGGQ-1c z_S3$E+dyp1(w~dIdgX~xW-_jU>!#oQ!s9>-lM5{#ZPFiQztH0o@CBHNG2EDnnp;Gp zYhhLx?KeA1m8uWRK5$`I1Y&kTpT55Dl*_kO!%KNE~B zyE_TOjTYvp6r~jdNM&DWg-fSw`24qCZa8iesZ};??IyOSqu*7QZ5k4orma%Ld{H7r4*LI#ty+jc9&}&Lj zve=jNdVZ8FNvuVhxjY^;_8Hf-&X_3BEKNrpCxt_dPp^kZo%VmzGM-H=5-0T3ePZil zZ1s6{i7{%l3by1Z7_)5hKGxanaDNUOOTs1VywaDJ$RRyi47*rMgShit)bm<-!@X|G zpWW>H^c>>zqO>c1LD!ChOFP@gn_hh}(G|UD^QvLIP~gK^ir%YF(A!u{88pcb-m)zj z{M?|5Y+ijMjF?>8L?ZaiOs9u3l|l4!sK~<4p2DcvO=P3uqU=_vKdxlXRdQN$ycZ6> z*5IL*!0$Usn!pC0EzQzD_{qNPA8@?*x5P5%gVShRw1Q|EpfLd5N-8V!+yO~a$!K>e zS`*5SE4W!Y#pk*`N`1nSEW*A$S@55z$6?Vs%ir|ELC+AG{9+KzI8?XymC-)oak6$6 z+a$;V*A07i#Jse$3_Byu94I}?A|cmvG)U6j^+2K3$lcql{Eio+vfpsBJ_z>i=tb9- z8}An8_8$hutQ7<8exi-oEB><%c&<%(Z~GS>hmD?c82<6+V*D(BaX!%F%AKkTmSuo!f#EP0$fb5eFG6AC6*yI z`)?b94D00f8~j&v{EyqB&^r;@x!uktXJ*14ut446g6Nrp#pP^^(m@&P!+Xrky;$$q zSSelFw)48d#~_f?w2rsrEU4Qo(9xupsd(CU;jn4||N8^QA0B1nlYvh^R5eB|3SIA! zWd^-=@^cT8h=62Lx`YQd@>txPz%_2)35J3afl1#kHFdo{9l)TG5_xyMb96jFAlB&U z{)`N@&RE;O7zf5)trtoc8pD$Zi&O=SmV>&+gjE>dAx=$TvJ0o# zC^XPDM_cN0rHDLR(uTc0=^z^&8mE&D1=AkX9Q#w5FZXY1#w>8B3p5^{EF`TDrhKtc z&eYM`gywKU4|B$h#2du;*=IngP#`5Ot`Nw{CErx>=)`+TqOU3~*UmpZUFwTZ%*?A> zTDTY>WQkS4tgp*QDcO?D4RWZ`T`yiBmkM`Q z+(0M*(FZoU`4$ZhB?Ss=R>=L>SYT5|B4{j<^;)z%^+rw0=jx=~r#Ow4H3Yqrqc1QW z0&Ijlq9>B9m8q*f`FrvR;}S>UkRNmXMc3b1#e!z1_oGKlJxzWaz`jt?SthYR$l?bK zAt?sX>aLWQ5ZcHGP8{6>#t+M&$SC86r`vg$X6F2Gh?c=^BlSCU$SdC{0sQ5Yt@Dc* z*@B7fa>K)u&a_w^LV0V$oo~Vwp5{6=rV)C=7&*%;SqGhJlKO{uM}RAa`cs#_4G3OE z)+AhX2Hrq}I=`|t?8l3nnlyXwu9u>HTqY+&w_;4JV9D}Y&R(O39!VpMo5!+sI{Ed{ zuiVY1J~(T`jg=4%aQZTtPgoyFX^(xcQx%?RR?X^|-UGt*DCcO4r3PolX*i zF1l4D_lExD;7^M6=3y7A3o<%yX&bXu-Viwm3x93G-Jpe~>XWPG@5(ABUz`V^Yp6q% zLJiSac9EGTckp|4^5xye!$TRlo(IoO>2C}G8OUzD)Og& zqq=EVpa31q46P6N6?UPB5OB?A1(rE{|56aU`T*HCix!?|bEo}Y^YzE;^tGQWOhxJ~ zpVgaB6~`fCYPV%lkrI<5-7>ibw;{SNC|N-a?a^wAbvhR#cE0C~sd)+{E(;J3=(1sl znqB*^0Yz)kTZKQ1k!kgA5AdXuvtM2n9<#(uPX$~l`CF|mm$<(0FD9PY1TBdGEF zD7_z6izTHf%B}>As#M)Q_=4*y&}l>y@c7w3fvnp98Qt@8pq^Ju97r|Zq10n}bElx# zhqv5iX?l{$>ay6hIn$e7aNe1_Ve$(`o_Qf3MEw(>(*5OcLx8D>-=jk^lV(45W612M zd52jmz=;oMZcMK(dRdeF#Q7^k0l*IFAM|lRDU1YVUxKZ)$~)_0FCrU+eUAc%BVBdg zt&Kk_PRi9iW8g7=e)>ctfiz7x6X-y`jxQW?waihcCOppp-2vdRQi~he1XEyC8^@dK zQEA<1iCTjt9+!3Wh-F-QBg)Zxs$_F?q*rz=sk1Rst6V)~*tCM8C@UPVfLUfPj+v7;e5@$vlV{Td?~A>KRRYRm0=u&UJfDjjAF52CLS~HJRdG+B zx0CD9gyYX8#}wsvbpSc%rP#Uy%%fl)GG?k}{C)QmO-QZt)bHwAHx9pLk|f5K6Db~- z!IF@Qae8@6`R(+=dYbspBq&3`TYo%(m8|D>O^%eRQjc2>2V9;Sv zl`!c9r=gmEKj5RvkAt3>%#da@s5$z+N<0t1Gu&n>h~Iyvj!4uLy5w;?po1Qkc1@bI zyblHz5kjN*8{W5PdaoovZ3|ALxP^1tukEp|4O~UfNGMbZ64r{#TIfjeeEIq3UBWj4j7UEBsz< zzeo^=37^LYhUUqJVxp;eMO5bcaD9yk+Kiz?+Gx+k`3mt% z!;Nho6wm>vYp8xhlWu7`!_szSp#|Q!(NR;$;Hc{|y_l7nh+r?L7A-oXeQg@T3$ReU zXWEHTaWMr}myteX)s>o%v`<7+>#1T{&H&hww1wxcS0*{C2ccKGNZxUu=(9Hp(T^VV zWU=dTv9<1tig`|@b$r5S9n2q0)P^B46XFbX#GFkQLaKfFY_4}`gd)q88MQ0%#v*{? zZMOg)v#6u|IUN#oGS?dp(U1R2nr&Rek+YGW1romwd|}F!u1TYCnf;YBF@|(a*Gj&8 zB&q+28Y^HTyTN~-ReZBT@Jm5NLQdip%fN>zJ_U9H=CqoC7lHo)k3y}erQ;_?XX+I0 z2%9-QwjnLJ2Sh}*v!MfzQGnOrPYYt@@w#W}dc~AU++^b+B+~&Vt6GsG$%f3F51cnz zi7Rq=2!Pw+29$QnJCrHSCC{CCuP-5uF$_{(UMF43y0S?e2)!;Hv?;zpu!#&Km}NQO zcPSvQrlWsFM+u&n8DPyGs-@|HZ}oC3nf3{;bhl>Rs4D zUEY9ci%s^Tri$?kvPl5*EY&!C__IC6?aioQ>hI|^urwUM+xd%?PR$J{Z%9mzUZ_g@oD02ifLpLU?D~-QwrU1ss6_Z!pV7T5WykMb} zv<5)U)4_qR9)EUm=cDUjqzM#d{}<4mVmwUru`mD_L%wN=KuC6gp#t%#xGnv^%)Ht&dmmW$0bJ?u$DmOPRcGzfC+?xYWOzPc6Wu&4ikxTlc`N_ZD9Uaew z)ho*8I0P2c;1|gO^?ug)wc)_@*djoy|GJk{KJGyo(FYNWHok}$5BIb<#a|P^b{+`; zl0;jNS&wJ+ts~&G{tssxf>^Ov*mm?uK0~iiV8FvlJc=AZh-=t}rp-c4Eurc!zyjmm zOj^pyv3ERrFW(r~IfmiC2+8=?I`uQ!>F9M*H0jzCS~><`O-b@Q4!5aB=l&FNcX>Q7 z>f1DtKrRODMYT`Vr_o&loJ_Lqi!Y;u9uu*IqDd_3T0z{Y61M#&e$4QVUa#- zbw}$X(3^`_GH+y=?n{g~3NEnr{!X77WprKhEO0FRw#DD0J0ZfG$jW;$-wT0dMUxcV z2LkZ5lPXPPMAuQyoV-eEY5ef3Ccb+%wvMi@odkrR*{gV?-<`Jq^{8*GcNQ>|q?lX< zfi!6CVyXH_qjSGXgt)xQUAlIN58C>@HWD3wr^*Jct!*lxoT_mhyiq#igV}2c^ER$G zX}N?UOXeSFJ1zv;SWcNAuIujXVJm6y{gZKtr$xl10VcIWZ2(6X#JMN3s-za>t2er1 zq=;{|L3HRl=>(It9l29DvycE|m9;?fy6YZ51GF7pf6)vQk`f|0&H25%`mwJp4_FQ% zr)Qv%^f9Rt&G(lNbU~nEYIAwO0$p5u1ZXxO^`aYhg$S_@3tw8&vx<>@mo$jV;r3sd z_)iyp$C{-^xvloAIWLH6J4&ho`x!%n1R8QD(}b)86n%OaZH|~`iAci!^5**tZ zC5Xf7a9HE+qhv|`YBU>3D_^;}n{Sb?h?rMyZTz9+FOTJGNmT#+lU%grvb zQ$H3M<~S6K*Nh7(><7OPzDRgQWCla_l=oa)?-?>zTRMHVvzmNvTa z`S_KyfwNeFOoNg??6_!xk$y7U#dfeF#=4yYd%gV(;cX=BpxqglzieR>=Xm{5f#s;lpmv{a2FxgyC$`~hf)c5o?z@F`J4k2i!Jc8 zln=WmzVJy4MHX4$`^SZnga#+5H-oJ^ktJK^I>z%#TFNVdKVLW)!o zDlPoo>;OYB)zz*8N(px)4SAEIip=o+2OevJBpF}M%`Wyr4L1)q@7ZiISU58nRyp=t zG8nW7rZ;*{T6qm0R0x3vi?3aePa|bJJ^ywI9X5*^i0|-PN|vj&6G%$D4C=o8riuq!O;Ru<=Bv*3LHOBn9SWpLKZuJ-3llqj-&akjUVu=$h1lF+U= ziQ`2g*k<60yh#h-B%2g*$D!ZHvLsUUR&bh0$GlJzU`CY1*zXdnY)Sv%{g<_$7pII8hQY|H z;M}OKufP^Y@@-UhKMMwZEd;Q6VjVLgAaq!1u0&5}t7Eg;Jti~udqXy=Ax}9oC6G3R z&&!Nj^@Bg3IDX~?8yuz;wHzmvtv>-qf&MtifsxuITYQr>A@kx})i3X3>7vQrjUydD zvsacWQ6H45Y(>o;Iz09FwLu?VX;?PRQj5@8z6g4pJ`cGMeL`m$VCks%MmS3mKte-g z@I?Bd`;*`Jsx58~`Roy2VV5PcVO!L<`eENpeGqMJC&WVT{*)O_uER3gZ5MsIbPLk! zab7)@UA|=>RcYv$9ceDG06{j#UP#jozZ1hCniwb#o&$yI2!{iiWfr$?J#jw%S+@Q)+iigEu z?v?^InGjy;UHBGi3E4E9wL3_=k~_9DMS#W)eY^xhcIZmUVx%rDu7PpSa@SdMq)x7Y zxS&B{HBf`DFDat^BB zPKiZEBse3}{Wpf9&<9~LipBETBToB=qhhTP2wH8X!u_4U1>z_}KSK!8^%}|ct!GLK zJ^Q@;Qv0FH@;B|b-8?y1E!EkaEhlx3wM_qz;nB*z^6lZAU2eLSfI=F0p-q@LF5&O3 zLr;>D0z^@T10+M~*}S_DRq@yBZf*(aTqyn~VdE)!bp^Lo<6WcCA3f+i zb_~K`-&|Y|em>8pc+@_nhXpv;x^LI(y8AUJDYRI=m6WL7Zmw|WL4G;3lrOLwmo*5q zrv+7jOGPWNeY|fB+n-Ek`oY%N4$V!3BylK3s$}jLmP@crSP(+#hCreF_z=2<9N}~E2jh+|ue)k#Eh7^Mv1{T$0)>+N z_z-onwbx#<(sDW`-HCAJ{*+oIadovKdt%JJKS4v`CEG(VJDy#?)+eoMRH6QMjw5X* z9obp%EoR^RW-xx?ZOCQu*+|QZt?-5C!n<2wln4+HD2TW9P8ll5;~f2QganU?1B1~d z%Vlih0gzv&pRcxh4R{!-ciV3_+>iWe>?zGH@t5AyoF3O-Nl3SBL=%s@&nl^1b|j{E zx~EKLLMN?XgAJw=(bi!tK(m=OK%mtA=dyFJeOaz{$L7B`b#?ZBXG2lMH?+*N7_2SW zhF@loToF2G+L8!tkE*Wb}GpnUpA!)TFfFt>zW=FV94=`_Q$ znZ_{IQ7T45Lkw*Y)o>E7eN=t}E)c_cD%k8U%YMXaOne!q7?`amTfvN}xN?HaSg=y2 zrm85iU@WFw%>S=fCHodC&WAkh`&`r7ZK9#_Ubab#urPKo{3i*Fr%R#J4vk}bf_B$;MeOyux66Rp{$aMd=alkWk3tj%jcg5cdE+pY7Bw&Ov3Ce^V*ywJ}L7UPEtgeKQhhNU{D@;#?tr@}C}oEf6o|H`?{s4*F6 zH2>Q+zSe_ZOhxKYb;m~%A*WI4nztc`{K{iczbg@S8g;PlW!zQ=L1v%{M~}aF0HS>9 zETsUI9)9eU+N7 zb(32MrAXTK3HL(!P*GA^2z$N;A?9y&p=PwgBus|i={D2YpcPkgtJkFprhPlWU@mZ7 zRnx;(Jrv&1Yk4oNr+%*$*U+~t$ zn=o<)>=ISR3$||IQt;HnzBh&iMlpAjbZG_NCIm5y`(gq&*B?LG##6o0y5GvUG4&54qm&+xDMQC{bT9Ea27lB+=A0 z>Yl2nr?W-vkk0QKJ1s1uiC;5ok&%016zS^O<@@PAeXy7>=0j9b!Fk53#Hh*l!n)y( z^$Xh?9;0Eq1Fyy+aaDPGhlcD|AJXN=C*p&F#lvc#z*L*Bjg-(8xdeM#Vv0>$3^`IA zWwojTPnWLd-ki~kqzBK}J##o&pyA%&GJcsjHo(Aa7Taq0+9xkV1KsS~l62=4dDcQh z(2Ui%*0EQG&(ib5Hd#Nxz5e z4V+WesYhJ=cg=Z<|8pZc{vR+NnRNu+*fcZEU>ML@3yo`yD;qCgD%_)6-EqzP$$%@G z`WGUuxDlOi2DRE|Kt-Y{jBKFUD6X zmG^lmLXRs5wd!}Qy{5k0Tazkw;T3XoGAHXYcps+anaVhY+bQ@?*|+mUPn~n(H&mXy z5<5(l$uVb)uAW0FO_u7uKCB?Hd(}j& zweze2>Ak_g&=@`7)Ae)upTL_BhxLLBB+Zt)rxbNZO`9<)-aR^wfd`$eF>|YXrx(jN z-CYDi7WI<@wWnuSPe^PW>mJ~xZvRCg5C1e!<3I1Jwp4DGi6y~Bv99y2eisg^ zx@AGaL>!Mih85z43t|fFjK8VFrtgX1k52hS55v-`H*O-~4^j+gLHo z?ArXoJE6qr>nm)SP^28i%<+J*R#L4fUO}TSttGHt>^=}nsG&n;-G!L9%#Ze{Slhgs zldu%B3wMwRxRJ!nM)2!19{ZFxFVqd!8<*A#`3qwQEVsKvS&Al2W_%_;$}D^;LcF(* zQHZA#^%~d?_}F>2QK<}xCLv{WjaxAgb-T70C@YvrFqZvU`7fkwsPO0#oLpd=Z7qr7 zESPxgak+Rx4Fn|;)*X|Y$~K@lzz-~{NE-iHC$Jv8EYL&vT@N)LXl$J*{S8 zo|fx0dQ`>}-=R+FT_=vh_4{F9fr5{f3gy1WCDW;__2nCXdDRR4!xctdh6(@+3d#1I zZi&!GI*KgJ>c(80dv&o80K=F#$7#Hi$J!}0ecRC)F|y_EktYH$msro>B^E7-r57jdbq{d9#t^ zV?2dIE*D`VAWn>?Y|YHtwt$h~n1b_(fzgeZ|NL2TnuoEYt6IAUP@XYQ#;2Z-AM@ZD zYI@|bWHJ3~ITVtj93CvpoIVlzem$x>-&6LsSbD!kH2`wp+u)Vov@j$9c5-c36B)G8 zvhCWIZqiY60^U^OcA9&z(a`Ji-yb?j8h8V3mchTnfYtE>vnInb)X)6%MqhqxeuytK zcChu*F$HYB!)Si`M98@D*l&9x-NN=yB^w-AgzBnLiffpxbI{k39fbst1<;qjC*&JA ziSi99KByND25+DERTbkE;bv!8vkD$ksF!4uA;W&k=w&akOm`%#H@ep1Lf;R(`lrJU zc3Aa@FEH6=#qaC8EZ4^Jizj}vX-d2|nPmawCUi_vhN!9IXEpi5 zy6ouE{%G0b=px2~23tn`gc=cN^=7%XPC8?Jn(||>f+1+p&O->DWlQm`B57RjKB?~= zDw%z@hVCuC9O;>5gBM_6B3#;($bHRi1`aWm6N8}DreO-YhvQ4i-};T!v|&;o0nm%0 z%B65jj5=+p>_RS?A$-CRr4}(onfZLbiBa07et|)oUk|eUOU2#iQPFH`!h!*t4g=q$Fc5dx4vnuP6a5y)$*fvMGA34%8lwCVhCNC6cf7o3!34U_3 zywLpE)KWxhvFzr2-}H(5nkJ4B$Gp4xENs&TcRS(5GbCGZO+-MU0jN=-t1eM$SLnz<7h%=wkhLrgyc{pS>@9(+ICVTM$J!JjH}_r$0! zJMlI1e|h!w2?(G%OalBX{zs#%>htL4$}snNS=;3`ba!LmuAC|2@8CT4>2{s1K-u5B zFU%`D>7Kwm?2;!@EBX8Mugv+$n~}Yk-A*A1-y@?|WhjHA+B0BoKIG#4>#KG4As0q9 zU!@Si`>o*zUsY4~+W%|sz2D*bzV~6mOG%W7C?Q%3K@h!@5Yn^|qj#eB-iCw_J&EYO zmocM{-fMJ`F?tQdOh#ughVSvduIGPve)!BUF2*_g?7i1od#!t|bszm4-P+71uX_F8 z#-Kb#NmzB}QDAcP?{$xqh<5K4zve%-p8~QrTB{jQL5&|1WR-d{UWGGWoXEcCb4vfui1e%miBgR zMnw9}w8#1E458q<0^xS~P1-h5_SXq8!*m}GX&ZRJOR+9Nak+&QPftKN*2u(4-kk2b zETy*NY9$3e6_;esUmeX3)W!#6I;*`d{(XA`iCRF{OYM3G1&{mxd+vP=@NiH|^!oxu zC_d|kq6lTivR0O<1Rw011`(y&)PYL5{cn^7wik)xirJj?las#tZQ zbjcOy>rlM{%(6V|wEg@n){{dw#@iXOw~~J*NLqTGz9cvW&JtHUZI>PM>v(~MxqbgW z4H45UE47Tt_P)EnFvIlnQdO;1dnm9Lr?)-m%~`iiW%ax)t2R` zfCW(M+?zhdv3ziK3}|i}Lf)$`v0+KMxO{ogR$;YHhHDaX)h_xK&L<&$+P!W-{Eid@ z1eCt&hH)=9oSxDO(6=Gct&yxWg=6Vk^+Ta>2MO;81>+5!kca|tJ>~5zPuz61#lxEm6EY zD6V*1*MT4D&!=$1eNW_0fX&r`b$uEcgOR3}+pzxn(ns|g;N7%WHKvhEa&Aventj@) zJ-_SprKB^QJpXc?1Su}HY|#fNHi|&a+Gm{9KWE;Ak7P>(OS(coguP6fEP$kBNHufA zN*3CUR)s=NK7w*JykB;*yqg|=*?10-53A2fIOb>ExDTI7B0&I{QE2g#z&W5;lk|4- z1C7>q$YItjp==)WmJA>}Q=HR!_~K+=Nuv2Nte`R5Lxnk$zF3ECzd4h&s{gDqu#rVk zv0^x|z@-F2UakW8;~f5F!b?wOpCmV403p^hBl@SP;=qkHe{M88FdZ?h!jI)=rh}>_ zhSW$)9(+Z~- zqu@U&m);Tsb|N=w{KQZ*2?I)5DMCVK$={LS)s0E+jb6kQMi`idh#o4^DSBnW^s z0!SNC(_TJBbfS$Rr`HqR51?~Z+IW&2ku5dPbXc>Urz;)VcQEln zkM2VP%_xzZ?|8{a@%dEsHdTBzAu_82j#(s?3OG^P7N+wXOFMhs?1hb>Y>5OUsifm# z8+J5cHk-+I`<2}t`5o}>Bvm)2j;q;MMmHND8pmKJ9Btcr9fy!2lH!dEep&IzpR4)3 zkfqJB%xwIH79hDr|H}nd37d{cKMMe>Xj#}!Mtz@BR;$--q7c74PuVS8W!!PJ+BOS+ z0`pc7ghhrl9nU78ANL*e7(;R>ke7w~1UJJ?$Cs^S$HU@#UtW=W(8MTE$Qr-VQU?y) z#)eZaTuYNA16_^qrAP7|`C{RaWZf84$#_t4eL~c2mE4gMGrXY*9|tmlv(UVVhNJRL-Pg#ClO>8xvEP}7@qyG2-_pBW#P$=aK8W{~N^YQJD zb0mCm1;4rKQeevK%BiD+#huS3(gtQvzYKSztJ~aJYsC-#I=Wm<3>H;%huVHc0}VhQ zQXANba~G!n7>meM{_FIMlu2IL))5E0EHvvZq$Vw;k5uCdvqL7T5+E^nLva0qKK7cs}-Yd0@=yq4L5T z$d843?33(H{|WMK`ar74H`}i_HrmJsuMQ@ZMYbIHuNfj!pdojMi8@ZL^}6d5%#^bs zh&J}izmT(4LnntlGjI*Wb<_5Zm8s!YTX6t2-*q>;|62S?-$K~>$9=Qlb%VSfDQO>{ zClKFprWWQKf8H49WPPYy#2)skpWJz;>IjhF^kWP^DEb(MJ7sVf+Wg=zM7Uu=P8A0Z zS56MM52v~_oXe59WPrMTbA4qM#E)CCl&r5*6+OkXhOkI9%lQPm6&~Gx`SlOg><;l`Xi&1etEcV@`Rg;zyZXaXOB2AO^lBkCcA%ylj6?5) z6%DBICvP`e&{p@l>MV)0mX-qCTf@l@9yQINJLvP&7Ob^R==v{*`~97)QYIQrv+p%U zs0Ql8@7DAkTSW!$MtRaBd&N*6N2JdU!3o2=;{6)6WXIPg;>78lK)BOXbpMJbgJgvl zK=HD1WEs<0vW!ybj&|K7kK@>tYc}UxG_jT9`2(uy}%(W`cu&6DL=Fzt zsyhtWSG?RwA(QVZrN6GeNjvR27{BiO31~l7ywC_3{4oiW3h;TtCgm=HnF%>5Dmkx7 zM`JWy?QEhB+I)uN_zF=*ZA-6I5-+Z{eHC|`cURpwO;hF;eu*ZBgB|}KyH#Y}HiB8- zqBHERvi4eovYde#mK&P6#?}GsSZ^4m8MA8G8o%T9X8Va65)W0GS+ZnC93K8zTR(HD zaeVUU_ci0;=nW#T0r(%Mn~If5w9#45LN2D;kGi5nKUN`F>j$Rm5k>;%Z~`jCPkymA zxMce9a3-Arx^NnIGB(6OIU5fo%oZzmfo$+^$S%jfd7G9*-sJLu!;+q%!VYH2n-Oyt zr;T7!yW=oR&%{O-W)3<_@S$fyLv2$Zwm)@l2(xEKSc48JTyZ>Es`76}peF1%Mo*nO#V>|5WSPSG7J=E)t2|`Qe0UM)BAz{2qg94ha zgLC>Q9fy0cxFFiEhXUqd*Ofo}br0*SFL--L*X$pZ??hi z(k?LOE$6Ljhd*SLmTm6`IVk7qOU2G0%b~enC&aHvZ&2${6B0N_9JQ_}XDY7-IpSWI z(_K9LdWfcd&um?O1C~1pHC-#VAOShwQGU+6TPD+hS6BDeL&&3+d_#g1_Wniv3OwGC z#$^VM;j%n;4XPNsKd~gHzxyK4e08EoI5E$5Vj9{`m!zY`k)%IXgPBruyIw6w#{5Az zbzNN&)~6=^`i7W$YnR`KN9aYEV^!od)6;K9B05%ojWiskU{9b?iiY9N-6)P&ZGP)p z$ILgTV3(aoF8YglCs6U7Rp}Ww0aFqs69C2lYABvhb%)nY55;nZD{|SLj>RmqV*d)M zIVe?4{|%$OESxJoB3v$!3;i4yx-8C9Vb?1ygP&gQ1$Un!;b{s z%qxo;wy_ZYpD6Unwzjz{97# z?o_%4x|R*wytjJni~J0qh*vT|IK2y_L9U5&{;3V6z6r=woy8p>^FTujG5)b!uz z`i5CV_m8%Y-@=vMd@-*D7lS#N%gz@D$v2I$_VA@Hp9@_< znGz4fb)U?FbX$4NA!=6#4bg!ebxEzxUiIMan?=vI*)3TcVS5vwCq61QCfnB=BEU+6 z^$q>XkT#pTJD=fh*|FaL?UnBps7Ptv39Dbp7s!hH#}IhQ!1%& zXKHf+%YY&9QwLlTtR@ReoV>t{f<=LlwVS0ObsnU1TDg20H5Fyn@0^>k9uB*i(ESMY z%UkggX&SzRuq@k>E}(X!;qv$RG^K8hi$U{K^IP5UNwf1WzM5uDpW)@ytu*tO=3-f| z5g$b&9lG8)$sWevM~|Kc@1VB$2O1GlnfwR|{L3=j$}RpTMwQX|#}zkeODM4laxYHE z2ST)3Goj7T@IyXBDF*}=V zl|?_TyPnq3^lag6VOM}^dt@{<-@=&pfw;8#6Y_<=s!_^UnL7TPOMZvi># z-$RaLkgN8yb(@THhaIPKh9{-CTNN8?Q zuxQA0H-xTaZNBOtNPVOSouYX)fSYDC2hH0m-{V%hELPbh+K?fhha9dtnh_E@E*-D- zmYHl{9L8}E@jG!q=br0t{4UYW)(w<4F`>U4rT@`dhV_!xmldizr1cEalHyPb+`->m z(M6o}yKc0QFIzYj43C(JV`$abwCW2Mg=wq-vkcTS5KRls!zU&GqvkS~3htZ8+jd*w z={P~?bv>t`1dCj~{WRpQ2XgBFWr>Y4@#e_o-u>?brHvl+=-tzv{vGlUJm&n^6ncog zJEO?%U1~Dtq}%xw@J}3y$eq&PK@soD5pW{Yrf;tW|y2U z(o~gh@kf9#mvab+T)9p5gH~0+=7wS3@)U)lz|+koJh?%n-?H}zZXbkYObdfgedDhP zH84Qj{+#EatH~geNxL+5HI5)S2%hKkp!5>klI7xTycge$b}62hbX@L&u}deFe}bEB z#M7uvp?5C`>rd$q%c&r5Jd4c_2>C5I5*gF9LDzw+c0@0S3^jNdX}?HVkW}!^&Vfgs zc7~%{HJciym@apa?E&Y@x_F8gb9&$-P4OlXtU8UAf$wopV{}2%rN{U6ptAG9s^jxW zcZmBjdJYXQ51~M5DHEJw*jzn(e64=uY-OOpOu}-YJq5{!Yiq$aoZpq}jQCTCJ$qZt0@uy6p>XI%m-1CL*%f`zJ<)xAj~mAo z{_B3@{eH@SFKE%nQW|wVjAi=FtuD~RTlK9n zqA5)L*iFAqLeGQtk18bmoRuvH!i_`d0R*%Y!8cHwv7hq=OYH(tbT#Rk%YX%1u!hWt zuzx-gTr6<@$|e3S4Uo%b$9rZiCX_6OAZOe8chnSPk&a2bK{(%-kc06{#I^%`&udtb zU}d%)n1&trwcSO8zIu|4Z|TESH902k!2Idvhb=i#o!(EQ$->}=u$qJF_2xSjS-!V1 zdmo*4SG{f3mp9xtT4e-@1ov%Jh={U$@A4=m9vow(gjqbE-v*hr8D&DPUuVxO1V5uq ztZ#tbpn}YTkIgPrs{x<(+}LH+)N#r(wQI=3=C?`xCwh|}ra85(hd$B!UmPKf)%Tb9 z&Bn9X&7}0$lPh!hnnSEE9p+DcF4}hoe6I4lI{fd9r|mVgzi$N-eRrd7yu%N;xn|{9 zPXuH7UOFSe6rlrcHs}wjjar>cgj_6Nu3TMIou*uEHfZ#~tMZ*Oi>}8-|9oP=%XRH8 ztWVZR{TSyv`@asYxE0;+zhB%oT6204JlBnxem%VAzaL;@#Q|L^sQ&8R+;niW3lEBu z@VY<#J?vEGw^Qg{z++_Y4==bm*A1b4(F5L>(QoU2x?%_!v;WJ4eaHgUeGeV zDrYbc29)?24dO4MMnGJ5%PnoAX?#cRi5spfDBJ$WeNe1=j{KZ~i2x&a)#2Ll^pyR6 zfcJ+dJ#oDqzFI-x;~$isrU?0zWLJA~)O@Gd>aKv$>F&ehx!KEVDI^R6Urb_fiR?q^ zk;o?fYR|EMz}j6u{JF;=zr{h@`2L?v-&T<_GZ|`G1Wg6w z{f0wqv%}O>(v^#dpHUleh_@;FA(6~ADnX^xx@#D3UTD|^^3^pJ=Mud6f|}|DqBw== zVwbxQ!_W!23f3K#ypSZ4zb0{wuzX)w`Q!~XGSpp~&j~@AdvvWQM~lEyxP=}3iKH?1 z8W#sRm45=-x>esO?)81O0RF|1i0IEXX~wzB`$AXh;H%k!tfG8UqTFjheZpwJEU(N+ zIY%L|u2w4@_|I!f>vN~?r4^R(j=3p23aPt4uNK~(e%;dwbAC)o^qlYd zdfELhvX&?g(ZCZs)NA`2MBo0$+4Ww(E&0Ga3^mS<{l}|HMC9>5!n`|rmDoo?FK1X} zbbb&K{RPB==$za8*l>m=pn{p0Xz&^bJyMN8IO5?Ez@4r;^0yNIA{?V{6NN4S+xt9c zL$nNv?mtR_FH){`ajKr7O*`2B&cmKb?#~5@6qOSRQpX*#-hFT1Mmhi-zK3KO++O2zoA|XB`QNr0Rry#fG*_H!s(YBk6DO_;;uWqvwQ%_G5izK*rfwO z5)s*5AAM4X2bb8F$2(?v)l^sGvI&X?d@pkUy;9<+xo5W8QL-PxaQ<`nMt;wM z_kA<+eeVw^Lga5Hu&ZRz33*L;bFgPo&TQ+-)!&c*-htS9uR02VMUMaZz^hzc6ST<* zIs8_hIp$Z%A(F8#BA?bdhjKkO~d zJiws=wi~YDAt!q0No{nD-iz40NMPM5p4{v14fGzVNm>JNNr*E_w(`6QV?gxop*VGt9xR_@xwLDGQAGm8oy&JDYe zqAQ!XHEL5e8^>dlgomLKmuiU0g_w}TM<@Ix>x@PaE~7xe7GyWDrbiO7ahh8VCa$0@ z;W`z&DS2_HHxt*MxtWBg2$U4{LezPwW zl=E}o$RMr(n=+t?opq1!#1yc&$_pkOmjw7!~op zWDG_!?YGc4>l!VFVw)JGj?8a>MdJ;+UtWL)+L!FVnSlqMnHq$Ga+K zJ5Q8d0^5}sD1ScJ5HVfZyO*~U{Q`pAI)BtE6g&dqpcvj|1C8tiyuNYNtnS+<8y%WQ zS0x-CwO6;LS>-dCD=zoM;?)FM)|RGHuITO=k$=6}EHD12N^pKiXJXb1pHA;M2{<%-khzSJM_T7(9P<-YLP(gQ*j znl)Ow;19UK-Ff!6fLEDXQCe9$1#bkdS9|}zV!vS=78(cB z4Cm>aEw8U$mOJw5L=#hdTPXQJZx;5qHq`wd$$((`pP*%JL`{FxY5oN&>cr^A-x zB)7Nx18HbQC~!v?TJ}VGT;>JC^s5emeG$L?Hr^PIk{fqu&YB$d;tGSX9jo<%c_CPb zK?&%Y0{@IY@@0_0=?gdAnYRshYbKErCYc0tt9MV_Y}h@X>+y7F?B`E!@+C6$&BCkq zd=rGy3f0Hj<`x`$Dxc=8WewzvWb(RgDA-J{_2N|iDbiSmczl2(3mlJv^E4^1G>p(j zN{XyXaQid)mb)5=te>~82W%bXAk_q&r>!T2EL%4p?0$?{hiGw=ua?E^#Jx@cq?5he zC(Hje)X_?ZmNUx84tfx#)Tt`T(_NdNrOUml1ZNVm9`>;G&pd5|P`U#}B#tTHs9{SI zJ)BM3ZrEou{VBX$x|9rSa@pB$N}uJ=ei zyyto4FE{8-1Y?t)Lxox_YQKLK5#Cr<1u*ZY;CN&5o7@D{{7}`3_D<%;;1dy=ZeL3d zX~(@(Mic#C$yG)FCJZ!(QTBEj>Jg5bJ&i?~LAKQG>(*d8Gu_fE9Wf?&7VIaGg4>r^ z^lAkCx8aE;ig?qhn!??k(Kl2cH_|O1zv&De`3hNObgz+#3Y#_{VRGVPZEez|#KNLD z`ACmjY+8)!g4J|q7eI{P@ zAQT?%uCn8XtxMqLS#iWzt^HNJAoKzLiNSY8JEKKGKD%6c(2YYQWyh)KEpjS91=Ye` z`@4+CCU}h}y4%8wF;vJg^{>hljAZ4BYfi(jG;mhAMCU(iI~8!C!^jQC+FPr0{}J9& zAUodw4Q8=tsDWa(LlV<>MJ-C=e)(;Ydqg<$=yFUIMfsfyZt&Oj(XRQ9*gTBK=kll9 zO>u;uJTwk-r>mO$FECBW0rAqWGKQR$r?>V%ahZSYbEk23uQ{E{tCNWichSwDI&rC?ZwJ~lIDcQsRAy=c-yfY={lGGJQf`dI|v-L$_UEdx7z(_ zBkij?`AMS9=x>{uhaFGij7#Wux~l4(Uv7=1&Nkl~i<&;H>b9ASEFq8Bs|@-Q zSledy_tySyicK?i#4-*rH6a`y&l@KMmuRuQC$`K&#H^0=IS~Lw6C+q)?%$Blgriyn z1Z&bnR>7sOFV(_qpL1tU8`gPAn2-qcqpd+l_2-3*)-wdNb4=f`%KoY+`Fxz~g7egc zL$9$H>yj7pi(yUZPBsfwnX?QsRk|*+44WGo8t1JZ?rHjmJ%xStXSbOa_Gkd*)n#nZQ9|87Xdnoyr-byMtkOR!oj_kv0s+ecDEytx%TgjNWi)E z`w}L{Z;pL|cH}?-radPIF>5eKX}jXj65D=Vp2P>^mEA3%%x5CfpwHoMP(351%8APG z_7egXNB!eQ+lp>_p%^($bTA9gTHCS$yv9as;O#8b&sEkAqCOri$G?;_cjsmWaE3KE zii-_AKPI$SiZq#v+!vh43({7Gv-X|4XwW$0c%{jP!fqi!=SdTqn-sI8xLEfG*^Yg| zsb9wJn>>XVQkM-(SLse0SlFY~vtlv>c(D2)nrb9Sfq89f=li=-x2 zhl~{lJ>t*nP@|qza_hiUSXp-umH%PDg=K%Wy*YdF>~+TBH`Y0^2~uKKXU&M7KD&qe zR_rw5*Efp%=G^kSgJ-_mV1R}ntwRN(#1(eK5KFMAPV zp^TQF-Ra}0SJDBbzzlasakI&d+K--?75c00_h0+gl8Ke10sjv``25bDKO+5EufpSj zzc_+$IB?ImTeK(iiXV})1Fs9SF{}1@Q*?_tyhlQ1kX@){!J9G^(n4;WSn!c~fqEIg zy6QgvUehnP)YB3djL5zIzc@?H$aaoV&-ODyb&G z*;j48QLwp`$(~<>3Un|1kDJraPr*fUCpKr}>){DA=q-8`o^-M@kqXm!A)d8@MYKLU z-t_xP&q{J&_2lym4TH85xojkn>tpi*ffim8golU}I?$t+lk>3_1!M-E6+!j-ZJ^(M z5^jQrZ&=`QkId+ur9kb*S30^8yhbX%ce&WoBJHL+lPG8Gj9c|IhaT=w*F;Z!6F6qF z2qTI3T@vouv=cp(*d0Aqt|_hYGzQzK+wiU;fGhH+`@n)kjULYG9q+*2%gGE4745#m zCmXLe*>EN#u(zG6tRE?NgJlk&c|hN%uS|2%Ef~K|t{lhV^u*zsJUd1~_ej;!q;>TD zC@$?=qRhPY zgP~#Z!5py$OONWZ@J2nb0%}14c0i~k=-`?|6&Yo_ zC}6^!bJV0+HjJ*{JYR&#Z*|_3n9Gjc^&c{~A`B-xFF%?opD$(rUJDQ!$BoFM0t$Ri z>6zf0WDp&dfx+{iOxdanb~zsF8-#iFa6|al-|0Yby4qTXFUTe8Xu9lc{L<_!v(Y+P z{K=5i$;(~jsj(wGGd-OuOrdDNlET23RWIcR+{h(XBZly9<}G(;$IBoQx5Xq}|I@nJ zVp{raYwFm{>yg=Rr%{#L@X(Xon;Mv$mTvvv6^HAa6Lrp( zoD;g^C;Ljy!*UU)vB1R zydd*`vsjxxOqAIT_P_F>r(gL=*`zCE>@8x7GxlFKRY!twuV}Gp5@RXKD&&Q?t0sRo z(+ec7V$>rNFd7jSkJP~Pn8=JqTBFjAW}HPv*;^L&+AXs=j2U-yJmVPC;cd>S(7571 zS$6j-;9|^v{#Pd-CWSv=Pv*)7UU8-{|I)rW#DK4Py5FB(<<%`zj6cPzxzVB|Z9zxP_HQ|4#8gvE#Ye zJ80dU-}OJ|)O+@x6iWB@_l9uK_k0pI4~E=2Ohkk$7&Q z&-@GTUJt&d7^Z8!rF{d=*7r*JqVqqZ)@y)7MgSJzZ+M7ZwICNQdaaClOmw|jM>+Ug z#8%&GqMh~jzt02iRT=QULcSXk&DQx84*P|V%=fb8=O~HiW71t4qT?E>?@%{eG>s{m zn(~aOggyc$s*Q@uZMbU$S&${$%24SJ^kv|HMPd*Gx*;3BC(4_CYIljhDRoA4Ib0zY zAE226b6qznck8366ZhQ{_%ZQ;3(@Q6(`;`Ab&0EJZb-;%08+10G|}>c>N)=%4xiiq zmV%#28gQpXJ+N~g`LX`0i{yAAz5Vjkk^ROaU^RahBnu6aonooQh^Or(SKC%Wq_WZ9 zJd?$jQRJ|bet!&=DR()v9{%0Y$$|Yw#h2@?Z>zdjVcIhcRB zV3u33fA$_7NAq{_G^qrbtKng=^yd_t;bs4k=_c(-IZ}Oq*j&9#_O{4Zdt1(`%B@iL z44(FoJc=?o0Q0Iio2E93H)3tLZ*w|7AP|CjAMLyb8il7i{%5o}KC2z}9LQ>3U+rPh zlD*ABUQXmlNAmrru9MNmqY2P^P@CPEjGWBAeWs)X{Oqs$J0(BQp2U|%_VkhWD(Se? z{_4vfqK#*ILMzjlj7{H_)%JQmUPNZy0*|ig9>Q-T1R$A^9a@B^E#@ru zp>u!{!wqlX9MN7R{(ftop%-82{3i&nO?e}jQLnC0?!8z=;jGS&)YyiPEgQ|hq%YIT z+f5OkZ%qKN+WCeA6=*;*l#-t+C7PBwv??0=XqB+aZs^(fy4(69S->VG2DdTi(B$F~ zufE&3yp)8NuKQxdR*L%Byj0bEuk>A~Di1kCXm?pczipvn6EC+y#2oIIll3R`A6Po= zpeNpw*TzFp=X1(id#mBtWvixQt7j6yEs3$TQd-E)Il#1P4}BROEm>Xzgd#DYwS$zT zLRXr1UI4wE+g?+3Gb;ZwOjNBugB8iv&nd4L;{J2Oj5)*9Wjd+aUe1*U;mvQX$OTyV zRA9wC05cbdV}5w`Wm!IwD6x55wv8i0z`Q`u_Xh_&YV7WE-%w2&<6^U|)1v!C1m`96 zSdQO8;W50cdiz11x+Vgh)a0WC99EDpgxjYR%;bNTd-}edR}VHQD~U|*HsItnE>hi= zbBn$?|Iy|?6YWF3o_)%4s=%9L?=+`2E5=YIK)=)idHd=5#M$UV$Ll(|-tY9nGIa}| zDXS1=-Q`v;Gg6UcNuQV^amSl43fLHdT7%xV8eFucyM|151~)~Yrg`I|16@KxEaWv~ z&LMYmPzz+4)v)^QP5eTUwz(atlNcHZwE?VX(K#PfokdSF@Ka=auD3eDNB~^2iPs45 ztAKY;lza+E%^ej9`rv1{q7)Sm8d4VZVKAFMaH?V3DMO9@In0(=3}k%y9GELLsCt85 z@W@-Ql1-vjzHUq?TrxnPJduuC0S!i3sV8}V?TAnu*?W%*cZ$O}gyx8Rob?cbBiGP5 zvXmT?)~srjCOZbRM@<1VsGvoJBag}hZmTOS^)w@SyZP zx|W^w6q|)=j)s2{y5p<0I9a)3^cO!E57gArA#kgy84M^ijKo!u9Ic>ornN2n@`l9u zv%zK@fWMYKP;2gN*uQ15NUXhl13rhcq#a)$O&*HsO)PGenTd#W2A2>ftv-yA=N|6iJ$?DipOVYgMJ6EH_J2Y+dnfGXqoK(YcR;vIlRsRZ$ zR(GC#t7&(I!pqS>D>VI@blYT4z}RGQgWSa?!v8i5+ThSmXu4xv3JfgZ%`m@hd$ji} z(fcoR=RbH|094!eM?E5poaiqL)mVU=V%IFo7_ptUzIU=G-lLIYT*M`DO>Ob^p{d@m zFj-_7Kj&`Wiak{#7GH+aY85h^KW^14jNIes7hw*NkF2&wC&X#42h$)C#W>Nr?Jnf; zW7La-DJv6^dq9c=8f~HJpyl3-wH)L!@^`WzN<8P^voKh4rbX)rrna!b65f0lGH;pO zx60zC%BEd^tGe~mLz@96zq&AXujUJR_Wcg%@ z>y_iyC%=2@6Dqr~r#PBHnHG#QCXo&BMJP@6~-(`I>~D_@0C&2|qJGs8A! zqNR+K7k3_08Ny|Pgy8<);kvk6iBRC3YT)nI-&k(WmJ1XFf@&YvUj>>3E1Ai;`iKVo zN#ufA4>QB$jgjTW`s1svien$fYl){lVb$9%PC6K;saGo5^Cht&)-eN_H5BHqizp(o zH=X^>;bYRr$bx6pXI@Wm)<#Vz!+7G6b-=FIz&<$)J#w6^$0iCNwM=!s5|Z(wiFot3 z|64ren0iJ0`Y}pnGl3@frT!>fbghOuar!66_~!NV1nH~B<4+ekeiJg*f(Jap9V2b- z?fR^I^ASC!){OAdgvYMVft}u&*iGeuX+#mncKr(bd19I(b)w8q0X&4R;rmAVSGlOJ z=dOJSHLlz9gM0248A~!e{=6h{x6^mLD@@Z_q?~&Z(I(Q<_Gjafyd<{s%G+U$+BUU= zEkB^>4h23Q&Q~TTaftoP4veN%Cd0NO6&Y@gfOk2m|Iy={kk=h~Zy0_nSeatQ6Iw_M zOq(hnyf1u^od}Mh~W)-+-;=xeOS~e!j*agt- zc96GCx($L%68o9!iXyOC!7Nr(w{rlnitp53HBhYw>W{-p^1l9hkDH)JGlymSu{|nKmBv66S_2Lpa8M3XQ9e)?}b2*-ljB z@DJ$OzHQvcqv)f3w%Glg-`Lt?X&z&5%rNsMz{EPW4))mEh%!^c@S@gnb~BUL_}0aP zH$q>&L4%w>W}2H=i`!+y<*N0Biyte1vVu9E`v=uNaDBoGH}DA%H#&83hpa^2Q#`5K zT1xKHZNf-9aqI#XfTL7detG<~dbQorgyZ2q0PRq2(tY0gaf6x1XLY-JB3dZ;WY+fW zr}`Mk7`XOR0?qIRN_Y^{?H&<>%^$2~JMtt+Jzy?jgG}uOD~yRzEW4~|C?3(ualEm>-{A_4(LGyiFN}1CVu1ufV8Z}?)Zz$ncSh@1!iZ&Q$EIyi z&0qVRnR=eFJ_cKIb>rcf+LEV&vL2ZLpn7YuZG?l3dwxpLnL4;e<4HtmVZVZJwi^q$1K&CJU!@E^nQ!%X_7?rGZQMni^lPV<6yiOt3&2Qg$G z79yf%NcEZZ>Mzd=qIBPvkfg%GzoO!0ohTa<%V>OutuiH#Rt+_fit2Eoi%OG^{o@G488sc^z04K19EPmuZ-Aw*%2 z^9v(6__L2q$j$-W17pXsY~=|FGd%+_=89ujwoxetRSip#e-df`-B55VcVwM5PgbvCdaB`! zr4%!U&J7lvazmlcHa{4+nOFs`wkL8T9j8olxQkMLu9q|cUW~dHYxXxi)JrKQCiTi2 z(flBI%qEzsq7@nWZPj>ItbW+?c)_$6zN{)gZYeJ*E`c>^l<1W(S)b`?eT<^5LNqlSB0yJo_BaeRv$E{@aIO;Pubrul#rLkcs0Vcxz(xEXxBM&L5qEX z38QQLC{DEI5K=Fl#gFGcMnjIrlO1Dm7A#>Tmq6--P4eFAQDv=yswwqUS2*fo-n1&P zeYiSt|NMIbK0n?4HC}Cye|&w}VDqNnT6i-{?Sg$?<5B``^p;i5ME^`(IjT*6{MEhj z$@1(5mdndFYUK1pF-{MCZXP$#!Zy&Oenh-Wvr7K35vUt5$y%uw|5n!el|Bv4K!zoE zy(wmCN5^fxy8PzC7t*&oALHg+)Y=_-ZmP`D(IG-7FVkTn3|lBi+q0Od6CCiSlM6mE z2rKH!^@jV7#|~(H&9yV%i8bj*=8lyWJ^rHTQ0zjl)q5|iFPL9vjvp=>whp3mV~tdV zu=L1A1Pn@fcIL@-F2l74RABRyJ zUeEd6kc-8v+f!@R&hTg4JI=W@ynuxG|B&RLqnHt+c!>h!e;~n zISbsY__Rjk2PMghsEAF)BK`vyC^s-G#YJFs?N}y8ndbGXZof2 zP)&2(8a(6ENQSu!=Sr@u^4ORs+38^s=vZ@docq<8AWC{GL)dOb?fCAmuuq7?5Aw7v z*?Q=Jd|0I-HzQ2=BM^TJEUhd)-y9*g@gE5=PEpRXmX|jZa>xAj${lEi@QSGJ^CojW z_pcChQ2!7)!;`Hzh9*xgaUdL-T265-h1hQdE$NMxSPX4yxms4LBV{-~Mt>5fubTAd zx9HOsa9Jh50{Rl2*AJ!Sd z3<{<>`}L_hyXWk}tn-wnwWMJKN=!{3{$=9NM=C!u5fEK-k}=t#Q!9nDxe;QI6UmH7KY> zZVB7I=!%U#p1}noh9DWAM5v!P%peA5zKZI_|F+BSUb7U!*}0tMnNwV>cJ9F z$_&?VrE!0^#W21t?8HHf+Use2d}LQaK)%h`{Fmv6oKji^`lH(D^=h35C`WIH2vd>N zy`iFyG))L*{RZkn?T9DTr)!a(?7LOOyYV? zCJTnXg|P~H|Ild^r84AgiA=6?M0jp7;ThX0ru%h}NIG!n;ynzbMI!*rC}%K(64Mln zSGslH`wlc(Vt>di#L`2d;vq?ir}CTX(7(|y`d#$m>xWlg8-VJ`&NhVA>&xey_nH~u zcRRcf7>2wVE8jbt<}6``A4$VVf6ejuh?~p$j&RwvOzop7KMgBYfm*U{s|l{&7mx@d z8$o?mm5g>5JX2#g15k`|@W{PcByD@B9Nr`4zR0@PbBm?4!<$Nrg?4($t-^pON$)P` z!P7Z8(2DL1$fO|MwCl)pnOj9V3=SiA;vbNULMi)sZ;*Gp#ZmC5qd*nbP?Q7SF9j_f z`yV6Pf6w{6nbgq@=nPclT3Uw$)U&~{1H|ZTghM?V2JPAc-#3Q|T*UH6mUrnVqP7qH zGH=;3R|MZ-1et2;nKqF$;Lc^Fhv-y~9s^TaK-A|(9j=P#mcrc*52n%nY!*hRUA|@6 zuO^EtF@=6?6|#~m>7-7v8dSpNQ6nEo!qN#+c9cv-MM*DaF)p&xCg`DvM19S`>g;$K zpNgp@D|NQmwyy&L0j*7)B(HdHK=Bg~&onJ$KfaBcp`>cwk zGCYMr&9M?y4)ctBhDV`irObo9gAa{Od@bhP#5EQUSMI!iXdbqvo9ZrVx)tp=*)sAz zh;87-@OK6WC~_W6S{Kmv>qP~`h6BTwU4RgF=|4PHPv*ap5weg~!3ZU_}QcSrJ1btc~ z>|E|ZLq8Tr(k1ivy3&q_D3sI;L<;;Q(#io!&4AAxpzjIza9%Ue{NJztw;KN^4FA8; dg{~_mbE5AMmM%XyjP`n8-l%B2gejSS{(tGQ1k?Zk literal 0 HcmV?d00001 diff --git a/tests/acceptance/filesForUpload/textfile.txt b/tests/acceptance/filesForUpload/textfile.txt new file mode 100644 index 000000000..efffdeff1 --- /dev/null +++ b/tests/acceptance/filesForUpload/textfile.txt @@ -0,0 +1,3 @@ +This is a testfile. + +Cheers. \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/zerobyte.txt b/tests/acceptance/filesForUpload/zerobyte.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/acceptance/filesForUpload/zzzz-must-be-last-file-in-folder.txt b/tests/acceptance/filesForUpload/zzzz-must-be-last-file-in-folder.txt new file mode 100644 index 000000000..dcc19d17f --- /dev/null +++ b/tests/acceptance/filesForUpload/zzzz-must-be-last-file-in-folder.txt @@ -0,0 +1,11 @@ +This must be the last skeleton file in this folder, when sorted alphabetically. +Tests for performing actions on the last file in the folder try to find and use +this file name. + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/filesForUpload/zzzz-zzzz-will-be-at-the-end-of-the-folder-when-uploaded.txt b/tests/acceptance/filesForUpload/zzzz-zzzz-will-be-at-the-end-of-the-folder-when-uploaded.txt new file mode 100644 index 000000000..1db21f1fb --- /dev/null +++ b/tests/acceptance/filesForUpload/zzzz-zzzz-will-be-at-the-end-of-the-folder-when-uploaded.txt @@ -0,0 +1,11 @@ +This file has a name so it would be the last after the upload, when sorted alphabetically. +Tests for performing actions on the last file in the folder try to find and use +this file name. + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, +totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae +dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, +sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est +qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora +incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum +exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? \ No newline at end of file diff --git a/tests/acceptance/lint-expected-failures.sh b/tests/acceptance/lint-expected-failures.sh new file mode 100755 index 000000000..a1f31ce4d --- /dev/null +++ b/tests/acceptance/lint-expected-failures.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +log_error() { + echo -e "\e[31m$1\e[0m" +} + +log_info() { + echo -e "\e[34m$1\e[0m" +} + +log_success() { + echo -e "\e[32m$1\e[0m" +} + +declare -A scenarioLines + +if [ -n "${EXPECTED_FAILURES_FILE}" ] +then + if [ -f "${EXPECTED_FAILURES_FILE}" ] + then + log_info "Checking expected failures in ${EXPECTED_FAILURES_FILE}" + else + log_error "Expected failures file ${EXPECTED_FAILURES_FILE} not found" + log_error "Check the setting of EXPECTED_FAILURES_FILE environment variable" + exit 1 + fi + FINAL_EXIT_STATUS=0 + # If the last line of the expected-failures file ends without a newline character + # then that line may not get processed by some of the bash code in this script + # So check that the last character in the file is a newline + if [ "$(tail -c1 "${EXPECTED_FAILURES_FILE}" | wc -l)" -eq 0 ] + then + log_error "Expected failures file ${EXPECTED_FAILURES_FILE} must end with a newline" + log_error "Put a newline at the end of the last line and try again" + FINAL_EXIT_STATUS=1 + fi + # Check the expected-failures file to ensure that the lines are self-consistent + # In most cases the features that are being run are in owncloud/core, + # so assume that by default. + FEATURE_FILE_REPO="owncloud/core" + FEATURE_FILE_PATH="tests/acceptance/features" + LINE_NUMBER=0 + while read -r INPUT_LINE + do + LINE_NUMBER=$(("$LINE_NUMBER" + 1)) + + # Ignore comment lines (starting with hash) + if [[ "${INPUT_LINE}" =~ ^# ]] + then + continue + fi + # A line of text in the feature file can be used to indicate that the + # features being run are actually from some other repo. For example: + # "The expected failures in this file are from features in the owncloud/ocis repo." + # Write a line near the top of the expected-failures file to "declare" this, + # overriding the default "owncloud/core" + FEATURE_FILE_SPEC_LINE_FOUND="false" + if [[ "${INPUT_LINE}" =~ features[[:blank:]]in[[:blank:]]the[[:blank:]]([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)[[:blank:]]repo ]]; then + FEATURE_FILE_REPO="${BASH_REMATCH[1]}" + log_info "Features are expected to be in the ${FEATURE_FILE_REPO} repo\n" + FEATURE_FILE_SPEC_LINE_FOUND="true" + fi + if [[ "${INPUT_LINE}" =~ repo[[:blank:]]in[[:blank:]]the[[:blank:]]([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)[[:blank:]]folder[[:blank:]]tree ]]; then + FEATURE_FILE_PATH="${BASH_REMATCH[1]}" + log_info "Features are expected to be in the ${FEATURE_FILE_PATH} folder tree\n" + FEATURE_FILE_SPEC_LINE_FOUND="true" + fi + if [[ $FEATURE_FILE_SPEC_LINE_FOUND == "true" ]]; then + continue + fi + # Match lines that have "- [someSuite/someName.feature:n]" pattern on start + # the part inside the brackets is the suite, feature and line number of the expected failure. + if [[ "${INPUT_LINE}" =~ ^-[[:space:]]\[([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\.feature:[0-9]+)] ]]; then + SUITE_SCENARIO_LINE="${BASH_REMATCH[1]}" + elif [[ + # report for lines like: " - someSuite/someName.feature:n" + "${INPUT_LINE}" =~ ^[[:space:]]*-[[:space:]][a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\.feature:[0-9]+[[:space:]]*$ || + # report for lines starting with: "[someSuite/someName.feature:n]" + "${INPUT_LINE}" =~ ^[[:space:]]*\[([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\.feature:[0-9]+)] + ]]; then + log_error "> Line ${LINE_NUMBER}: Not in the correct format." + log_error " + Actual Line : '${INPUT_LINE}'" + log_error " - Expected Format : '- [suite/scenario.feature:line_number](scenario_line_url)'" + FINAL_EXIT_STATUS=1 + continue + else + # otherwise, ignore the line + continue + fi + # Find the link in round-brackets that should be after the SUITE_SCENARIO_LINE + if [[ "${INPUT_LINE}" =~ \(([a-zA-Z0-9:/.#_-]+)\) ]]; then + ACTUAL_LINK="${BASH_REMATCH[1]}" + else + log_error "Line ${LINE_NUMBER}: ${INPUT_LINE} : Link is empty" + FINAL_EXIT_STATUS=1 + continue + fi + if [[ -n "${scenarioLines[${SUITE_SCENARIO_LINE}]:-}" ]]; + then + log_error "> Line ${LINE_NUMBER}: Scenario line ${SUITE_SCENARIO_LINE} is duplicated" + FINAL_EXIT_STATUS=1 + fi + scenarioLines[${SUITE_SCENARIO_LINE}]="exists" + OLD_IFS=${IFS} + IFS=':' + read -ra FEATURE_PARTS <<< "${SUITE_SCENARIO_LINE}" + IFS=${OLD_IFS} + SUITE_FEATURE="${FEATURE_PARTS[0]}" + FEATURE_LINE="${FEATURE_PARTS[1]}" + EXPECTED_LINK="https://github.com/${FEATURE_FILE_REPO}/blob/master/${FEATURE_FILE_PATH}/${SUITE_FEATURE}#L${FEATURE_LINE}" + if [[ "${ACTUAL_LINK}" != "${EXPECTED_LINK}" ]]; then + log_error "> Line ${LINE_NUMBER}: Link is not correct for ${SUITE_SCENARIO_LINE}" + log_error " + Actual link : ${ACTUAL_LINK}" + log_error " - Expected link : ${EXPECTED_LINK}" + FINAL_EXIT_STATUS=1 + fi + + done < "${EXPECTED_FAILURES_FILE}" +else + log_error "Environment variable EXPECTED_FAILURES_FILE must be defined to be the file to check" + exit 1 +fi + +if [ ${FINAL_EXIT_STATUS} == 1 ] +then + log_error "\nErrors were found in the expected failures file - see the messages above!" +else + log_success "\nNo problems were found in the expected failures file." +fi +exit ${FINAL_EXIT_STATUS} diff --git a/tests/acceptance/run.sh b/tests/acceptance/run.sh new file mode 100755 index 000000000..cd9180ef3 --- /dev/null +++ b/tests/acceptance/run.sh @@ -0,0 +1,1376 @@ +#!/usr/bin/env bash +[[ "${DEBUG}" == "true" ]] && set -x + +# from http://stackoverflow.com/a/630387 +SCRIPT_PATH="`dirname \"$0\"`" # relative +SCRIPT_PATH="`( cd \"${SCRIPT_PATH}\" && pwd )`" # absolutized and normalized + +echo 'Script path: '${SCRIPT_PATH} + +OC_PATH=${SCRIPT_PATH}/../../ +OCC=${OC_PATH}occ + +# Allow optionally passing in the path to the behat program. +# This gives flexibility for callers that have installed their own behat +if [ -z "${BEHAT_BIN}" ] +then + BEHAT=${OC_PATH}vendor-bin/behat/vendor/bin/behat +else + BEHAT=${BEHAT_BIN} +fi +BEHAT_TAGS_OPTION_FOUND=false + +if [ -n "${STEP_THROUGH}" ] +then + STEP_THROUGH_OPTION="--step-through" +fi + +# The following environment variables can be specified: +# +# ACCEPTANCE_TEST_TYPE - see "--type" description +# BEHAT_FEATURE - see "--feature" description +# BEHAT_FILTER_TAGS - see "--tags" description +# BEHAT_SUITE - see "--suite" description +# BEHAT_YML - see "--config" description +# BROWSER - see "--browser" description +# NORERUN - see "--norerun" description +# RERUN_FAILED_WEBUI_SCENARIOS - opposite of NORERUN +# RUN_PART and DIVIDE_INTO_NUM_PARTS - see "--part" description +# SHOW_OC_LOGS - see "--show-oc-logs" description +# TESTING_REMOTE_SYSTEM - see "--remote" description +# EXPECTED_FAILURES_FILE - a file that contains a list of the scenarios that are expected to fail + +if [ -n "${EXPECTED_FAILURES_FILE}" ] +then + # Check the expected-failures file + ${SCRIPT_PATH}/lint-expected-failures.sh + LINT_STATUS=$? + if [ ${LINT_STATUS} -ne 0 ] + then + echo "Error: expected failures file ${EXPECTED_FAILURES_FILE} is invalid" + exit ${LINT_STATUS} + fi +fi + +# Default to testing a local system +if [ -z "${TESTING_REMOTE_SYSTEM}" ] +then + TESTING_REMOTE_SYSTEM=false +fi + +# Default to not show ownCloud logs +if [ -z "${SHOW_OC_LOGS}" ] +then + SHOW_OC_LOGS=false +fi + +# Default to re-run failed webUI scenarios +if [ -z "${RERUN_FAILED_WEBUI_SCENARIOS}" ] +then + RERUN_FAILED_WEBUI_SCENARIOS=true +fi + +# Allow callers to specify NORERUN=true as an environment variable +if [ "${NORERUN}" = true ] +then + RERUN_FAILED_WEBUI_SCENARIOS=false +fi + +# Default to API tests +# Note: if a specific feature or suite is also specified, then the acceptance +# test type is deduced from the suite name, and this environment variable +# ACCEPTANCE_TEST_TYPE is overridden. +if [ -z "${ACCEPTANCE_TEST_TYPE}" ] +then + ACCEPTANCE_TEST_TYPE="api" +fi + +# Look for command line options for: +# -c or --config - specify a behat.yml to use +# --feature - specify a single feature to run +# --suite - specify a single suite to run +# --type - api, cli or webui - if no individual feature or suite is specified, then +# specify the type of acceptance tests to run. Default api. +# --tags - specify tags for scenarios to run (or not) +# --browser - for webUI tests, which browser to use. "chrome", "firefox", +# "internet explorer" and "MicrosoftEdge" are possible. +# --remote - the server under test is remote, so we cannot locally enable the +# testing app. We have to assume it is already enabled. +# --show-oc-logs - tail the ownCloud log after the test run +# --norerun - do not rerun failed webUI scenarios +# --loop - loop tests for given number of times. Only use it for debugging purposes +# --part - run a subset of scenarios, need two numbers. +# first number: which part to run +# second number: in how many parts to divide the set of scenarios +# --step-through - pause after each test step + +# Command line options processed here will override environment variables that +# might have been set by the caller, or in the code above. +while [[ $# -gt 0 ]] +do + key="$1" + case ${key} in + -c|--config) + BEHAT_YML="$2" + shift + ;; + --feature) + BEHAT_FEATURE="$2" + shift + ;; + --suite) + BEHAT_SUITE="$2" + shift + ;; + --loop) + BEHAT_RERUN_TIMES="$2" + shift + ;; + --type) + # Lowercase the parameter value, so the user can provide "API", "CLI", "webUI" etc + ACCEPTANCE_TEST_TYPE="${2,,}" + shift + ;; + --tags) + BEHAT_FILTER_TAGS="$2" + BEHAT_TAGS_OPTION_FOUND=true + shift + ;; + --browser) + BROWSER="$2" + shift + ;; + --part) + RUN_PART="$2" + DIVIDE_INTO_NUM_PARTS="$3" + if [ ${RUN_PART} -gt ${DIVIDE_INTO_NUM_PARTS} ] + then + echo "cannot run part ${RUN_PART} of ${DIVIDE_INTO_NUM_PARTS}" + exit 1 + fi + shift 2 + ;; + --remote) + TESTING_REMOTE_SYSTEM=true + ;; + --show-oc-logs) + SHOW_OC_LOGS=true + ;; + --norerun) + RERUN_FAILED_WEBUI_SCENARIOS=false + ;; + --step-through) + STEP_THROUGH_OPTION="--step-through" + ;; + *) + # A "random" parameter is presumed to be a feature file to run. + # Typically that will be specified at the end, or as the only + # parameter. + BEHAT_FEATURE="$1" + ;; + esac + shift +done + +# Save the current language and set the language to "C" +# We want to have it all in english to be able to parse outputs +OLD_LANG=${LANG} +export LANG=C + +# @param $1 admin authentication string username:password +# @param $2 occ url +# @param $3 command +# sets $REMOTE_OCC_STDOUT and $REMOTE_OCC_STDERR from returned xml data +# @return occ return code given in the xml data +function remote_occ() { + if [ "${TEST_OCIS}" == "true" ] || [ "${TEST_REVA}" == "true" ] + then + return 0 + fi + COMMAND=`echo $3 | xargs` + CURL_OCC_RESULT=`curl -k -s -u $1 $2 -d "command=${COMMAND}"` + # xargs is (miss)used to trim the output + RETURN=`echo ${CURL_OCC_RESULT} | xmllint --xpath "string(ocs/data/code)" - | xargs` + # We could not find a proper return of the testing app, so something went wrong + if [ -z "${RETURN}" ] + then + RETURN=1 + REMOTE_OCC_STDERR=${CURL_OCC_RESULT} + else + REMOTE_OCC_STDOUT=`echo ${CURL_OCC_RESULT} | xmllint --xpath "string(ocs/data/stdOut)" - | xargs` + REMOTE_OCC_STDERR=`echo ${CURL_OCC_RESULT} | xmllint --xpath "string(ocs/data/stdErr)" - | xargs` + fi + return ${RETURN} +} + +# @param $1 admin authentication string username:password +# @param $2 occ url (without /bulk appendix) +# @param $3 commands +# exists with 1 and sets $REMOTE_OCC_STDERR if any of the occ commands returned a non-zero code +function remote_bulk_occ() { + if [ "${TEST_OCIS}" == "true" ] || [ "${TEST_REVA}" == "true" ] + then + return 0 + fi + CURL_OCC_RESULT=`curl -k -s -u $1 $2/bulk -d "${3}"` + COUNT_RESULTS=`echo ${CURL_OCC_RESULT} | xmllint --xpath "ocs/data/element/code" - | wc -l` + + RETURN=0 + REMOTE_OCC_STDERR="" + for ((n=1;n<=${COUNT_RESULTS};n++)) + do + EXIT_CODE=`echo ${CURL_OCC_RESULT} | xmllint --xpath "string((ocs/data/element/code)[${n}])" -` + if [ ${EXIT_CODE} -ne 0 ] + then + REMOTE_OCC_STDERR+=`echo ${CURL_OCC_RESULT} | xmllint --xpath "string((ocs/data/element/stdErr)[${n}])" - | xargs` + REMOTE_OCC_STDERR+="\n" + RETURN=1 + fi + + done + return ${RETURN} +} + +# @param $1 admin authentication string username:password +# @param $2 occ url +# @param $3 directory to create, relative to the server's root +# sets $REMOTE_OCC_STDOUT and $REMOTE_OCC_STDERR from returned xml data +# @return return code given in the xml data +function remote_dir() { + COMMAND=`echo $3 | xargs` + CURL_OCC_RESULT=`curl -k -s -u $1 $2 -d "dir=${COMMAND}"` + # xargs is (miss)used to trim the output + HTTP_STATUS=`echo ${CURL_OCC_RESULT} | xmllint --xpath "string(ocs/meta/statuscode)" - | xargs` + # We could not find a proper return of the testing app, so something went wrong + if [ -z "${HTTP_STATUS}" ] + then + RETURN=1 + REMOTE_OCC_STDERR=${CURL_OCC_RESULT} + else + if [ "${HTTP_STATUS}" = 200 ] + then + RETURN=0 + else + RETURN=1 + REMOTE_OCC_STDERR=${CURL_OCC_RESULT} + fi + fi + return ${RETURN} +} + +# $1 admin auth as needed for curl +# $2 full URL of the occ command in the testing app +# $3 system|app +# $4 app-name if app used +# $5 setting name +# $6 type of the variable to set it back correctly (optional) +# adds a new element to the array PREVIOUS_SETTINGS +# PREVIOUS_SETTINGS will be an array of strings containing: +# URL;system|app;app-name;setting-name;value;variable-type +function save_config_setting() { + remote_occ $1 $2 "--no-warnings config:$3:get $4 $5" + PREVIOUS_SETTINGS+=("$2;$3;$4;$5;${REMOTE_OCC_STDOUT};$6") +} + +declare -a PREVIOUS_SETTINGS + +# get the sub path from an URL +# $1 the full URL including the protocol +# echos the path without trailing slash +function get_path_from_url() { + PROTOCOL="$(echo $1 | grep :// | sed -e's,^\(.*://\).*,\1,g')" + URL="$(echo ${1/$PROTOCOL/})" + PATH="$(echo ${URL} | grep / | cut -d/ -f2-)" + echo ${PATH%/} +} + +# check that server is up and working correctly +# $1 the full URL including the protocol +# exits if server is not working correctly (by pinging status.php), otherwise returns +function assert_server_up() { + curl -k -sSf -L $1/status.php -o /dev/null + if [[ $? -eq 0 ]] + then + return + else + echo >&2 "Server on $1 is down or not working correctly." + exit 98 + fi +} + +# check that testing app is installed +# $1 the full URL including the protocol +# if testing app is not installed, this returns 500 status code +# we are not sending request with auth, so if it is installed 401 Not Authorized is returned. +# anyway, if it's not 500, assume testing app is installed and move on. +# Otherwise, print to stderr and exit. +function assert_testing_app_enabled() { + CURL_RESULT=`curl -s $1/ocs/v2.php/apps/testing/api/v1/app/testing -o /dev/null -w "%{http_code}"` + if [[ ${CURL_RESULT} -eq 500 ]] + then + echo >&2 "Testing app is not enabled on the server on $1." + echo >&2 "Please install and enable it to run the tests." + exit 98 + else + return + fi +} + +# check if certain apache_module is enabled +# $1 admin authentication string username:password +# $2 the full url to the testing app on the server to check +# $3 Module to check for +# $4 text description of the server being checked, e.g. "local", "remote" +# return 0 if given module is enabled, else return with 1 +function check_apache_module_enabled() { + if [ "${TEST_OCIS}" == "true" ] || [ "${TEST_REVA}" == "true" ] + then + return 0 + fi + # test if mod_rewrite is enabled + CURL_RESULT=`curl -k -s -u $1 $2apache_modules/$3` + STATUS_CODE=`echo ${CURL_RESULT} | xmllint --xpath "string(ocs/meta/statuscode)" -` + if [[ ${STATUS_CODE} -ne 200 ]] + then + echo -n "Could not reliably determine if '$3' module is enabled on the $4 server, because " + echo ${CURL_RESULT} | xmllint --xpath "string(ocs/meta/message)" - + echo "" + return 1 + fi + return 0 +} + +# Provide a default admin username and password. +# But let the caller pass them if they wish +if [ -z "${ADMIN_USERNAME}" ] +then + ADMIN_USERNAME="admin" +fi + +if [ -z "${ADMIN_PASSWORD}" ] +then + ADMIN_PASSWORD="admin" +fi + +ADMIN_AUTH="${ADMIN_USERNAME}:${ADMIN_PASSWORD}" + +export ADMIN_USERNAME +export ADMIN_PASSWORD + +if [ -z "${BEHAT_RERUN_TIMES}" ] +then + BEHAT_RERUN_TIMES=1 +fi + +function env_alt_home_enable { + remote_occ ${ADMIN_AUTH} ${OCC_URL} "config:app:set testing enable_alt_user_backend --value yes" +} + +function env_alt_home_clear { + remote_occ ${ADMIN_AUTH} ${OCC_URL} "app:disable testing" || { echo "Unable to disable testing app" >&2; exit 1; } +} + +# expected variables +# -------------------- +# $SUITE_FEATURE_TEXT - human readable which test to run +# $BEHAT_SUITE_OPTION - suite setting with "--suite" or empty if all suites have to be run +# $BEHAT_FEATURE - feature file, or empty +# $BEHAT_FILTER_TAGS - list of tags +# $BEHAT_TAGS_OPTION_FOUND +# $BROWSER_TEXT +# $BROWSER_VERSION_TEXT +# $PLATFORM_TEXT +# $TEST_LOG_FILE +# $BEHAT - behat executable +# $BEHAT_YML +# $RUNNING_WEBUI_TESTS +# $RERUN_FAILED_WEBUI_SCENARIOS +# +# set arrays +# --------------- +# $UNEXPECTED_FAILED_SCENARIOS array of scenarios that failed unexpectedly +# $UNEXPECTED_PASSED_SCENARIOS array of scenarios that passed unexpectedly (while running with expected-failures.txt) + +declare -a UNEXPECTED_FAILED_SCENARIOS +declare -a UNEXPECTED_PASSED_SCENARIOS +declare -a UNEXPECTED_BEHAT_EXIT_STATUSES + +function run_behat_tests() { + echo "Running ${SUITE_FEATURE_TEXT} tests tagged ${BEHAT_FILTER_TAGS} ${BROWSER_TEXT}${BROWSER_VERSION_TEXT}${PLATFORM_TEXT}" | tee ${TEST_LOG_FILE} + + if [ "${REPLACE_USERNAMES}" == "true" ] + then + echo "Usernames and attributes in tests are being replaced:" + cat ${SCRIPT_PATH}/usernames.json + fi + + ${BEHAT} --colors --strict ${STEP_THROUGH_OPTION} -c ${BEHAT_YML} -f pretty ${BEHAT_SUITE_OPTION} --tags ${BEHAT_FILTER_TAGS} ${BEHAT_FEATURE} -v 2>&1 | tee -a ${TEST_LOG_FILE} + + BEHAT_EXIT_STATUS=${PIPESTATUS[0]} + + # remove nullbytes from the test log + TEMP_CONTENT=$(tr < ${TEST_LOG_FILE} -d '\000') + OLD_IFS="${IFS}" + IFS="" + echo ${TEMP_CONTENT} > ${TEST_LOG_FILE} + IFS="${OLD_IFS}" + + # Find the count of scenarios that passed + SCENARIO_RESULTS_COLORED=`grep -Ea '^[0-9]+[[:space:]]scenario(|s)[[:space:]]\(' ${TEST_LOG_FILE}` + SCENARIO_RESULTS=$(echo "${SCENARIO_RESULTS_COLORED}" | sed "s/\x1b[^m]*m//g") + if [ ${BEHAT_EXIT_STATUS} -eq 0 ] + then + # They (SCENARIO_RESULTS) all passed, so just get the first number. + # The text looks like "1 scenario (1 passed)" or "123 scenarios (123 passed)" + [[ ${SCENARIO_RESULTS} =~ ([0-9]+) ]] + SCENARIOS_THAT_PASSED=$((SCENARIOS_THAT_PASSED + BASH_REMATCH[1])) + else + # "Something went wrong" with the Behat run (non-zero exit status). + # If there were "ordinary" test fails, then we process that later. Maybe they are all "expected failures". + # But if there were steps in a feature file that are undefined, we want to fail immediately. + # So exit the tests and do not lint expected failures when undefined steps exist. + if [[ ${SCENARIO_RESULTS} == *"undefined"* ]] + then + echo -e "\033[31m Undefined steps: There were some undefined steps found." + exit 1 + fi + # If there were no scenarios in the requested suite or feature that match + # the requested combination of tags, then Behat exits with an error status + # and reports "No scenarios" in its output. + # This can happen, for example, when running core suites from an app and + # requesting some tag combination that does not happen frequently. Then + # sometimes there may not be any matching scenarios in one of the suites. + # In this case, consider the test has passed. + MATCHING_COUNT=`grep -ca '^No scenarios$' ${TEST_LOG_FILE}` + if [ ${MATCHING_COUNT} -eq 1 ] + then + echo "Information: no matching scenarios were found." + BEHAT_EXIT_STATUS=0 + else + # Find the count of scenarios that passed and failed + SCENARIO_RESULTS_COLORED=`grep -Ea '^[0-9]+[[:space:]]scenario(|s)[[:space:]]\(' ${TEST_LOG_FILE}` + SCENARIO_RESULTS=$(echo "${SCENARIO_RESULTS_COLORED}" | sed "s/\x1b[^m]*m//g") + if [[ ${SCENARIO_RESULTS} =~ [0-9]+[^0-9]+([0-9]+)[^0-9]+([0-9]+)[^0-9]+ ]] + then + # Some passed and some failed, we got the second and third numbers. + # The text looked like "15 scenarios (6 passed, 9 failed)" + SCENARIOS_THAT_PASSED=$((SCENARIOS_THAT_PASSED + BASH_REMATCH[1])) + SCENARIOS_THAT_FAILED=$((SCENARIOS_THAT_FAILED + BASH_REMATCH[2])) + elif [[ ${SCENARIO_RESULTS} =~ [0-9]+[^0-9]+([0-9]+)[^0-9]+ ]] + then + # All failed, we got the second number. + # The text looked like "4 scenarios (4 failed)" + SCENARIOS_THAT_FAILED=$((SCENARIOS_THAT_FAILED + BASH_REMATCH[1])) + fi + fi + fi + + FAILED_SCENARIO_PATHS_COLORED=`awk '/Failed scenarios:/',0 ${TEST_LOG_FILE} | grep -a feature` + # There will be some ANSI escape codes for color in the FEATURE_COLORED var. + # Strip them out so we can pass just the ordinary feature details to Behat. + # Thanks to https://en.wikipedia.org/wiki/Tee_(command) and + # https://stackoverflow.com/questions/23416278/how-to-strip-ansi-escape-sequences-from-a-variable + # for ideas. + FAILED_SCENARIO_PATHS=$(echo "${FAILED_SCENARIO_PATHS_COLORED}" | sed "s/\x1b[^m]*m//g") + + # If something else went wrong, and there were no failed scenarios, + # then the awk, grep, sed command sequence above ends up with an empty string. + # Unset FAILED_SCENARIO_PATHS to avoid later code thinking that there might be + # one failed scenario. + if [ -z "${FAILED_SCENARIO_PATHS}" ] + then + unset FAILED_SCENARIO_PATHS + fi + + if [ -n "${EXPECTED_FAILURES_FILE}" ] + then + if [ -n "${BEHAT_SUITE_TO_RUN}" ] + then + echo "Checking expected failures for suite ${BEHAT_SUITE_TO_RUN}" + else + echo "Checking expected failures" + fi + + # Check that every failed scenario is in the list of expected failures + for FAILED_SCENARIO_PATH in ${FAILED_SCENARIO_PATHS} + do + SUITE_PATH=`dirname ${FAILED_SCENARIO_PATH}` + SUITE=`basename ${SUITE_PATH}` + SCENARIO=`basename ${FAILED_SCENARIO_PATH}` + SUITE_SCENARIO="${SUITE}/${SCENARIO}" + grep "\[${SUITE_SCENARIO}\]" "${EXPECTED_FAILURES_FILE}" > /dev/null + if [ $? -ne 0 ] + then + echo "Error: Scenario ${SUITE_SCENARIO} failed but was not expected to fail." + UNEXPECTED_FAILED_SCENARIOS+=("${SUITE_SCENARIO}") + fi + done + + # Check that every scenario in the list of expected failures did fail + while read SUITE_SCENARIO + do + # Ignore comment lines (starting with hash) + if [[ "${SUITE_SCENARIO}" =~ ^# ]] + then + continue + fi + # Match lines that have [someSuite/someName.feature:n] - the part inside the + # brackets is the suite, feature and line number of the expected failure. + # Else ignore the line. + if [[ "${SUITE_SCENARIO}" =~ \[([a-zA-Z0-9-]+/[a-zA-Z0-9-]+\.feature:[0-9]+)] ]]; then + SUITE_SCENARIO="${BASH_REMATCH[1]}" + else + continue + fi + if [ -n "${BEHAT_SUITE_TO_RUN}" ] + then + # If the expected failure is not in the suite that is currently being run, + # then do not try and check that it failed. + REGEX_TO_MATCH="^${BEHAT_SUITE_TO_RUN}/" + if ! [[ "${SUITE_SCENARIO}" =~ ${REGEX_TO_MATCH} ]] + then + continue + fi + fi + + # look for the expected suite-scenario at the end of a line in the + # FAILED_SCENARIO_PATHS - for example looking for apiComments/comments.feature:9 + # we want to match lines like: + # tests/acceptance/features/apiComments/comments.feature:9 + # but not lines like:: + # tests/acceptance/features/apiComments/comments.feature:902 + echo "${FAILED_SCENARIO_PATHS}" | grep ${SUITE_SCENARIO}$ > /dev/null + if [ $? -ne 0 ] + then + echo "Info: Scenario ${SUITE_SCENARIO} was expected to fail but did not fail." + UNEXPECTED_PASSED_SCENARIOS+=("${SUITE_SCENARIO}") + fi + done < ${EXPECTED_FAILURES_FILE} + else + for FAILED_SCENARIO_PATH in ${FAILED_SCENARIO_PATHS} + do + SUITE_PATH=$(dirname "${FAILED_SCENARIO_PATH}") + SUITE=$(basename "${SUITE_PATH}") + SCENARIO=$(basename "${FAILED_SCENARIO_PATH}") + SUITE_SCENARIO="${SUITE}/${SCENARIO}" + UNEXPECTED_FAILED_SCENARIOS+=("${SUITE_SCENARIO}") + done + fi + + if [ ${BEHAT_EXIT_STATUS} -ne 0 ] && [ ${#FAILED_SCENARIO_PATHS[@]} -eq 0 ] + then + # Behat had some problem and there were no failed scenarios reported + # So the problem is something else. + # Possibly there were missing step definitions. Or Behat crashed badly, or... + UNEXPECTED_BEHAT_EXIT_STATUSES+=("${SUITE_FEATURE_TEXT} had behat exit status ${BEHAT_EXIT_STATUS}") + fi + + # With webUI tests, we try running failed tests again. + if [ ${#UNEXPECTED_FAILED_SCENARIOS[@]} -gt 0 ] && [ "${RUNNING_WEBUI_TESTS}" = true ] && [ "${RERUN_FAILED_WEBUI_SCENARIOS}" = true ] + then + echo "webUI test run failed with exit status: ${BEHAT_EXIT_STATUS}" + FAILED_SCENARIO_PATHS_COLORED=`awk '/Failed scenarios:/',0 ${TEST_LOG_FILE} | grep feature` + # There will be some ANSI escape codes for color in the FEATURE_COLORED var. + # Strip them out so we can pass just the ordinary feature details to Behat. + # Thanks to https://en.wikipedia.org/wiki/Tee_(command) and + # https://stackoverflow.com/questions/23416278/how-to-strip-ansi-escape-sequences-from-a-variable + # for ideas. + FAILED_SCENARIO_PATHS=$(echo "${FAILED_SCENARIO_PATHS_COLORED}" | sed "s/\x1b[^m]*m//g") + + # If something else went wrong, and there were no failed scenarios, + # then the awk, grep, sed command sequence above ends up with an empty string. + # Unset FAILED_SCENARIO_PATHS to avoid later code thinking that there might be + # one failed scenario. + if [ -z "${FAILED_SCENARIO_PATHS}" ] + then + unset FAILED_SCENARIO_PATHS + FAILED_SCENARIO_PATHS=() + fi + + for FAILED_SCENARIO_PATH in ${FAILED_SCENARIO_PATHS} + do + SUITE_PATH=`dirname ${FAILED_SCENARIO_PATH}` + SUITE=`basename ${SUITE_PATH}` + SCENARIO=`basename ${FAILED_SCENARIO_PATH}` + SUITE_SCENARIO="${SUITE}/${SCENARIO}" + + if [ -n "${EXPECTED_FAILURES_FILE}" ] + then + grep -x ${SUITE_SCENARIO} ${EXPECTED_FAILURES_FILE} > /dev/null + if [ $? -eq 0 ] + then + echo "Notice: Scenario ${SUITE_SCENARIO} is expected to fail so do not rerun it." + continue + fi + fi + + echo "Rerun failed scenario: ${FAILED_SCENARIO_PATH}" + ${BEHAT} --colors --strict -c ${BEHAT_YML} -f pretty ${BEHAT_SUITE_OPTION} --tags ${BEHAT_FILTER_TAGS} ${FAILED_SCENARIO_PATH} -v 2>&1 | tee -a ${TEST_LOG_FILE} + BEHAT_EXIT_STATUS=${PIPESTATUS[0]} + if [ ${BEHAT_EXIT_STATUS} -eq 0 ] + then + # The scenario was not expected to fail but had failed and is present in the + # unexpected_failures list. We've checked the scenario with a re-run and + # it passed. So remove it from the unexpected_failures list. + for i in "${!UNEXPECTED_FAILED_SCENARIOS[@]}" + do + if [ "${UNEXPECTED_FAILED_SCENARIOS[i]}" == "$SUITE_SCENARIO" ] + then + unset "UNEXPECTED_FAILED_SCENARIOS[i]" + fi + done + else + echo "webUI test rerun failed with exit status: ${BEHAT_EXIT_STATUS}" + # The scenario is not expected to fail but is failing also after the rerun. + # Since it is already reported in the unexpected_failures list, there is no + # need to touch that again. Continue processing the next scenario to rerun. + fi + done + fi + + if [ "${BEHAT_TAGS_OPTION_FOUND}" != true ] + then + # The behat run specified to skip scenarios tagged @skip + # Report them in a dry-run so they can be seen + # Big red error output is displayed if there are no matching scenarios - send it to null + DRY_RUN_FILE=$(mktemp) + SKIP_TAGS="${TEST_TYPE_TAG}&&@skip" + ${BEHAT} --dry-run --colors -c ${BEHAT_YML} -f pretty ${BEHAT_SUITE_OPTION} --tags "${SKIP_TAGS}" ${BEHAT_FEATURE} 1>${DRY_RUN_FILE} 2>/dev/null + if grep -q -m 1 'No scenarios' "${DRY_RUN_FILE}" + then + # If there are no skip scenarios, then no need to report that + : + else + echo "" + echo "The following tests were skipped because they are tagged @skip:" + cat "${DRY_RUN_FILE}" | tee -a ${TEST_LOG_FILE} + fi + rm -f "${DRY_RUN_FILE}" + fi +} + +function teardown() { + # Enable any apps that were disabled for the test run + for i in "${!APPS_TO_REENABLE[@]}" + do + read -r -a APP <<< "${APPS_TO_REENABLE[$i]}" + remote_occ ${ADMIN_AUTH} ${APP[0]} "--no-warnings app:enable ${APP[1]}" + done + + # Disable any apps that were enabled for the test run + for i in "${!APPS_TO_REDISABLE[@]}" + do + read -r -a APP <<< "${APPS_TO_REDISABLE[$i]}" + remote_occ ${ADMIN_AUTH} ${APP[0]} "--no-warnings app:disable ${APP[1]}" + done + + # Put back settings that were set for the test-run + for i in "${!PREVIOUS_SETTINGS[@]}" + do + PREVIOUS_IFS=${IFS} + IFS=';' + read -r -a SETTING <<< "${PREVIOUS_SETTINGS[$i]}" + IFS=${PREVIOUS_IFS} + if [ -z "${SETTING[4]}" ] + then + remote_occ ${ADMIN_AUTH} ${SETTING[0]} "config:${SETTING[1]}:delete ${SETTING[2]} ${SETTING[3]}" + else + TYPE_STRING="" + if [ -n "${SETTING[5]}" ] + then + #place the space here not in the command line, so that there is no space if the string is empty + TYPE_STRING=" --type ${SETTING[5]}" + fi + remote_occ ${ADMIN_AUTH} ${SETTING[0]} "config:${SETTING[1]}:set ${SETTING[2]} ${SETTING[3]} --value=${SETTING[4]}${TYPE_STRING}" + fi + done + + # Put back state of the testing app + if [ "${TESTING_ENABLED_BY_SCRIPT}" = true ] + then + ${OCC} app:disable testing + fi + + if [ "${OC_TEST_ALT_HOME}" = "1" ] + then + env_alt_home_clear + fi + + if [ "${TEST_WITH_PHPDEVSERVER}" == "true" ] + then + kill ${PHPPID} + kill ${PHPPID_FED} + fi + + if [ "${SHOW_OC_LOGS}" = true ] + then + tail "${OC_PATH}/data/owncloud.log" + fi + + # Reset the original language + export LANG=${OLD_LANG} + + rm -f "${TEST_LOG_FILE}" + + echo "runsh: Exit code of main run: ${BEHAT_EXIT_STATUS}" +} + +declare -x TEST_SERVER_URL +declare -x TEST_SERVER_FED_URL +declare -x TEST_WITH_PHPDEVSERVER +[[ -z "${TEST_SERVER_URL}" && -z "${TEST_SERVER_FED_URL}" ]] && TEST_WITH_PHPDEVSERVER="true" + +if [ -z "${IPV4_URL}" ] +then + IPV4_URL="${TEST_SERVER_URL}" +fi + +if [ -z "${IPV6_URL}" ] +then + IPV6_URL="${TEST_SERVER_URL}" +fi + +if [ "${TEST_WITH_PHPDEVSERVER}" != "true" ] +then + # The endpoint to use to do occ commands via the testing app + # set it already here, so it can be used for remote_occ + # we know the TEST_SERVER_URL already + TESTING_APP_URL="${TEST_SERVER_URL}/ocs/v2.php/apps/testing/api/v1/" + OCC_URL="${TESTING_APP_URL}occ" + # test that server is up and running, and testing app is enabled. + assert_server_up ${TEST_SERVER_URL} + if [ "${TEST_OCIS}" != "true" ] && [ "${TEST_REVA}" != "true" ] + then + assert_testing_app_enabled ${TEST_SERVER_URL} + fi + + if [ -n "${TEST_SERVER_FED_URL}" ] + then + TESTING_APP_FED_URL="${TEST_SERVER_FED_URL}/ocs/v2.php/apps/testing/api/v1/" + OCC_FED_URL="${TESTING_APP_FED_URL}occ" + # test that fed server is up and running, and testing app is enabled. + assert_server_up ${TEST_SERVER_FED_URL} + if [ "${TEST_OCIS}" != "true" ] && [ "${TEST_REVA}" != "true" ] + then + assert_testing_app_enabled ${TEST_SERVER_URL} + fi + fi + + echo "Not using php inbuilt server for running scenario ..." + echo "Updating .htaccess for proper rewrites" + #get the sub path of the webserver and set the correct RewriteBase + WEBSERVER_PATH=$(get_path_from_url ${TEST_SERVER_URL}) + HTACCESS_UPDATE_FAILURE_MSG="Could not update .htaccess in local server. Some tests might fail as a result." + remote_occ ${ADMIN_AUTH} ${OCC_URL} "config:system:set htaccess.RewriteBase --value /${WEBSERVER_PATH}/" + remote_occ ${ADMIN_AUTH} ${OCC_URL} "maintenance:update:htaccess" + [[ $? -eq 0 ]] || { echo "${HTACCESS_UPDATE_FAILURE_MSG}"; } + # check if mod_rewrite module is enabled + check_apache_module_enabled ${ADMIN_AUTH} ${TESTING_APP_URL} "mod_rewrite" "local" + + if [ -n "${TEST_SERVER_FED_URL}" ] + then + WEBSERVER_PATH=$(get_path_from_url ${TEST_SERVER_FED_URL}) + remote_occ ${ADMIN_AUTH} ${OCC_FED_URL} "config:system:set htaccess.RewriteBase --value /${WEBSERVER_PATH}/" + remote_occ ${ADMIN_AUTH} ${OCC_FED_URL} "maintenance:update:htaccess" + [[ $? -eq 0 ]] || { echo "${HTACCESS_UPDATE_FAILURE_MSG/local/federated}"; } + # check if mod_rewrite module is enabled + check_apache_module_enabled ${ADMIN_AUTH} ${TESTING_APP_FED_URL} "mod_rewrite" "remote" + fi +else + echo "Using php inbuilt server for running scenario ..." + + # Avoid port collision on jenkins - use $EXECUTOR_NUMBER + declare -x EXECUTOR_NUMBER + [[ -z "${EXECUTOR_NUMBER}" ]] && EXECUTOR_NUMBER=0 + + PORT=$((8080 + ${EXECUTOR_NUMBER})) + echo ${PORT} + php -S localhost:${PORT} -t "${OC_PATH}" & + PHPPID=$! + echo ${PHPPID} + + PORT_FED=$((8180 + ${EXECUTOR_NUMBER})) + echo ${PORT_FED} + php -S localhost:${PORT_FED} -t ../.. & + PHPPID_FED=$! + echo ${PHPPID_FED} + + export TEST_SERVER_URL="http://localhost:${PORT}" + export TEST_SERVER_FED_URL="http://localhost:${PORT_FED}" + + # The endpoint to use to do occ commands via the testing app + TESTING_APP_URL="${TEST_SERVER_URL}/ocs/v2.php/apps/testing/api/v1/" + OCC_URL="${TESTING_APP_URL}occ" + + # Give time for the PHP dev server to become available + # because we want to use it to get and change settings with the testing app + sleep 5 +fi + +# If a feature file has been specified but no suite, then deduce the suite +if [ -n "${BEHAT_FEATURE}" ] && [ -z "${BEHAT_SUITE}" ] +then + SUITE_PATH=`dirname ${BEHAT_FEATURE}` + BEHAT_SUITE=`basename ${SUITE_PATH}` +fi + +if [ -z "${BEHAT_YML}" ] +then + # Look for a behat.yml somewhere below the current working directory + # This saves app acceptance tests being forced to specify BEHAT_YML + BEHAT_YML="config/behat.yml" + if [ ! -f "${BEHAT_YML}" ] + then + BEHAT_YML="acceptance/config/behat.yml" + fi + if [ ! -f "${BEHAT_YML}" ] + then + BEHAT_YML="tests/acceptance/config/behat.yml" + fi + # If no luck above, then use the core behat.yml that should live below this script + if [ ! -f "${BEHAT_YML}" ] + then + BEHAT_YML="${SCRIPT_PATH}/config/behat.yml" + fi +fi + +BEHAT_CONFIG_DIR=$(dirname "${BEHAT_YML}") +ACCEPTANCE_DIR=$(dirname "${BEHAT_CONFIG_DIR}") +BEHAT_FEATURES_DIR="${ACCEPTANCE_DIR}/features" + +declare -a BEHAT_SUITES +if [[ -n "${BEHAT_SUITE}" ]] +then + BEHAT_SUITES+=(${BEHAT_SUITE}) +else + if [[ -n "${RUN_PART}" ]] + then + ALL_SUITES=`find ${BEHAT_FEATURES_DIR}/ -type d -iname ${ACCEPTANCE_TEST_TYPE}* | sort | rev | cut -d"/" -f1 | rev` + COUNT_ALL_SUITES=`echo "${ALL_SUITES}" | wc -l` + #divide the suites letting it round down (could be zero) + MIN_SUITES_PER_RUN=$((${COUNT_ALL_SUITES} / ${DIVIDE_INTO_NUM_PARTS})) + #some jobs might need an extra suite + MAX_SUITES_PER_RUN=$((${MIN_SUITES_PER_RUN} + 1)) + # the remaining number of suites that need to be distributed (could be zero) + REMAINING_SUITES=$((${COUNT_ALL_SUITES} - (${DIVIDE_INTO_NUM_PARTS} * ${MIN_SUITES_PER_RUN}))) + + if [[ ${RUN_PART} -le ${REMAINING_SUITES} ]] + then + SUITES_THIS_RUN=${MAX_SUITES_PER_RUN} + SUITES_IN_PREVIOUS_RUNS=$((${MAX_SUITES_PER_RUN} * (${RUN_PART} - 1))) + else + SUITES_THIS_RUN=${MIN_SUITES_PER_RUN} + SUITES_IN_PREVIOUS_RUNS=$((((${MAX_SUITES_PER_RUN} * ${REMAINING_SUITES}) + (${MIN_SUITES_PER_RUN} * (${RUN_PART} - ${REMAINING_SUITES} - 1))))) + fi + + if [ ${SUITES_THIS_RUN} -eq 0 ] + then + echo "there are only ${COUNT_ALL_SUITES} suites, nothing to do in part ${RUN_PART}" + teardown + exit 0 + fi + + COUNT_FINISH_AND_TODO_SUITES=$((${SUITES_IN_PREVIOUS_RUNS} + ${SUITES_THIS_RUN})) + BEHAT_SUITES+=(`echo "${ALL_SUITES}" | head -n ${COUNT_FINISH_AND_TODO_SUITES} | tail -n ${SUITES_THIS_RUN}`) + fi +fi + +# If the suite name begins with "webUI" or the user explicitly specified type "webui" +# then we will setup for webUI testing and run tests tagged as @webUI, +# otherwise it is API tests. +# Currently, running both API and webUI tests in a single run is not supported. +if [[ "${BEHAT_SUITE}" == webUI* ]] || [ "${ACCEPTANCE_TEST_TYPE}" = "webui" ] +then + TEST_TYPE_TAG="@webUI" + TEST_TYPE_TEXT="webUI" + RUNNING_API_TESTS=false + RUNNING_CLI_TESTS=false + RUNNING_WEBUI_TESTS=true +elif [[ "${BEHAT_SUITE}" == cli* ]] || [ "${ACCEPTANCE_TEST_TYPE}" = "cli" ] +then + TEST_TYPE_TAG="@cli" + TEST_TYPE_TEXT="cli" + RUNNING_API_TESTS=false + RUNNING_CLI_TESTS=true + RUNNING_WEBUI_TESTS=false +else + TEST_TYPE_TAG="@api" + TEST_TYPE_TEXT="API" + RUNNING_API_TESTS=true + RUNNING_CLI_TESTS=false + RUNNING_WEBUI_TESTS=false +fi + +# Always have one of "@api", "@cli" or "@webUI" filter tags +if [ -z "${BEHAT_FILTER_TAGS}" ] +then + BEHAT_FILTER_TAGS="${TEST_TYPE_TAG}" +else + # Be nice to the caller + # Remove any extra "&&" at the end of their tags list + BEHAT_FILTER_TAGS="${BEHAT_FILTER_TAGS%&&}" + # Remove any extra "&&" at the beginning of their tags list + BEHAT_FILTER_TAGS="${BEHAT_FILTER_TAGS#&&}" + BEHAT_FILTER_TAGS="${BEHAT_FILTER_TAGS}&&${TEST_TYPE_TAG}" +fi + +# EMAIL_HOST defines where the system-under-test can find the email server (inbucket) +# for sending email. +if [ -z "${EMAIL_HOST}" ] +then + EMAIL_HOST="127.0.0.1" +fi + +# LOCAL_INBUCKET_HOST defines where this test script can find the Inbucket server +# for sending email. When testing a remote system, the Inbucket server somewhere +# "in the middle" might have a different host name from the point of view of +# the test script. +if [ -z "${LOCAL_EMAIL_HOST}" ] +then + LOCAL_EMAIL_HOST="${EMAIL_HOST}" +fi + +if [ -z "${EMAIL_SMTP_PORT}" ] +then + EMAIL_SMTP_PORT="2500" +fi + +if [ -z "${SELENIUM_HOST}" ] +then + SELENIUM_HOST=localhost +fi + +if [ -z "${SELENIUM_PORT}" ] +then + SELENIUM_PORT=4445 +fi + +if [ -z "${BROWSER}" ] +then + BROWSER="chrome" +fi + +# Check if we can rely on a local ./occ command or if we are testing +# a remote instance (e.g. inside docker). +# If we have a remote instance we cannot enable the testing app and +# we have to hope it is enabled already, by other ways +if [ "${TESTING_REMOTE_SYSTEM}" = false ] +then + # Enable testing app + PREVIOUS_TESTING_APP_STATUS=$(${OCC} --no-warnings app:list "^testing$") + if [[ "${PREVIOUS_TESTING_APP_STATUS}" =~ ^Disabled: ]] + then + ${OCC} app:enable testing || { echo "Unable to enable testing app" >&2; exit 1; } + TESTING_ENABLED_BY_SCRIPT=true; + else + TESTING_ENABLED_BY_SCRIPT=false; + fi +else + TESTING_ENABLED_BY_SCRIPT=false; +fi + +# table of settings to be remembered and set +#system|app;app-name;setting-name;value;variable-type +declare -a SETTINGS +SETTINGS+=("system;;mail_domain;foobar.com") +SETTINGS+=("system;;mail_from_address;owncloud") +SETTINGS+=("system;;mail_smtpmode;smtp") +SETTINGS+=("system;;mail_smtphost;${EMAIL_HOST}") +SETTINGS+=("system;;mail_smtpport;${EMAIL_SMTP_PORT}") +SETTINGS+=("app;core;backgroundjobs_mode;webcron") +SETTINGS+=("system;;sharing.federation.allowHttpFallback;true;boolean") +SETTINGS+=("system;;grace_period.demo_key.show_popup;false;boolean") +SETTINGS+=("app;core;enable_external_storage;yes") +SETTINGS+=("system;;files_external_allow_create_new_local;true") +SETTINGS+=("system;;skeletondirectory;;") + +# Set various settings +for URL in ${OCC_URL} ${OCC_FED_URL} +do + declare SETTINGS_CMDS='[' + for i in "${!SETTINGS[@]}" + do + PREVIOUS_IFS=${IFS} + IFS=';' + read -r -a SETTING <<< "${SETTINGS[$i]}" + IFS=${PREVIOUS_IFS} + + save_config_setting "${ADMIN_AUTH}" "${URL}" "${SETTING[0]}" "${SETTING[1]}" "${SETTING[2]}" "${SETTING[4]}" + + TYPE_STRING="" + if [ -n "${SETTING[4]}" ] + then + #place the space here not in the command line, so that there is no space if the string is empty + TYPE_STRING=" --type ${SETTING[4]}" + fi + + SETTINGS_CMDS+="{\"command\": \"config:${SETTING[0]}:set ${SETTING[1]} ${SETTING[2]} --value=${SETTING[3]}${TYPE_STRING}\"}," + done + SETTINGS_CMDS=${SETTINGS_CMDS%?} # removes the last comma + SETTINGS_CMDS+="]" + remote_bulk_occ ${ADMIN_AUTH} ${URL} "${SETTINGS_CMDS}" + if [ $? -ne 0 ] + then + echo -e "Could not set some settings on ${URL}. Result:\n${REMOTE_OCC_STDERR}" + teardown + exit 1 + fi +done + +#set the skeleton folder +if [ -n "${SKELETON_DIR}" ] +then + for URL in ${OCC_URL} ${OCC_FED_URL} + do + remote_occ ${ADMIN_AUTH} ${URL} "config:system:set skeletondirectory --value=${SKELETON_DIR}" + done +fi + +#Enable and disable apps as required for default +if [ -z "${APPS_TO_DISABLE}" ] +then + APPS_TO_DISABLE="firstrunwizard notifications" +fi + +if [ -z "${APPS_TO_ENABLE}" ] +then + APPS_TO_ENABLE="" +fi + +declare -a APPS_TO_REENABLE; + +for URL in ${OCC_URL} ${OCC_FED_URL} + do + for APP_TO_DISABLE in ${APPS_TO_DISABLE}; do + remote_occ ${ADMIN_AUTH} ${URL} "--no-warnings app:list ^${APP_TO_DISABLE}$" + PREVIOUS_APP_STATUS=${REMOTE_OCC_STDOUT} + if [[ "${PREVIOUS_APP_STATUS}" =~ ^Enabled: ]] + then + APPS_TO_REENABLE+=("${URL} ${APP_TO_DISABLE}"); + remote_occ ${ADMIN_AUTH} ${URL} "--no-warnings app:disable ${APP_TO_DISABLE}" + fi + done +done + +declare -a APPS_TO_REDISABLE; + +for URL in ${OCC_URL} ${OCC_FED_URL} + do + for APP_TO_ENABLE in ${APPS_TO_ENABLE}; do + remote_occ ${ADMIN_AUTH} ${URL} "--no-warnings app:list ^${APP_TO_ENABLE}$" + PREVIOUS_APP_STATUS=${REMOTE_OCC_STDOUT} + if [[ "${PREVIOUS_APP_STATUS}" =~ ^Disabled: ]] + then + APPS_TO_REDISABLE+=("${URL} ${APP_TO_ENABLE}"); + remote_occ ${ADMIN_AUTH} ${URL} "--no-warnings app:enable ${APP_TO_ENABLE}" + fi + done +done + +if [ "${OC_TEST_ALT_HOME}" = "1" ] +then + env_alt_home_enable +fi + +# We need to skip some tests in certain browsers. +if [ "${BROWSER}" == "internet explorer" ] || [ "${BROWSER}" == "MicrosoftEdge" ] || [ "${BROWSER}" == "firefox" ] +then + BROWSER_IN_CAPITALS=${BROWSER//[[:blank:]]/} + BROWSER_IN_CAPITALS=${BROWSER_IN_CAPITALS^^} + BEHAT_FILTER_TAGS="${BEHAT_FILTER_TAGS}&&~@skipOn${BROWSER_IN_CAPITALS}" +fi + +# Skip tests tagged with the current oC version +# One, two or three parts of the version can be used +# e.g. +# @skipOnOcV10.0.4 +# @skipOnOcV10.0 +# @skipOnOcV10 + +remote_occ ${ADMIN_AUTH} ${OCC_URL} "config:system:get version" +OWNCLOUD_VERSION_THREE_DIGIT=`echo ${REMOTE_OCC_STDOUT} | cut -d"." -f1-3` +BEHAT_FILTER_TAGS='~@skipOnOcV'${OWNCLOUD_VERSION_THREE_DIGIT}'&&'${BEHAT_FILTER_TAGS} +OWNCLOUD_VERSION_TWO_DIGIT=`echo ${OWNCLOUD_VERSION_THREE_DIGIT} | cut -d"." -f1-2` +BEHAT_FILTER_TAGS='~@skipOnOcV'${OWNCLOUD_VERSION_TWO_DIGIT}'&&'${BEHAT_FILTER_TAGS} +OWNCLOUD_VERSION_ONE_DIGIT=`echo ${OWNCLOUD_VERSION_TWO_DIGIT} | cut -d"." -f1` +BEHAT_FILTER_TAGS='~@skipOnOcV'${OWNCLOUD_VERSION_ONE_DIGIT}'&&'${BEHAT_FILTER_TAGS} + +function version { echo "$@" | awk -F. '{ printf("%d%03d%03d\n", $1,$2,$3); }'; } + +# Skip tests for OC versions greater than 10.8.0 +if [ $(version $OWNCLOUD_VERSION_THREE_DIGIT) -gt $(version "10.8.0") ]; then + BEHAT_FILTER_TAGS='~@skipOnAllVersionsGreaterThanOcV10.8.0&&'${BEHAT_FILTER_TAGS} +fi + +if [ -n "${TEST_SERVER_FED_URL}" ] +then + remote_occ ${ADMIN_AUTH} ${OCC_FED_URL} "config:system:get version" + OWNCLOUD_FED_VERSION=`echo ${REMOTE_OCC_STDOUT} | cut -d"." -f1-3` + BEHAT_FILTER_TAGS='~@skipOnFedOcV'${OWNCLOUD_FED_VERSION}'&&'${BEHAT_FILTER_TAGS} + OWNCLOUD_FED_VERSION=`echo ${OWNCLOUD_FED_VERSION} | cut -d"." -f1-2` + BEHAT_FILTER_TAGS='~@skipOnFedOcV'${OWNCLOUD_FED_VERSION}'&&'${BEHAT_FILTER_TAGS} + OWNCLOUD_FED_VERSION=`echo ${OWNCLOUD_FED_VERSION} | cut -d"." -f1` + BEHAT_FILTER_TAGS='~@skipOnFedOcV'${OWNCLOUD_FED_VERSION}'&&'${BEHAT_FILTER_TAGS} +fi + +# If we are running remote only tests add another skip '@skipWhenTestingRemoteSystems' +if [ "${TESTING_REMOTE_SYSTEM}" = true ] +then + BEHAT_FILTER_TAGS='~@skipWhenTestingRemoteSystems&&'${BEHAT_FILTER_TAGS} +fi + +if [ "${OC_TEST_ON_OBJECTSTORE}" = "1" ] +then + BEHAT_FILTER_TAGS="${BEHAT_FILTER_TAGS}&&~@skip_on_objectstore" +fi + +# If the caller did not mention specific tags, skip the skipped tests by default +if [ "${BEHAT_TAGS_OPTION_FOUND}" = false ] +then + # If the caller has already specified specifically to run "@skip" scenarios + # then do not append "not @skip" + if [[ ! ${BEHAT_FILTER_TAGS} =~ "&&@skip&&" ]] + then + BEHAT_FILTER_TAGS="${BEHAT_FILTER_TAGS}&&~@skip" + fi +fi + +if [ -n "${BROWSER_VERSION}" ] +then + BROWSER_VERSION_EXTRA_CAPABILITIES_STRING='"browserVersion":"'${BROWSER_VERSION}'",' + BROWSER_VERSION_SELENIUM_STRING='"version": "'${BROWSER_VERSION}'", ' + BROWSER_VERSION_TEXT='('${BROWSER_VERSION}') ' +else + BROWSER_VERSION_EXTRA_CAPABILITIES_STRING="" + BROWSER_VERSION_SELENIUM_STRING="" + BROWSER_VERSION_TEXT="" +fi + +if [ -n "${PLATFORM}" ] +then + PLATFORM_SELENIUM_STRING='"platform": "'${PLATFORM}'", ' + PLATFORM_TEXT="on platform '${PLATFORM}' " +else + PLATFORM_SELENIUM_STRING="" + PLATFORM_TEXT="" +fi + +if [ "${RUNNING_API_TESTS}" = true ] +then + EXTRA_CAPABILITIES="" + BROWSER_TEXT="" +elif [ "${RUNNING_CLI_TESTS}" = true ] +then + EXTRA_CAPABILITIES="" + BROWSER_TEXT="" +else + BROWSER_TEXT="on browser '${BROWSER}' " + + # If we are running in some automated CI, use the provided details + if [ -n "${CI_REPO}" ] + then + CAPABILITIES_NAME_TEXT="${CI_REPO} - ${CI_BRANCH}" + else + # Otherwise this is a non-CI run, probably a local developer run + CAPABILITIES_NAME_TEXT="ownCloud non-CI" + fi + + # If SauceLabs credentials have been passed, then use them + if [ -n "${SAUCE_USERNAME}" ] && [ -n "${SAUCE_ACCESS_KEY}" ] + then + SAUCE_CREDENTIALS="${SAUCE_USERNAME}:${SAUCE_ACCESS_KEY}@" + else + SAUCE_CREDENTIALS="" + fi + + if [ "${BROWSER}" == "firefox" ] + then + # Set the screen resolution so that hopefully draggable elements will be visible + # FF gives problems if the destination element is not visible + EXTRA_CAPABILITIES='"screenResolution":"1920x1080",' + + # This selenium version works for Firefox after V47 + # We no longer need to support testing of Firefox V47 or earlier + EXTRA_CAPABILITIES='"seleniumVersion":"3.4.0",'${EXTRA_CAPABILITIES} + fi + + if [ "${BROWSER}" == "internet explorer" ] + then + EXTRA_CAPABILITIES='"iedriverVersion": "3.4.0","requiresWindowFocus":true,"screenResolution":"1920x1080",' + fi + + EXTRA_CAPABILITIES=${EXTRA_CAPABILITIES}${BROWSER_VERSION_EXTRA_CAPABILITIES_STRING}'"maxDuration":"3600"' + export BEHAT_PARAMS='{"extensions" : {"Behat\\MinkExtension" : {"browser_name": "'${BROWSER}'", "base_url" : "'${TEST_SERVER_URL}'", "selenium2":{"capabilities": {"marionette":null, "browser": "'${BROWSER}'", '${BROWSER_VERSION_SELENIUM_STRING}${PLATFORM_SELENIUM_STRING}'"name": "'${CAPABILITIES_NAME_TEXT}'", "extra_capabilities": {'${EXTRA_CAPABILITIES}'}}, "wd_host":"http://'${SAUCE_CREDENTIALS}${SELENIUM_HOST}':'${SELENIUM_PORT}'/wd/hub"}}, "SensioLabs\\Behat\\PageObjectExtension" : {}}}' +fi + +echo ${EXTRA_CAPABILITIES} +echo ${BEHAT_PARAMS} + +export IPV4_URL +export IPV6_URL +export FILES_FOR_UPLOAD="${SCRIPT_PATH}/filesForUpload/" + +if [ "${TEST_OCIS}" != "true" ] && [ "${TEST_REVA}" != "true" ] +then + # We are testing on an ownCloud core server. + # Tell the tests to wait 1 second between each upload/delete action + # to avoid problems with actions that depend on timestamps in seconds. + export UPLOAD_DELETE_WAIT_TIME=1 +fi + +TEST_LOG_FILE=$(mktemp) +SCENARIOS_THAT_PASSED=0 +SCENARIOS_THAT_FAILED=0 + +if [ ${#BEHAT_SUITES[@]} -eq 0 ] && [ -z "${BEHAT_FEATURE}" ] +then + SUITE_FEATURE_TEXT="all ${TEST_TYPE_TEXT}" + run_behat_tests +else + if [ -n "${BEHAT_SUITE}" ] + then + SUITE_FEATURE_TEXT="${BEHAT_SUITE}" + fi + + if [ -n "${BEHAT_FEATURE}" ] + then + # If running a whole feature, it will be something like login.feature + # If running just a single scenario, it will also have the line number + # like login.feature:36 - which will be parsed correctly like a "file" + # by basename. + BEHAT_FEATURE_FILE=`basename ${BEHAT_FEATURE}` + SUITE_FEATURE_TEXT="${SUITE_FEATURE_TEXT} ${BEHAT_FEATURE_FILE}" + fi +fi + +for i in "${!BEHAT_SUITES[@]}" + do + BEHAT_SUITE_TO_RUN="${BEHAT_SUITES[$i]}" + BEHAT_SUITE_OPTION="--suite=${BEHAT_SUITE_TO_RUN}" + SUITE_FEATURE_TEXT="${BEHAT_SUITES[$i]}" + for rerun_number in $(seq 1 ${BEHAT_RERUN_TIMES}) + do + if ((${BEHAT_RERUN_TIMES} > 1)) + then + echo -e "\nTest repeat $rerun_number of ${BEHAT_RERUN_TIMES}" + fi + run_behat_tests + done +done + +TOTAL_SCENARIOS=$((SCENARIOS_THAT_PASSED + SCENARIOS_THAT_FAILED)) + +echo "runsh: Total ${TOTAL_SCENARIOS} scenarios (${SCENARIOS_THAT_PASSED} passed, ${SCENARIOS_THAT_FAILED} failed)" + +teardown + +# 3 types of things can have gone wrong: +# - some scenario failed (and it was not expected to fail) +# - some scenario passed (but it was expected to fail) +# - Behat exited with non-zero status because of some other error +# If any of these happened then report about it and exit with status 1 (error) + +if [ ${#UNEXPECTED_FAILED_SCENARIOS[@]} -gt 0 ] +then + UNEXPECTED_FAILURE=true +else + UNEXPECTED_FAILURE=false +fi + +if [ ${#UNEXPECTED_PASSED_SCENARIOS[@]} -gt 0 ] +then + UNEXPECTED_SUCCESS=true +else + UNEXPECTED_SUCCESS=false +fi + +if [ ${#UNEXPECTED_BEHAT_EXIT_STATUSES[@]} -gt 0 ] +then + UNEXPECTED_BEHAT_EXIT_STATUS=true +else + UNEXPECTED_BEHAT_EXIT_STATUS=false +fi + +# If we got some unexpected success, and we only ran a single feature or scenario +# then the fact that some expected failures did not happen might be because those +# scenarios were never even run. +# Filter the UNEXPECTED_PASSED_SCENARIOS to remove scenarios that were not run. +if [ "${UNEXPECTED_SUCCESS}" = true ] +then + ACTUAL_UNEXPECTED_PASS=() + # if running a single feature or a single scenario + if [[ -n "${BEHAT_FEATURE}" ]] + then + for unexpected_passed_value in "${UNEXPECTED_PASSED_SCENARIOS[@]}" + do + # check only for the running feature + if [[ $BEHAT_FEATURE == *":"* ]] + then + BEHAT_FEATURE_WITH_LINE_NUM=$BEHAT_FEATURE + else + LINE_NUM=$(echo ${unexpected_passed_value} | cut -d":" -f2) + BEHAT_FEATURE_WITH_LINE_NUM=$BEHAT_FEATURE:$LINE_NUM + fi + if [[ $BEHAT_FEATURE_WITH_LINE_NUM == *"${unexpected_passed_value}" ]] + then + ACTUAL_UNEXPECTED_PASS+=("${unexpected_passed_value}") + fi + done + else + ACTUAL_UNEXPECTED_PASS=("${UNEXPECTED_PASSED_SCENARIOS[@]}") + fi + + if [ ${#ACTUAL_UNEXPECTED_PASS[@]} -eq 0 ] + then + UNEXPECTED_SUCCESS=false + fi +fi + +if [ "${UNEXPECTED_FAILURE}" = false ] && [ "${UNEXPECTED_SUCCESS}" = false ] && [ "${UNEXPECTED_BEHAT_EXIT_STATUS}" = false ] +then + FINAL_EXIT_STATUS=0 +else + FINAL_EXIT_STATUS=1 +fi + +if [ -n "${EXPECTED_FAILURES_FILE}" ] +then + echo "runsh: Exit code after checking expected failures: ${FINAL_EXIT_STATUS}" +fi + +if [ "${UNEXPECTED_FAILURE}" = true ] +then + tput setaf 3; echo "runsh: Total unexpected failed scenarios throughout the test run:" + tput setaf 1; printf "%s\n" "${UNEXPECTED_FAILED_SCENARIOS[@]}" +else + tput setaf 2; echo "runsh: There were no unexpected failures." +fi + +if [ "${UNEXPECTED_SUCCESS}" = true ] +then + tput setaf 3; echo "runsh: Total unexpected passed scenarios throughout the test run:" + tput setaf 1; printf "%s\n" "${ACTUAL_UNEXPECTED_PASS[@]}" +else + tput setaf 2; echo "runsh: There were no unexpected success." +fi + +if [ "${UNEXPECTED_BEHAT_EXIT_STATUS}" = true ] +then + tput setaf 3; echo "runsh: The following Behat test runs exited with non-zero status:" + tput setaf 1; printf "%s\n" "${UNEXPECTED_BEHAT_EXIT_STATUSES[@]}" +fi + +# sync the file-system so all output will be flushed to storage. +# In drone we sometimes see that the last lines of output are missing from the +# drone log. +sync + +# If we are running in drone CI, then sleep for a bit to (hopefully) let the +# drone agent send all the output to the drone server. +if [ -n "${CI_REPO}" ] +then + echo "sleeping for 30 seconds at end of test run" + sleep 30 +fi + +exit ${FINAL_EXIT_STATUS} diff --git a/tests/parallelDeployAcceptance/features/bootstrap/bootstrap.php b/tests/parallelDeployAcceptance/features/bootstrap/bootstrap.php index ec7e80734..4a3991c26 100644 --- a/tests/parallelDeployAcceptance/features/bootstrap/bootstrap.php +++ b/tests/parallelDeployAcceptance/features/bootstrap/bootstrap.php @@ -20,18 +20,14 @@ * */ -$pathToCore = \getenv('PATH_TO_CORE'); -if ($pathToCore === false) { - $pathToCore = "../core"; -} +use Composer\Autoload\ClassLoader; -require_once $pathToCore . '/tests/acceptance/features/bootstrap/bootstrap.php'; - -$classLoader = new \Composer\Autoload\ClassLoader(); +$classLoader = new ClassLoader(); $classLoader->addPsr4( "", - $pathToCore . "/tests/acceptance/features/bootstrap", + __DIR__ . "/../../../tests/acceptance/features/bootstrap", true ); +$classLoader->addPsr4("TestHelpers\\", __DIR__ . "/../../../TestHelpers", true); $classLoader->register(); diff --git a/vendor-bin/behat/composer.json b/vendor-bin/behat/composer.json index 999f00df8..56b0324d1 100644 --- a/vendor-bin/behat/composer.json +++ b/vendor-bin/behat/composer.json @@ -9,6 +9,7 @@ }, "require": { "behat/behat": "^3.9", + "behat/gherkin": "^4.9", "behat/mink": "1.7.1", "friends-of-behat/mink-extension": "^2.5", "ciaranmcnulty/behat-stepthroughextension" : "dev-master",