diff options
Diffstat (limited to 'build/integration/features')
55 files changed, 7572 insertions, 2756 deletions
diff --git a/build/integration/features/auth.feature b/build/integration/features/auth.feature new file mode 100644 index 00000000000..f9c8b7d0e46 --- /dev/null +++ b/build/integration/features/auth.feature @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2016 ownCloud, Inc. +# SPDX-License-Identifier: AGPL-3.0-only +Feature: auth + + Background: + Given user "user0" exists + Given a new restricted client token is added + Given a new unrestricted client token is added + Given the cookie jar is reset + + # FILES APP + Scenario: access files app anonymously + When requesting "/index.php/apps/files" with "GET" + Then the HTTP status code should be "401" + + Scenario: access files app with basic auth + When requesting "/index.php/apps/files" with "GET" using basic auth + Then the HTTP status code should be "200" + + Scenario: access files app with unrestricted basic token auth + When requesting "/index.php/apps/files" with "GET" using unrestricted basic token auth + Then the HTTP status code should be "200" + Then requesting "/remote.php/files/welcome.txt" with "GET" using browser session + Then the HTTP status code should be "200" + + Scenario: access files app with restricted basic token auth + When requesting "/index.php/apps/files" with "GET" using restricted basic token auth + Then the HTTP status code should be "200" + Then requesting "/remote.php/files/welcome.txt" with "GET" using browser session + Then the HTTP status code should be "404" + + Scenario: access files app with an unrestricted client token + When requesting "/index.php/apps/files" with "GET" using an unrestricted client token + Then the HTTP status code should be "200" + + Scenario: access files app with browser session + Given a new browser session is started + When requesting "/index.php/apps/files" with "GET" using browser session + Then the HTTP status code should be "200" + + # WebDAV + Scenario: using WebDAV anonymously + When requesting "/remote.php/webdav" with "PROPFIND" + Then the HTTP status code should be "401" + + Scenario: using WebDAV with basic auth + When requesting "/remote.php/webdav" with "PROPFIND" using basic auth + Then the HTTP status code should be "207" + + Scenario: using WebDAV with unrestricted basic token auth + When requesting "/remote.php/webdav" with "PROPFIND" using unrestricted basic token auth + Then the HTTP status code should be "207" + + Scenario: using WebDAV with restricted basic token auth + When requesting "/remote.php/webdav" with "PROPFIND" using restricted basic token auth + Then the HTTP status code should be "207" + + Scenario: using old WebDAV endpoint with unrestricted client token + When requesting "/remote.php/webdav" with "PROPFIND" using an unrestricted client token + Then the HTTP status code should be "207" + + Scenario: using new WebDAV endpoint with unrestricted client token + When requesting "/remote.php/dav/" with "PROPFIND" using an unrestricted client token + Then the HTTP status code should be "207" + + Scenario: using WebDAV with browser session + Given a new browser session is started + When requesting "/remote.php/webdav" with "PROPFIND" using browser session + Then the HTTP status code should be "207" + + # OCS + Scenario: using OCS anonymously + When requesting "/ocs/v1.php/apps/files_sharing/api/v1/remote_shares" with "GET" + Then the OCS status code should be "997" + + Scenario: using OCS with basic auth + When requesting "/ocs/v1.php/apps/files_sharing/api/v1/remote_shares" with "GET" using basic auth + Then the OCS status code should be "100" + + Scenario: using OCS with token auth + When requesting "/ocs/v1.php/apps/files_sharing/api/v1/remote_shares" with "GET" using unrestricted basic token auth + Then the OCS status code should be "100" + + Scenario: using OCS with an unrestricted client token + When requesting "/ocs/v1.php/apps/files_sharing/api/v1/remote_shares" with "GET" using an unrestricted client token + Then the OCS status code should be "100" + + Scenario: using OCS with browser session + Given a new browser session is started + When requesting "/ocs/v1.php/apps/files_sharing/api/v1/remote_shares" with "GET" using browser session + Then the OCS status code should be "100" + + # REMEMBER ME + Scenario: remember login + Given a new remembered browser session is started + When the session cookie expires + And requesting "/index.php/apps/files" with "GET" using browser session + Then the HTTP status code should be "200" + + # AUTH TOKENS + Scenario: Creating an auth token with regular auth token should not work + When requesting "/index.php/apps/files" with "GET" using restricted basic token auth + Then the HTTP status code should be "200" + When the CSRF token is extracted from the previous response + When a new unrestricted client token is added using restricted basic token auth + Then the HTTP status code should be "503" + + Scenario: Creating a restricted auth token with regular login should work + When a new restricted client token is added + Then the HTTP status code should be "200" + + Scenario: Creating an unrestricted auth token with regular login should work + When a new unrestricted client token is added + Then the HTTP status code should be "200" + diff --git a/build/integration/features/avatar.feature b/build/integration/features/avatar.feature new file mode 100644 index 00000000000..4c8c37fb98c --- /dev/null +++ b/build/integration/features/avatar.feature @@ -0,0 +1,217 @@ +# SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +Feature: avatar + + Background: + Given user "user0" exists + + Scenario: get default user avatar + When user "user0" gets avatar for user "user0" + Then The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 0 | + And last avatar is a square of size 512 + And last avatar is not a single color + + Scenario: get default user avatar as an anonymous user + When user "anonymous" gets avatar for user "user0" + Then The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 0 | + And last avatar is a square of size 512 + And last avatar is not a single color + + + + Scenario: get temporary non-square user avatar before cropping it + Given Logging in using web as "user0" + And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + When logged in user gets temporary avatar + Then The following headers should be set + | Content-Type | image/png | + # "last avatar" also includes the last temporary avatar + And last avatar is not a square + And last avatar is not a single color + + Scenario: get non-square user avatar before cropping it + Given Logging in using web as "user0" + And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + # Avatar needs to be cropped to finish setting it + When user "user0" gets avatar for user "user0" + Then The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 0 | + And last avatar is a square of size 512 + And last avatar is not a single color + + Scenario: set square user avatar from file + Given Logging in using web as "user0" + When logged in user posts temporary avatar from file "data/green-square-256.png" + And user "user0" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + # Last avatar size is 512 by default when getting avatar without size parameter + And last avatar is a square of size 512 + And last avatar is a single "#00FF00" color + And user "anonymous" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#00FF00" color + + Scenario: set square user avatar from internal path + Given user "user0" uploads file "data/green-square-256.png" to "/internal-green-square-256.png" + And Logging in using web as "user0" + When logged in user posts temporary avatar from internal path "internal-green-square-256.png" + And user "user0" gets avatar for user "user0" with size "64" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 64 + And last avatar is a single "#00FF00" color + And user "anonymous" gets avatar for user "user0" with size "64" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 64 + And last avatar is a single "#00FF00" color + + Scenario: set non-square user avatar from file + Given Logging in using web as "user0" + When logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + And logged in user crops temporary avatar + | x | 384 | + | y | 256 | + | w | 128 | + | h | 128 | + Then logged in user gets temporary avatar with 404 + And user "user0" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#FF0000" color + And user "anonymous" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#FF0000" color + + Scenario: set non-square user avatar from internal path + Given user "user0" uploads file "data/coloured-pattern-non-square.png" to "/internal-coloured-pattern-non-square.png" + And Logging in using web as "user0" + When logged in user posts temporary avatar from internal path "internal-coloured-pattern-non-square.png" + And logged in user crops temporary avatar + | x | 704 | + | y | 320 | + | w | 64 | + | h | 64 | + Then logged in user gets temporary avatar with 404 + And user "user0" gets avatar for user "user0" with size "64" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 64 + And last avatar is a single "#00FF00" color + And user "anonymous" gets avatar for user "user0" with size "64" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 64 + And last avatar is a single "#00FF00" color + + Scenario: cropped user avatar needs to be squared + Given Logging in using web as "user0" + And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + When logged in user crops temporary avatar with 400 + | x | 384 | + | y | 256 | + | w | 192 | + | h | 128 | + + + + Scenario: delete user avatar + Given Logging in using web as "user0" + And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + And logged in user crops temporary avatar + | x | 384 | + | y | 256 | + | w | 128 | + | h | 128 | + And user "user0" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#FF0000" color + And user "anonymous" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#FF0000" color + When logged in user deletes the user avatar + Then user "user0" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 0 | + And last avatar is a square of size 512 + And last avatar is not a single color + And user "anonymous" gets avatar for user "user0" + And The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 0 | + And last avatar is a square of size 512 + And last avatar is not a single color + + + + Scenario: get user avatar with a larger size than the original one + Given Logging in using web as "user0" + And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + And logged in user crops temporary avatar + | x | 384 | + | y | 256 | + | w | 128 | + | h | 128 | + When user "user0" gets avatar for user "user0" with size "192" + Then The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#FF0000" color + + Scenario: get user avatar with a smaller size than the original one + Given Logging in using web as "user0" + And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png" + And logged in user crops temporary avatar + | x | 384 | + | y | 256 | + | w | 128 | + | h | 128 | + When user "user0" gets avatar for user "user0" with size "96" + Then The following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size 512 + And last avatar is a single "#FF0000" color + + + + Scenario: get default guest avatar + When user "user0" gets avatar for guest "guest0" + Then The following headers should be set + | Content-Type | image/png | + And last avatar is a square of size 512 + And last avatar is not a single color + + Scenario: get default guest avatar as an anonymous user + When user "anonymous" gets avatar for guest "guest0" + Then The following headers should be set + | Content-Type | image/png | + And last avatar is a square of size 512 + And last avatar is not a single color diff --git a/build/integration/features/bootstrap/Activity.php b/build/integration/features/bootstrap/Activity.php new file mode 100644 index 00000000000..4172776304d --- /dev/null +++ b/build/integration/features/bootstrap/Activity.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +trait Activity { + use BasicStructure; + + /** + * @Then last activity should be + * @param TableNode $activity + */ + public function lastActivityIs(TableNode $activity): void { + $this->sendRequestForJSON('GET', '/apps/activity/api/v2/activity'); + $this->theHTTPStatusCodeShouldBe('200'); + $data = json_decode($this->response->getBody()->getContents(), true); + $activities = $data['ocs']['data']; + /* Sort by id */ + uasort($activities, fn ($a, $b) => $a['activity_id'] <=> $b['activity_id']); + $lastActivity = array_pop($activities); + foreach ($activity->getRowsHash() as $key => $value) { + Assert::assertEquals($value, $lastActivity[$key]); + } + } +} diff --git a/build/integration/features/bootstrap/AppConfiguration.php b/build/integration/features/bootstrap/AppConfiguration.php index af904a30896..e8580ed537b 100644 --- a/build/integration/features/bootstrap/AppConfiguration.php +++ b/build/integration/features/bootstrap/AppConfiguration.php @@ -1,8 +1,14 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use GuzzleHttp\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; require __DIR__ . '/../../vendor/autoload.php'; @@ -42,20 +48,37 @@ trait AppConfiguration { $body = new \Behat\Gherkin\Node\TableNode([['value', $value]]); $this->sendingToWith('post', "/apps/testing/api/v1/app/{$app}/{$parameter}", $body); $this->theHTTPStatusCodeShouldBe('200'); - $this->theOCSStatusCodeShouldBe('100'); + if ($this->apiVersion === 1) { + $this->theOCSStatusCodeShouldBe('100'); + } + } + + /** + * @param string $app + * @param string $parameter + * @param string $value + */ + protected function deleteServerConfig($app, $parameter) { + $this->sendingTo('DELETE', "/apps/testing/api/v1/app/{$app}/{$parameter}"); + $this->theHTTPStatusCodeShouldBe('200'); + if ($this->apiVersion === 1) { + $this->theOCSStatusCodeShouldBe('100'); + } } protected function setStatusTestingApp($enabled) { $this->sendingTo(($enabled ? 'post' : 'delete'), '/cloud/apps/testing'); $this->theHTTPStatusCodeShouldBe('200'); - $this->theOCSStatusCodeShouldBe('100'); + if ($this->apiVersion === 1) { + $this->theOCSStatusCodeShouldBe('100'); + } $this->sendingTo('get', '/cloud/apps?filter=enabled'); $this->theHTTPStatusCodeShouldBe('200'); if ($enabled) { - PHPUnit_Framework_Assert::assertContains('testing', $this->response->getBody()->getContents()); + Assert::assertStringContainsString('testing', $this->response->getBody()->getContents()); } else { - PHPUnit_Framework_Assert::assertNotContains('testing', $this->response->getBody()->getContents()); + Assert::assertStringNotContainsString('testing', $this->response->getBody()->getContents()); } } @@ -68,7 +91,7 @@ trait AppConfiguration { * reset the configs before each scenario * @param BeforeScenarioScope $event */ - public function prepareParameters(BeforeScenarioScope $event){ + public function prepareParameters(BeforeScenarioScope $event) { $user = $this->currentUser; $this->currentUser = 'admin'; diff --git a/build/integration/features/bootstrap/Auth.php b/build/integration/features/bootstrap/Auth.php new file mode 100644 index 00000000000..aeaade85383 --- /dev/null +++ b/build/integration/features/bootstrap/Auth.php @@ -0,0 +1,256 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use GuzzleHttp\Client; +use GuzzleHttp\Cookie\CookieJar; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ServerException; + +require __DIR__ . '/../../vendor/autoload.php'; + +trait Auth { + /** @var string */ + private $unrestrictedClientToken; + /** @var string */ + private $restrictedClientToken; + /** @var Client */ + private $client; + /** @var string */ + private $responseXml; + + /** @BeforeScenario */ + public function setUpScenario() { + $this->client = new Client(); + $this->responseXml = ''; + $this->cookieJar = new CookieJar(); + } + + /** + * @When requesting :url with :method + */ + public function requestingWith($url, $method) { + $this->sendRequest($url, $method); + } + + private function sendRequest($url, $method, $authHeader = null, $useCookies = false) { + $fullUrl = substr($this->baseUrl, 0, -5) . $url; + try { + if ($useCookies) { + $options = [ + 'cookies' => $this->cookieJar, + ]; + } else { + $options = []; + } + if ($authHeader) { + $options['headers'] = [ + 'Authorization' => $authHeader + ]; + } + $options['headers']['OCS_APIREQUEST'] = 'true'; + $options['headers']['requesttoken'] = $this->requestToken; + $this->response = $this->client->request($method, $fullUrl, $options); + } catch (ClientException $ex) { + $this->response = $ex->getResponse(); + } catch (ServerException $ex) { + $this->response = $ex->getResponse(); + } + } + + /** + * @When the CSRF token is extracted from the previous response + */ + public function theCsrfTokenIsExtractedFromThePreviousResponse() { + $this->requestToken = substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $this->response->getBody()->getContents()), 0, 89); + } + + /** + * @param bool $loginViaWeb + * @return object + */ + private function createClientToken($loginViaWeb = true) { + if ($loginViaWeb) { + $this->loggingInUsingWebAs('user0'); + } + + $fullUrl = substr($this->baseUrl, 0, -5) . '/index.php/settings/personal/authtokens'; + $client = new Client(); + $options = [ + 'auth' => [ + 'user0', + $loginViaWeb ? '123456' : $this->restrictedClientToken, + ], + 'form_params' => [ + 'requesttoken' => $this->requestToken, + 'name' => md5(microtime()), + ], + 'cookies' => $this->cookieJar, + ]; + + try { + $this->response = $client->request('POST', $fullUrl, $options); + } catch (\GuzzleHttp\Exception\ServerException $e) { + $this->response = $e->getResponse(); + } + return json_decode($this->response->getBody()->getContents()); + } + + /** + * @Given a new restricted client token is added + */ + public function aNewRestrictedClientTokenIsAdded() { + $tokenObj = $this->createClientToken(); + $newCreatedTokenId = $tokenObj->deviceToken->id; + $fullUrl = substr($this->baseUrl, 0, -5) . '/index.php/settings/personal/authtokens/' . $newCreatedTokenId; + $client = new Client(); + $options = [ + 'auth' => ['user0', '123456'], + 'headers' => [ + 'requesttoken' => $this->requestToken, + ], + 'json' => [ + 'name' => md5(microtime()), + 'scope' => [ + 'filesystem' => false, + ], + ], + 'cookies' => $this->cookieJar, + ]; + $this->response = $client->request('PUT', $fullUrl, $options); + $this->restrictedClientToken = $tokenObj->token; + } + + /** + * @Given a new unrestricted client token is added + */ + public function aNewUnrestrictedClientTokenIsAdded() { + $this->unrestrictedClientToken = $this->createClientToken()->token; + } + + /** + * @When a new unrestricted client token is added using restricted basic token auth + */ + public function aNewUnrestrictedClientTokenIsAddedUsingRestrictedBasicTokenAuth() { + $this->createClientToken(false); + } + + /** + * @When requesting :url with :method using basic auth + * + * @param string $url + * @param string $method + */ + public function requestingWithBasicAuth($url, $method) { + $this->sendRequest($url, $method, 'basic ' . base64_encode('user0:123456')); + } + + /** + * @When requesting :url with :method using unrestricted basic token auth + * + * @param string $url + * @param string $method + */ + public function requestingWithUnrestrictedBasicTokenAuth($url, $method) { + $this->sendRequest($url, $method, 'basic ' . base64_encode('user0:' . $this->unrestrictedClientToken), true); + } + + /** + * @When requesting :url with :method using restricted basic token auth + * + * @param string $url + * @param string $method + */ + public function requestingWithRestrictedBasicTokenAuth($url, $method) { + $this->sendRequest($url, $method, 'basic ' . base64_encode('user0:' . $this->restrictedClientToken), true); + } + + /** + * @When requesting :url with :method using an unrestricted client token + * + * @param string $url + * @param string $method + */ + public function requestingWithUsingAnUnrestrictedClientToken($url, $method) { + $this->sendRequest($url, $method, 'Bearer ' . $this->unrestrictedClientToken); + } + + /** + * @When requesting :url with :method using a restricted client token + * + * @param string $url + * @param string $method + */ + public function requestingWithUsingARestrictedClientToken($url, $method) { + $this->sendRequest($url, $method, 'Bearer ' . $this->restrictedClientToken); + } + + /** + * @When requesting :url with :method using browser session + * + * @param string $url + * @param string $method + */ + public function requestingWithBrowserSession($url, $method) { + $this->sendRequest($url, $method, null, true); + } + + /** + * @Given a new browser session is started + * + * @param bool $remember + */ + public function aNewBrowserSessionIsStarted($remember = false) { + $baseUrl = substr($this->baseUrl, 0, -5); + $loginUrl = $baseUrl . '/login'; + // Request a new session and extract CSRF token + $client = new Client(); + $response = $client->get($loginUrl, [ + 'cookies' => $this->cookieJar, + ]); + $this->extracRequestTokenFromResponse($response); + + // Login and extract new token + $client = new Client(); + $response = $client->post( + $loginUrl, [ + 'form_params' => [ + 'user' => 'user0', + 'password' => '123456', + 'remember_login' => $remember ? '1' : '0', + 'requesttoken' => $this->requestToken, + ], + 'cookies' => $this->cookieJar, + 'headers' => [ + 'Origin' => $baseUrl, + ], + ] + ); + $this->extracRequestTokenFromResponse($response); + } + + /** + * @Given a new remembered browser session is started + */ + public function aNewRememberedBrowserSessionIsStarted() { + $this->aNewBrowserSessionIsStarted(true); + } + + + /** + * @Given the cookie jar is reset + */ + public function theCookieJarIsReset() { + $this->cookieJar = new CookieJar(); + } + + /** + * @When the session cookie expires + */ + public function whenTheSessionCookieExpires() { + $this->cookieJar->clearSessionCookies(); + } +} diff --git a/build/integration/features/bootstrap/Avatar.php b/build/integration/features/bootstrap/Avatar.php new file mode 100644 index 00000000000..beebf1c024a --- /dev/null +++ b/build/integration/features/bootstrap/Avatar.php @@ -0,0 +1,262 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +require __DIR__ . '/../../vendor/autoload.php'; + +trait Avatar { + /** @var string * */ + private $lastAvatar; + + /** @AfterScenario **/ + public function cleanupLastAvatar() { + $this->lastAvatar = null; + } + + private function getLastAvatar() { + $this->lastAvatar = ''; + + $body = $this->response->getBody(); + while (!$body->eof()) { + $this->lastAvatar .= $body->read(8192); + } + $body->close(); + } + + /** + * @When user :user gets avatar for user :userAvatar + * + * @param string $user + * @param string $userAvatar + */ + public function userGetsAvatarForUser(string $user, string $userAvatar) { + $this->userGetsAvatarForUserWithSize($user, $userAvatar, '128'); + } + + /** + * @When user :user gets avatar for user :userAvatar with size :size + * + * @param string $user + * @param string $userAvatar + * @param string $size + */ + public function userGetsAvatarForUserWithSize(string $user, string $userAvatar, string $size) { + $this->asAn($user); + $this->sendingToDirectUrl('GET', '/index.php/avatar/' . $userAvatar . '/' . $size); + $this->theHTTPStatusCodeShouldBe('200'); + + $this->getLastAvatar(); + } + + /** + * @When user :user gets avatar for guest :guestAvatar + * + * @param string $user + * @param string $guestAvatar + */ + public function userGetsAvatarForGuest(string $user, string $guestAvatar) { + $this->asAn($user); + $this->sendingToDirectUrl('GET', '/index.php/avatar/guest/' . $guestAvatar . '/128'); + $this->theHTTPStatusCodeShouldBe('201'); + + $this->getLastAvatar(); + } + + /** + * @When logged in user gets temporary avatar + */ + public function loggedInUserGetsTemporaryAvatar() { + $this->loggedInUserGetsTemporaryAvatarWith('200'); + } + + /** + * @When logged in user gets temporary avatar with :statusCode + * + * @param string $statusCode + */ + public function loggedInUserGetsTemporaryAvatarWith(string $statusCode) { + $this->sendingAToWithRequesttoken('GET', '/index.php/avatar/tmp'); + $this->theHTTPStatusCodeShouldBe($statusCode); + + $this->getLastAvatar(); + } + + /** + * @When logged in user posts temporary avatar from file :source + * + * @param string $source + */ + public function loggedInUserPostsTemporaryAvatarFromFile(string $source) { + $file = \GuzzleHttp\Psr7\Utils::streamFor(fopen($source, 'r')); + + $this->sendingAToWithRequesttoken('POST', '/index.php/avatar', + [ + 'multipart' => [ + [ + 'name' => 'files[]', + 'contents' => $file + ] + ] + ]); + $this->theHTTPStatusCodeShouldBe('200'); + } + + /** + * @When logged in user posts temporary avatar from internal path :path + * + * @param string $path + */ + public function loggedInUserPostsTemporaryAvatarFromInternalPath(string $path) { + $this->sendingAToWithRequesttoken('POST', '/index.php/avatar?path=' . $path); + $this->theHTTPStatusCodeShouldBe('200'); + } + + /** + * @When logged in user crops temporary avatar + * + * @param TableNode $crop + */ + public function loggedInUserCropsTemporaryAvatar(TableNode $crop) { + $this->loggedInUserCropsTemporaryAvatarWith('200', $crop); + } + + /** + * @When logged in user crops temporary avatar with :statusCode + * + * @param string $statusCode + * @param TableNode $crop + */ + public function loggedInUserCropsTemporaryAvatarWith(string $statusCode, TableNode $crop) { + $parameters = []; + foreach ($crop->getRowsHash() as $key => $value) { + $parameters[] = 'crop[' . $key . ']=' . $value; + } + + $this->sendingAToWithRequesttoken('POST', '/index.php/avatar/cropped?' . implode('&', $parameters)); + $this->theHTTPStatusCodeShouldBe($statusCode); + } + + /** + * @When logged in user deletes the user avatar + */ + public function loggedInUserDeletesTheUserAvatar() { + $this->sendingAToWithRequesttoken('DELETE', '/index.php/avatar'); + $this->theHTTPStatusCodeShouldBe('200'); + } + + /** + * @Then last avatar is a square of size :size + * + * @param string size + */ + public function lastAvatarIsASquareOfSize(string $size) { + [$width, $height] = getimagesizefromstring($this->lastAvatar); + + Assert::assertEquals($width, $height, 'Expected avatar to be a square'); + Assert::assertEquals($size, $width); + } + + /** + * @Then last avatar is not a square + */ + public function lastAvatarIsNotASquare() { + [$width, $height] = getimagesizefromstring($this->lastAvatar); + + Assert::assertNotEquals($width, $height, 'Expected avatar to not be a square'); + } + + /** + * @Then last avatar is not a single color + */ + public function lastAvatarIsNotASingleColor() { + Assert::assertEquals(null, $this->getColorFromLastAvatar()); + } + + /** + * @Then last avatar is a single :color color + * + * @param string $color + * @param string $size + */ + public function lastAvatarIsASingleColor(string $color) { + $expectedColor = $this->hexStringToRgbColor($color); + $colorFromLastAvatar = $this->getColorFromLastAvatar(); + + Assert::assertTrue($this->isSameColor($expectedColor, $colorFromLastAvatar), + $this->rgbColorToHexString($colorFromLastAvatar) . ' does not match expected ' . $color); + } + + private function hexStringToRgbColor($hexString) { + // Strip initial "#" + $hexString = substr($hexString, 1); + + $rgbColorInt = hexdec($hexString); + + // RGBA hex strings are not supported; the given string is assumed to be + // an RGB hex string. + return [ + 'red' => ($rgbColorInt >> 16) & 0xFF, + 'green' => ($rgbColorInt >> 8) & 0xFF, + 'blue' => $rgbColorInt & 0xFF, + 'alpha' => 0 + ]; + } + + private function rgbColorToHexString($rgbColor) { + $rgbColorInt = ($rgbColor['red'] << 16) + ($rgbColor['green'] << 8) + ($rgbColor['blue']); + + return '#' . str_pad(strtoupper(dechex($rgbColorInt)), 6, '0', STR_PAD_LEFT); + } + + private function getColorFromLastAvatar() { + $image = imagecreatefromstring($this->lastAvatar); + + $firstPixelColorIndex = imagecolorat($image, 0, 0); + $firstPixelColor = imagecolorsforindex($image, $firstPixelColorIndex); + + for ($i = 0; $i < imagesx($image); $i++) { + for ($j = 0; $j < imagesx($image); $j++) { + $currentPixelColorIndex = imagecolorat($image, $i, $j); + $currentPixelColor = imagecolorsforindex($image, $currentPixelColorIndex); + + // The colors are compared with a small allowed delta, as even + // on solid color images the resizing can cause some small + // artifacts that slightly modify the color of certain pixels. + if (!$this->isSameColor($firstPixelColor, $currentPixelColor)) { + imagedestroy($image); + + return null; + } + } + } + + imagedestroy($image); + + return $firstPixelColor; + } + + private function isSameColor(array $firstColor, array $secondColor, int $allowedDelta = 1) { + if ($this->isSameColorComponent($firstColor['red'], $secondColor['red'], $allowedDelta) + && $this->isSameColorComponent($firstColor['green'], $secondColor['green'], $allowedDelta) + && $this->isSameColorComponent($firstColor['blue'], $secondColor['blue'], $allowedDelta) + && $this->isSameColorComponent($firstColor['alpha'], $secondColor['alpha'], $allowedDelta)) { + return true; + } + + return false; + } + + private function isSameColorComponent(int $firstColorComponent, int $secondColorComponent, int $allowedDelta) { + if ($firstColorComponent >= ($secondColorComponent - $allowedDelta) + && $firstColorComponent <= ($secondColorComponent + $allowedDelta)) { + return true; + } + + return false; + } +} diff --git a/build/integration/features/bootstrap/BasicStructure.php b/build/integration/features/bootstrap/BasicStructure.php index d2aed82055a..59a4312913e 100644 --- a/build/integration/features/bootstrap/BasicStructure.php +++ b/build/integration/features/bootstrap/BasicStructure.php @@ -1,11 +1,27 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Gherkin\Node\TableNode; use GuzzleHttp\Client; -use GuzzleHttp\Message\ResponseInterface; +use GuzzleHttp\Cookie\CookieJar; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ServerException; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; require __DIR__ . '/../../vendor/autoload.php'; trait BasicStructure { + use Auth; + use Avatar; + use Download; + use Mail; + use Theming; + /** @var string */ private $currentUser = ''; @@ -21,22 +37,26 @@ trait BasicStructure { /** @var ResponseInterface */ private $response = null; - /** @var \GuzzleHttp\Cookie\CookieJar */ + /** @var CookieJar */ private $cookieJar; /** @var string */ - private $requesttoken; + private $requestToken; - public function __construct($baseUrl, $admin, $regular_user_password) { + protected $adminUser; + protected $regularUser; + protected $localBaseUrl; + protected $remoteBaseUrl; + public function __construct($baseUrl, $admin, $regular_user_password) { // Initialize your context here $this->baseUrl = $baseUrl; $this->adminUser = $admin; $this->regularUser = $regular_user_password; - $this->localBaseUrl = substr($this->baseUrl, 0, -4); - $this->remoteBaseUrl = substr($this->baseUrl, 0, -4); + $this->localBaseUrl = $this->baseUrl; + $this->remoteBaseUrl = $this->baseUrl; $this->currentServer = 'LOCAL'; - $this->cookieJar = new \GuzzleHttp\Cookie\CookieJar(); + $this->cookieJar = new CookieJar(); // in case of ci deployment we take the server url from the environment $testServerUrl = getenv('TEST_SERVER_URL'); @@ -53,11 +73,11 @@ trait BasicStructure { } /** - * @Given /^using api version "([^"]*)"$/ + * @Given /^using api version "(\d+)"$/ * @param string $version */ public function usingApiVersion($version) { - $this->apiVersion = $version; + $this->apiVersion = (int)$version; } /** @@ -75,7 +95,7 @@ trait BasicStructure { */ public function usingServer($server) { $previousServer = $this->currentServer; - if ($server === 'LOCAL'){ + if ($server === 'LOCAL') { $this->baseUrl = $this->localBaseUrl; $this->currentServer = 'LOCAL'; return $previousServer; @@ -98,20 +118,28 @@ trait BasicStructure { /** * 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 */ public function getOCSResponse($response) { - return $response->xml()->meta[0]->statuscode; + $body = simplexml_load_string((string)$response->getBody()); + if ($body === false) { + throw new \RuntimeException('Could not parse OCS response, body is not valid XML'); + } + return $body->meta[0]->statuscode; } /** * This function is needed to use a vertical fashion in the gherkin tables. + * * @param array $arrayOfArrays * @return array */ - public function simplifyArray($arrayOfArrays){ - $a = array_map(function($subArray) { return $subArray[0]; }, $arrayOfArrays); + public function simplifyArray($arrayOfArrays) { + $a = array_map(function ($subArray) { + return $subArray[0]; + }, $arrayOfArrays); return $a; } @@ -119,7 +147,7 @@ trait BasicStructure { * @When /^sending "([^"]*)" to "([^"]*)" with$/ * @param string $verb * @param string $url - * @param \Behat\Gherkin\Node\TableNode $body + * @param TableNode $body */ public function sendingToWith($verb, $url, $body) { $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php" . $url; @@ -127,25 +155,101 @@ trait BasicStructure { $options = []; if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; - } else { + } elseif (strpos($this->currentUser, 'anonymous') !== 0) { $options['auth'] = [$this->currentUser, $this->regularUser]; } - if ($body instanceof \Behat\Gherkin\Node\TableNode) { + $options['headers'] = [ + 'OCS-APIRequest' => 'true' + ]; + if ($body instanceof TableNode) { $fd = $body->getRowsHash(); - $options['body'] = $fd; + $options['form_params'] = $fd; + } + + // TODO: Fix this hack! + if ($verb === 'PUT' && $body === null) { + $options['form_params'] = [ + 'foo' => 'bar', + ]; } try { - $this->response = $client->send($client->createRequest($verb, $fullUrl, $options)); - } catch (\GuzzleHttp\Exception\ClientException $ex) { + $this->response = $client->request($verb, $fullUrl, $options); + } catch (ClientException $ex) { + $this->response = $ex->getResponse(); + } catch (ServerException $ex) { $this->response = $ex->getResponse(); } } - public function isExpectedUrl($possibleUrl, $finalPart){ + /** + * @param string $verb + * @param string $url + * @param TableNode|array|null $body + * @param array $headers + */ + protected function sendRequestForJSON(string $verb, string $url, $body = null, array $headers = []): void { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php" . $url; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = ['admin', 'admin']; + } elseif (strpos($this->currentUser, 'anonymous') !== 0) { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + if ($body instanceof TableNode) { + $fd = $body->getRowsHash(); + $options['form_params'] = $fd; + } elseif (is_array($body)) { + $options['form_params'] = $body; + } + + $options['headers'] = array_merge($headers, [ + 'OCS-ApiRequest' => 'true', + 'Accept' => 'application/json', + ]); + + try { + $this->response = $client->{$verb}($fullUrl, $options); + } catch (ClientException $ex) { + $this->response = $ex->getResponse(); + } + } + + /** + * @When /^sending "([^"]*)" with exact url to "([^"]*)"$/ + * @param string $verb + * @param string $url + */ + public function sendingToDirectUrl($verb, $url) { + $this->sendingToWithDirectUrl($verb, $url, null); + } + + public function sendingToWithDirectUrl($verb, $url, $body) { + $fullUrl = substr($this->baseUrl, 0, -5) . $url; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } elseif (strpos($this->currentUser, 'anonymous') !== 0) { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + if ($body instanceof TableNode) { + $fd = $body->getRowsHash(); + $options['form_params'] = $fd; + } + + try { + $this->response = $client->request($verb, $fullUrl, $options); + } catch (ClientException $ex) { + $this->response = $ex->getResponse(); + } + } + + public function isExpectedUrl($possibleUrl, $finalPart) { $baseUrlChopped = substr($this->baseUrl, 0, -4); $endCharacter = strlen($baseUrlChopped) + strlen($finalPart); - return (substr($possibleUrl,0,$endCharacter) == "$baseUrlChopped" . "$finalPart"); + return (substr($possibleUrl, 0, $endCharacter) == "$baseUrlChopped" . "$finalPart"); } /** @@ -153,7 +257,7 @@ trait BasicStructure { * @param int $statusCode */ public function theOCSStatusCodeShouldBe($statusCode) { - PHPUnit_Framework_Assert::assertEquals($statusCode, $this->getOCSResponse($this->response)); + Assert::assertEquals($statusCode, $this->getOCSResponse($this->response)); } /** @@ -161,14 +265,22 @@ trait BasicStructure { * @param int $statusCode */ public function theHTTPStatusCodeShouldBe($statusCode) { - PHPUnit_Framework_Assert::assertEquals($statusCode, $this->response->getStatusCode()); + Assert::assertEquals($statusCode, $this->response->getStatusCode()); + } + + /** + * @Then /^the Content-Type should be "([^"]*)"$/ + * @param string $contentType + */ + public function theContentTypeShouldbe($contentType) { + Assert::assertEquals($contentType, $this->response->getHeader('Content-Type')[0]); } /** * @param ResponseInterface $response */ private function extracRequestTokenFromResponse(ResponseInterface $response) { - $this->requesttoken = substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89); + $this->requestToken = substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89); } /** @@ -176,7 +288,8 @@ trait BasicStructure { * @param string $user */ public function loggingInUsingWebAs($user) { - $loginUrl = substr($this->baseUrl, 0, -5); + $baseUrl = substr($this->baseUrl, 0, -5); + $loginUrl = $baseUrl . '/index.php/login'; // Request a new session and extract CSRF token $client = new Client(); $response = $client->get( @@ -193,12 +306,15 @@ trait BasicStructure { $response = $client->post( $loginUrl, [ - 'body' => [ + 'form_params' => [ 'user' => $user, 'password' => $password, - 'requesttoken' => $this->requesttoken, + 'requesttoken' => $this->requestToken, ], 'cookies' => $this->cookieJar, + 'headers' => [ + 'Origin' => $baseUrl, + ], ] ); $this->extracRequestTokenFromResponse($response); @@ -208,22 +324,33 @@ trait BasicStructure { * @When Sending a :method to :url with requesttoken * @param string $method * @param string $url + * @param TableNode|array|null $body */ - public function sendingAToWithRequesttoken($method, $url) { + public function sendingAToWithRequesttoken($method, $url, $body = null) { $baseUrl = substr($this->baseUrl, 0, -5); + $options = [ + 'cookies' => $this->cookieJar, + 'headers' => [ + 'requesttoken' => $this->requestToken + ], + ]; + + if ($body instanceof TableNode) { + $fd = $body->getRowsHash(); + $options['form_params'] = $fd; + } elseif ($body) { + $options = array_merge_recursive($options, $body); + } + $client = new Client(); - $request = $client->createRequest( - $method, - $baseUrl . $url, - [ - 'cookies' => $this->cookieJar, - ] - ); - $request->addHeader('requesttoken', $this->requesttoken); try { - $this->response = $client->send($request); - } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $client->request( + $method, + $baseUrl . $url, + $options + ); + } catch (ClientException $e) { $this->response = $e->getResponse(); } } @@ -237,64 +364,199 @@ trait BasicStructure { $baseUrl = substr($this->baseUrl, 0, -5); $client = new Client(); - $request = $client->createRequest( - $method, - $baseUrl . $url, - [ - 'cookies' => $this->cookieJar, - ] - ); try { - $this->response = $client->send($request); - } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $client->request( + $method, + $baseUrl . $url, + [ + 'cookies' => $this->cookieJar + ] + ); + } catch (ClientException $e) { $this->response = $e->getResponse(); } } - public static function removeFile($path, $filename){ + public static function removeFile($path, $filename) { if (file_exists("$path" . "$filename")) { unlink("$path" . "$filename"); } } /** + * @Given User :user modifies text of :filename with text :text + * @param string $user + * @param string $filename + * @param string $text + */ + public function modifyTextOfFile($user, $filename, $text) { + self::removeFile($this->getDataDirectory() . "/$user/files", "$filename"); + file_put_contents($this->getDataDirectory() . "/$user/files" . "$filename", "$text"); + } + + private function getDataDirectory() { + // Based on "runOcc" from CommandLine trait + $args = ['config:system:get', 'datadirectory']; + $args = array_map(function ($arg) { + return escapeshellarg($arg); + }, $args); + $args[] = '--no-ansi --no-warnings'; + $args = implode(' ', $args); + + $descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open('php console.php ' . $args, $descriptor, $pipes, $ocPath = '../..'); + $lastStdOut = stream_get_contents($pipes[1]); + proc_close($process); + + return trim($lastStdOut); + } + + /** + * @Given file :filename is created :times times in :user user data + * @param string $filename + * @param string $times + * @param string $user + */ + public function fileIsCreatedTimesInUserData($filename, $times, $user) { + for ($i = 0; $i < $times; $i++) { + file_put_contents($this->getDataDirectory() . "/$user/files" . "$filename-$i", "content-$i"); + } + } + + public function createFileSpecificSize($name, $size) { + $file = fopen('work/' . "$name", 'w'); + fseek($file, $size - 1, SEEK_CUR); + fwrite($file, 'a'); // write a dummy char at SIZE position + fclose($file); + } + + public function createFileWithText($name, $text) { + $file = fopen('work/' . "$name", 'w'); + fwrite($file, $text); + fclose($file); + } + + /** + * @Given file :filename of size :size is created in local storage + * @param string $filename + * @param string $size + */ + public function fileIsCreatedInLocalStorageWithSize($filename, $size) { + $this->createFileSpecificSize("local_storage/$filename", $size); + } + + /** + * @Given file :filename with text :text is created in local storage + * @param string $filename + * @param string $text + */ + public function fileIsCreatedInLocalStorageWithText($filename, $text) { + $this->createFileWithText("local_storage/$filename", $text); + } + + /** + * @When Sleep for :seconds seconds + * @param int $seconds + */ + public function sleepForSeconds($seconds) { + sleep((int)$seconds); + } + + /** * @BeforeSuite */ - public static function addFilesToSkeleton(){ - for ($i=0; $i<5; $i++){ - file_put_contents("../../core/skeleton/" . "textfile" . "$i" . ".txt", "ownCloud test text file\n"); + public static function addFilesToSkeleton() { + for ($i = 0; $i < 5; $i++) { + file_put_contents('../../core/skeleton/' . 'textfile' . "$i" . '.txt', "Nextcloud test text file\n"); } - if (!file_exists("../../core/skeleton/FOLDER")) { - mkdir("../../core/skeleton/FOLDER", 0777, true); + if (!file_exists('../../core/skeleton/FOLDER')) { + mkdir('../../core/skeleton/FOLDER', 0777, true); } - if (!file_exists("../../core/skeleton/PARENT")) { - mkdir("../../core/skeleton/PARENT", 0777, true); + if (!file_exists('../../core/skeleton/PARENT')) { + mkdir('../../core/skeleton/PARENT', 0777, true); } - file_put_contents("../../core/skeleton/PARENT/" . "parent.txt", "ownCloud test text file\n"); - if (!file_exists("../../core/skeleton/PARENT/CHILD")) { - mkdir("../../core/skeleton/PARENT/CHILD", 0777, true); + file_put_contents('../../core/skeleton/PARENT/' . 'parent.txt', "Nextcloud test text file\n"); + if (!file_exists('../../core/skeleton/PARENT/CHILD')) { + mkdir('../../core/skeleton/PARENT/CHILD', 0777, true); } - file_put_contents("../../core/skeleton/PARENT/CHILD/" . "child.txt", "ownCloud test text file\n"); + file_put_contents('../../core/skeleton/PARENT/CHILD/' . 'child.txt', "Nextcloud test text file\n"); } /** * @AfterSuite */ - public static function removeFilesFromSkeleton(){ - for ($i=0; $i<5; $i++){ - self::removeFile("../../core/skeleton/", "textfile" . "$i" . ".txt"); + public static function removeFilesFromSkeleton() { + for ($i = 0; $i < 5; $i++) { + self::removeFile('../../core/skeleton/', 'textfile' . "$i" . '.txt'); } - if (is_dir("../../core/skeleton/FOLDER")) { - rmdir("../../core/skeleton/FOLDER"); + if (is_dir('../../core/skeleton/FOLDER')) { + rmdir('../../core/skeleton/FOLDER'); } - self::removeFile("../../core/skeleton/PARENT/CHILD/", "child.txt"); - if (is_dir("../../core/skeleton/PARENT/CHILD")) { - rmdir("../../core/skeleton/PARENT/CHILD"); + self::removeFile('../../core/skeleton/PARENT/CHILD/', 'child.txt'); + if (is_dir('../../core/skeleton/PARENT/CHILD')) { + rmdir('../../core/skeleton/PARENT/CHILD'); } - self::removeFile("../../core/skeleton/PARENT/", "parent.txt"); - if (is_dir("../../core/skeleton/PARENT")) { - rmdir("../../core/skeleton/PARENT"); + self::removeFile('../../core/skeleton/PARENT/', 'parent.txt'); + if (is_dir('../../core/skeleton/PARENT')) { + rmdir('../../core/skeleton/PARENT'); + } + } + + /** + * @BeforeScenario @local_storage + */ + public static function removeFilesFromLocalStorageBefore() { + $dir = './work/local_storage/'; + $di = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); + $ri = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($ri as $file) { + $file->isDir() ? rmdir($file) : unlink($file); } } -} + /** + * @AfterScenario @local_storage + */ + public static function removeFilesFromLocalStorageAfter() { + $dir = './work/local_storage/'; + $di = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); + $ri = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($ri as $file) { + $file->isDir() ? rmdir($file) : unlink($file); + } + } + + /** + * @Given /^cookies are reset$/ + */ + public function cookiesAreReset() { + $this->cookieJar = new CookieJar(); + } + + /** + * @Then The following headers should be set + * @param TableNode $table + * @throws \Exception + */ + public function theFollowingHeadersShouldBeSet(TableNode $table) { + foreach ($table->getTable() as $header) { + $headerName = $header[0]; + $expectedHeaderValue = $header[1]; + $returnedHeader = $this->response->getHeader($headerName)[0]; + if ($returnedHeader !== $expectedHeaderValue) { + throw new \Exception( + sprintf( + "Expected value '%s' for header '%s', got '%s'", + $expectedHeaderValue, + $headerName, + $returnedHeader + ) + ); + } + } + } +} diff --git a/build/integration/features/bootstrap/CalDavContext.php b/build/integration/features/bootstrap/CalDavContext.php index 30c50630b3e..459c35089fa 100644 --- a/build/integration/features/bootstrap/CalDavContext.php +++ b/build/integration/features/bootstrap/CalDavContext.php @@ -1,31 +1,18 @@ <?php + /** - * @author Lukas Reschke <lukas@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - require __DIR__ . '/../../vendor/autoload.php'; use GuzzleHttp\Client; -use GuzzleHttp\Message\ResponseInterface; +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\ResponseInterface; class CalDavContext implements \Behat\Behat\Context\Context { - /** @var string */ + /** @var string */ private $baseUrl; /** @var Client */ private $client; @@ -48,14 +35,14 @@ class CalDavContext implements \Behat\Behat\Context\Context { } /** @BeforeScenario */ - public function tearUpScenario() { + public function setUpScenario() { $this->client = new Client(); $this->responseXml = ''; } /** @AfterScenario */ public function afterScenario() { - $davUrl = $this->baseUrl. '/remote.php/dav/calendars/admin/MyCalendar'; + $davUrl = $this->baseUrl . '/remote.php/dav/calendars/admin/MyCalendar'; try { $this->client->delete( $davUrl, @@ -64,28 +51,34 @@ class CalDavContext implements \Behat\Behat\Context\Context { 'admin', 'admin', ], + 'headers' => [ + 'X-NC-CalDAV-No-Trashbin' => '1', + ] ] ); - } catch (\GuzzleHttp\Exception\ClientException $e) {} + } catch (\GuzzleHttp\Exception\ClientException $e) { + } } /** - * @When :user requests calendar :calendar + * @When :user requests calendar :calendar on the endpoint :endpoint * @param string $user * @param string $calendar + * @param string $endpoint */ - public function requestsCalendar($user, $calendar) { - $davUrl = $this->baseUrl . '/remote.php/dav/calendars/'.$calendar; + public function requestsCalendar($user, $calendar, $endpoint) { + $davUrl = $this->baseUrl . $endpoint . $calendar; $password = ($user === 'admin') ? 'admin' : '123456'; try { - $this->response = $this->client->get( + $this->response = $this->client->request( + 'PROPFIND', $davUrl, [ 'auth' => [ $user, $password, - ] + ], ] ); } catch (\GuzzleHttp\Exception\ClientException $e) { @@ -94,12 +87,125 @@ class CalDavContext implements \Behat\Behat\Context\Context { } /** + * @When :user requests principal :principal on the endpoint :endpoint + */ + public function requestsPrincipal(string $user, string $principal, string $endpoint): void { + $davUrl = $this->baseUrl . $endpoint . $principal; + + $password = ($user === 'admin') ? 'admin' : '123456'; + try { + $this->response = $this->client->request( + 'PROPFIND', + $davUrl, + [ + 'headers' => [ + 'Content-Type' => 'application/xml; charset=UTF-8', + 'Depth' => 0, + ], + 'body' => '<x0:propfind xmlns:x0="DAV:"><x0:prop><x0:displayname/><x1:calendar-user-type xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:calendar-user-address-set xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x0:principal-URL/><x0:alternate-URI-set/><x2:email-address xmlns:x2="http://sabredav.org/ns"/><x3:language xmlns:x3="http://nextcloud.com/ns"/><x1:calendar-home-set xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:schedule-inbox-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:schedule-outbox-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:schedule-default-calendar-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x3:resource-type xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-type xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-make xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-model xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-is-electric xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-range xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-seating-capacity xmlns:x3="http://nextcloud.com/ns"/><x3:resource-contact-person xmlns:x3="http://nextcloud.com/ns"/><x3:resource-contact-person-vcard xmlns:x3="http://nextcloud.com/ns"/><x3:room-type xmlns:x3="http://nextcloud.com/ns"/><x3:room-seating-capacity xmlns:x3="http://nextcloud.com/ns"/><x3:room-building-address xmlns:x3="http://nextcloud.com/ns"/><x3:room-building-story xmlns:x3="http://nextcloud.com/ns"/><x3:room-building-room-number xmlns:x3="http://nextcloud.com/ns"/><x3:room-features xmlns:x3="http://nextcloud.com/ns"/><x0:principal-collection-set/><x0:supported-report-set/></x0:prop></x0:propfind>', + 'auth' => [ + $user, + $password, + ], + ] + ); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @Then The CalDAV response should contain a property :key + * @throws \Exception + */ + public function theCaldavResponseShouldContainAProperty(string $key): void { + /** @var \Sabre\DAV\Xml\Response\MultiStatus $multiStatus */ + $multiStatus = $this->responseXml['value']; + $responses = $multiStatus->getResponses()[0]->getResponseProperties(); + if (!isset($responses[200])) { + throw new \Exception( + sprintf( + 'Expected code 200 got [%s]', + implode(',', array_keys($responses)), + ) + ); + } + + $props = $responses[200]; + if (!array_key_exists($key, $props)) { + throw new \Exception( + sprintf( + 'Expected property %s in %s', + $key, + json_encode($props, JSON_PRETTY_PRINT), + ) + ); + } + } + + /** + * @Then The CalDAV response should contain a property :key with a href value :value + * @throws \Exception + */ + public function theCaldavResponseShouldContainAPropertyWithHrefValue( + string $key, + string $value, + ): void { + /** @var \Sabre\DAV\Xml\Response\MultiStatus $multiStatus */ + $multiStatus = $this->responseXml['value']; + $responses = $multiStatus->getResponses()[0]->getResponseProperties(); + if (!isset($responses[200])) { + throw new \Exception( + sprintf( + 'Expected code 200 got [%s]', + implode(',', array_keys($responses)), + ) + ); + } + + $props = $responses[200]; + if (!array_key_exists($key, $props)) { + throw new \Exception("Cannot find property \"$key\""); + } + + $actualValue = $props[$key]->getHref(); + if ($actualValue !== $value) { + throw new \Exception("Property \"$key\" found with value \"$actualValue\", expected \"$value\""); + } + } + + /** + * @Then The CalDAV response should be multi status + * @throws \Exception + */ + public function theCaldavResponseShouldBeMultiStatus(): void { + if ($this->response->getStatusCode() !== 207) { + throw new \Exception( + sprintf( + 'Expected code 207 got %s', + $this->response->getStatusCode() + ) + ); + } + + $body = $this->response->getBody()->getContents(); + if ($body && substr($body, 0, 1) === '<') { + $reader = new Sabre\Xml\Reader(); + $reader->xml($body); + $reader->elementMap['{DAV:}multistatus'] = \Sabre\DAV\Xml\Response\MultiStatus::class; + $reader->elementMap['{DAV:}response'] = \Sabre\DAV\Xml\Element\Response::class; + $reader->elementMap['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'] = \Sabre\DAV\Xml\Property\Href::class; + $this->responseXml = $reader->parse(); + } + } + + /** * @Then The CalDAV HTTP status code should be :code * @param int $code * @throws \Exception */ public function theCaldavHttpStatusCodeShouldBe($code) { - if((int)$code !== $this->response->getStatusCode()) { + if ((int)$code !== $this->response->getStatusCode()) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -110,7 +216,7 @@ class CalDavContext implements \Behat\Behat\Context\Context { } $body = $this->response->getBody()->getContents(); - if($body && substr($body, 0, 1) === '<') { + if ($body && substr($body, 0, 1) === '<') { $reader = new Sabre\Xml\Reader(); $reader->xml($body); $this->responseXml = $reader->parse(); @@ -125,7 +231,7 @@ class CalDavContext implements \Behat\Behat\Context\Context { public function theExceptionIs($message) { $result = $this->responseXml['value'][0]['value']; - if($message !== $result) { + if ($message !== $result) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -144,7 +250,7 @@ class CalDavContext implements \Behat\Behat\Context\Context { public function theErrorMessageIs($message) { $result = $this->responseXml['value'][1]['value']; - if($message !== $result) { + if ($message !== $result) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -161,10 +267,10 @@ class CalDavContext implements \Behat\Behat\Context\Context { * @param string $name */ public function createsACalendarNamed($user, $name) { - $davUrl = $this->baseUrl . '/remote.php/dav/calendars/'.$user.'/'.$name; + $davUrl = $this->baseUrl . '/remote.php/dav/calendars/' . $user . '/' . $name; $password = ($user === 'admin') ? 'admin' : '123456'; - $request = $this->client->createRequest( + $this->response = $this->client->request( 'MKCALENDAR', $davUrl, [ @@ -175,8 +281,110 @@ class CalDavContext implements \Behat\Behat\Context\Context { ], ] ); + } + + /** + * @Then :user publicly shares the calendar named :name + * + * @param string $user + * @param string $name + */ + public function publiclySharesTheCalendarNamed($user, $name) { + $davUrl = $this->baseUrl . '/remote.php/dav/calendars/' . $user . '/' . $name; + $password = ($user === 'admin') ? 'admin' : '123456'; + + $this->response = $this->client->request( + 'POST', + $davUrl, + [ + 'body' => '<cs:publish-calendar xmlns:cs="http://calendarserver.org/ns/"/>', + 'auth' => [ + $user, + $password, + ], + 'headers' => [ + 'Content-Type' => 'application/xml; charset=UTF-8', + ], + ] + ); + } + + /** + * @Then There should be :amount calendars in the response body + * + * @param string $amount + */ + public function t($amount) { + $jsonEncoded = json_encode($this->responseXml); + $arrayElement = json_decode($jsonEncoded, true); + $actual = count($arrayElement['value']) - 1; + if ($actual !== (int)$amount) { + throw new InvalidArgumentException( + sprintf( + 'Expected %s got %s', + $amount, + $actual + ) + ); + } + } - $this->response = $this->client->send($request); + /** + * @When :user sends a create calendar request to :calendar on the endpoint :endpoint + */ + public function sendsCreateCalendarRequest(string $user, string $calendar, string $endpoint) { + $davUrl = $this->baseUrl . $endpoint . $calendar; + $password = ($user === 'admin') ? 'admin' : '123456'; + + try { + $this->response = $this->client->request( + 'MKCALENDAR', + $davUrl, + [ + 'body' => '<c:mkcalendar xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/" xmlns:o="http://owncloud.org/ns"><d:set><d:prop><d:displayname>test</d:displayname><o:calendar-enabled>1</o:calendar-enabled><a:calendar-color>#21213D</a:calendar-color><c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set></d:prop></d:set></c:mkcalendar>', + 'auth' => [ + $user, + $password, + ], + ] + ); + } catch (GuzzleException $e) { + $this->response = $e->getResponse(); + } } + /** + * @Given :user updates property :key to href :value of principal :principal on the endpoint :endpoint + */ + public function updatesHrefPropertyOfPrincipal( + string $user, + string $key, + string $value, + string $principal, + string $endpoint, + ): void { + $davUrl = $this->baseUrl . $endpoint . $principal; + $password = ($user === 'admin') ? 'admin' : '123456'; + + $propPatch = new \Sabre\DAV\Xml\Request\PropPatch(); + $propPatch->properties = [$key => new \Sabre\DAV\Xml\Property\Href($value)]; + + $xml = new \Sabre\Xml\Service(); + $body = $xml->write('{DAV:}propertyupdate', $propPatch, '/'); + + $this->response = $this->client->request( + 'PROPPATCH', + $davUrl, + [ + 'headers' => [ + 'Content-Type' => 'application/xml; charset=UTF-8', + ], + 'body' => $body, + 'auth' => [ + $user, + $password, + ], + ] + ); + } } diff --git a/build/integration/features/bootstrap/CapabilitiesContext.php b/build/integration/features/bootstrap/CapabilitiesContext.php index 91a4265504c..7d09ab6ddcf 100644 --- a/build/integration/features/bootstrap/CapabilitiesContext.php +++ b/build/integration/features/bootstrap/CapabilitiesContext.php @@ -1,11 +1,13 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ use Behat\Behat\Context\Context; use Behat\Behat\Context\SnippetAcceptingContext; -use Behat\Behat\Hook\Scope\AfterScenarioScope; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use GuzzleHttp\Client; -use GuzzleHttp\Message\ResponseInterface; +use PHPUnit\Framework\Assert; require __DIR__ . '/../../vendor/autoload.php'; @@ -13,7 +15,6 @@ require __DIR__ . '/../../vendor/autoload.php'; * Capabilities context. */ class CapabilitiesContext implements Context, SnippetAcceptingContext { - use BasicStructure; use AppConfiguration; @@ -21,35 +22,37 @@ class CapabilitiesContext implements Context, SnippetAcceptingContext { * @Then /^fields of capabilities match with$/ * @param \Behat\Gherkin\Node\TableNode|null $formData */ - public function checkCapabilitiesResponse(\Behat\Gherkin\Node\TableNode $formData){ - $capabilitiesXML = $this->response->xml()->data->capabilities; + public function checkCapabilitiesResponse(\Behat\Gherkin\Node\TableNode $formData) { + $capabilitiesXML = simplexml_load_string($this->response->getBody()); + Assert::assertNotFalse($capabilitiesXML, 'Failed to fetch capabilities'); + $capabilitiesXML = $capabilitiesXML->data->capabilities; foreach ($formData->getHash() as $row) { $path_to_element = explode('@@@', $row['path_to_element']); $answeredValue = $capabilitiesXML->{$row['capability']}; - for ($i = 0; $i < count($path_to_element); $i++){ + for ($i = 0; $i < count($path_to_element); $i++) { $answeredValue = $answeredValue->{$path_to_element[$i]}; } $answeredValue = (string)$answeredValue; - PHPUnit_Framework_Assert::assertEquals( - $row['value']==="EMPTY" ? '' : $row['value'], + Assert::assertEquals( + $row['value'] === 'EMPTY' ? '' : $row['value'], $answeredValue, - "Failed field " . $row['capability'] . " " . $row['path_to_element'] + 'Failed field ' . $row['capability'] . ' ' . $row['path_to_element'] ); - } } protected function resetAppConfigs() { - $this->modifyServerConfig('core', 'shareapi_enabled', 'yes'); - $this->modifyServerConfig('core', 'shareapi_allow_links', 'yes'); - $this->modifyServerConfig('core', 'shareapi_allow_public_upload', 'yes'); - $this->modifyServerConfig('core', 'shareapi_allow_resharing', 'yes'); - $this->modifyServerConfig('files_sharing', 'outgoing_server2server_share_enabled', 'yes'); - $this->modifyServerConfig('files_sharing', 'incoming_server2server_share_enabled', 'yes'); - $this->modifyServerConfig('core', 'shareapi_enforce_links_password', 'no'); - $this->modifyServerConfig('core', 'shareapi_allow_public_notification', 'no'); - $this->modifyServerConfig('core', 'shareapi_default_expire_date', 'no'); - $this->modifyServerConfig('core', 'shareapi_enforce_expire_date', 'no'); + $this->deleteServerConfig('core', 'shareapi_enabled'); + $this->deleteServerConfig('core', 'shareapi_allow_links'); + $this->deleteServerConfig('core', 'shareapi_allow_public_upload'); + $this->deleteServerConfig('core', 'shareapi_allow_resharing'); + $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); + $this->deleteServerConfig('files_sharing', 'incoming_server2server_share_enabled'); + $this->deleteServerConfig('core', 'shareapi_enforce_links_password'); + $this->deleteServerConfig('core', 'shareapi_allow_public_notification'); + $this->deleteServerConfig('core', 'shareapi_default_expire_date'); + $this->deleteServerConfig('core', 'shareapi_enforce_expire_date'); + $this->deleteServerConfig('core', 'shareapi_allow_group_sharing'); } } diff --git a/build/integration/features/bootstrap/CardDavContext.php b/build/integration/features/bootstrap/CardDavContext.php index d317ba5193f..733c98dca02 100644 --- a/build/integration/features/bootstrap/CardDavContext.php +++ b/build/integration/features/bootstrap/CardDavContext.php @@ -1,31 +1,18 @@ <?php + /** - * @author Lukas Reschke <lukas@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - require __DIR__ . '/../../vendor/autoload.php'; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Message\ResponseInterface; class CardDavContext implements \Behat\Behat\Context\Context { - /** @var string */ + /** @var string */ private $baseUrl; /** @var Client */ private $client; @@ -48,7 +35,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { } /** @BeforeScenario */ - public function tearUpScenario() { + public function setUpScenario() { $this->client = new Client(); $this->responseXml = ''; } @@ -67,23 +54,25 @@ class CardDavContext implements \Behat\Behat\Context\Context { ], ] ); - } catch (\GuzzleHttp\Exception\ClientException $e) {} + } catch (\GuzzleHttp\Exception\ClientException $e) { + } } - /** - * @When :user requests addressbook :addressBook with statuscode :statusCode + * @When :user requests addressbook :addressBook with statuscode :statusCode on the endpoint :endpoint * @param string $user * @param string $addressBook * @param int $statusCode + * @param string $endpoint * @throws \Exception */ - public function requestsAddressbookWithStatuscode($user, $addressBook, $statusCode) { - $davUrl = $this->baseUrl . '/remote.php/dav/addressbooks/users/'.$addressBook; + public function requestsAddressbookWithStatuscodeOnTheEndpoint($user, $addressBook, $statusCode, $endpoint) { + $davUrl = $this->baseUrl . $endpoint . $addressBook; $password = ($user === 'admin') ? 'admin' : '123456'; try { - $this->response = $this->client->get( + $this->response = $this->client->request( + 'PROPFIND', $davUrl, [ 'auth' => [ @@ -96,7 +85,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { $this->response = $e->getResponse(); } - if((int)$statusCode !== $this->response->getStatusCode()) { + if ((int)$statusCode !== $this->response->getStatusCode()) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -107,7 +96,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { } $body = $this->response->getBody()->getContents(); - if(substr($body, 0, 1) === '<') { + if (substr($body, 0, 1) === '<') { $reader = new Sabre\Xml\Reader(); $reader->xml($body); $this->responseXml = $reader->parse(); @@ -122,10 +111,10 @@ class CardDavContext implements \Behat\Behat\Context\Context { * @throws \Exception */ public function createsAnAddressbookNamedWithStatuscode($user, $addressBook, $statusCode) { - $davUrl = $this->baseUrl . '/remote.php/dav/addressbooks/users/'.$user.'/'.$addressBook; + $davUrl = $this->baseUrl . '/remote.php/dav/addressbooks/users/' . $user . '/' . $addressBook; $password = ($user === 'admin') ? 'admin' : '123456'; - $request = $this->client->createRequest( + $this->response = $this->client->request( 'MKCOL', $davUrl, [ @@ -135,7 +124,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { <d:prop> <d:resourcetype> <d:collection />,<card:addressbook /> - </d:resourcetype>,<d:displayname>'.$addressBook.'</d:displayname> + </d:resourcetype>,<d:displayname>' . $addressBook . '</d:displayname> </d:prop> </d:set> </d:mkcol>', @@ -149,9 +138,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { ] ); - $this->response = $this->client->send($request); - - if($this->response->getStatusCode() !== (int)$statusCode) { + if ($this->response->getStatusCode() !== (int)$statusCode) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -170,7 +157,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { public function theCarddavExceptionIs($message) { $result = $this->responseXml['value'][0]['value']; - if($message !== $result) { + if ($message !== $result) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -189,7 +176,7 @@ class CardDavContext implements \Behat\Behat\Context\Context { public function theCarddavErrorMessageIs($message) { $result = $this->responseXml['value'][1]['value']; - if($message !== $result) { + if ($message !== $result) { throw new \Exception( sprintf( 'Expected %s got %s', @@ -200,4 +187,171 @@ class CardDavContext implements \Behat\Behat\Context\Context { } } + /** + * @Given :user uploads the contact :fileName to the addressbook :addressbook + */ + public function uploadsTheContactToTheAddressbook($user, $fileName, $addressBook) { + $davUrl = $this->baseUrl . '/remote.php/dav/addressbooks/users/' . $user . '/' . $addressBook . '/' . $fileName; + $password = ($user === 'admin') ? 'admin' : '123456'; + + $this->response = $this->client->request( + 'PUT', + $davUrl, + [ + 'body' => file_get_contents(__DIR__ . '/../../data/' . $fileName), + 'auth' => [ + $user, + $password, + ], + 'headers' => [ + 'Content-Type' => 'application/xml;charset=UTF-8', + ], + ] + ); + + if ($this->response->getStatusCode() !== 201) { + throw new \Exception( + sprintf( + 'Expected %s got %s', + 201, + $this->response->getStatusCode() + ) + ); + } + } + + /** + * @When Exporting the picture of contact :fileName from addressbook :addressBook as user :user + */ + public function whenExportingThePictureOfContactFromAddressbookAsUser($fileName, $addressBook, $user) { + $davUrl = $this->baseUrl . '/remote.php/dav/addressbooks/users/' . $user . '/' . $addressBook . '/' . $fileName . '?photo=true'; + $password = ($user === 'admin') ? 'admin' : '123456'; + + try { + $this->response = $this->client->request( + 'GET', + $davUrl, + [ + 'auth' => [ + $user, + $password, + ], + 'headers' => [ + 'Content-Type' => 'application/xml;charset=UTF-8', + ], + ] + ); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @When Downloading the contact :fileName from addressbook :addressBook as user :user + */ + public function whenDownloadingTheContactFromAddressbookAsUser($fileName, $addressBook, $user) { + $davUrl = $this->baseUrl . '/remote.php/dav/addressbooks/users/' . $user . '/' . $addressBook . '/' . $fileName; + $password = ($user === 'admin') ? 'admin' : '123456'; + + try { + $this->response = $this->client->request( + 'GET', + $davUrl, + [ + 'auth' => [ + $user, + $password, + ], + 'headers' => [ + 'Content-Type' => 'application/xml;charset=UTF-8', + ], + ] + ); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @Then The following HTTP headers should be set + * @param \Behat\Gherkin\Node\TableNode $table + * @throws \Exception + */ + public function theFollowingHttpHeadersShouldBeSet(\Behat\Gherkin\Node\TableNode $table) { + foreach ($table->getTable() as $header) { + $headerName = $header[0]; + $expectedHeaderValue = $header[1]; + $returnedHeader = $this->response->getHeader($headerName)[0]; + if ($returnedHeader !== $expectedHeaderValue) { + throw new \Exception( + sprintf( + "Expected value '%s' for header '%s', got '%s'", + $expectedHeaderValue, + $headerName, + $returnedHeader + ) + ); + } + } + } + + /** + * @When :user sends a create addressbook request to :addressbook on the endpoint :endpoint + */ + public function sendsCreateAddressbookRequest(string $user, string $addressbook, string $endpoint) { + $davUrl = $this->baseUrl . $endpoint . $addressbook; + $password = ($user === 'admin') ? 'admin' : '123456'; + + try { + $this->response = $this->client->request( + 'MKCOL', + $davUrl, + [ + 'body' => '<d:mkcol xmlns:card="urn:ietf:params:xml:ns:carddav" + xmlns:d="DAV:"> + <d:set> + <d:prop> + <d:resourcetype> + <d:collection />,<card:addressbook /> + </d:resourcetype>,<d:displayname>' . $addressbook . '</d:displayname> + </d:prop> + </d:set> + </d:mkcol>', + 'auth' => [ + $user, + $password, + ], + 'headers' => [ + 'Content-Type' => 'application/xml;charset=UTF-8', + ], + ] + ); + } catch (GuzzleException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @Then The CardDAV HTTP status code should be :code + * @param int $code + * @throws \Exception + */ + public function theCarddavHttpStatusCodeShouldBe($code) { + if ((int)$code !== $this->response->getStatusCode()) { + throw new \Exception( + sprintf( + 'Expected %s got %s', + (int)$code, + $this->response->getStatusCode() + ) + ); + } + + $body = $this->response->getBody()->getContents(); + if ($body && substr($body, 0, 1) === '<') { + $reader = new Sabre\Xml\Reader(); + $reader->xml($body); + $this->responseXml = $reader->parse(); + } + } } diff --git a/build/integration/features/bootstrap/ChecksumsContext.php b/build/integration/features/bootstrap/ChecksumsContext.php index af8f9e5590d..c8abf91127e 100644 --- a/build/integration/features/bootstrap/ChecksumsContext.php +++ b/build/integration/features/bootstrap/ChecksumsContext.php @@ -1,12 +1,17 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ require __DIR__ . '/../../vendor/autoload.php'; use GuzzleHttp\Client; use GuzzleHttp\Message\ResponseInterface; class ChecksumsContext implements \Behat\Behat\Context\Context { - /** @var string */ + /** @var string */ private $baseUrl; /** @var Client */ private $client; @@ -27,7 +32,7 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { } /** @BeforeScenario */ - public function tearUpScenario() { + public function setUpScenario() { $this->client = new Client(); } @@ -41,7 +46,7 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @return string */ private function getPasswordForUser($userName) { - if($userName === 'admin') { + if ($userName === 'admin') { return 'admin'; } return '123456'; @@ -54,9 +59,8 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @param string $destination * @param string $checksum */ - public function userUploadsFileToWithChecksum($user, $source, $destination, $checksum) - { - $file = \GuzzleHttp\Stream\Stream::factory(fopen($source, 'r')); + public function userUploadsFileToWithChecksum($user, $source, $destination, $checksum) { + $file = \GuzzleHttp\Psr7\Utils::streamFor(fopen($source, 'r')); try { $this->response = $this->client->put( $this->baseUrl . '/remote.php/webdav' . $destination, @@ -83,8 +87,8 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @throws \Exception */ public function theWebdavResponseShouldHaveAStatusCode($statusCode) { - if((int)$statusCode !== $this->response->getStatusCode()) { - throw new \Exception("Expected $statusCode, got ".$this->response->getStatusCode()); + if ((int)$statusCode !== $this->response->getStatusCode()) { + throw new \Exception("Expected $statusCode, got " . $this->response->getStatusCode()); } } @@ -93,9 +97,8 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @param string $user * @param string $path */ - public function userRequestTheChecksumOfViaPropfind($user, $path) - { - $request = $this->client->createRequest( + public function userRequestTheChecksumOfViaPropfind($user, $path) { + $this->response = $this->client->request( 'PROPFIND', $this->baseUrl . '/remote.php/webdav' . $path, [ @@ -111,7 +114,6 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { ] ] ); - $this->response = $this->client->send($request); } /** @@ -119,8 +121,7 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @param string $checksum * @throws \Exception */ - public function theWebdavChecksumShouldMatch($checksum) - { + public function theWebdavChecksumShouldMatch($checksum) { $service = new Sabre\Xml\Service(); $parsed = $service->parse($this->response->getBody()->getContents()); @@ -131,7 +132,7 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { $checksums = $parsed[0]['value'][1]['value'][0]['value'][0]; if ($checksums['value'][0]['value'] !== $checksum) { - throw new \Exception("Expected $checksum, got ".$checksums['value'][0]['value']); + throw new \Exception("Expected $checksum, got " . $checksums['value'][0]['value']); } } @@ -140,8 +141,7 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @param string $user * @param string $path */ - public function userDownloadsTheFile($user, $path) - { + public function userDownloadsTheFile($user, $path) { $this->response = $this->client->get( $this->baseUrl . '/remote.php/webdav' . $path, [ @@ -158,10 +158,9 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @param string $checksum * @throws \Exception */ - public function theHeaderChecksumShouldMatch($checksum) - { - if ($this->response->getHeader('OC-Checksum') !== $checksum) { - throw new \Exception("Expected $checksum, got ".$this->response->getHeader('OC-Checksum')); + public function theHeaderChecksumShouldMatch($checksum) { + if ($this->response->getHeader('OC-Checksum')[0] !== $checksum) { + throw new \Exception("Expected $checksum, got " . $this->response->getHeader('OC-Checksum')[0]); } } @@ -171,9 +170,8 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { * @param string $source * @param string $destination */ - public function userCopiedFileTo($user, $source, $destination) - { - $request = $this->client->createRequest( + public function userCopiedFileTo($user, $source, $destination) { + $this->response = $this->client->request( 'MOVE', $this->baseUrl . '/remote.php/webdav' . $source, [ @@ -186,14 +184,12 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { ], ] ); - $this->response = $this->client->send($request); } /** * @Then The webdav checksum should be empty */ - public function theWebdavChecksumShouldBeEmpty() - { + public function theWebdavChecksumShouldBeEmpty() { $service = new Sabre\Xml\Service(); $parsed = $service->parse($this->response->getBody()->getContents()); @@ -204,46 +200,16 @@ class ChecksumsContext implements \Behat\Behat\Context\Context { $status = $parsed[0]['value'][1]['value'][1]['value']; if ($status !== 'HTTP/1.1 404 Not Found') { - throw new \Exception("Expected 'HTTP/1.1 404 Not Found', got ".$status); + throw new \Exception("Expected 'HTTP/1.1 404 Not Found', got " . $status); } } /** * @Then The OC-Checksum header should not be there */ - public function theOcChecksumHeaderShouldNotBeThere() - { + public function theOcChecksumHeaderShouldNotBeThere() { if ($this->response->hasHeader('OC-Checksum')) { - throw new \Exception("Expected no checksum header but got ".$this->response->getHeader('OC-Checksum')); + throw new \Exception('Expected no checksum header but got ' . $this->response->getHeader('OC-Checksum')[0]); } } - - /** - * @Given user :user uploads chunk file :num of :total with :data to :destination with checksum :checksum - * @param string $user - * @param int $num - * @param int $total - * @param string $data - * @param string $destination - * @param string $checksum - */ - public function userUploadsChunkFileOfWithToWithChecksum($user, $num, $total, $data, $destination, $checksum) - { - $num -= 1; - $this->response = $this->client->put( - $this->baseUrl . '/remote.php/webdav' . $destination . '-chunking-42-'.$total.'-'.$num, - [ - 'auth' => [ - $user, - $this->getPasswordForUser($user) - ], - 'body' => $data, - 'headers' => [ - 'OC-Checksum' => $checksum, - 'OC-Chunked' => '1', - ] - ] - ); - - } } diff --git a/build/integration/features/bootstrap/CollaborationContext.php b/build/integration/features/bootstrap/CollaborationContext.php new file mode 100644 index 00000000000..27fa1795c5d --- /dev/null +++ b/build/integration/features/bootstrap/CollaborationContext.php @@ -0,0 +1,206 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; +use Behat\Gherkin\Node\TableNode; +use GuzzleHttp\Client; +use PHPUnit\Framework\Assert; + +require __DIR__ . '/../../vendor/autoload.php'; + +class CollaborationContext implements Context { + use Provisioning; + use AppConfiguration; + use WebDav; + + /** + * @Then /^get autocomplete for "([^"]*)"$/ + * @param TableNode|null $formData + */ + public function getAutocompleteForUser(string $search, TableNode $formData): void { + $this->getAutocompleteWithType(0, $search, $formData); + } + + /** + * @Then /^get email autocomplete for "([^"]*)"$/ + * @param TableNode|null $formData + */ + public function getAutocompleteForEmail(string $search, TableNode $formData): void { + $this->getAutocompleteWithType(4, $search, $formData); + } + + private function getAutocompleteWithType(int $type, string $search, TableNode $formData): void { + $query = $search === 'null' ? null : $search; + + $this->sendRequestForJSON('GET', '/core/autocomplete/get?itemType=files&itemId=123&shareTypes[]=' . $type . '&search=' . $query, [ + 'itemType' => 'files', + 'itemId' => '123', + 'search' => $query, + ]); + $this->theHTTPStatusCodeShouldBe(200); + + $data = json_decode($this->response->getBody()->getContents(), true); + $suggestions = $data['ocs']['data']; + + Assert::assertCount(count($formData->getHash()), $suggestions, 'Suggestion count does not match'); + Assert::assertEquals($formData->getHash(), array_map(static function ($suggestion, $expected) { + $data = []; + if (isset($expected['id'])) { + $data['id'] = $suggestion['id']; + } + if (isset($expected['source'])) { + $data['source'] = $suggestion['source']; + } + if (isset($expected['status'])) { + $data['status'] = json_encode($suggestion['status']); + } + return $data; + }, $suggestions, $formData->getHash())); + } + + /** + * @Given /^there is a contact in an addressbook$/ + */ + public function thereIsAContactInAnAddressbook() { + $this->usingNewDavPath(); + try { + $destination = '/users/admin/myaddressbook'; + $data = '<x0:mkcol xmlns:x0="DAV:"><x0:set><x0:prop><x0:resourcetype><x0:collection/><x4:addressbook xmlns:x4="urn:ietf:params:xml:ns:carddav"/></x0:resourcetype><x0:displayname>myaddressbook</x0:displayname></x0:prop></x0:set></x0:mkcol>'; + $this->response = $this->makeDavRequest($this->currentUser, 'MKCOL', $destination, ['Content-Type' => 'application/xml'], $data, 'addressbooks'); + } catch (\GuzzleHttp\Exception\ServerException $e) { + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception + $this->response = $e->getResponse(); + } + + try { + $destination = '/users/admin/myaddressbook/contact1.vcf'; + $data = <<<EOF +BEGIN:VCARD +VERSION:4.0 +PRODID:-//Nextcloud Contacts v4.0.2 +UID:a0f4088a-4dca-4308-9b63-09a1ebcf78f3 +FN:A person +ADR;TYPE=HOME:;;;;;; +EMAIL;TYPE=HOME:user@example.com +REV;VALUE=DATE-AND-OR-TIME:20211130T140111Z +END:VCARD +EOF; + $this->response = $this->makeDavRequest($this->currentUser, 'PUT', $destination, [], $data, 'addressbooks'); + } catch (\GuzzleHttp\Exception\ServerException $e) { + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception + $this->response = $e->getResponse(); + } + } + + protected function resetAppConfigs(): void { + $this->deleteServerConfig('core', 'shareapi_allow_share_dialog_user_enumeration'); + $this->deleteServerConfig('core', 'shareapi_restrict_user_enumeration_to_group'); + $this->deleteServerConfig('core', 'shareapi_restrict_user_enumeration_to_phone'); + $this->deleteServerConfig('core', 'shareapi_restrict_user_enumeration_full_match'); + $this->deleteServerConfig('core', 'shareapi_restrict_user_enumeration_full_match_userid'); + $this->deleteServerConfig('core', 'shareapi_restrict_user_enumeration_full_match_email'); + $this->deleteServerConfig('core', 'shareapi_restrict_user_enumeration_full_match_ignore_second_dn'); + $this->deleteServerConfig('core', 'shareapi_only_share_with_group_members'); + } + + /** + * @Given /^user "([^"]*)" has status "([^"]*)"$/ + * @param string $user + * @param string $status + */ + public function assureUserHasStatus($user, $status) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/user_status/api/v1/user_status/status"; + $client = new Client(); + $options = [ + 'headers' => [ + 'OCS-APIREQUEST' => 'true', + ], + ]; + if ($user === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$user, $this->regularUser]; + } + + $options['form_params'] = [ + 'statusType' => $status + ]; + + $this->response = $client->put($fullUrl, $options); + $this->theHTTPStatusCodeShouldBe(200); + + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/user_status/api/v1/user_status"; + unset($options['form_params']); + $this->response = $client->get($fullUrl, $options); + $this->theHTTPStatusCodeShouldBe(200); + + $returnedStatus = json_decode(json_encode(simplexml_load_string($this->response->getBody()->getContents())->data), true)['status']; + Assert::assertEquals($status, $returnedStatus); + } + + /** + * @param string $user + * @return null|array + */ + public function getStatusList(string $user): ?array { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/user_status/api/v1/statuses"; + $client = new Client(); + $options = [ + 'headers' => [ + 'OCS-APIREQUEST' => 'true', + ], + ]; + if ($user === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$user, $this->regularUser]; + } + + $this->response = $client->get($fullUrl, $options); + $this->theHTTPStatusCodeShouldBe(200); + + $contents = $this->response->getBody()->getContents(); + return json_decode(json_encode(simplexml_load_string($contents)->data), true); + } + + /** + * @Given /^user statuses for "([^"]*)" list "([^"]*)" with status "([^"]*)"$/ + * @param string $user + * @param string $statusUser + * @param string $status + */ + public function assertStatusesList(string $user, string $statusUser, string $status): void { + $statusList = $this->getStatusList($user); + Assert::assertArrayHasKey('element', $statusList, 'Returned status list empty or broken'); + if (array_key_exists('userId', $statusList['element'])) { + // If only one user has a status set, the API returns their status directly + Assert::assertArrayHasKey('status', $statusList['element'], 'Returned status list empty or broken'); + $filteredStatusList = [ $statusList['element']['userId'] => $statusList['element']['status'] ]; + } else { + // If more than one user have their status set, the API returns an array of their statuses + $filteredStatusList = array_column($statusList['element'], 'status', 'userId'); + } + Assert::assertArrayHasKey($statusUser, $filteredStatusList, 'User not listed in statuses: ' . $statusUser); + Assert::assertEquals($status, $filteredStatusList[$statusUser]); + } + + /** + * @Given /^user statuses for "([^"]*)" are empty$/ + * @param string $user + */ + public function assertStatusesEmpty(string $user): void { + $statusList = $this->getStatusList($user); + Assert::assertEmpty($statusList); + } +} diff --git a/build/integration/features/bootstrap/CommandLine.php b/build/integration/features/bootstrap/CommandLine.php new file mode 100644 index 00000000000..924d723daa6 --- /dev/null +++ b/build/integration/features/bootstrap/CommandLine.php @@ -0,0 +1,135 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +use PHPUnit\Framework\Assert; + +require __DIR__ . '/../../vendor/autoload.php'; + +trait CommandLine { + /** @var int return code of last command */ + private $lastCode; + /** @var string stdout of last command */ + private $lastStdOut; + /** @var string stderr of last command */ + private $lastStdErr; + + /** @var string */ + protected $ocPath = '../..'; + + /** + * Invokes an OCC command + * + * @param []string $args OCC command, the part behind "occ". For example: "files:transfer-ownership" + * @return int exit code + */ + public function runOcc($args = []) { + $args = array_map(function ($arg) { + return escapeshellarg($arg); + }, $args); + $args[] = '--no-ansi'; + $args = implode(' ', $args); + + $descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open('php console.php ' . $args, $descriptor, $pipes, $this->ocPath); + $this->lastStdOut = stream_get_contents($pipes[1]); + $this->lastStdErr = stream_get_contents($pipes[2]); + $this->lastCode = proc_close($process); + + // Clean opcode cache + $client = new GuzzleHttp\Client(); + $client->request('GET', 'http://localhost:8080/apps/testing/clean_opcode_cache.php'); + + return $this->lastCode; + } + + /** + * @Given /^invoking occ with "([^"]*)"$/ + */ + public function invokingTheCommand($cmd) { + $args = explode(' ', $cmd); + $this->runOcc($args); + } + + /** + * Find exception texts in stderr + */ + public function findExceptions() { + $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; + } + + /** + * @Then /^the command was successful$/ + */ + public function theCommandWasSuccessful() { + $exceptions = $this->findExceptions(); + if ($this->lastCode !== 0) { + $msg = 'The command was not successful, exit code was ' . $this->lastCode . '.'; + if (!empty($exceptions)) { + $msg .= ' Exceptions: ' . implode(', ', $exceptions); + } + throw new \Exception($msg); + } elseif (!empty($exceptions)) { + $msg = 'The command was successful but triggered exceptions: ' . implode(', ', $exceptions); + throw new \Exception($msg); + } + } + + /** + * @Then /^the command failed with exit code ([0-9]+)$/ + */ + public function theCommandFailedWithExitCode($exitCode) { + if ($this->lastCode !== (int)$exitCode) { + throw new \Exception('The command was expected to fail with exit code ' . $exitCode . ' but got ' . $this->lastCode); + } + } + + /** + * @Then /^the command failed with exception text "([^"]*)"$/ + */ + public function theCommandFailedWithException($exceptionText) { + $exceptions = $this->findExceptions(); + if (empty($exceptions)) { + throw new \Exception('The command did not throw any exceptions'); + } + + if (!in_array($exceptionText, $exceptions)) { + throw new \Exception('The command did not throw any exception with the text "' . $exceptionText . '"'); + } + } + + /** + * @Then /^the command output contains the text "([^"]*)"$/ + */ + public function theCommandOutputContainsTheText($text) { + Assert::assertStringContainsString($text, $this->lastStdOut, 'The command did not output the expected text on stdout'); + } + + /** + * @Then /^the command error output contains the text "([^"]*)"$/ + */ + public function theCommandErrorOutputContainsTheText($text) { + Assert::assertStringContainsString($text, $this->lastStdErr, 'The command did not output the expected text on stderr'); + } +} diff --git a/build/integration/features/bootstrap/CommandLineContext.php b/build/integration/features/bootstrap/CommandLineContext.php new file mode 100644 index 00000000000..e7764356270 --- /dev/null +++ b/build/integration/features/bootstrap/CommandLineContext.php @@ -0,0 +1,127 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +require __DIR__ . '/../../vendor/autoload.php'; + +use Behat\Behat\Context\Exception\ContextNotFoundException; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use PHPUnit\Framework\Assert; + +class CommandLineContext implements \Behat\Behat\Context\Context { + use CommandLine; + + private $lastTransferPath; + + private $featureContext; + private $localBaseUrl; + private $remoteBaseUrl; + + public function __construct($ocPath, $baseUrl) { + $this->ocPath = rtrim($ocPath, '/') . '/'; + $this->localBaseUrl = $baseUrl; + $this->remoteBaseUrl = $baseUrl; + } + + /** + * @Given Maintenance mode is enabled + */ + public function maintenanceModeIsEnabled() { + $this->runOcc(['maintenance:mode', '--on']); + } + + /** + * @Then Maintenance mode is disabled + */ + public function maintenanceModeIsDisabled() { + $this->runOcc(['maintenance:mode', '--off']); + } + + /** @BeforeScenario */ + public function gatherContexts(BeforeScenarioScope $scope) { + $environment = $scope->getEnvironment(); + // this should really be "WebDavContext" + try { + $this->featureContext = $environment->getContext('FeatureContext'); + } catch (ContextNotFoundException) { + $this->featureContext = $environment->getContext('DavFeatureContext'); + } + } + + private function findLastTransferFolderForUser($sourceUser, $targetUser) { + $foundPaths = []; + $results = $this->featureContext->listFolder($targetUser, '', 1); + foreach ($results as $path => $data) { + $path = rawurldecode($path); + $parts = explode(' ', $path); + if (basename($parts[0]) !== 'Transferred') { + continue; + } + if (isset($parts[2]) && $parts[2] === $sourceUser) { + // 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->featureContext->getDavFilesPath($targetUser), '/'); + + $foundPath = end($foundPaths)['path']; + // strip dav path + return substr($foundPath, strlen($davPath) + 1); + } + + /** + * @When /^transferring ownership from "([^"]+)" to "([^"]+)"$/ + */ + public function transferringOwnership($user1, $user2) { + if ($this->runOcc(['files:transfer-ownership', $user1, $user2]) === 0) { + $this->lastTransferPath = $this->findLastTransferFolderForUser($user1, $user2); + } else { + // failure + $this->lastTransferPath = null; + } + } + + /** + * @When /^transferring ownership of path "([^"]+)" from "([^"]+)" to "([^"]+)"$/ + */ + public function transferringOwnershipPath($path, $user1, $user2) { + $path = '--path=' . $path; + if ($this->runOcc(['files:transfer-ownership', $path, $user1, $user2]) === 0) { + $this->lastTransferPath = $this->findLastTransferFolderForUser($user1, $user2); + } else { + // failure + $this->lastTransferPath = null; + } + } + + /** + * @When /^using received transfer folder of "([^"]+)" as dav path$/ + */ + public function usingTransferFolderAsDavPath($user) { + $davPath = $this->featureContext->getDavFilesPath($user); + $davPath = rtrim($davPath, '/') . $this->lastTransferPath; + $this->featureContext->usingDavPath($davPath); + } + + /** + * @Then /^transfer folder name contains "([^"]+)"$/ + */ + public function transferFolderNameContains($text) { + Assert::assertStringContainsString($text, $this->lastTransferPath); + } +} diff --git a/build/integration/features/bootstrap/CommentsContext.php b/build/integration/features/bootstrap/CommentsContext.php index e74e9580dcc..53001b1c204 100644 --- a/build/integration/features/bootstrap/CommentsContext.php +++ b/build/integration/features/bootstrap/CommentsContext.php @@ -1,24 +1,10 @@ <?php + /** - * @author Lukas Reschke <lukas@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - require __DIR__ . '/../../vendor/autoload.php'; class CommentsContext implements \Behat\Behat\Context\Context { @@ -44,12 +30,41 @@ class CommentsContext implements \Behat\Behat\Context\Context { } } + /** + * get a named entry from response instead of picking a random entry from values + * + * @param string $path + * + * @return array|string + * @throws Exception + */ + private function getValueFromNamedEntries(string $path, array $response): mixed { + $next = ''; + if (str_contains($path, ' ')) { + [$key, $next] = explode(' ', $path, 2); + } else { + $key = $path; + } + + foreach ($response as $entry) { + if ($entry['name'] === $key) { + if ($next !== '') { + return $this->getValueFromNamedEntries($next, $entry['value']); + } else { + return $entry['value']; + } + } + } + + return null; + } + /** @AfterScenario */ public function teardownScenario() { $client = new \GuzzleHttp\Client(); try { $client->delete( - $this->baseUrl.'/remote.php/webdav/myFileToComment.txt', + $this->baseUrl . '/remote.php/webdav/myFileToComment.txt', [ 'auth' => [ 'user0', @@ -70,9 +85,9 @@ class CommentsContext implements \Behat\Behat\Context\Context { * @return int */ private function getFileIdForPath($path) { - $url = $this->baseUrl.'/remote.php/webdav/'.$path; - $context = stream_context_create(array( - 'http' => array( + $url = $this->baseUrl . '/remote.php/webdav/' . $path; + $context = stream_context_create([ + 'http' => [ 'method' => 'PROPFIND', 'header' => "Authorization: Basic dXNlcjA6MTIzNDU2\r\nContent-Type: application/x-www-form-urlencoded", 'content' => '<?xml version="1.0"?> @@ -81,8 +96,8 @@ class CommentsContext implements \Behat\Behat\Context\Context { <oc:fileid /> </d:prop> </d:propfind>' - ) - )); + ] + ]); $response = file_get_contents($url, false, $context); preg_match_all('/\<oc:fileid\>(.*)\<\/oc:fileid\>/', $response, $matches); @@ -97,10 +112,10 @@ class CommentsContext implements \Behat\Behat\Context\Context { * @param int $statusCode * @throws \Exception */ - public function postsACommentWithContentOnTheFileNamedItShouldReturn($user, $content, $fileName, $statusCode) { + public function postsACommentWithContentOnTheFileNamedItShouldReturn($user, $content, $fileName, $statusCode) { $fileId = $this->getFileIdForPath($fileName); $this->fileId = (int)$fileId; - $url = $this->baseUrl.'/remote.php/dav/comments/files/'.$fileId.'/'; + $url = $this->baseUrl . '/remote.php/dav/comments/files/' . $fileId . '/'; $client = new \GuzzleHttp\Client(); try { @@ -121,8 +136,8 @@ class CommentsContext implements \Behat\Behat\Context\Context { $res = $e->getResponse(); } - if($res->getStatusCode() !== (int)$statusCode) { - throw new \Exception("Response status code was not $statusCode (".$res->getStatusCode().")"); + if ($res->getStatusCode() !== (int)$statusCode) { + throw new \Exception("Response status code was not $statusCode (" . $res->getStatusCode() . ')'); } } @@ -136,11 +151,11 @@ class CommentsContext implements \Behat\Behat\Context\Context { */ public function asLoadloadAllTheCommentsOfTheFileNamedItShouldReturn($user, $fileName, $statusCode) { $fileId = $this->getFileIdForPath($fileName); - $url = $this->baseUrl.'/remote.php/dav/comments/files/'.$fileId.'/'; + $url = $this->baseUrl . '/remote.php/dav/comments/files/' . $fileId . '/'; try { $client = new \GuzzleHttp\Client(); - $res = $client->createRequest( + $res = $client->request( 'REPORT', $url, [ @@ -159,19 +174,18 @@ class CommentsContext implements \Behat\Behat\Context\Context { ], ] ); - $res = $client->send($res); } catch (\GuzzleHttp\Exception\ClientException $e) { $res = $e->getResponse(); } - if($res->getStatusCode() !== (int)$statusCode) { - throw new \Exception("Response status code was not $statusCode (".$res->getStatusCode().")"); + if ($res->getStatusCode() !== (int)$statusCode) { + throw new \Exception("Response status code was not $statusCode (" . $res->getStatusCode() . ')'); } - if($res->getStatusCode() === 207) { + if ($res->getStatusCode() === 207) { $service = new Sabre\Xml\Service(); $this->response = $service->parse($res->getBody()->getContents()); - $this->commentId = (int)$this->response[0]['value'][2]['value'][0]['value'][0]['value']; + $this->commentId = (int)($this->getValueFromNamedEntries('{DAV:}response {DAV:}propstat {DAV:}prop {http://owncloud.org/ns}id', $this->response ?? []) ?? 0); } } @@ -188,8 +202,11 @@ class CommentsContext implements \Behat\Behat\Context\Context { $options = []; $options['auth'] = [$user, '123456']; $fd = $body->getRowsHash(); - $options['body'] = $fd; - $client->send($client->createRequest($verb, $this->baseUrl.'/ocs/v1.php/'.$url, $options)); + $options['form_params'] = $fd; + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + $client->request($verb, $this->baseUrl . '/ocs/v1.php/' . $url, $options); } /** @@ -199,7 +216,7 @@ class CommentsContext implements \Behat\Behat\Context\Context { * @throws \Exception */ public function asDeleteTheCreatedCommentItShouldReturn($user, $statusCode) { - $url = $this->baseUrl.'/remote.php/dav/comments/files/'.$this->fileId.'/'.$this->commentId; + $url = $this->baseUrl . '/remote.php/dav/comments/files/' . $this->fileId . '/' . $this->commentId; $client = new \GuzzleHttp\Client(); try { @@ -219,8 +236,8 @@ class CommentsContext implements \Behat\Behat\Context\Context { $res = $e->getResponse(); } - if($res->getStatusCode() !== (int)$statusCode) { - throw new \Exception("Response status code was not $statusCode (".$res->getStatusCode().")"); + if ($res->getStatusCode() !== (int)$statusCode) { + throw new \Exception("Response status code was not $statusCode (" . $res->getStatusCode() . ')'); } } @@ -231,16 +248,17 @@ class CommentsContext implements \Behat\Behat\Context\Context { * @throws \Exception */ public function theResponseShouldContainAPropertyWithValue($key, $value) { - $keys = $this->response[0]['value'][2]['value'][0]['value']; + // $keys = $this->response[0]['value'][1]['value'][0]['value']; + $keys = $this->getValueFromNamedEntries('{DAV:}response {DAV:}propstat {DAV:}prop', $this->response); $found = false; - foreach($keys as $singleKey) { - if($singleKey['name'] === '{http://owncloud.org/ns}'.substr($key, 3)) { - if($singleKey['value'] === $value) { + foreach ($keys as $singleKey) { + if ($singleKey['name'] === '{http://owncloud.org/ns}' . substr($key, 3)) { + if ($singleKey['value'] === $value) { $found = true; } } } - if($found === false) { + if ($found === false) { throw new \Exception("Cannot find property $key with $value"); } } @@ -251,8 +269,12 @@ class CommentsContext implements \Behat\Behat\Context\Context { * @throws \Exception */ public function theResponseShouldContainOnlyComments($number) { - if(count($this->response) !== (int)$number) { - throw new \Exception("Found more comments than $number (".count($this->response).")"); + $count = 0; + if ($this->response !== null) { + $count = count($this->response); + } + if ($count !== (int)$number) { + throw new \Exception("Found more comments than $number (" . $count . ')'); } } @@ -271,20 +293,18 @@ class CommentsContext implements \Behat\Behat\Context\Context { <d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> <d:set> <d:prop> - <oc:message>'.$text.'</oc:message> + <oc:message>' . $text . '</oc:message> </d:prop> </d:set> </d:propertyupdate>'; try { - $res = $client->send($client->createRequest('PROPPATCH', $this->baseUrl.'/remote.php/dav/comments/files/' . $this->fileId . '/' . $this->commentId, $options)); + $res = $client->request('PROPPATCH', $this->baseUrl . '/remote.php/dav/comments/files/' . $this->fileId . '/' . $this->commentId, $options); } catch (\GuzzleHttp\Exception\ClientException $e) { $res = $e->getResponse(); } - if($res->getStatusCode() !== (int)$statusCode) { - throw new \Exception("Response status code was not $statusCode (".$res->getStatusCode().")"); + if ($res->getStatusCode() !== (int)$statusCode) { + throw new \Exception("Response status code was not $statusCode (" . $res->getStatusCode() . ')'); } } - - } diff --git a/build/integration/features/bootstrap/ContactsMenu.php b/build/integration/features/bootstrap/ContactsMenu.php new file mode 100644 index 00000000000..f6bf6b9422b --- /dev/null +++ b/build/integration/features/bootstrap/ContactsMenu.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use PHPUnit\Framework\Assert; + +trait ContactsMenu { + // BasicStructure trait is expected to be used in the class that uses this + // trait. + + /** + * @When /^searching for contacts matching with "([^"]*)"$/ + * + * @param string $filter + */ + public function searchingForContactsMatchingWith(string $filter) { + $url = '/index.php/contactsmenu/contacts'; + + $parameters[] = 'filter=' . $filter; + + $url .= '?' . implode('&', $parameters); + + $this->sendingAToWithRequesttoken('POST', $url); + } + + /** + * @Then /^the list of searched contacts has "(\d+)" contacts$/ + */ + public function theListOfSearchedContactsHasContacts(int $count) { + $this->theHTTPStatusCodeShouldBe(200); + + $searchedContacts = json_decode($this->response->getBody(), $asAssociativeArray = true)['contacts']; + + Assert::assertEquals($count, count($searchedContacts)); + } + + /** + * @Then /^searched contact "(\d+)" is named "([^"]*)"$/ + * + * @param int $index + * @param string $expectedName + */ + public function searchedContactXIsNamed(int $index, string $expectedName) { + $searchedContacts = json_decode($this->response->getBody(), $asAssociativeArray = true)['contacts']; + $searchedContact = $searchedContacts[$index]; + + Assert::assertEquals($expectedName, $searchedContact['fullName']); + } +} diff --git a/build/integration/features/bootstrap/ConversionsContext.php b/build/integration/features/bootstrap/ConversionsContext.php new file mode 100644 index 00000000000..ccd14c460f8 --- /dev/null +++ b/build/integration/features/bootstrap/ConversionsContext.php @@ -0,0 +1,60 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +require __DIR__ . '/../../vendor/autoload.php'; + +use Behat\Behat\Context\Context; +use Behat\Behat\Context\SnippetAcceptingContext; +use Behat\Gherkin\Node\TableNode; + +class ConversionsContext implements Context, SnippetAcceptingContext { + use AppConfiguration; + use BasicStructure; + use WebDav; + + /** @BeforeScenario */ + public function setUpScenario() { + $this->asAn('admin'); + $this->setStatusTestingApp(true); + } + + /** @AfterScenario */ + public function tearDownScenario() { + $this->asAn('admin'); + $this->setStatusTestingApp(false); + } + + protected function resetAppConfigs() { + } + + /** + * @When /^user "([^"]*)" converts file "([^"]*)" to "([^"]*)"$/ + */ + public function userConvertsTheSavedFileId(string $user, string $path, string $mime) { + $this->userConvertsTheSavedFileIdTo($user, $path, $mime, null); + } + + /** + * @When /^user "([^"]*)" converts file "([^"]*)" to "([^"]*)" and saves it to "([^"]*)"$/ + */ + public function userConvertsTheSavedFileIdTo(string $user, string $path, string $mime, ?string $destination) { + try { + $fileId = $this->getFileIdForPath($user, $path); + } catch (Exception $e) { + // return a fake value to keep going and be able to test the error + $fileId = 0; + } + + $data = [['fileId', $fileId], ['targetMimeType', $mime]]; + if ($destination !== null) { + $data[] = ['destination', $destination]; + } + + $this->asAn($user); + $this->sendingToWith('post', '/apps/files/api/v1/convert', new TableNode($data)); + } +} diff --git a/build/integration/features/bootstrap/DavFeatureContext.php b/build/integration/features/bootstrap/DavFeatureContext.php new file mode 100644 index 00000000000..ec6085cff98 --- /dev/null +++ b/build/integration/features/bootstrap/DavFeatureContext.php @@ -0,0 +1,24 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Context\SnippetAcceptingContext; + +require __DIR__ . '/../../vendor/autoload.php'; + +class DavFeatureContext implements Context, SnippetAcceptingContext { + use AppConfiguration; + use ContactsMenu; + use ExternalStorage; + use Search; + use WebDav; + use Trashbin; + + protected function resetAppConfigs() { + $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); + } +} diff --git a/build/integration/features/bootstrap/Download.php b/build/integration/features/bootstrap/Download.php new file mode 100644 index 00000000000..549a033346e --- /dev/null +++ b/build/integration/features/bootstrap/Download.php @@ -0,0 +1,155 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use PHPUnit\Framework\Assert; +use Psr\Http\Message\StreamInterface; + +require __DIR__ . '/../../vendor/autoload.php'; + +trait Download { + /** @var string * */ + private $downloadedFile; + + /** @AfterScenario **/ + public function cleanupDownloadedFile() { + $this->downloadedFile = null; + } + + /** + * @When user :user downloads zip file for entries :entries in folder :folder + */ + public function userDownloadsZipFileForEntriesInFolder($user, $entries, $folder) { + $folder = trim($folder, '/'); + $this->asAn($user); + $this->sendingToDirectUrl('GET', "/remote.php/dav/files/$user/$folder?accept=zip&files=[" . $entries . ']'); + $this->theHTTPStatusCodeShouldBe('200'); + } + + private function getDownloadedFile() { + $this->downloadedFile = ''; + + /** @var StreamInterface */ + $body = $this->response->getBody(); + while (!$body->eof()) { + $this->downloadedFile .= $body->read(8192); + } + $body->close(); + } + + /** + * @Then the downloaded file is a zip file + */ + public function theDownloadedFileIsAZipFile() { + $this->getDownloadedFile(); + + Assert::assertTrue( + strpos($this->downloadedFile, "\x50\x4B\x01\x02") !== false, + 'File does not contain the central directory file header' + ); + } + + /** + * @Then the downloaded zip file is a zip32 file + */ + public function theDownloadedZipFileIsAZip32File() { + $this->theDownloadedFileIsAZipFile(); + + // assertNotContains is not used to prevent the whole file from being + // printed in case of error. + Assert::assertTrue( + strpos($this->downloadedFile, "\x50\x4B\x06\x06") === false, + 'File contains the zip64 end of central dir signature' + ); + } + + /** + * @Then the downloaded zip file is a zip64 file + */ + public function theDownloadedZipFileIsAZip64File() { + $this->theDownloadedFileIsAZipFile(); + + // assertNotContains is not used to prevent the whole file from being + // printed in case of error. + Assert::assertTrue( + strpos($this->downloadedFile, "\x50\x4B\x06\x06") !== false, + 'File does not contain the zip64 end of central dir signature' + ); + } + + /** + * @Then the downloaded zip file contains a file named :fileName with the contents of :sourceFileName from :user data + */ + public function theDownloadedZipFileContainsAFileNamedWithTheContentsOfFromData($fileName, $sourceFileName, $user) { + $fileHeaderRegExp = '/'; + $fileHeaderRegExp .= "\x50\x4B\x03\x04"; // Local file header signature + $fileHeaderRegExp .= '.{22,22}'; // Ignore from "version needed to extract" to "uncompressed size" + $fileHeaderRegExp .= preg_quote(pack('v', strlen($fileName)), '/'); // File name length + $fileHeaderRegExp .= '(.{2,2})'; // Get "extra field length" + $fileHeaderRegExp .= preg_quote($fileName, '/'); // File name + $fileHeaderRegExp .= '/s'; // PCRE_DOTALL, so all characters (including bytes that happen to be new line characters) match + + // assertRegExp is not used to prevent the whole file from being printed + // in case of error and to be able to get the extra field length. + Assert::assertEquals( + 1, preg_match($fileHeaderRegExp, $this->downloadedFile, $matches), + 'Local header for file did not appear once in zip file' + ); + + $extraFieldLength = unpack('vextraFieldLength', $matches[1])['extraFieldLength']; + $expectedFileContents = file_get_contents($this->getDataDirectory() . "/$user/files" . $sourceFileName); + + $fileHeaderAndContentRegExp = '/'; + $fileHeaderAndContentRegExp .= "\x50\x4B\x03\x04"; // Local file header signature + $fileHeaderAndContentRegExp .= '.{22,22}'; // Ignore from "version needed to extract" to "uncompressed size" + $fileHeaderAndContentRegExp .= preg_quote(pack('v', strlen($fileName)), '/'); // File name length + $fileHeaderAndContentRegExp .= '.{2,2}'; // Ignore "extra field length" + $fileHeaderAndContentRegExp .= preg_quote($fileName, '/'); // File name + $fileHeaderAndContentRegExp .= '.{' . $extraFieldLength . ',' . $extraFieldLength . '}'; // Ignore "extra field" + $fileHeaderAndContentRegExp .= preg_quote($expectedFileContents, '/'); // File contents + $fileHeaderAndContentRegExp .= '/s'; // PCRE_DOTALL, so all characters (including bytes that happen to be new line characters) match + + // assertRegExp is not used to prevent the whole file from being printed + // in case of error. + Assert::assertEquals( + 1, preg_match($fileHeaderAndContentRegExp, $this->downloadedFile), + 'Local header and contents for file did not appear once in zip file' + ); + } + + /** + * @Then the downloaded zip file contains a folder named :folderName + */ + public function theDownloadedZipFileContainsAFolderNamed($folderName) { + $folderHeaderRegExp = '/'; + $folderHeaderRegExp .= "\x50\x4B\x03\x04"; // Local file header signature + $folderHeaderRegExp .= '.{22,22}'; // Ignore from "version needed to extract" to "uncompressed size" + $folderHeaderRegExp .= preg_quote(pack('v', strlen($folderName)), '/'); // File name length + $folderHeaderRegExp .= '.{2,2}'; // Ignore "extra field length" + $folderHeaderRegExp .= preg_quote($folderName, '/'); // File name + $folderHeaderRegExp .= '/s'; // PCRE_DOTALL, so all characters (including bytes that happen to be new line characters) match + + // assertRegExp is not used to prevent the whole file from being printed + // in case of error. + Assert::assertEquals( + 1, preg_match($folderHeaderRegExp, $this->downloadedFile), + 'Local header for folder did not appear once in zip file' + ); + } + + /** + * @Then the downloaded file has the content of :sourceFilename from :user data + */ + public function theDownloadedFileHasContentOfUserFile($sourceFilename, $user) { + $this->getDownloadedFile(); + $expectedFileContents = file_get_contents($this->getDataDirectory() . "/$user/files" . $sourceFilename); + + // prevent the whole file from being printed in case of error. + Assert::assertEquals( + 0, strcmp($expectedFileContents, $this->downloadedFile), + 'Downloaded file content does not match local file content' + ); + } +} diff --git a/build/integration/features/bootstrap/ExternalStorage.php b/build/integration/features/bootstrap/ExternalStorage.php new file mode 100644 index 00000000000..8fe2653a026 --- /dev/null +++ b/build/integration/features/bootstrap/ExternalStorage.php @@ -0,0 +1,123 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +require __DIR__ . '/../../vendor/autoload.php'; + +trait ExternalStorage { + private array $storageIds = []; + + private array $lastExternalStorageData; + + /** + * @AfterScenario + **/ + public function deleteCreatedStorages(): void { + foreach ($this->storageIds as $storageId) { + $this->deleteStorage($storageId); + } + $this->storageIds = []; + } + + private function deleteStorage(string $storageId): void { + // Based on "runOcc" from CommandLine trait + $args = ['files_external:delete', '--yes', $storageId]; + $args = array_map(function ($arg) { + return escapeshellarg($arg); + }, $args); + $args[] = '--no-ansi --no-warnings'; + $args = implode(' ', $args); + + $descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open('php console.php ' . $args, $descriptor, $pipes, $ocPath = '../..'); + $lastStdOut = stream_get_contents($pipes[1]); + proc_close($process); + } + + /** + * @When logged in user creates external global storage + * + * @param TableNode $fields + */ + public function loggedInUserCreatesExternalGlobalStorage(TableNode $fields): void { + $this->sendJsonWithRequestTokenAndBasicAuth('POST', '/index.php/apps/files_external/globalstorages', $fields); + $this->theHTTPStatusCodeShouldBe('201'); + + $this->lastExternalStorageData = json_decode($this->response->getBody(), $asAssociativeArray = true); + + $this->storageIds[] = $this->lastExternalStorageData['id']; + } + + /** + * @When logged in user updates last external userglobal storage + * + * @param TableNode $fields + */ + public function loggedInUserUpdatesLastExternalUserglobalStorage(TableNode $fields): void { + $this->sendJsonWithRequestTokenAndBasicAuth('PUT', '/index.php/apps/files_external/userglobalstorages/' . $this->lastExternalStorageData['id'], $fields); + $this->theHTTPStatusCodeShouldBe('200'); + + $this->lastExternalStorageData = json_decode($this->response->getBody(), $asAssociativeArray = true); + } + + /** + * @Then fields of last external storage match with + * + * @param TableNode $fields + */ + public function fieldsOfLastExternalStorageMatchWith(TableNode $fields): void { + foreach ($fields->getRowsHash() as $expectedField => $expectedValue) { + if (!array_key_exists($expectedField, $this->lastExternalStorageData)) { + Assert::fail("$expectedField was not found in response"); + } + + Assert::assertEquals($expectedValue, $this->lastExternalStorageData[$expectedField], "Field '$expectedField' does not match ({$this->lastExternalStorageData[$expectedField]})"); + } + } + + private function sendJsonWithRequestToken(string $method, string $url, TableNode $fields): void { + $isFirstField = true; + $fieldsAsJsonString = '{'; + foreach ($fields->getRowsHash() as $key => $value) { + $fieldsAsJsonString .= ($isFirstField ? '' : ',') . '"' . $key . '":' . $value; + $isFirstField = false; + } + $fieldsAsJsonString .= '}'; + + $body = [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'body' => $fieldsAsJsonString, + ]; + $this->sendingAToWithRequesttoken($method, $url, $body); + } + + private function sendJsonWithRequestTokenAndBasicAuth(string $method, string $url, TableNode $fields): void { + $isFirstField = true; + $fieldsAsJsonString = '{'; + foreach ($fields->getRowsHash() as $key => $value) { + $fieldsAsJsonString .= ($isFirstField ? '' : ',') . '"' . $key . '":' . $value; + $isFirstField = false; + } + $fieldsAsJsonString .= '}'; + + $body = [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Basic ' . base64_encode('admin:admin'), + ], + 'body' => $fieldsAsJsonString, + ]; + $this->sendingAToWithRequesttoken($method, $url, $body); + } +} diff --git a/build/integration/features/bootstrap/FakeSMTPHelper.php b/build/integration/features/bootstrap/FakeSMTPHelper.php new file mode 100644 index 00000000000..32387869edd --- /dev/null +++ b/build/integration/features/bootstrap/FakeSMTPHelper.php @@ -0,0 +1,163 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// Code below modified from https://github.com/axllent/fake-smtp/blob/f0856f8a0df6f4ca5a573cf31428c09ebc5b9ea3/fakeSMTP.php, +// which is under the MIT license (https://github.com/axllent/fake-smtp/blob/f0856f8a0df6f4ca5a573cf31428c09ebc5b9ea3/LICENSE) + +/** + * fakeSMTP - A PHP / inetd fake smtp server. + * Allows client<->server interaction + * The comunication is based upon the SMPT standards defined in http://www.lesnikowski.com/mail/Rfc/rfc2821.txt + */ + +class fakeSMTP { + public $logFile = false; + public $serverHello = 'fakeSMTP ESMTP PHP Mail Server Ready'; + + public function __construct($fd) { + $this->mail = []; + $this->mail['ipaddress'] = false; + $this->mail['emailSender'] = ''; + $this->mail['emailRecipients'] = []; + $this->mail['emailSubject'] = false; + $this->mail['rawEmail'] = false; + $this->mail['emailHeaders'] = false; + $this->mail['emailBody'] = false; + + $this->fd = $fd; + } + + public function receive() { + $hasValidFrom = false; + $hasValidTo = false; + $receivingData = false; + $header = true; + $this->reply('220 ' . $this->serverHello); + $this->mail['ipaddress'] = $this->detectIP(); + while ($data = fgets($this->fd)) { + $data = preg_replace('@\r\n@', "\n", $data); + + if (!$receivingData) { + $this->log($data); + } + + if (!$receivingData && preg_match('/^MAIL FROM:\s?<(.*)>/i', $data, $match)) { + if (preg_match('/(.*)@\[.*\]/i', $match[1]) || $match[1] != '' || $this->validateEmail($match[1])) { + $this->mail['emailSender'] = $match[1]; + $this->reply('250 2.1.0 Ok'); + $hasValidFrom = true; + } else { + $this->reply('551 5.1.7 Bad sender address syntax'); + } + } elseif (!$receivingData && preg_match('/^RCPT TO:\s?<(.*)>/i', $data, $match)) { + if (!$hasValidFrom) { + $this->reply('503 5.5.1 Error: need MAIL command'); + } else { + if (preg_match('/postmaster@\[.*\]/i', $match[1]) || $this->validateEmail($match[1])) { + array_push($this->mail['emailRecipients'], $match[1]); + $this->reply('250 2.1.5 Ok'); + $hasValidTo = true; + } else { + $this->reply('501 5.1.3 Bad recipient address syntax ' . $match[1]); + } + } + } elseif (!$receivingData && preg_match('/^RSET$/i', trim($data))) { + $this->reply('250 2.0.0 Ok'); + $hasValidFrom = false; + $hasValidTo = false; + } elseif (!$receivingData && preg_match('/^NOOP$/i', trim($data))) { + $this->reply('250 2.0.0 Ok'); + } elseif (!$receivingData && preg_match('/^VRFY (.*)/i', trim($data), $match)) { + $this->reply('250 2.0.0 ' . $match[1]); + } elseif (!$receivingData && preg_match('/^DATA/i', trim($data))) { + if (!$hasValidTo) { + $this->reply('503 5.5.1 Error: need RCPT command'); + } else { + $this->reply('354 Ok Send data ending with <CRLF>.<CRLF>'); + $receivingData = true; + } + } elseif (!$receivingData && preg_match('/^(HELO|EHLO)/i', $data)) { + $this->reply('250 HELO ' . $this->mail['ipaddress']); + } elseif (!$receivingData && preg_match('/^QUIT/i', trim($data))) { + break; + } elseif (!$receivingData) { + //~ $this->reply('250 Ok'); + $this->reply('502 5.5.2 Error: command not recognized'); + } elseif ($receivingData && $data == ".\n") { + /* Email Received, now let's look at it */ + $receivingData = false; + $this->reply('250 2.0.0 Ok: queued as ' . $this->generateRandom(10)); + $splitmail = explode("\n\n", $this->mail['rawEmail'], 2); + if (count($splitmail) == 2) { + $this->mail['emailHeaders'] = $splitmail[0]; + $this->mail['emailBody'] = $splitmail[1]; + $headers = preg_replace("/ \s+/", ' ', preg_replace("/\n\s/", ' ', $this->mail['emailHeaders'])); + $headerlines = explode("\n", $headers); + for ($i = 0; $i < count($headerlines); $i++) { + if (preg_match('/^Subject: (.*)/i', $headerlines[$i], $matches)) { + $this->mail['emailSubject'] = trim($matches[1]); + } + } + } else { + $this->mail['emailBody'] = $splitmail[0]; + } + set_time_limit(5); // Just run the exit to prevent open threads / abuse + } elseif ($receivingData) { + $this->mail['rawEmail'] .= $data; + } + } + /* Say good bye */ + $this->reply('221 2.0.0 Bye ' . $this->mail['ipaddress']); + + fclose($this->fd); + } + + public function log($s) { + if ($this->logFile) { + file_put_contents($this->logFile, trim($s) . "\n", FILE_APPEND); + } + } + + private function reply($s) { + $this->log("REPLY:$s"); + fwrite($this->fd, $s . "\r\n"); + } + + private function detectIP() { + $raw = explode(':', stream_socket_get_name($this->fd, true)); + return $raw[0]; + } + + private function validateEmail($email) { + return preg_match('/^[_a-z0-9-+]+(\.[_a-z0-9-+]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,4})$/', strtolower($email)); + } + + private function generateRandom($length = 8) { + $password = ''; + $possible = '2346789BCDFGHJKLMNPQRTVWXYZ'; + $maxlength = strlen($possible); + $i = 0; + for ($i = 0; $i < $length; $i++) { + $char = substr($possible, mt_rand(0, $maxlength - 1), 1); + if (!strstr($password, $char)) { + $password .= $char; + } + } + return $password; + } +} + +$socket = stream_socket_server('tcp://127.0.0.1:2525', $errno, $errstr); +if (!$socket) { + exit(); +} + +while ($fd = stream_socket_accept($socket)) { + $fakeSMTP = new fakeSMTP($fd); + $fakeSMTP->receive(); +} + +fclose($socket); diff --git a/build/integration/features/bootstrap/FeatureContext.php b/build/integration/features/bootstrap/FeatureContext.php index 21ca8d87295..ab37556f931 100644 --- a/build/integration/features/bootstrap/FeatureContext.php +++ b/build/integration/features/bootstrap/FeatureContext.php @@ -1,14 +1,29 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ use Behat\Behat\Context\Context; use Behat\Behat\Context\SnippetAcceptingContext; require __DIR__ . '/../../vendor/autoload.php'; - /** * Features context. */ class FeatureContext implements Context, SnippetAcceptingContext { + use AppConfiguration; + use ContactsMenu; + use ExternalStorage; + use Search; use WebDav; + use Trashbin; + + protected function resetAppConfigs(): void { + $this->deleteServerConfig('bruteForce', 'whitelist_0'); + $this->deleteServerConfig('bruteForce', 'whitelist_1'); + $this->deleteServerConfig('bruteforcesettings', 'apply_allowlist_to_ratelimit'); + } } diff --git a/build/integration/features/bootstrap/FederationContext.php b/build/integration/features/bootstrap/FederationContext.php index 2809c6974fa..95dc8119ad6 100644 --- a/build/integration/features/bootstrap/FederationContext.php +++ b/build/integration/features/bootstrap/FederationContext.php @@ -1,9 +1,14 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ use Behat\Behat\Context\Context; use Behat\Behat\Context\SnippetAcceptingContext; -use GuzzleHttp\Client; -use GuzzleHttp\Message\ResponseInterface; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; require __DIR__ . '/../../vendor/autoload.php'; @@ -11,8 +16,45 @@ require __DIR__ . '/../../vendor/autoload.php'; * Federation context. */ class FederationContext implements Context, SnippetAcceptingContext { + use WebDav; + use AppConfiguration; + use CommandLine; + + /** @var string */ + private static $phpFederatedServerPid = ''; - use Sharing; + /** @var string */ + private $lastAcceptedRemoteShareId; + + /** + * @BeforeScenario + * @AfterScenario + * + * The server is started also after the scenarios to ensure that it is + * properly cleaned up if stopped. + */ + public function startFederatedServer() { + if (self::$phpFederatedServerPid !== '') { + return; + } + + $port = getenv('PORT_FED'); + + self::$phpFederatedServerPid = exec('PHP_CLI_SERVER_WORKERS=2 php -S localhost:' . $port . ' -t ../../ >/dev/null & echo $!'); + } + + /** + * @BeforeScenario + */ + public function cleanupRemoteStorages() { + // Ensure that dangling remote storages from previous tests will not + // interfere with the current scenario. + // The storages must be cleaned before each scenario; they can not be + // cleaned after each scenario, as this hook is executed before the hook + // that removes the users, so the shares would be still valid and thus + // the storages would not be dangling yet. + $this->runOcc(['sharing:cleanup-remote-storages']); + } /** * @Given /^User "([^"]*)" from server "(LOCAL|REMOTE)" shares "([^"]*)" with user "([^"]*)" from server "(LOCAL|REMOTE)"$/ @@ -23,8 +65,8 @@ class FederationContext implements Context, SnippetAcceptingContext { * @param string $shareeUser * @param string $shareeServer "LOCAL" or "REMOTE" */ - public function federateSharing($sharerUser, $sharerServer, $sharerPath, $shareeUser, $shareeServer){ - if ($shareeServer == "REMOTE"){ + public function federateSharing($sharerUser, $sharerServer, $sharerPath, $shareeUser, $shareeServer) { + if ($shareeServer == 'REMOTE') { $shareWith = "$shareeUser@" . substr($this->remoteBaseUrl, 0, -4); } else { $shareWith = "$shareeUser@" . substr($this->localBaseUrl, 0, -4); @@ -34,21 +76,146 @@ class FederationContext implements Context, SnippetAcceptingContext { $this->usingServer($previous); } + + /** + * @Given /^User "([^"]*)" from server "(LOCAL|REMOTE)" shares "([^"]*)" with group "([^"]*)" from server "(LOCAL|REMOTE)"$/ + * + * @param string $sharerUser + * @param string $sharerServer "LOCAL" or "REMOTE" + * @param string $sharerPath + * @param string $shareeUser + * @param string $shareeServer "LOCAL" or "REMOTE" + */ + public function federateGroupSharing($sharerUser, $sharerServer, $sharerPath, $shareeGroup, $shareeServer) { + if ($shareeServer == 'REMOTE') { + $shareWith = "$shareeGroup@" . substr($this->remoteBaseUrl, 0, -4); + } else { + $shareWith = "$shareeGroup@" . substr($this->localBaseUrl, 0, -4); + } + $previous = $this->usingServer($sharerServer); + $this->createShare($sharerUser, $sharerPath, 9, $shareWith, null, null, null); + $this->usingServer($previous); + } + + /** + * @Then remote share :count is returned with + * + * @param int $number + * @param TableNode $body + */ + public function remoteShareXIsReturnedWith(int $number, TableNode $body) { + $this->theHTTPStatusCodeShouldBe('200'); + $this->theOCSStatusCodeShouldBe('100'); + + if (!($body instanceof TableNode)) { + return; + } + + $returnedShare = $this->getXmlResponse()->data[0]; + if ($returnedShare->element) { + $returnedShare = $returnedShare->element[$number]; + } + + $defaultExpectedFields = [ + 'id' => 'A_NUMBER', + 'remote_id' => 'A_NUMBER', + 'accepted' => '1', + ]; + $expectedFields = array_merge($defaultExpectedFields, $body->getRowsHash()); + + foreach ($expectedFields as $field => $value) { + $this->assertFieldIsInReturnedShare($field, $value, $returnedShare); + } + } + /** * @When /^User "([^"]*)" from server "(LOCAL|REMOTE)" accepts last pending share$/ * @param string $user * @param string $server */ - public function acceptLastPendingShare($user, $server){ + public function acceptLastPendingShare($user, $server) { $previous = $this->usingServer($server); $this->asAn($user); - $this->sendingToWith('GET', "/apps/files_sharing/api/v1/remote_shares/pending", null); + $this->sendingToWith('GET', '/apps/files_sharing/api/v1/remote_shares/pending', null); $this->theHTTPStatusCodeShouldBe('200'); $this->theOCSStatusCodeShouldBe('100'); - $share_id = $this->response->xml()->data[0]->element[0]->id; + $share_id = simplexml_load_string($this->response->getBody())->data[0]->element[0]->id; $this->sendingToWith('POST', "/apps/files_sharing/api/v1/remote_shares/pending/{$share_id}", null); $this->theHTTPStatusCodeShouldBe('200'); $this->theOCSStatusCodeShouldBe('100'); $this->usingServer($previous); + + $this->lastAcceptedRemoteShareId = $share_id; + } + + /** + * @When /^user "([^"]*)" deletes last accepted remote share$/ + * @param string $user + */ + public function deleteLastAcceptedRemoteShare($user) { + $this->asAn($user); + $this->sendingToWith('DELETE', '/apps/files_sharing/api/v1/remote_shares/' . $this->lastAcceptedRemoteShareId, null); + } + + /** + * @When /^remote server is stopped$/ + */ + public function remoteServerIsStopped() { + if (self::$phpFederatedServerPid === '') { + return; + } + + exec('kill ' . self::$phpFederatedServerPid); + + self::$phpFederatedServerPid = ''; + } + + /** + * @BeforeScenario @TrustedFederation + */ + public function theServersAreTrustingEachOther() { + $this->asAn('admin'); + // Trust the remote server on the local server + $this->usingServer('LOCAL'); + $this->sendRequestForJSON('POST', '/apps/federation/trusted-servers', ['url' => 'http://localhost:' . getenv('PORT')]); + Assert::assertTrue(($this->response->getStatusCode() === 200 || $this->response->getStatusCode() === 409)); + + // Trust the local server on the remote server + $this->usingServer('REMOTE'); + $this->sendRequestForJSON('POST', '/apps/federation/trusted-servers', ['url' => 'http://localhost:' . getenv('PORT_FED')]); + // If the server is already trusted, we expect a 409 + Assert::assertTrue(($this->response->getStatusCode() === 200 || $this->response->getStatusCode() === 409)); + } + + /** + * @AfterScenario @TrustedFederation + */ + public function theServersAreNoLongerTrustingEachOther() { + $this->asAn('admin'); + // Untrust the remote servers on the local server + $this->usingServer('LOCAL'); + $this->sendRequestForJSON('GET', '/apps/federation/trusted-servers'); + $this->theHTTPStatusCodeShouldBe('200'); + $trustedServersIDs = array_map(fn ($server) => $server->id, json_decode($this->response->getBody())->ocs->data); + foreach ($trustedServersIDs as $id) { + $this->sendRequestForJSON('DELETE', '/apps/federation/trusted-servers/' . $id); + $this->theHTTPStatusCodeShouldBe('200'); + } + + // Untrust the local server on the remote server + $this->usingServer('REMOTE'); + $this->sendRequestForJSON('GET', '/apps/federation/trusted-servers'); + $this->theHTTPStatusCodeShouldBe('200'); + $trustedServersIDs = array_map(fn ($server) => $server->id, json_decode($this->response->getBody())->ocs->data); + foreach ($trustedServersIDs as $id) { + $this->sendRequestForJSON('DELETE', '/apps/federation/trusted-servers/' . $id); + $this->theHTTPStatusCodeShouldBe('200'); + } + } + + protected function resetAppConfigs() { + $this->deleteServerConfig('files_sharing', 'incoming_server2server_group_share_enabled'); + $this->deleteServerConfig('files_sharing', 'outgoing_server2server_group_share_enabled'); + $this->deleteServerConfig('files_sharing', 'federated_trusted_share_auto_accept'); } } diff --git a/build/integration/features/bootstrap/FilesDropContext.php b/build/integration/features/bootstrap/FilesDropContext.php new file mode 100644 index 00000000000..0c437f28a72 --- /dev/null +++ b/build/integration/features/bootstrap/FilesDropContext.php @@ -0,0 +1,94 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; +use Behat\Behat\Context\SnippetAcceptingContext; +use GuzzleHttp\Client; + +require __DIR__ . '/../../vendor/autoload.php'; + +class FilesDropContext implements Context, SnippetAcceptingContext { + use WebDav; + + /** + * @When Dropping file :path with :content + */ + public function droppingFileWith($path, $content, $nickname = null) { + $client = new Client(); + $options = []; + if (count($this->lastShareData->data->element) > 0) { + $token = $this->lastShareData->data[0]->token; + } else { + $token = $this->lastShareData->data[0]->token; + } + + $base = substr($this->baseUrl, 0, -4); + $fullUrl = str_replace('//', '/', $base . "/public.php/dav/files/$token/$path"); + + $options['headers'] = [ + 'X-REQUESTED-WITH' => 'XMLHttpRequest', + ]; + + if ($nickname) { + $options['headers']['X-NC-NICKNAME'] = $nickname; + } + + $options['body'] = \GuzzleHttp\Psr7\Utils::streamFor($content); + + try { + $this->response = $client->request('PUT', $fullUrl, $options); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + + /** + * @When Dropping file :path with :content as :nickName + */ + public function droppingFileWithAs($path, $content, $nickname) { + $this->droppingFileWith($path, $content, $nickname); + } + + + /** + * @When Creating folder :folder in drop + */ + public function creatingFolderInDrop($folder, $nickname = null) { + $client = new Client(); + $options = []; + if (count($this->lastShareData->data->element) > 0) { + $token = $this->lastShareData->data[0]->token; + } else { + $token = $this->lastShareData->data[0]->token; + } + + $base = substr($this->baseUrl, 0, -4); + $fullUrl = str_replace('//', '/', $base . "/public.php/dav/files/$token/$folder"); + + $options['headers'] = [ + 'X-REQUESTED-WITH' => 'XMLHttpRequest', + ]; + + if ($nickname) { + $options['headers']['X-NC-NICKNAME'] = $nickname; + } + + try { + $this->response = $client->request('MKCOL', $fullUrl, $options); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + + /** + * @When Creating folder :folder in drop as :nickName + */ + public function creatingFolderInDropWithNickname($folder, $nickname) { + return $this->creatingFolderInDrop($folder, $nickname); + } +} diff --git a/build/integration/features/bootstrap/LDAPContext.php b/build/integration/features/bootstrap/LDAPContext.php new file mode 100644 index 00000000000..986dced77a1 --- /dev/null +++ b/build/integration/features/bootstrap/LDAPContext.php @@ -0,0 +1,198 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +class LDAPContext implements Context { + use AppConfiguration, + CommandLine, + Sharing; // Pulls in BasicStructure + + protected $configID; + + protected $apiUrl; + + /** @AfterScenario */ + public function teardown() { + if ($this->configID === null) { + return; + } + $this->disableLDAPConfiguration(); # via occ in case of big config issues + $this->asAn('admin'); + $this->sendingTo('DELETE', $this->apiUrl . '/' . $this->configID); + } + + /** + * @Given /^the response should contain a tag "([^"]*)"$/ + */ + public function theResponseShouldContainATag($arg1) { + $configID = simplexml_load_string($this->response->getBody())->data[0]->$arg1; + Assert::assertInstanceOf(SimpleXMLElement::class, $configID[0]); + } + + /** + * @Given /^creating an LDAP configuration at "([^"]*)"$/ + */ + public function creatingAnLDAPConfigurationAt($apiUrl) { + $this->apiUrl = $apiUrl; + $this->sendingToWith('POST', $this->apiUrl, null); + $configElements = simplexml_load_string($this->response->getBody())->data[0]->configID; + $this->configID = $configElements[0]; + } + + /** + * @When /^deleting the LDAP configuration$/ + */ + public function deletingTheLDAPConfiguration() { + $this->sendingToWith('DELETE', $this->apiUrl . '/' . $this->configID, null); + } + + /** + * @Given /^the response should contain a tag "([^"]*)" with value "([^"]*)"$/ + */ + public function theResponseShouldContainATagWithValue($tagName, $expectedValue) { + $data = simplexml_load_string($this->response->getBody())->data[0]->$tagName; + Assert::assertEquals($expectedValue, $data[0]); + } + + /** + * @When /^getting the LDAP configuration with showPassword "([^"]*)"$/ + */ + public function gettingTheLDAPConfigurationWithShowPassword($showPassword) { + $this->sendingToWith( + 'GET', + $this->apiUrl . '/' . $this->configID . '?showPassword=' . $showPassword, + null + ); + } + + /** + * @Given /^setting the LDAP configuration to$/ + */ + public function settingTheLDAPConfigurationTo(TableNode $configData) { + $this->sendingToWith('PUT', $this->apiUrl . '/' . $this->configID, $configData); + } + + /** + * @Given /^having a valid LDAP configuration$/ + */ + public function havingAValidLDAPConfiguration() { + $this->asAn('admin'); + $this->creatingAnLDAPConfigurationAt('/apps/user_ldap/api/v1/config'); + $data = new TableNode([ + ['configData[ldapHost]', getenv('LDAP_HOST') ?: 'openldap'], + ['configData[ldapPort]', '389'], + ['configData[ldapBase]', 'dc=nextcloud,dc=ci'], + ['configData[ldapAgentName]', 'cn=admin,dc=nextcloud,dc=ci'], + ['configData[ldapAgentPassword]', 'admin'], + ['configData[ldapUserFilter]', '(&(objectclass=inetorgperson))'], + ['configData[ldapLoginFilter]', '(&(objectclass=inetorgperson)(uid=%uid))'], + ['configData[ldapUserDisplayName]', 'displayname'], + ['configData[ldapGroupDisplayName]', 'cn'], + ['configData[ldapEmailAttribute]', 'mail'], + ['configData[ldapConfigurationActive]', '1'], + ]); + $this->settingTheLDAPConfigurationTo($data); + $this->asAn(''); + } + + /** + * @Given /^looking up details for the first result matches expectations$/ + * @param TableNode $expectations + */ + public function lookingUpDetailsForTheFirstResult(TableNode $expectations) { + $userResultElements = simplexml_load_string($this->response->getBody())->data[0]->users[0]->element; + $userResults = json_decode(json_encode($userResultElements), 1); + $userId = array_shift($userResults); + + $this->sendingTo('GET', '/cloud/users/' . $userId); + $this->theRecordFieldsShouldMatch($expectations); + } + + /** + * @Given /^modify LDAP configuration$/ + */ + public function modifyLDAPConfiguration(TableNode $table) { + $originalAsAn = $this->currentUser; + $this->asAn('admin'); + $configData = $table->getRows(); + foreach ($configData as &$row) { + if (str_contains($row[0], 'Host') && getenv('LDAP_HOST')) { + $row[1] = str_replace('openldap', getenv('LDAP_HOST'), $row[1]); + } + $row[0] = 'configData[' . $row[0] . ']'; + } + $this->settingTheLDAPConfigurationTo(new TableNode($configData)); + $this->asAn($originalAsAn); + } + + /** + * @Given /^the "([^"]*)" result should match$/ + */ + public function theGroupResultShouldMatch(string $type, TableNode $expectations) { + $listReturnedElements = simplexml_load_string($this->response->getBody())->data[0]->$type[0]->element; + $extractedIDsArray = json_decode(json_encode($listReturnedElements), 1); + foreach ($expectations->getRows() as $expectation) { + if ((int)$expectation[1] === 1) { + Assert::assertContains($expectation[0], $extractedIDsArray); + } else { + Assert::assertNotContains($expectation[0], $extractedIDsArray); + } + } + } + + /** + * @Given /^Expect ServerException on failed web login as "([^"]*)"$/ + */ + public function expectServerExceptionOnFailedWebLoginAs($login) { + try { + $this->loggingInUsingWebAs($login); + } catch (\GuzzleHttp\Exception\ServerException $e) { + Assert::assertEquals(500, $e->getResponse()->getStatusCode()); + return; + } + Assert::assertTrue(false, 'expected Exception not received'); + } + + /** + * @Given /^the "([^"]*)" result should contain "([^"]*)" of$/ + */ + public function theResultShouldContainOf($type, $expectedCount, TableNode $expectations) { + $listReturnedElements = simplexml_load_string($this->response->getBody())->data[0]->$type[0]->element; + $extractedIDsArray = json_decode(json_encode($listReturnedElements), 1); + $uidsFound = 0; + foreach ($expectations->getRows() as $expectation) { + if (in_array($expectation[0], $extractedIDsArray)) { + $uidsFound++; + } + } + Assert::assertSame((int)$expectedCount, $uidsFound); + } + + /** + * @Given /^the record's fields should match$/ + */ + public function theRecordFieldsShouldMatch(TableNode $expectations) { + foreach ($expectations->getRowsHash() as $k => $v) { + $value = (string)simplexml_load_string($this->response->getBody())->data[0]->$k; + Assert::assertEquals($v, $value, "got $value"); + } + + $backend = (string)simplexml_load_string($this->response->getBody())->data[0]->backend; + Assert::assertEquals('LDAP', $backend); + } + + public function disableLDAPConfiguration() { + $configKey = $this->configID . 'ldap_configuration_active'; + $this->invokingTheCommand('config:app:set user_ldap ' . $configKey . ' --value="0"'); + } + + protected function resetAppConfigs() { + // not implemented + } +} diff --git a/build/integration/features/bootstrap/Mail.php b/build/integration/features/bootstrap/Mail.php new file mode 100644 index 00000000000..d48ed6399c5 --- /dev/null +++ b/build/integration/features/bootstrap/Mail.php @@ -0,0 +1,39 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +trait Mail { + // CommandLine trait is expected to be used in the class that uses this + // trait. + + /** + * @var string + */ + private $fakeSmtpServerPid; + + /** + * @AfterScenario + */ + public function killDummyMailServer() { + if (!$this->fakeSmtpServerPid) { + return; + } + + exec('kill ' . $this->fakeSmtpServerPid); + + $this->invokingTheCommand('config:system:delete mail_smtpport'); + } + + /** + * @Given /^dummy mail server is listening$/ + */ + public function dummyMailServerIsListening() { + // Default smtpport (25) is restricted for regular users, so the + // FakeSMTP uses 2525 instead. + $this->invokingTheCommand('config:system:set mail_smtpport --value=2525 --type integer'); + + $this->fakeSmtpServerPid = exec('php features/bootstrap/FakeSMTPHelper.php >/dev/null 2>&1 & echo $!'); + } +} diff --git a/build/integration/features/bootstrap/MetadataContext.php b/build/integration/features/bootstrap/MetadataContext.php new file mode 100644 index 00000000000..32042590c86 --- /dev/null +++ b/build/integration/features/bootstrap/MetadataContext.php @@ -0,0 +1,124 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +use Behat\Behat\Context\Context; +use Behat\Step\Then; +use Behat\Step\When; +use PHPUnit\Framework\Assert; +use Sabre\DAV\Client as SClient; + +require __DIR__ . '/../../vendor/autoload.php'; + +class MetadataContext implements Context { + private string $davPath = '/remote.php/dav'; + + public function __construct( + private string $baseUrl, + private array $admin, + private string $regular_user_password, + ) { + // in case of ci deployment we take the server url from the environment + $testServerUrl = getenv('TEST_SERVER_URL'); + if ($testServerUrl !== false) { + $this->baseUrl = substr($testServerUrl, 0, -5); + } + } + + #[When('User :user sets the :metadataKey prop with value :metadataValue on :fileName')] + public function userSetsProp(string $user, string $metadataKey, string $metadataValue, string $fileName) { + $client = new SClient([ + 'baseUri' => $this->baseUrl, + 'userName' => $user, + 'password' => '123456', + 'authType' => SClient::AUTH_BASIC, + ]); + + $body = '<?xml version="1.0"?> +<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.com/ns"> + <d:set> + <d:prop> + <nc:' . $metadataKey . '>' . $metadataValue . '</nc:' . $metadataKey . '> + </d:prop> + </d:set> +</d:propertyupdate>'; + + $davUrl = $this->getDavUrl($user, $fileName); + $client->request('PROPPATCH', $this->baseUrl . $davUrl, $body); + } + + #[When('User :user deletes the :metadataKey prop on :fileName')] + public function userDeletesProp(string $user, string $metadataKey, string $fileName) { + $client = new SClient([ + 'baseUri' => $this->baseUrl, + 'userName' => $user, + 'password' => '123456', + 'authType' => SClient::AUTH_BASIC, + ]); + + $body = '<?xml version="1.0"?> +<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.com/ns"> + <d:remove> + <d:prop> + <nc:' . $metadataKey . '></nc:' . $metadataKey . '> + </d:prop> + </d:remove> +</d:propertyupdate>'; + + $davUrl = $this->getDavUrl($user, $fileName); + $client->request('PROPPATCH', $this->baseUrl . $davUrl, $body); + } + + #[Then('User :user should see the prop :metadataKey equal to :metadataValue for file :fileName')] + public function checkPropForFile(string $user, string $metadataKey, string $metadataValue, string $fileName) { + $client = new SClient([ + 'baseUri' => $this->baseUrl, + 'userName' => $user, + 'password' => '123456', + 'authType' => SClient::AUTH_BASIC, + ]); + + $body = '<?xml version="1.0"?> +<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.com/ns"> + <d:prop> + <nc:' . $metadataKey . '></nc:' . $metadataKey . '> + </d:prop> +</d:propfind>'; + + $davUrl = $this->getDavUrl($user, $fileName); + $response = $client->request('PROPFIND', $this->baseUrl . $davUrl, $body); + $parsedResponse = $client->parseMultistatus($response['body']); + + Assert::assertEquals($parsedResponse[$davUrl]['200']['{http://nextcloud.com/ns}' . $metadataKey], $metadataValue); + } + + #[Then('User :user should not see the prop :metadataKey for file :fileName')] + public function checkPropDoesNotExistsForFile(string $user, string $metadataKey, string $fileName) { + $client = new SClient([ + 'baseUri' => $this->baseUrl, + 'userName' => $user, + 'password' => '123456', + 'authType' => SClient::AUTH_BASIC, + ]); + + $body = '<?xml version="1.0"?> +<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.com/ns"> + <d:prop> + <nc:' . $metadataKey . '></nc:' . $metadataKey . '> + </d:prop> +</d:propfind>'; + + $davUrl = $this->getDavUrl($user, $fileName); + $response = $client->request('PROPFIND', $this->baseUrl . $davUrl, $body); + $parsedResponse = $client->parseMultistatus($response['body']); + + Assert::assertEquals($parsedResponse[$davUrl]['404']['{http://nextcloud.com/ns}' . $metadataKey], null); + } + + private function getDavUrl(string $user, string $fileName) { + return $this->davPath . '/files/' . $user . $fileName; + } +} diff --git a/build/integration/features/bootstrap/PrincipalPropertySearchContext.php b/build/integration/features/bootstrap/PrincipalPropertySearchContext.php new file mode 100644 index 00000000000..9dfd9379240 --- /dev/null +++ b/build/integration/features/bootstrap/PrincipalPropertySearchContext.php @@ -0,0 +1,141 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +require __DIR__ . '/../../vendor/autoload.php'; + +use Behat\Behat\Context\Context; +use GuzzleHttp\BodySummarizer; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Utils; +use Psr\Http\Message\ResponseInterface; + +class PrincipalPropertySearchContext implements Context { + private string $baseUrl; + private Client $client; + private ResponseInterface $response; + + public function __construct(string $baseUrl) { + $this->baseUrl = $baseUrl; + + // in case of ci deployment we take the server url from the environment + $testServerUrl = getenv('TEST_SERVER_URL'); + if ($testServerUrl !== false) { + $this->baseUrl = substr($testServerUrl, 0, -5); + } + } + + /** @BeforeScenario */ + public function setUpScenario(): void { + $this->client = $this->createGuzzleInstance(); + } + + /** + * Create a Guzzle client with a higher truncateAt value to read full error responses. + */ + private function createGuzzleInstance(): Client { + $bodySummarizer = new BodySummarizer(2048); + + $stack = new HandlerStack(Utils::chooseHandler()); + $stack->push(Middleware::httpErrors($bodySummarizer), 'http_errors'); + $stack->push(Middleware::redirect(), 'allow_redirects'); + $stack->push(Middleware::cookies(), 'cookies'); + $stack->push(Middleware::prepareBody(), 'prepare_body'); + + return new Client(['handler' => $stack]); + } + + /** + * @When searching for a principal matching :match + * @param string $match + * @throws \Exception + */ + public function principalPropertySearch(string $match) { + $davUrl = $this->baseUrl . '/remote.php/dav/'; + $user = 'admin'; + $password = 'admin'; + + $this->response = $this->client->request( + 'REPORT', + $davUrl, + [ + 'body' => '<x0:principal-property-search xmlns:x0="DAV:" test="anyof"> + <x0:property-search> + <x0:prop> + <x0:displayname/> + <x2:email-address xmlns:x2="http://sabredav.org/ns"/> + </x0:prop> + <x0:match>' . $match . '</x0:match> + </x0:property-search> + <x0:prop> + <x0:displayname/> + <x1:calendar-user-type xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + <x1:calendar-user-address-set xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + <x0:principal-URL/> + <x0:alternate-URI-set/> + <x2:email-address xmlns:x2="http://sabredav.org/ns"/> + <x3:language xmlns:x3="http://nextcloud.com/ns"/> + <x1:calendar-home-set xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + <x1:schedule-inbox-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + <x1:schedule-outbox-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + <x1:schedule-default-calendar-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + <x3:resource-type xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-vehicle-type xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-vehicle-make xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-vehicle-model xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-vehicle-is-electric xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-vehicle-range xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-vehicle-seating-capacity xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-contact-person xmlns:x3="http://nextcloud.com/ns"/> + <x3:resource-contact-person-vcard xmlns:x3="http://nextcloud.com/ns"/> + <x3:room-type xmlns:x3="http://nextcloud.com/ns"/> + <x3:room-seating-capacity xmlns:x3="http://nextcloud.com/ns"/> + <x3:room-building-address xmlns:x3="http://nextcloud.com/ns"/> + <x3:room-building-story xmlns:x3="http://nextcloud.com/ns"/> + <x3:room-building-room-number xmlns:x3="http://nextcloud.com/ns"/> + <x3:room-features xmlns:x3="http://nextcloud.com/ns"/> + </x0:prop> + <x0:apply-to-principal-collection-set/> +</x0:principal-property-search> +', + 'auth' => [ + $user, + $password, + ], + 'headers' => [ + 'Content-Type' => 'application/xml; charset=UTF-8', + 'Depth' => '0', + ], + ] + ); + } + + /** + * @Then The search HTTP status code should be :code + * @param string $code + * @throws \Exception + */ + public function theHttpStatusCodeShouldBe(string $code): void { + if ((int)$code !== $this->response->getStatusCode()) { + throw new \Exception('Expected ' . (int)$code . ' got ' . $this->response->getStatusCode()); + } + } + + /** + * @Then The search response should contain :needle + * @param string $needle + * @throws \Exception + */ + public function theResponseShouldContain(string $needle): void { + $body = $this->response->getBody()->getContents(); + + if (str_contains($body, $needle) === false) { + throw new \Exception('Response does not contain "' . $needle . '"'); + } + } +} diff --git a/build/integration/features/bootstrap/Provisioning.php b/build/integration/features/bootstrap/Provisioning.php index feeb850ae7d..935ad2a4a1d 100644 --- a/build/integration/features/bootstrap/Provisioning.php +++ b/build/integration/features/bootstrap/Provisioning.php @@ -1,7 +1,15 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +use Behat\Gherkin\Node\TableNode; use GuzzleHttp\Client; use GuzzleHttp\Message\ResponseInterface; +use PHPUnit\Framework\Assert; require __DIR__ . '/../../vendor/autoload.php'; @@ -16,7 +24,7 @@ trait Provisioning { /** @var array */ private $createdRemoteGroups = []; - + /** @var array */ private $createdGroups = []; @@ -29,13 +37,29 @@ trait Provisioning { $this->userExists($user); } catch (\GuzzleHttp\Exception\ClientException $ex) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->creatingTheUser($user); $this->currentUser = $previous_user; } $this->userExists($user); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertEquals(200, $this->response->getStatusCode()); + } + /** + * @Given /^user "([^"]*)" with displayname "((?:[^"]|\\")*)" exists$/ + * @param string $user + */ + public function assureUserWithDisplaynameExists($user, $displayname) { + try { + $this->userExists($user); + } catch (\GuzzleHttp\Exception\ClientException $ex) { + $previous_user = $this->currentUser; + $this->currentUser = 'admin'; + $this->creatingTheUser($user, $displayname); + $this->currentUser = $previous_user; + } + $this->userExists($user); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** @@ -47,22 +71,22 @@ trait Provisioning { $this->userExists($user); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); - PHPUnit_Framework_Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); + Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); return; } $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->deletingTheUser($user); $this->currentUser = $previous_user; try { $this->userExists($user); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); - PHPUnit_Framework_Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); + Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); } } - public function creatingTheUser($user) { + public function creatingTheUser($user, $displayname = '') { $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users"; $client = new Client(); $options = []; @@ -70,13 +94,19 @@ trait Provisioning { $options['auth'] = $this->adminUser; } - $options['body'] = [ - 'userid' => $user, - 'password' => '123456' - ]; + $options['form_params'] = [ + 'userid' => $user, + 'password' => '123456' + ]; + if ($displayname !== '') { + $options['form_params']['displayName'] = $displayname; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; - $this->response = $client->send($client->createRequest("POST", $fullUrl, $options)); - if ($this->currentServer === 'LOCAL'){ + $this->response = $client->post($fullUrl, $options); + if ($this->currentServer === 'LOCAL') { $this->createdUsers[$user] = $user; } elseif ($this->currentServer === 'REMOTE') { $this->createdRemoteUsers[$user] = $user; @@ -86,13 +116,175 @@ trait Provisioning { $options2 = [ 'auth' => [$user, '123456'], ]; - $url = $fullUrl.'/'.$user; - $client->send($client->createRequest('GET', $url, $options2)); + $options2['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + $url = $fullUrl . '/' . $user; + $client->get($url, $options2); + } + + /** + * @Then /^user "([^"]*)" has$/ + * + * @param string $user + * @param TableNode|null $settings + */ + public function userHasSetting($user, $settings) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $response = $client->get($fullUrl, $options); + foreach ($settings->getRows() as $setting) { + $value = json_decode(json_encode(simplexml_load_string($response->getBody())->data->{$setting[0]}), 1); + if (isset($value['element']) && in_array($setting[0], ['additional_mail', 'additional_mailScope'], true)) { + $expectedValues = explode(';', $setting[1]); + foreach ($expectedValues as $expected) { + Assert::assertTrue(in_array($expected, $value['element'], true), 'Data wrong for field: ' . $setting[0]); + } + } elseif (isset($value[0])) { + Assert::assertEqualsCanonicalizing($setting[1], $value[0], 'Data wrong for field: ' . $setting[0]); + } else { + Assert::assertEquals('', $setting[1], 'Data wrong for field: ' . $setting[0]); + } + } + } + + /** + * @Then /^user "([^"]*)" has the following profile data$/ + */ + public function userHasProfileData(string $user, ?TableNode $settings): void { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/profile/$user"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + 'Accept' => 'application/json', + ]; + + $response = $client->get($fullUrl, $options); + $body = $response->getBody()->getContents(); + $data = json_decode($body, true); + $data = $data['ocs']['data']; + foreach ($settings->getRows() as $setting) { + Assert::assertArrayHasKey($setting[0], $data, 'Profile data field missing: ' . $setting[0]); + if ($setting[1] === 'NULL') { + Assert::assertNull($data[$setting[0]], 'Profile data wrong for field: ' . $setting[0]); + } else { + Assert::assertEquals($setting[1], $data[$setting[0]], 'Profile data wrong for field: ' . $setting[0]); + } + } + } + + /** + * @Then /^group "([^"]*)" has$/ + * + * @param string $user + * @param TableNode|null $settings + */ + public function groupHasSetting($group, $settings) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/groups/details?search=$group"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $response = $client->get($fullUrl, $options); + $groupDetails = simplexml_load_string($response->getBody())->data[0]->groups[0]->element; + foreach ($settings->getRows() as $setting) { + $value = json_decode(json_encode($groupDetails->{$setting[0]}), 1); + if (isset($value[0])) { + Assert::assertEqualsCanonicalizing($setting[1], $value[0]); + } else { + Assert::assertEquals('', $setting[1]); + } + } + } + + + /** + * @Then /^user "([^"]*)" has editable fields$/ + * + * @param string $user + * @param TableNode|null $fields + */ + public function userHasEditableFields($user, $fields) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/user/fields"; + if ($user !== 'self') { + $fullUrl .= '/' . $user; + } + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $response = $client->get($fullUrl, $options); + $fieldsArray = json_decode(json_encode(simplexml_load_string($response->getBody())->data->element), 1); + + $expectedFields = $fields->getRows(); + $expectedFields = $this->simplifyArray($expectedFields); + Assert::assertEquals($expectedFields, $fieldsArray); + } + + /** + * @Then /^search users by phone for region "([^"]*)" with$/ + * + * @param string $user + * @param TableNode|null $settings + */ + public function searchUserByPhone($region, TableNode $searchTable) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/search/by-phone"; + $client = new Client(); + $options = []; + $options['auth'] = $this->adminUser; + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $search = []; + foreach ($searchTable->getRows() as $row) { + if (!isset($search[$row[0]])) { + $search[$row[0]] = []; + } + $search[$row[0]][] = $row[1]; + } + + $options['form_params'] = [ + 'location' => $region, + 'search' => $search, + ]; + + $this->response = $client->post($fullUrl, $options); } public function createUser($user) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->creatingTheUser($user); $this->userExists($user); $this->currentUser = $previous_user; @@ -100,7 +292,7 @@ trait Provisioning { public function deleteUser($user) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->deletingTheUser($user); $this->userDoesNotExist($user); $this->currentUser = $previous_user; @@ -108,7 +300,7 @@ trait Provisioning { public function createGroup($group) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->creatingTheGroup($group); $this->groupExists($group); $this->currentUser = $previous_user; @@ -116,17 +308,20 @@ trait Provisioning { public function deleteGroup($group) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->deletingTheGroup($group); $this->groupDoesNotExist($group); $this->currentUser = $previous_user; } - public function userExists($user){ + public function userExists($user) { $fullUrl = $this->baseUrl . "v2.php/cloud/users/$user"; $client = new Client(); $options = []; $options['auth'] = $this->adminUser; + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true' + ]; $this->response = $client->get($fullUrl, $options); } @@ -143,12 +338,15 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); $respondedArray = $this->getArrayOfGroupsResponded($this->response); sort($respondedArray); - PHPUnit_Framework_Assert::assertContains($group, $respondedArray); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertContains($group, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); } public function userBelongsToGroup($user, $group) { @@ -158,14 +356,17 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); $respondedArray = $this->getArrayOfGroupsResponded($this->response); if (array_key_exists($group, $respondedArray)) { - return True; - } else{ - return False; + return true; + } else { + return false; } } @@ -174,11 +375,11 @@ trait Provisioning { * @param string $user * @param string $group */ - public function assureUserBelongsToGroup($user, $group){ + public function assureUserBelongsToGroup($user, $group) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; - if (!$this->userBelongsToGroup($user, $group)){ + if (!$this->userBelongsToGroup($user, $group)) { $this->addingUserToGroup($user, $group); } @@ -198,12 +399,15 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); - $groups = array($group); + $groups = [$group]; $respondedArray = $this->getArrayOfGroupsResponded($this->response); - PHPUnit_Framework_Assert::assertNotEquals($groups, $respondedArray, "", 0.0, 10, true); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertNotEqualsCanonicalizing($groups, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** @@ -218,12 +422,15 @@ trait Provisioning { $options['auth'] = $this->adminUser; } - $options['body'] = [ - 'groupid' => $group, - ]; + $options['form_params'] = [ + 'groupid' => $group, + ]; + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; - $this->response = $client->send($client->createRequest("POST", $fullUrl, $options)); - if ($this->currentServer === 'LOCAL'){ + $this->response = $client->post($fullUrl, $options); + if ($this->currentServer === 'LOCAL') { $this->createdGroups[$group] = $group; } elseif ($this->currentServer === 'REMOTE') { $this->createdRemoteGroups[$group] = $group; @@ -231,6 +438,27 @@ trait Provisioning { } /** + * @When /^assure user "([^"]*)" is disabled$/ + */ + public function assureUserIsDisabled($user) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user/disable"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + // TODO: fix hack + $options['form_params'] = [ + 'foo' => 'bar' + ]; + + $this->response = $client->put($fullUrl, $options); + } + + /** * @When /^Deleting the user "([^"]*)"$/ * @param string $user */ @@ -241,8 +469,11 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; - $this->response = $client->send($client->createRequest("DELETE", $fullUrl, $options)); + $this->response = $client->delete($fullUrl, $options); } /** @@ -256,8 +487,17 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; - $this->response = $client->send($client->createRequest("DELETE", $fullUrl, $options)); + $this->response = $client->delete($fullUrl, $options); + + if ($this->currentServer === 'LOCAL') { + unset($this->createdGroups[$group]); + } elseif ($this->currentServer === 'REMOTE') { + unset($this->createdRemoteGroups[$group]); + } } /** @@ -269,7 +509,6 @@ trait Provisioning { $this->userExists($user); $this->groupExists($group); $this->addingUserToGroup($user, $group); - } /** @@ -284,12 +523,15 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; - $options['body'] = [ - 'groupid' => $group, - ]; + $options['form_params'] = [ + 'groupid' => $group, + ]; - $this->response = $client->send($client->createRequest("POST", $fullUrl, $options)); + $this->response = $client->post($fullUrl, $options); } @@ -298,6 +540,9 @@ trait Provisioning { $client = new Client(); $options = []; $options['auth'] = $this->adminUser; + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); } @@ -311,12 +556,12 @@ trait Provisioning { $this->groupExists($group); } catch (\GuzzleHttp\Exception\ClientException $ex) { $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->creatingTheGroup($group); $this->currentUser = $previous_user; } $this->groupExists($group); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** @@ -328,18 +573,18 @@ trait Provisioning { $this->groupExists($group); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); - PHPUnit_Framework_Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); + Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); return; } $previous_user = $this->currentUser; - $this->currentUser = "admin"; + $this->currentUser = 'admin'; $this->deletingTheGroup($group); $this->currentUser = $previous_user; try { $this->groupExists($group); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); - PHPUnit_Framework_Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); + Assert::assertEquals(404, $ex->getResponse()->getStatusCode()); } } @@ -355,12 +600,37 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); $respondedArray = $this->getArrayOfSubadminsResponded($this->response); sort($respondedArray); - PHPUnit_Framework_Assert::assertContains($user, $respondedArray); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertContains($user, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); + } + + /** + * @Given /^Assure user "([^"]*)" is subadmin of group "([^"]*)"$/ + * @param string $user + * @param string $group + */ + public function assureUserIsSubadminOfGroup($user, $group) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user/subadmins"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } + $options['form_params'] = [ + 'groupid' => $group + ]; + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + $this->response = $client->post($fullUrl, $options); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** @@ -375,73 +645,99 @@ trait Provisioning { if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); $respondedArray = $this->getArrayOfSubadminsResponded($this->response); sort($respondedArray); - PHPUnit_Framework_Assert::assertNotContains($user, $respondedArray); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertNotContains($user, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** * @Then /^users returned are$/ - * @param \Behat\Gherkin\Node\TableNode|null $usersList + * @param TableNode|null $usersList */ public function theUsersShouldBe($usersList) { - if ($usersList instanceof \Behat\Gherkin\Node\TableNode) { + if ($usersList instanceof TableNode) { $users = $usersList->getRows(); $usersSimplified = $this->simplifyArray($users); $respondedArray = $this->getArrayOfUsersResponded($this->response); - PHPUnit_Framework_Assert::assertEquals($usersSimplified, $respondedArray, "", 0.0, 10, true); + Assert::assertEqualsCanonicalizing($usersSimplified, $respondedArray); } + } + /** + * @Then /^phone matches returned are$/ + * @param TableNode|null $usersList + */ + public function thePhoneUsersShouldBe($usersList) { + if ($usersList instanceof TableNode) { + $users = $usersList->getRowsHash(); + $listCheckedElements = simplexml_load_string($this->response->getBody())->data; + $respondedArray = json_decode(json_encode($listCheckedElements), true); + Assert::assertEquals($users, $respondedArray); + } + } + + /** + * @Then /^detailed users returned are$/ + * @param TableNode|null $usersList + */ + public function theDetailedUsersShouldBe($usersList) { + if ($usersList instanceof TableNode) { + $users = $usersList->getRows(); + $usersSimplified = $this->simplifyArray($users); + $respondedArray = $this->getArrayOfDetailedUsersResponded($this->response); + $respondedArray = array_keys($respondedArray); + Assert::assertEquals($usersSimplified, $respondedArray); + } } /** * @Then /^groups returned are$/ - * @param \Behat\Gherkin\Node\TableNode|null $groupsList + * @param TableNode|null $groupsList */ public function theGroupsShouldBe($groupsList) { - if ($groupsList instanceof \Behat\Gherkin\Node\TableNode) { + if ($groupsList instanceof TableNode) { $groups = $groupsList->getRows(); $groupsSimplified = $this->simplifyArray($groups); $respondedArray = $this->getArrayOfGroupsResponded($this->response); - PHPUnit_Framework_Assert::assertEquals($groupsSimplified, $respondedArray, "", 0.0, 10, true); + Assert::assertEqualsCanonicalizing($groupsSimplified, $respondedArray); } - } /** * @Then /^subadmin groups returned are$/ - * @param \Behat\Gherkin\Node\TableNode|null $groupsList + * @param TableNode|null $groupsList */ public function theSubadminGroupsShouldBe($groupsList) { - if ($groupsList instanceof \Behat\Gherkin\Node\TableNode) { + if ($groupsList instanceof TableNode) { $groups = $groupsList->getRows(); $groupsSimplified = $this->simplifyArray($groups); $respondedArray = $this->getArrayOfSubadminsResponded($this->response); - PHPUnit_Framework_Assert::assertEquals($groupsSimplified, $respondedArray, "", 0.0, 10, true); + Assert::assertEqualsCanonicalizing($groupsSimplified, $respondedArray); } - } /** * @Then /^apps returned are$/ - * @param \Behat\Gherkin\Node\TableNode|null $appList + * @param TableNode|null $appList */ public function theAppsShouldBe($appList) { - if ($appList instanceof \Behat\Gherkin\Node\TableNode) { + if ($appList instanceof TableNode) { $apps = $appList->getRows(); $appsSimplified = $this->simplifyArray($apps); $respondedArray = $this->getArrayOfAppsResponded($this->response); - PHPUnit_Framework_Assert::assertEquals($appsSimplified, $respondedArray, "", 0.0, 10, true); + Assert::assertEqualsCanonicalizing($appsSimplified, $respondedArray); } - } /** * @Then /^subadmin users returned are$/ - * @param \Behat\Gherkin\Node\TableNode|null $groupsList + * @param TableNode|null $groupsList */ public function theSubadminUsersShouldBe($groupsList) { $this->theSubadminGroupsShouldBe($groupsList); @@ -449,44 +745,60 @@ trait Provisioning { /** * Parses the xml answer to get the array of users returned. + * * @param ResponseInterface $resp * @return array */ public function getArrayOfUsersResponded($resp) { - $listCheckedElements = $resp->xml()->data[0]->users[0]->element; + $listCheckedElements = simplexml_load_string($resp->getBody())->data[0]->users[0]->element; + $extractedElementsArray = json_decode(json_encode($listCheckedElements), 1); + return $extractedElementsArray; + } + + /** + * Parses the xml answer to get the array of detailed users returned. + * + * @param ResponseInterface $resp + * @return array + */ + public function getArrayOfDetailedUsersResponded($resp) { + $listCheckedElements = simplexml_load_string($resp->getBody())->data[0]->users; $extractedElementsArray = json_decode(json_encode($listCheckedElements), 1); return $extractedElementsArray; } /** * Parses the xml answer to get the array of groups returned. + * * @param ResponseInterface $resp * @return array */ public function getArrayOfGroupsResponded($resp) { - $listCheckedElements = $resp->xml()->data[0]->groups[0]->element; + $listCheckedElements = simplexml_load_string($resp->getBody())->data[0]->groups[0]->element; $extractedElementsArray = json_decode(json_encode($listCheckedElements), 1); return $extractedElementsArray; } /** * Parses the xml answer to get the array of apps returned. + * * @param ResponseInterface $resp * @return array */ public function getArrayOfAppsResponded($resp) { - $listCheckedElements = $resp->xml()->data[0]->apps[0]->element; + $listCheckedElements = simplexml_load_string($resp->getBody())->data[0]->apps[0]->element; $extractedElementsArray = json_decode(json_encode($listCheckedElements), 1); return $extractedElementsArray; } /** * Parses the xml answer to get the array of subadmins returned. + * * @param ResponseInterface $resp * @return array */ public function getArrayOfSubadminsResponded($resp) { - $listCheckedElements = $resp->xml()->data[0]->element; + $listCheckedElements = simplexml_load_string($resp->getBody())->data[0]->element; $extractedElementsArray = json_decode(json_encode($listCheckedElements), 1); return $extractedElementsArray; } @@ -497,17 +809,20 @@ trait Provisioning { * @param string $app */ public function appIsDisabled($app) { - $fullUrl = $this->baseUrl . "v2.php/cloud/apps?filter=disabled"; + $fullUrl = $this->baseUrl . 'v2.php/cloud/apps?filter=disabled'; $client = new Client(); $options = []; if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); $respondedArray = $this->getArrayOfAppsResponded($this->response); - PHPUnit_Framework_Assert::assertContains($app, $respondedArray); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertContains($app, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** @@ -515,17 +830,84 @@ trait Provisioning { * @param string $app */ public function appIsEnabled($app) { - $fullUrl = $this->baseUrl . "v2.php/cloud/apps?filter=enabled"; + $fullUrl = $this->baseUrl . 'v2.php/cloud/apps?filter=enabled'; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $this->response = $client->get($fullUrl, $options); + $respondedArray = $this->getArrayOfAppsResponded($this->response); + Assert::assertContains($app, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); + } + + /** + * @Given /^app "([^"]*)" is not enabled$/ + * + * Checks that the app is disabled or not installed. + * + * @param string $app + */ + public function appIsNotEnabled($app) { + $fullUrl = $this->baseUrl . 'v2.php/cloud/apps?filter=enabled'; $client = new Client(); $options = []; if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); $respondedArray = $this->getArrayOfAppsResponded($this->response); - PHPUnit_Framework_Assert::assertContains($app, $respondedArray); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertNotContains($app, $respondedArray); + Assert::assertEquals(200, $this->response->getStatusCode()); + } + + /** + * @Then /^user "([^"]*)" is disabled$/ + * @param string $user + */ + public function userIsDisabled($user) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $this->response = $client->get($fullUrl, $options); + // false in xml is empty + Assert::assertTrue(empty(simplexml_load_string($this->response->getBody())->data[0]->enabled)); + } + + /** + * @Then /^user "([^"]*)" is enabled$/ + * @param string $user + */ + public function userIsEnabled($user) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $this->response = $client->get($fullUrl, $options); + // boolean to string is integer + Assert::assertEquals('1', simplexml_load_string($this->response->getBody())->data[0]->enabled); } /** @@ -533,39 +915,50 @@ trait Provisioning { * @param string $user * @param string $quota */ - public function userHasAQuotaOf($user, $quota) - { - $body = new \Behat\Gherkin\Node\TableNode([ + public function userHasAQuotaOf($user, $quota) { + $body = new TableNode([ 0 => ['key', 'quota'], 1 => ['value', $quota], ]); // method used from BasicStructure trait - $this->sendingToWith("PUT", "/cloud/users/" . $user, $body); + $this->sendingToWith('PUT', '/cloud/users/' . $user, $body); } /** * @Given user :user has unlimited quota * @param string $user */ - public function userHasUnlimitedQuota($user) - { + public function userHasUnlimitedQuota($user) { $this->userHasAQuotaOf($user, 'none'); } /** + * Returns home path of the given user + * + * @param string $user + */ + public function getUserHome($user) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user"; + $client = new Client(); + $options = []; + $options['auth'] = $this->adminUser; + $this->response = $client->get($fullUrl, $options); + return simplexml_load_string($this->response->getBody())->data[0]->home; + } + + /** * @BeforeScenario * @AfterScenario */ - public function cleanupUsers() - { + public function cleanupUsers() { $previousServer = $this->currentServer; $this->usingServer('LOCAL'); - foreach($this->createdUsers as $user) { + foreach ($this->createdUsers as $user) { $this->deleteUser($user); } $this->usingServer('REMOTE'); - foreach($this->createdRemoteUsers as $remoteUser) { + foreach ($this->createdRemoteUsers as $remoteUser) { $this->deleteUser($remoteUser); } $this->usingServer($previousServer); @@ -575,17 +968,50 @@ trait Provisioning { * @BeforeScenario * @AfterScenario */ - public function cleanupGroups() - { + public function cleanupGroups() { $previousServer = $this->currentServer; $this->usingServer('LOCAL'); - foreach($this->createdGroups as $group) { + foreach ($this->createdGroups as $group) { $this->deleteGroup($group); } $this->usingServer('REMOTE'); - foreach($this->createdRemoteGroups as $remoteGroup) { - $this->deleteUser($remoteGroup); + foreach ($this->createdRemoteGroups as $remoteGroup) { + $this->deleteGroup($remoteGroup); } $this->usingServer($previousServer); } + + /** + * @Then /^user "([^"]*)" has not$/ + */ + public function userHasNotSetting($user, TableNode $settings) { + $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/cloud/users/$user"; + $client = new Client(); + $options = []; + if ($this->currentUser === 'admin') { + $options['auth'] = $this->adminUser; + } else { + $options['auth'] = [$this->currentUser, $this->regularUser]; + } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; + + $response = $client->get($fullUrl, $options); + foreach ($settings->getRows() as $setting) { + $value = json_decode(json_encode(simplexml_load_string($response->getBody())->data->{$setting[0]}), 1); + if (isset($value[0])) { + if (in_array($setting[0], ['additional_mail', 'additional_mailScope'], true)) { + $expectedValues = explode(';', $setting[1]); + foreach ($expectedValues as $expected) { + Assert::assertFalse(in_array($expected, $value, true)); + } + } else { + Assert::assertNotEqualsCanonicalizing($setting[1], $value[0]); + } + } else { + Assert::assertNotEquals('', $setting[1]); + } + } + } } diff --git a/build/integration/features/bootstrap/RateLimitingContext.php b/build/integration/features/bootstrap/RateLimitingContext.php new file mode 100644 index 00000000000..15c8c5c8379 --- /dev/null +++ b/build/integration/features/bootstrap/RateLimitingContext.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; + +class RateLimitingContext implements Context { + use BasicStructure; + use CommandLine; + use Provisioning; + + /** + * @BeforeScenario @RateLimiting + */ + public function enableRateLimiting() { + // Enable rate limiting for the tests. + // Ratelimiting is disabled by default, so we need to enable it + $this->runOcc(['config:system:set', 'ratelimit.protection.enabled', '--value', 'true', '--type', 'bool']); + } + + /** + * @AfterScenario @RateLimiting + */ + public function disableRateLimiting() { + // Restore the default rate limiting configuration. + // Ratelimiting is disabled by default, so we need to disable it + $this->runOcc(['config:system:set', 'ratelimit.protection.enabled', '--value', 'false', '--type', 'bool']); + } +} diff --git a/build/integration/features/bootstrap/RemoteContext.php b/build/integration/features/bootstrap/RemoteContext.php new file mode 100644 index 00000000000..6102f686ea7 --- /dev/null +++ b/build/integration/features/bootstrap/RemoteContext.php @@ -0,0 +1,140 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; +use OCP\Http\Client\IClientService; +use PHPUnit\Framework\Assert; + +require __DIR__ . '/../../vendor/autoload.php'; + +/** + * Remote context. + */ +class RemoteContext implements Context { + /** @var \OC\Remote\Instance */ + protected $remoteInstance; + + /** @var \OC\Remote\Credentials */ + protected $credentails; + + /** @var \OC\Remote\User */ + protected $userResult; + + protected $remoteUrl; + + protected $lastException; + + public function __construct($remote) { + require_once __DIR__ . '/../../../../lib/base.php'; + $this->remoteUrl = $remote; + } + + protected function getApiClient() { + return new \OC\Remote\Api\OCS($this->remoteInstance, $this->credentails, \OC::$server->get(IClientService::class)); + } + + /** + * @Given /^using remote server "(REMOTE|NON_EXISTING)"$/ + * + * @param string $remoteServer "NON_EXISTING" or "REMOTE" + */ + public function selectRemoteInstance($remoteServer) { + if ($remoteServer == 'REMOTE') { + $baseUri = $this->remoteUrl; + } else { + $baseUri = 'nonexistingnextcloudserver.local'; + } + $this->lastException = null; + try { + $this->remoteInstance = new \OC\Remote\Instance($baseUri, \OC::$server->getMemCacheFactory()->createLocal(), \OC::$server->get(IClientService::class)); + // trigger the status request + $this->remoteInstance->getProtocol(); + } catch (\Exception $e) { + $this->lastException = $e; + } + } + + /** + * @Then /^the remote version should be "([^"]*)"$/ + * @param string $version + */ + public function theRemoteVersionShouldBe($version) { + if ($version === '__current_version__') { + $version = \OC::$server->getConfig()->getSystemValue('version', '0.0.0.0'); + } + + Assert::assertEquals($version, $this->remoteInstance->getVersion()); + } + + /** + * @Then /^the remote protocol should be "([^"]*)"$/ + * @param string $protocol + */ + public function theRemoteProtocolShouldBe($protocol) { + Assert::assertEquals($protocol, $this->remoteInstance->getProtocol()); + } + + /** + * @Given /^using credentials "([^"]*)", "([^"]*)"/ + * @param string $user + * @param string $password + */ + public function usingCredentials($user, $password) { + $this->credentails = new \OC\Remote\Credentials($user, $password); + } + + /** + * @When /^getting the remote user info for "([^"]*)"$/ + * @param string $user + */ + public function remoteUserInfo($user) { + $this->lastException = null; + try { + $this->userResult = $this->getApiClient()->getUser($user); + } catch (\Exception $e) { + $this->lastException = $e; + } + } + + /** + * @Then /^the remote user should have userid "([^"]*)"$/ + * @param string $user + */ + public function remoteUserId($user) { + Assert::assertEquals($user, $this->userResult->getUserId()); + } + + /** + * @Then /^the request should throw a "([^"]*)"$/ + * @param string $class + */ + public function lastError($class) { + Assert::assertEquals($class, get_class($this->lastException)); + } + + /** + * @Then /^the capability "([^"]*)" is "([^"]*)"$/ + * @param string $key + * @param string $value + */ + public function hasCapability($key, $value) { + try { + $capabilities = $this->getApiClient()->getCapabilities(); + } catch (\Exception $e) { + Assert::assertInstanceOf($value, $e); + $this->lastException = $e; + return; + } + $current = $capabilities; + $parts = explode('.', $key); + foreach ($parts as $part) { + if ($current !== null) { + $current = isset($current[$part]) ? $current[$part] : null; + } + } + Assert::assertEquals($value, $current); + } +} diff --git a/build/integration/features/bootstrap/RoutingContext.php b/build/integration/features/bootstrap/RoutingContext.php new file mode 100644 index 00000000000..762570547e0 --- /dev/null +++ b/build/integration/features/bootstrap/RoutingContext.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; +use Behat\Behat\Context\SnippetAcceptingContext; + +require __DIR__ . '/../../vendor/autoload.php'; + +class RoutingContext implements Context, SnippetAcceptingContext { + use Provisioning; + use AppConfiguration; + use CommandLine; + + protected function resetAppConfigs(): void { + } +} diff --git a/build/integration/features/bootstrap/Search.php b/build/integration/features/bootstrap/Search.php new file mode 100644 index 00000000000..49a4fe92822 --- /dev/null +++ b/build/integration/features/bootstrap/Search.php @@ -0,0 +1,71 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +trait Search { + // BasicStructure trait is expected to be used in the class that uses this + // trait. + + /** + * @When /^searching for "([^"]*)"$/ + * @param string $query + */ + public function searchingFor(string $query) { + $this->searchForInApp($query, ''); + } + + /** + * @When /^searching for "([^"]*)" in app "([^"]*)"$/ + * @param string $query + * @param string $app + */ + public function searchingForInApp(string $query, string $app) { + $url = '/index.php/core/search'; + + $parameters[] = 'query=' . $query; + $parameters[] = 'inApps[]=' . $app; + + $url .= '?' . implode('&', $parameters); + + $this->sendingAToWithRequesttoken('GET', $url); + } + + /** + * @Then /^the list of search results has "(\d+)" results$/ + */ + public function theListOfSearchResultsHasResults(int $count) { + $this->theHTTPStatusCodeShouldBe(200); + + $searchResults = json_decode($this->response->getBody()); + + Assert::assertEquals($count, count($searchResults)); + } + + /** + * @Then /^search result "(\d+)" contains$/ + * + * @param int $number + * @param TableNode $body + */ + public function searchResultXContains(int $number, TableNode $body) { + if (!($body instanceof TableNode)) { + return; + } + + $searchResults = json_decode($this->response->getBody(), $asAssociativeArray = true); + $searchResult = $searchResults[$number]; + + foreach ($body->getRowsHash() as $expectedField => $expectedValue) { + if (!array_key_exists($expectedField, $searchResult)) { + Assert::fail("$expectedField was not found in response"); + } + + Assert::assertEquals($expectedValue, $searchResult[$expectedField], "Field '$expectedField' does not match ({$searchResult[$expectedField]})"); + } + } +} diff --git a/build/integration/features/bootstrap/SetupContext.php b/build/integration/features/bootstrap/SetupContext.php new file mode 100644 index 00000000000..aa131cec597 --- /dev/null +++ b/build/integration/features/bootstrap/SetupContext.php @@ -0,0 +1,17 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; + +require __DIR__ . '/../../vendor/autoload.php'; + + +/** + * Setup context. + */ +class SetupContext implements Context { + use BasicStructure; +} diff --git a/build/integration/features/bootstrap/ShareesContext.php b/build/integration/features/bootstrap/ShareesContext.php index bd08ae6e138..37e0e63e547 100644 --- a/build/integration/features/bootstrap/ShareesContext.php +++ b/build/integration/features/bootstrap/ShareesContext.php @@ -1,8 +1,12 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ use Behat\Behat\Context\Context; use Behat\Behat\Context\SnippetAcceptingContext; -use GuzzleHttp\Message\ResponseInterface; require __DIR__ . '/../../vendor/autoload.php'; @@ -11,63 +15,12 @@ require __DIR__ . '/../../vendor/autoload.php'; * Features context. */ class ShareesContext implements Context, SnippetAcceptingContext { - use Provisioning; + use Sharing; use AppConfiguration; - /** - * @When /^getting sharees for$/ - * @param \Behat\Gherkin\Node\TableNode $body - */ - public function whenGettingShareesFor($body) { - $url = '/apps/files_sharing/api/v1/sharees'; - if ($body instanceof \Behat\Gherkin\Node\TableNode) { - $parameters = []; - foreach ($body->getRowsHash() as $key => $value) { - $parameters[] = $key . '=' . $value; - } - if (!empty($parameters)) { - $url .= '?' . implode('&', $parameters); - } - } - - $this->sendingTo('GET', $url); - } - - /** - * @Then /^"([^"]*)" sharees returned (are|is empty)$/ - * @param string $shareeType - * @param string $isEmpty - * @param \Behat\Gherkin\Node\TableNode|null $shareesList - */ - public function thenListOfSharees($shareeType, $isEmpty, $shareesList = null) { - if ($isEmpty !== 'is empty') { - $sharees = $shareesList->getRows(); - $respondedArray = $this->getArrayOfShareesResponded($this->response, $shareeType); - PHPUnit_Framework_Assert::assertEquals($sharees, $respondedArray); - } else { - $respondedArray = $this->getArrayOfShareesResponded($this->response, $shareeType); - PHPUnit_Framework_Assert::assertEmpty($respondedArray); - } - } - - public function getArrayOfShareesResponded(ResponseInterface $response, $shareeType) { - $elements = $response->xml()->data; - $elements = json_decode(json_encode($elements), 1); - if (strpos($shareeType, 'exact ') === 0) { - $elements = $elements['exact']; - $shareeType = substr($shareeType, 6); - } - - $sharees = []; - foreach ($elements[$shareeType] as $element) { - $sharees[] = [$element['label'], $element['value']['shareType'], $element['value']['shareWith']]; - } - return $sharees; - } - protected function resetAppConfigs() { - $this->modifyServerConfig('core', 'shareapi_only_share_with_group_members', 'no'); - $this->modifyServerConfig('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes'); - $this->modifyServerConfig('core', 'shareapi_allow_group_sharing', 'yes'); + $this->deleteServerConfig('core', 'shareapi_only_share_with_group_members'); + $this->deleteServerConfig('core', 'shareapi_allow_share_dialog_user_enumeration'); + $this->deleteServerConfig('core', 'shareapi_allow_group_sharing'); } } diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index d423a28f196..0cc490ff110 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -1,7 +1,14 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Gherkin\Node\TableNode; use GuzzleHttp\Client; -use GuzzleHttp\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; require __DIR__ . '/../../vendor/autoload.php'; @@ -16,95 +23,175 @@ trait Sharing { /** @var SimpleXMLElement */ private $lastShareData = null; + /** @var SimpleXMLElement[] */ + private $storedShareData = []; + /** @var int */ private $savedShareId = null; + /** @var ResponseInterface */ + private $response; + /** * @Given /^as "([^"]*)" creating a share with$/ * @param string $user - * @param \Behat\Gherkin\Node\TableNode|null $body + * @param TableNode|null $body */ public function asCreatingAShareWith($user, $body) { $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/files_sharing/api/v{$this->sharingApiVersion}/shares"; $client = new Client(); - $options = []; + $options = [ + 'headers' => [ + 'OCS-APIREQUEST' => 'true', + ], + ]; if ($user === 'admin') { $options['auth'] = $this->adminUser; } else { $options['auth'] = [$user, $this->regularUser]; } - if ($body instanceof \Behat\Gherkin\Node\TableNode) { + if ($body instanceof TableNode) { $fd = $body->getRowsHash(); - if (array_key_exists('expireDate', $fd)){ + if (array_key_exists('expireDate', $fd)) { $dateModification = $fd['expireDate']; - $fd['expireDate'] = date('Y-m-d', strtotime($dateModification)); + if ($dateModification === 'null') { + $fd['expireDate'] = null; + } elseif (!empty($dateModification)) { + $fd['expireDate'] = date('Y-m-d', strtotime($dateModification)); + } else { + $fd['expireDate'] = ''; + } } - $options['body'] = $fd; + $options['form_params'] = $fd; } try { - $this->response = $client->send($client->createRequest("POST", $fullUrl, $options)); + $this->response = $client->request('POST', $fullUrl, $options); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); } - $this->lastShareData = $this->response->xml(); + $this->lastShareData = simplexml_load_string($this->response->getBody()); + } + + /** + * @When /^save the last share data as "([^"]*)"$/ + */ + public function saveLastShareData($name) { + $this->storedShareData[$name] = $this->lastShareData; + } + + /** + * @When /^restore the last share data from "([^"]*)"$/ + */ + public function restoreLastShareData($name) { + $this->lastShareData = $this->storedShareData[$name]; } /** * @When /^creating a share with$/ - * @param \Behat\Gherkin\Node\TableNode|null $body + * @param TableNode|null $body */ public function creatingShare($body) { $this->asCreatingAShareWith($this->currentUser, $body); } /** - * @Then /^Public shared file "([^"]*)" can be downloaded$/ + * @When /^accepting last share$/ */ - public function checkPublicSharedFile($filename) { - $client = new Client(); - $options = []; - if (count($this->lastShareData->data->element) > 0){ + public function acceptingLastShare() { + $share_id = $this->lastShareData->data[0]->id; + $url = "/apps/files_sharing/api/v{$this->sharingApiVersion}/shares/pending/$share_id"; + $this->sendingToWith('POST', $url, null); + + $this->theHTTPStatusCodeShouldBe('200'); + } + + /** + * @When /^user "([^"]*)" accepts last share$/ + * + * @param string $user + */ + public function userAcceptsLastShare(string $user) { + // "As userXXX" and "user userXXX accepts last share" steps are not + // expected to be used in the same scenario, but restore the user just + // in case. + $previousUser = $this->currentUser; + + $this->currentUser = $user; + + $share_id = $this->lastShareData->data[0]->id; + $url = "/apps/files_sharing/api/v{$this->sharingApiVersion}/shares/pending/$share_id"; + $this->sendingToWith('POST', $url, null); + + $this->currentUser = $previousUser; + + $this->theHTTPStatusCodeShouldBe('200'); + } + + /** + * @Then /^last link share can be downloaded$/ + */ + public function lastLinkShareCanBeDownloaded() { + if (count($this->lastShareData->data->element) > 0) { $url = $this->lastShareData->data[0]->url; - } - else{ + } else { $url = $this->lastShareData->data->url; } - $fullUrl = $url . "/download"; - $options['save_to'] = "./$filename"; - $this->response = $client->get($fullUrl, $options); - $finfo = new finfo; - $fileinfo = $finfo->file("./$filename", FILEINFO_MIME_TYPE); - PHPUnit_Framework_Assert::assertEquals($fileinfo, "text/plain"); - if (file_exists("./$filename")) { - unlink("./$filename"); - } + $fullUrl = $url . '/download'; + $this->checkDownload($fullUrl, null, 'text/plain'); } /** - * @Then /^Public shared file "([^"]*)" with password "([^"]*)" can be downloaded$/ + * @Then /^last share can be downloaded$/ */ - public function checkPublicSharedFileWithPassword($filename, $password) { - $client = new Client(); - $options = []; - if (count($this->lastShareData->data->element) > 0){ + public function lastShareCanBeDownloaded() { + if (count($this->lastShareData->data->element) > 0) { $token = $this->lastShareData->data[0]->token; + } else { + $token = $this->lastShareData->data->token; } - else{ + + $fullUrl = substr($this->baseUrl, 0, -4) . 'index.php/s/' . $token . '/download'; + $this->checkDownload($fullUrl, null, 'text/plain'); + } + + /** + * @Then /^last share with password "([^"]*)" can be downloaded$/ + */ + public function lastShareWithPasswordCanBeDownloaded($password) { + if (count($this->lastShareData->data->element) > 0) { + $token = $this->lastShareData->data[0]->token; + } else { $token = $this->lastShareData->data->token; } - $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/webdav"; - $options['auth'] = [$token, $password]; - $options['save_to'] = "./$filename"; - $this->response = $client->get($fullUrl, $options); - $finfo = new finfo; - $fileinfo = $finfo->file("./$filename", FILEINFO_MIME_TYPE); - PHPUnit_Framework_Assert::assertEquals($fileinfo, "text/plain"); - if (file_exists("./$filename")) { - unlink("./$filename"); + $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token/"; + $this->checkDownload($fullUrl, ['', $password], 'text/plain'); + } + + private function checkDownload($url, $auth = null, $mimeType = null) { + if ($auth !== null) { + $options['auth'] = $auth; + } + $options['stream'] = true; + + $client = new Client(); + $this->response = $client->get($url, $options); + Assert::assertEquals(200, $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)); } } @@ -112,7 +199,7 @@ trait Sharing { * @When /^Adding expiration date to last share$/ */ public function addingExpirationDate() { - $share_id = (string) $this->lastShareData->data[0]->id; + $share_id = (string)$this->lastShareData->data[0]->id; $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/files_sharing/api/v{$this->sharingApiVersion}/shares/$share_id"; $client = new Client(); $options = []; @@ -121,130 +208,139 @@ trait Sharing { } else { $options['auth'] = [$this->currentUser, $this->regularUser]; } - $date = date('Y-m-d', strtotime("+3 days")); - $options['body'] = ['expireDate' => $date]; - $this->response = $client->send($client->createRequest("PUT", $fullUrl, $options)); - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); + $date = date('Y-m-d', strtotime('+3 days')); + $options['form_params'] = ['expireDate' => $date]; + $this->response = $this->response = $client->request('PUT', $fullUrl, $options); + Assert::assertEquals(200, $this->response->getStatusCode()); } /** * @When /^Updating last share with$/ - * @param \Behat\Gherkin\Node\TableNode|null $body + * @param TableNode|null $body */ public function updatingLastShare($body) { - $share_id = (string) $this->lastShareData->data[0]->id; + $share_id = (string)$this->lastShareData->data[0]->id; $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/files_sharing/api/v{$this->sharingApiVersion}/shares/$share_id"; $client = new Client(); - $options = []; + $options = [ + 'headers' => [ + 'OCS-APIREQUEST' => 'true', + ], + ]; if ($this->currentUser === 'admin') { $options['auth'] = $this->adminUser; } else { $options['auth'] = [$this->currentUser, $this->regularUser]; } - if ($body instanceof \Behat\Gherkin\Node\TableNode) { + if ($body instanceof TableNode) { $fd = $body->getRowsHash(); - if (array_key_exists('expireDate', $fd)){ + if (array_key_exists('expireDate', $fd)) { $dateModification = $fd['expireDate']; $fd['expireDate'] = date('Y-m-d', strtotime($dateModification)); } - $options['body'] = $fd; + $options['form_params'] = $fd; } try { - $this->response = $client->send($client->createRequest("PUT", $fullUrl, $options)); + $this->response = $client->request('PUT', $fullUrl, $options); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); } - - PHPUnit_Framework_Assert::assertEquals(200, $this->response->getStatusCode()); } public function createShare($user, - $path = null, - $shareType = null, - $shareWith = null, - $publicUpload = null, - $password = null, - $permissions = null){ + $path = null, + $shareType = null, + $shareWith = null, + $publicUpload = null, + $password = null, + $permissions = null, + $viewOnly = false) { $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/files_sharing/api/v{$this->sharingApiVersion}/shares"; $client = new Client(); - $options = []; + $options = [ + 'headers' => [ + 'OCS-APIREQUEST' => 'true', + ], + ]; if ($user === 'admin') { $options['auth'] = $this->adminUser; } else { $options['auth'] = [$user, $this->regularUser]; } - $fd = []; - if (!is_null($path)){ - $fd['path'] = $path; + $body = []; + if (!is_null($path)) { + $body['path'] = $path; + } + if (!is_null($shareType)) { + $body['shareType'] = $shareType; } - if (!is_null($shareType)){ - $fd['shareType'] = $shareType; + if (!is_null($shareWith)) { + $body['shareWith'] = $shareWith; } - if (!is_null($shareWith)){ - $fd['shareWith'] = $shareWith; + if (!is_null($publicUpload)) { + $body['publicUpload'] = $publicUpload; } - if (!is_null($publicUpload)){ - $fd['publicUpload'] = $publicUpload; + if (!is_null($password)) { + $body['password'] = $password; } - if (!is_null($password)){ - $fd['password'] = $password; + if (!is_null($permissions)) { + $body['permissions'] = $permissions; } - if (!is_null($permissions)){ - $fd['permissions'] = $permissions; + + if ($viewOnly === true) { + $body['attributes'] = json_encode([['scope' => 'permissions', 'key' => 'download', 'value' => false]]); } - $options['body'] = $fd; + $options['form_params'] = $body; try { - $this->response = $client->send($client->createRequest("POST", $fullUrl, $options)); - $this->lastShareData = $this->response->xml(); + $this->response = $client->request('POST', $fullUrl, $options); + $this->lastShareData = simplexml_load_string($this->response->getBody()); } catch (\GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); + throw new \Exception($this->response->getBody()); } } - public function isFieldInResponse($field, $contentExpected){ - $data = $this->response->xml()->data[0]; - if ((string)$field == 'expiration'){ - $contentExpected = date('Y-m-d', strtotime($contentExpected)) . " 00:00:00"; + public function isFieldInResponse($field, $contentExpected) { + $data = simplexml_load_string($this->response->getBody())->data[0]; + if ((string)$field == 'expiration') { + if (!empty($contentExpected)) { + $contentExpected = date('Y-m-d', strtotime($contentExpected)) . ' 00:00:00'; + } } - if (count($data->element) > 0){ - foreach($data as $element) { - if ($contentExpected == "A_TOKEN"){ + if (count($data->element) > 0) { + foreach ($data as $element) { + if ($contentExpected == 'A_TOKEN') { return (strlen((string)$element->$field) == 15); - } - elseif ($contentExpected == "A_NUMBER"){ + } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$element->$field); - } - elseif($contentExpected == "AN_URL"){ - return $this->isExpectedUrl((string)$element->$field, "index.php/s/"); - } - elseif ((string)$element->$field == $contentExpected){ - return True; - } - else{ + } elseif ($contentExpected == 'AN_URL') { + return $this->isExpectedUrl((string)$element->$field, 'index.php/s/'); + } elseif ((string)$element->$field == $contentExpected) { + return true; + } else { print($element->$field); } } - return False; + return false; } else { - if ($contentExpected == "A_TOKEN"){ - return (strlen((string)$data->$field) == 15); - } - elseif ($contentExpected == "A_NUMBER"){ - return is_numeric((string)$data->$field); - } - elseif($contentExpected == "AN_URL"){ - return $this->isExpectedUrl((string)$data->$field, "index.php/s/"); + if ($contentExpected == 'A_TOKEN') { + return (strlen((string)$data->$field) == 15); + } elseif ($contentExpected == 'A_NUMBER') { + return is_numeric((string)$data->$field); + } elseif ($contentExpected == 'AN_URL') { + return $this->isExpectedUrl((string)$data->$field, 'index.php/s/'); + } elseif ($contentExpected == $data->$field) { + return true; + } else { + print($data->$field); } - elseif ($data->$field == $contentExpected){ - return True; - } - return False; + return false; } } @@ -253,8 +349,8 @@ trait Sharing { * * @param string $filename */ - public function checkSharedFileInResponse($filename){ - PHPUnit_Framework_Assert::assertEquals(True, $this->isFieldInResponse('file_target', "/$filename")); + public function checkSharedFileInResponse($filename) { + Assert::assertEquals(true, $this->isFieldInResponse('file_target', "/$filename")); } /** @@ -262,8 +358,8 @@ trait Sharing { * * @param string $filename */ - public function checkSharedFileNotInResponse($filename){ - PHPUnit_Framework_Assert::assertEquals(False, $this->isFieldInResponse('file_target', "/$filename")); + public function checkSharedFileNotInResponse($filename) { + Assert::assertEquals(false, $this->isFieldInResponse('file_target', "/$filename")); } /** @@ -271,8 +367,8 @@ trait Sharing { * * @param string $user */ - public function checkSharedUserInResponse($user){ - PHPUnit_Framework_Assert::assertEquals(True, $this->isFieldInResponse('share_with', "$user")); + public function checkSharedUserInResponse($user) { + Assert::assertEquals(true, $this->isFieldInResponse('share_with', "$user")); } /** @@ -280,28 +376,32 @@ trait Sharing { * * @param string $user */ - public function checkSharedUserNotInResponse($user){ - PHPUnit_Framework_Assert::assertEquals(False, $this->isFieldInResponse('share_with', "$user")); + public function checkSharedUserNotInResponse($user) { + Assert::assertEquals(false, $this->isFieldInResponse('share_with', "$user")); } - public function isUserOrGroupInSharedData($userOrGroup){ - $data = $this->response->xml()->data[0]; - foreach($data as $element) { - if ($element->share_with == $userOrGroup){ - return True; + public function isUserOrGroupInSharedData($userOrGroup, $permissions = null) { + $data = simplexml_load_string($this->response->getBody())->data[0]; + foreach ($data as $element) { + if ($element->share_with == $userOrGroup && ($permissions === null || $permissions == $element->permissions)) { + return true; } } - return False; + return false; } /** - * @Given /^file "([^"]*)" of user "([^"]*)" is shared with user "([^"]*)"$/ + * @Given /^(file|folder|entry) "([^"]*)" of user "([^"]*)" is shared with user "([^"]*)"( with permissions ([\d]*))?( view-only)?$/ * * @param string $filepath * @param string $user1 * @param string $user2 */ - public function assureFileIsShared($filepath, $user1, $user2){ + public function assureFileIsShared($entry, $filepath, $user1, $user2, $withPerms = null, $permissions = null, $viewOnly = null) { + // when view-only is set, permissions is empty string instead of null... + if ($permissions === '') { + $permissions = null; + } $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/files_sharing/api/v{$this->sharingApiVersion}/shares" . "?path=$filepath"; $client = new Client(); $options = []; @@ -310,24 +410,31 @@ trait Sharing { } else { $options['auth'] = [$user1, $this->regularUser]; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); - if ($this->isUserOrGroupInSharedData($user2)){ + if ($this->isUserOrGroupInSharedData($user2, $permissions)) { return; } else { - $this->createShare($user1, $filepath, 0, $user2, null, null, null); + $this->createShare($user1, $filepath, 0, $user2, null, null, $permissions, $viewOnly !== null); } $this->response = $client->get($fullUrl, $options); - PHPUnit_Framework_Assert::assertEquals(True, $this->isUserOrGroupInSharedData($user2)); + Assert::assertEquals(true, $this->isUserOrGroupInSharedData($user2, $permissions)); } /** - * @Given /^file "([^"]*)" of user "([^"]*)" is shared with group "([^"]*)"$/ + * @Given /^(file|folder|entry) "([^"]*)" of user "([^"]*)" is shared with group "([^"]*)"( with permissions ([\d]*))?( view-only)?$/ * * @param string $filepath * @param string $user * @param string $group */ - public function assureFileIsSharedWithGroup($filepath, $user, $group){ + public function assureFileIsSharedWithGroup($entry, $filepath, $user, $group, $withPerms = null, $permissions = null, $viewOnly = null) { + // when view-only is set, permissions is empty string instead of null... + if ($permissions === '') { + $permissions = null; + } $fullUrl = $this->baseUrl . "v{$this->apiVersion}.php/apps/files_sharing/api/v{$this->sharingApiVersion}/shares" . "?path=$filepath"; $client = new Client(); $options = []; @@ -336,79 +443,190 @@ trait Sharing { } else { $options['auth'] = [$user, $this->regularUser]; } + $options['headers'] = [ + 'OCS-APIREQUEST' => 'true', + ]; $this->response = $client->get($fullUrl, $options); - if ($this->isUserOrGroupInSharedData($group)){ + if ($this->isUserOrGroupInSharedData($group, $permissions)) { return; } else { - $this->createShare($user, $filepath, 1, $group, null, null, null); + $this->createShare($user, $filepath, 1, $group, null, null, $permissions, $viewOnly !== null); } $this->response = $client->get($fullUrl, $options); - PHPUnit_Framework_Assert::assertEquals(True, $this->isUserOrGroupInSharedData($group)); + Assert::assertEquals(true, $this->isUserOrGroupInSharedData($group, $permissions)); } /** * @When /^Deleting last share$/ */ - public function deletingLastShare(){ + public function deletingLastShare() { $share_id = $this->lastShareData->data[0]->id; $url = "/apps/files_sharing/api/v{$this->sharingApiVersion}/shares/$share_id"; - $this->sendingToWith("DELETE", $url, null); + $this->sendingToWith('DELETE', $url, null); } /** * @When /^Getting info of last share$/ */ - public function gettingInfoOfLastShare(){ + public function gettingInfoOfLastShare() { $share_id = $this->lastShareData->data[0]->id; $url = "/apps/files_sharing/api/v{$this->sharingApiVersion}/shares/$share_id"; - $this->sendingToWith("GET", $url, null); + $this->sendingToWith('GET', $url, null); } /** * @Then /^last share_id is included in the answer$/ */ - public function checkingLastShareIDIsIncluded(){ + public function checkingLastShareIDIsIncluded() { $share_id = $this->lastShareData->data[0]->id; - if (!$this->isFieldInResponse('id', $share_id)){ - PHPUnit_Framework_Assert::fail("Share id $share_id not found in response"); + if (!$this->isFieldInResponse('id', $share_id)) { + Assert::fail("Share id $share_id not found in response"); } } /** * @Then /^last share_id is not included in the answer$/ */ - public function checkingLastShareIDIsNotIncluded(){ + public function checkingLastShareIDIsNotIncluded() { $share_id = $this->lastShareData->data[0]->id; - if ($this->isFieldInResponse('id', $share_id)){ - PHPUnit_Framework_Assert::fail("Share id $share_id has been found in response"); + if ($this->isFieldInResponse('id', $share_id)) { + Assert::fail("Share id $share_id has been found in response"); } } /** * @Then /^Share fields of last share match with$/ - * @param \Behat\Gherkin\Node\TableNode|null $body + * @param TableNode|null $body */ - public function checkShareFields($body){ - if ($body instanceof \Behat\Gherkin\Node\TableNode) { + public function checkShareFields($body) { + if ($body instanceof TableNode) { $fd = $body->getRowsHash(); - foreach($fd as $field => $value) { - if (substr($field, 0, 10 ) === "share_with"){ - $value = str_replace("REMOTE", substr($this->remoteBaseUrl, 0, -5), $value); - $value = str_replace("LOCAL", substr($this->localBaseUrl, 0, -5), $value); + foreach ($fd as $field => $value) { + if (substr($field, 0, 10) === 'share_with') { + $value = str_replace('REMOTE', substr($this->remoteBaseUrl, 0, -5), $value); + $value = str_replace('LOCAL', substr($this->localBaseUrl, 0, -5), $value); } - if (substr($field, 0, 6 ) === "remote"){ - $value = str_replace("REMOTE", substr($this->remoteBaseUrl, 0, -4), $value); - $value = str_replace("LOCAL", substr($this->localBaseUrl, 0, -4), $value); + if (substr($field, 0, 6) === 'remote') { + $value = str_replace('REMOTE', substr($this->remoteBaseUrl, 0, -4), $value); + $value = str_replace('LOCAL', substr($this->localBaseUrl, 0, -4), $value); } - if (!$this->isFieldInResponse($field, $value)){ - PHPUnit_Framework_Assert::fail("$field" . " doesn't have value " . "$value"); + if (!$this->isFieldInResponse($field, $value)) { + Assert::fail("$field" . " doesn't have value " . "$value"); } } } } /** + * @Then the list of returned shares has :count shares + */ + public function theListOfReturnedSharesHasShares(int $count) { + $this->theHTTPStatusCodeShouldBe('200'); + $this->theOCSStatusCodeShouldBe('100'); + + $returnedShares = $this->getXmlResponse()->data[0]; + + Assert::assertEquals($count, count($returnedShares->element)); + } + + /** + * @Then share :count is returned with + * + * @param int $number + * @param TableNode $body + */ + public function shareXIsReturnedWith(int $number, TableNode $body) { + $this->theHTTPStatusCodeShouldBe('200'); + $this->theOCSStatusCodeShouldBe('100'); + + if (!($body instanceof TableNode)) { + return; + } + + $returnedShare = $this->getXmlResponse()->data[0]; + if ($returnedShare->element) { + $returnedShare = $returnedShare->element[$number]; + } + + $defaultExpectedFields = [ + 'id' => 'A_NUMBER', + 'permissions' => '19', + 'stime' => 'A_NUMBER', + 'parent' => '', + 'expiration' => '', + 'token' => '', + 'storage' => 'A_NUMBER', + 'item_source' => 'A_NUMBER', + 'file_source' => 'A_NUMBER', + 'file_parent' => 'A_NUMBER', + 'mail_send' => '0' + ]; + $expectedFields = array_merge($defaultExpectedFields, $body->getRowsHash()); + + if (!array_key_exists('uid_file_owner', $expectedFields) + && array_key_exists('uid_owner', $expectedFields)) { + $expectedFields['uid_file_owner'] = $expectedFields['uid_owner']; + } + if (!array_key_exists('displayname_file_owner', $expectedFields) + && array_key_exists('displayname_owner', $expectedFields)) { + $expectedFields['displayname_file_owner'] = $expectedFields['displayname_owner']; + } + + if (array_key_exists('share_type', $expectedFields) + && $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */ + && array_key_exists('share_with', $expectedFields)) { + if ($expectedFields['share_with'] === 'private_conversation') { + $expectedFields['share_with'] = 'REGEXP /^private_conversation_[0-9a-f]{6}$/'; + } else { + $expectedFields['share_with'] = FeatureContext::getTokenForIdentifier($expectedFields['share_with']); + } + } + + foreach ($expectedFields as $field => $value) { + $this->assertFieldIsInReturnedShare($field, $value, $returnedShare); + } + } + + /** + * @return SimpleXMLElement + */ + private function getXmlResponse(): \SimpleXMLElement { + return simplexml_load_string($this->response->getBody()); + } + + /** + * @param string $field + * @param string $contentExpected + * @param \SimpleXMLElement $returnedShare + */ + private function assertFieldIsInReturnedShare(string $field, string $contentExpected, \SimpleXMLElement $returnedShare) { + if ($contentExpected === 'IGNORE') { + return; + } + + if (!property_exists($returnedShare, $field)) { + Assert::fail("$field was not found in response"); + } + + if ($field === 'expiration' && !empty($contentExpected)) { + $contentExpected = date('Y-m-d', strtotime($contentExpected)) . ' 00:00:00'; + } + + if ($contentExpected === 'A_NUMBER') { + Assert::assertTrue(is_numeric((string)$returnedShare->$field), "Field '$field' is not a number: " . $returnedShare->$field); + } elseif ($contentExpected === 'A_TOKEN') { + // A token is composed by 15 characters from + // ISecureRandom::CHAR_HUMAN_READABLE. + Assert::assertRegExp('/^[abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789]{15}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); + } elseif (strpos($contentExpected, 'REGEXP ') === 0) { + Assert::assertRegExp(substr($contentExpected, strlen('REGEXP ')), (string)$returnedShare->$field, "Field '$field' does not match"); + } else { + Assert::assertEquals($contentExpected, (string)$returnedShare->$field, "Field '$field' does not match"); + } + } + + /** * @Then As :user remove all shares from the file named :fileName */ public function asRemoveAllSharesFromTheFileNamed($user, $fileName) { @@ -423,12 +641,13 @@ trait Sharing { ], 'headers' => [ 'Content-Type' => 'application/json', + 'OCS-APIREQUEST' => 'true', ], ] ); $json = json_decode($res->getBody()->getContents(), true); $deleted = false; - foreach($json['ocs']['data'] as $data) { + foreach ($json['ocs']['data'] as $data) { if (stripslashes($data['path']) === $fileName) { $id = $data['id']; $client->delete( @@ -440,6 +659,7 @@ trait Sharing { ], 'headers' => [ 'Content-Type' => 'application/json', + 'OCS-APIREQUEST' => 'true', ], ] ); @@ -447,7 +667,7 @@ trait Sharing { } } - if($deleted === false) { + if ($deleted === false) { throw new \Exception("Could not delete file $fileName"); } } @@ -455,19 +675,67 @@ trait Sharing { /** * @When save last share id */ - public function saveLastShareId() - { - $this->savedShareId = $this->lastShareData['data']['id']; + public function saveLastShareId() { + $this->savedShareId = ($this->lastShareData['data']['id'] ?? null); } /** * @Then share ids should match */ - public function shareIdsShouldMatch() - { - if ($this->savedShareId !== $this->lastShareData['data']['id']) { + public function shareIdsShouldMatch() { + if ($this->savedShareId !== ($this->lastShareData['data']['id'] ?? null)) { throw new \Exception('Expected the same link share to be returned'); } } -} + /** + * @When /^getting sharees for$/ + * @param TableNode $body + */ + public function whenGettingShareesFor($body) { + $url = '/apps/files_sharing/api/v1/sharees'; + if ($body instanceof TableNode) { + $parameters = []; + foreach ($body->getRowsHash() as $key => $value) { + $parameters[] = $key . '=' . $value; + } + if (!empty($parameters)) { + $url .= '?' . implode('&', $parameters); + } + } + + $this->sendingTo('GET', $url); + } + + /** + * @Then /^"([^"]*)" sharees returned (are|is empty)$/ + * @param string $shareeType + * @param string $isEmpty + * @param TableNode|null $shareesList + */ + public function thenListOfSharees($shareeType, $isEmpty, $shareesList = null) { + if ($isEmpty !== 'is empty') { + $sharees = $shareesList->getRows(); + $respondedArray = $this->getArrayOfShareesResponded($this->response, $shareeType); + Assert::assertEquals($sharees, $respondedArray); + } else { + $respondedArray = $this->getArrayOfShareesResponded($this->response, $shareeType); + Assert::assertEmpty($respondedArray); + } + } + + public function getArrayOfShareesResponded(ResponseInterface $response, $shareeType) { + $elements = simplexml_load_string($response->getBody())->data; + $elements = json_decode(json_encode($elements), 1); + if (strpos($shareeType, 'exact ') === 0) { + $elements = $elements['exact']; + $shareeType = substr($shareeType, 6); + } + + $sharees = []; + foreach ($elements[$shareeType] as $element) { + $sharees[] = [$element['label'], $element['value']['shareType'], $element['value']['shareWith']]; + } + return $sharees; + } +} diff --git a/build/integration/features/bootstrap/SharingContext.php b/build/integration/features/bootstrap/SharingContext.php new file mode 100644 index 00000000000..a9dd99108a9 --- /dev/null +++ b/build/integration/features/bootstrap/SharingContext.php @@ -0,0 +1,38 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; +use Behat\Behat\Context\SnippetAcceptingContext; + +require __DIR__ . '/../../vendor/autoload.php'; + + +/** + * Features context. + */ +class SharingContext implements Context, SnippetAcceptingContext { + use WebDav; + use Trashbin; + use AppConfiguration; + use CommandLine; + use Activity; + + protected function resetAppConfigs() { + $this->deleteServerConfig('core', 'shareapi_default_permissions'); + $this->deleteServerConfig('core', 'shareapi_default_internal_expire_date'); + $this->deleteServerConfig('core', 'shareapi_internal_expire_after_n_days'); + $this->deleteServerConfig('core', 'internal_defaultExpDays'); + $this->deleteServerConfig('core', 'shareapi_enforce_links_password'); + $this->deleteServerConfig('core', 'shareapi_default_expire_date'); + $this->deleteServerConfig('core', 'shareapi_expire_after_n_days'); + $this->deleteServerConfig('core', 'link_defaultExpDays'); + $this->deleteServerConfig('core', 'shareapi_allow_federation_on_public_shares'); + $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); + $this->deleteServerConfig('core', 'shareapi_allow_view_without_download'); + + $this->runOcc(['config:system:delete', 'share_folder']); + } +} diff --git a/build/integration/features/bootstrap/TagsContext.php b/build/integration/features/bootstrap/TagsContext.php index 10d0b9ae545..c64626de68d 100644 --- a/build/integration/features/bootstrap/TagsContext.php +++ b/build/integration/features/bootstrap/TagsContext.php @@ -1,24 +1,10 @@ <?php + /** - * @author Lukas Reschke <lukas@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - require __DIR__ . '/../../vendor/autoload.php'; use Behat\Gherkin\Node\TableNode; @@ -26,7 +12,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Message\ResponseInterface; class TagsContext implements \Behat\Behat\Context\Context { - /** @var string */ + /** @var string */ private $baseUrl; /** @var Client */ private $client; @@ -47,7 +33,7 @@ class TagsContext implements \Behat\Behat\Context\Context { } /** @BeforeScenario */ - public function tearUpScenario() { + public function setUpScenario() { $this->client = new Client(); } @@ -55,9 +41,9 @@ class TagsContext implements \Behat\Behat\Context\Context { public function tearDownScenario() { $user = 'admin'; $tags = $this->requestTagsForUser($user); - foreach($tags as $tagId => $tag) { + foreach ($tags as $tagId => $tag) { $this->response = $this->client->delete( - $this->baseUrl . '/remote.php/dav/systemtags/'.$tagId, + $this->baseUrl . '/remote.php/dav/systemtags/' . $tagId, [ 'auth' => [ $user, @@ -82,7 +68,8 @@ class TagsContext implements \Behat\Behat\Context\Context { ], ] ); - } catch (\GuzzleHttp\Exception\ClientException $e) {} + } catch (\GuzzleHttp\Exception\ClientException $e) { + } } /** @@ -90,35 +77,43 @@ class TagsContext implements \Behat\Behat\Context\Context { * @return string */ private function getPasswordForUser($userName) { - if($userName === 'admin') { + if ($userName === 'admin') { return 'admin'; } return '123456'; } /** - * @When :user creates a :type tag with name :name * @param string $user * @param string $type * @param string $name - * @throws \Exception + * @param string $groups */ - public function createsATagWithName($user, $type, $name) { - $userVisible = 'true'; - $userAssignable = 'true'; + private function createTag($user, $type, $name, $groups = null) { + $userVisible = true; + $userAssignable = true; switch ($type) { case 'normal': break; case 'not user-assignable': - $userAssignable = 'false'; + $userAssignable = false; break; case 'not user-visible': - $userVisible = 'false'; + $userVisible = false; break; default: throw new \Exception('Unsupported type'); } + $body = [ + 'name' => $name, + 'userVisible' => $userVisible, + 'userAssignable' => $userAssignable, + ]; + if ($groups !== null) { + $body['groups'] = $groups; + } + try { $this->response = $this->client->post( $this->baseUrl . '/remote.php/dav/systemtags/', @@ -130,22 +125,45 @@ class TagsContext implements \Behat\Behat\Context\Context { 'headers' => [ 'Content-Type' => 'application/json', ], - 'body' => '{"name":"'.$name.'","userVisible":'.$userVisible.',"userAssignable":'.$userAssignable.'}', + 'body' => json_encode($body) ] ); - } catch (\GuzzleHttp\Exception\ClientException $e){ + } catch (\GuzzleHttp\Exception\ClientException $e) { $this->response = $e->getResponse(); } } /** + * @When :user creates a :type tag with name :name + * @param string $user + * @param string $type + * @param string $name + * @throws \Exception + */ + public function createsATagWithName($user, $type, $name) { + $this->createTag($user, $type, $name); + } + + /** + * @When :user creates a :type tag with name :name and groups :groups + * @param string $user + * @param string $type + * @param string $name + * @param string $groups + * @throws \Exception + */ + public function createsATagWithNameAndGroups($user, $type, $name, $groups) { + $this->createTag($user, $type, $name, $groups); + } + + /** * @Then The response should have a status code :statusCode * @param int $statusCode * @throws \Exception */ public function theResponseShouldHaveAStatusCode($statusCode) { - if((int)$statusCode !== $this->response->getStatusCode()) { - throw new \Exception("Expected $statusCode, got ".$this->response->getStatusCode()); + if ((int)$statusCode !== $this->response->getStatusCode()) { + throw new \Exception("Expected $statusCode, got " . $this->response->getStatusCode()); } } @@ -155,21 +173,30 @@ class TagsContext implements \Behat\Behat\Context\Context { * @param string $user * @return array */ - private function requestTagsForUser($user) { + private function requestTagsForUser($user, $withGroups = false) { try { - $request = $this->client->createRequest( - 'PROPFIND', - $this->baseUrl . '/remote.php/dav/systemtags/', - [ - 'body' => '<?xml version="1.0"?> + $body = '<?xml version="1.0"?> <d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> <d:prop> <oc:id /> <oc:display-name /> <oc:user-visible /> - <oc:user-assignable /> + <oc:user-assignable /> + <oc:can-assign /> +'; + + if ($withGroups) { + $body .= '<oc:groups />'; + } + + $body .= ' </d:prop> -</d:propfind>', +</d:propfind>'; + $this->response = $this->client->request( + 'PROPFIND', + $this->baseUrl . '/remote.php/dav/systemtags/', + [ + 'body' => $body, 'auth' => [ $user, $this->getPasswordForUser($user), @@ -179,7 +206,6 @@ class TagsContext implements \Behat\Behat\Context\Context { ], ] ); - $this->response = $this->client->send($request); } catch (\GuzzleHttp\Exception\ClientException $e) { $this->response = $e->getResponse(); } @@ -187,17 +213,22 @@ class TagsContext implements \Behat\Behat\Context\Context { $tags = []; $service = new Sabre\Xml\Service(); $parsed = $service->parse($this->response->getBody()->getContents()); - foreach($parsed as $entry) { + foreach ($parsed as $entry) { $singleEntry = $entry['value'][1]['value'][0]['value']; - if(empty($singleEntry[0]['value'])) { + if (empty($singleEntry[0]['value'])) { continue; } + // FIXME: use actual property names instead of guessing index position $tags[$singleEntry[0]['value']] = [ 'display-name' => $singleEntry[1]['value'], 'user-visible' => $singleEntry[2]['value'], 'user-assignable' => $singleEntry[3]['value'], + 'can-assign' => $singleEntry[4]['value'], ]; + if (isset($singleEntry[5])) { + $tags[$singleEntry[0]['value']]['groups'] = $singleEntry[5]['value']; + } } return $tags; @@ -212,42 +243,117 @@ class TagsContext implements \Behat\Behat\Context\Context { public function theFollowingTagsShouldExistFor($user, TableNode $table) { $tags = $this->requestTagsForUser($user); - if(count($table->getRows()) !== count($tags)) { + if (count($table->getRows()) !== count($tags)) { throw new \Exception( sprintf( - "Expected %s tags, got %s.", + 'Expected %s tags, got %s.', count($table->getRows()), count($tags) ) ); } - foreach($table->getRowsHash() as $rowDisplayName => $row) { - foreach($tags as $key => $tag) { - if( - $tag['display-name'] === $rowDisplayName && - $tag['user-visible'] === $row[0] && - $tag['user-assignable'] === $row[1] + foreach ($table->getRowsHash() as $rowDisplayName => $row) { + foreach ($tags as $key => $tag) { + if ( + $tag['display-name'] === $rowDisplayName + && $tag['user-visible'] === $row[0] + && $tag['user-assignable'] === $row[1] ) { unset($tags[$key]); } } } - if(count($tags) !== 0) { + if (count($tags) !== 0) { throw new \Exception('Not expected response'); } } /** + * @Then the user :user :can assign The :type tag with name :tagName + */ + public function theUserCanAssignTheTag($user, $can, $type, $tagName) { + $foundTag = $this->findTag($type, $tagName, $user); + if ($foundTag === null) { + throw new \Exception('No matching tag found'); + } + + if ($can === 'can') { + $expected = 'true'; + } elseif ($can === 'cannot') { + $expected = 'false'; + } else { + throw new \Exception('Invalid condition, must be "can" or "cannot"'); + } + + if ($foundTag['can-assign'] !== $expected) { + throw new \Exception('Tag cannot be assigned by user'); + } + } + + /** + * @Then The :type tag with name :tagName has the groups :groups + */ + public function theTagHasGroup($type, $tagName, $groups) { + $foundTag = $this->findTag($type, $tagName, 'admin', true); + if ($foundTag === null) { + throw new \Exception('No matching tag found'); + } + + if ($foundTag['groups'] !== $groups) { + throw new \Exception('Tag has groups "' . $foundTag['group'] . '" instead of the expected "' . $groups . '"'); + } + } + + /** * @Then :count tags should exist for :user * @param int $count * @param string $user * @throws \Exception */ - public function tagsShouldExistFor($count, $user) { - if((int)$count !== count($this->requestTagsForUser($user))) { - throw new \Exception("Expected $count tags, got ".count($this->requestTagsForUser($user))); + public function tagsShouldExistFor($count, $user) { + if ((int)$count !== count($this->requestTagsForUser($user))) { + throw new \Exception("Expected $count tags, got " . count($this->requestTagsForUser($user))); + } + } + + /** + * Find tag by type and name + * + * @param string $type tag type + * @param string $tagName tag name + * @param string $user retrieved from which user + * @param bool $withGroups whether to also query the tag's groups + * + * @return array tag values or null if not found + */ + private function findTag($type, $tagName, $user = 'admin', $withGroups = false) { + $tags = $this->requestTagsForUser($user, $withGroups); + $userAssignable = 'true'; + $userVisible = 'true'; + switch ($type) { + case 'normal': + break; + case 'not user-assignable': + $userAssignable = 'false'; + break; + case 'not user-visible': + $userVisible = 'false'; + break; + default: + throw new \Exception('Unsupported type'); } + + $foundTag = null; + foreach ($tags as $tag) { + if ($tag['display-name'] === $tagName + && $tag['user-visible'] === $userVisible + && $tag['user-assignable'] === $userAssignable) { + $foundTag = $tag; + break; + } + } + return $foundTag; } /** @@ -257,8 +363,8 @@ class TagsContext implements \Behat\Behat\Context\Context { private function findTagIdByName($name) { $tags = $this->requestTagsForUser('admin'); $tagId = 0; - foreach($tags as $id => $tag) { - if($tag['display-name'] === $name) { + foreach ($tags as $id => $tag) { + if ($tag['display-name'] === $name) { $tagId = $id; break; } @@ -275,12 +381,12 @@ class TagsContext implements \Behat\Behat\Context\Context { */ public function editsTheTagWithNameAndSetsItsNameTo($user, $oldName, $newName) { $tagId = $this->findTagIdByName($oldName); - if($tagId === 0) { + if ($tagId === 0) { throw new \Exception('Could not find tag to rename'); } try { - $request = $this->client->createRequest( + $this->response = $this->client->request( 'PROPPATCH', $this->baseUrl . '/remote.php/dav/systemtags/' . $tagId, [ @@ -298,7 +404,43 @@ class TagsContext implements \Behat\Behat\Context\Context { ], ] ); - $this->response = $this->client->send($request); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @When :user edits the tag with name :oldNmae and sets its groups to :groups + * @param string $user + * @param string $oldName + * @param string $groups + * @throws \Exception + */ + public function editsTheTagWithNameAndSetsItsGroupsTo($user, $oldName, $groups) { + $tagId = $this->findTagIdByName($oldName); + if ($tagId === 0) { + throw new \Exception('Could not find tag to rename'); + } + + try { + $this->response = $this->client->request( + 'PROPPATCH', + $this->baseUrl . '/remote.php/dav/systemtags/' . $tagId, + [ + 'body' => '<?xml version="1.0"?> +<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> + <d:set> + <d:prop> + <oc:groups>' . $groups . '</oc:groups> + </d:prop> + </d:set> +</d:propertyupdate>', + 'auth' => [ + $user, + $this->getPasswordForUser($user), + ], + ] + ); } catch (\GuzzleHttp\Exception\ClientException $e) { $this->response = $e->getResponse(); } @@ -309,7 +451,7 @@ class TagsContext implements \Behat\Behat\Context\Context { * @param string $user * @param string $name */ - public function deletesTheTagWithName($user, $name) { + public function deletesTheTagWithName($user, $name) { $tagId = $this->findTagIdByName($name); try { $this->response = $this->client->delete( @@ -335,10 +477,10 @@ class TagsContext implements \Behat\Behat\Context\Context { * @return int */ private function getFileIdForPath($path, $user) { - $url = $this->baseUrl.'/remote.php/webdav/'.$path; - $credentials = base64_encode($user .':'.$this->getPasswordForUser($user)); - $context = stream_context_create(array( - 'http' => array( + $url = $this->baseUrl . '/remote.php/webdav/' . $path; + $credentials = base64_encode($user . ':' . $this->getPasswordForUser($user)); + $context = stream_context_create([ + 'http' => [ 'method' => 'PROPFIND', 'header' => "Authorization: Basic $credentials\r\nContent-Type: application/x-www-form-urlencoded", 'content' => '<?xml version="1.0"?> @@ -347,27 +489,27 @@ class TagsContext implements \Behat\Behat\Context\Context { <oc:fileid /> </d:prop> </d:propfind>' - ) - )); + ] + ]); $response = file_get_contents($url, false, $context); - preg_match_all('/\<oc:fileid\>(.*)\<\/oc:fileid\>/', $response, $matches); + preg_match_all('/\<oc:fileid\>(.*?)\<\/oc:fileid\>/', $response, $matches); return (int)$matches[1][0]; } /** - * @When :taggingUser adds the tag :tagName to :fileName shared by :sharingUser + * @When /^"([^"]*)" adds the tag "([^"]*)" to "([^"]*)" (shared|owned) by "([^"]*)"$/ * @param string $taggingUser * @param string $tagName * @param string $fileName * @param string $sharingUser */ - public function addsTheTagToSharedBy($taggingUser, $tagName, $fileName, $sharingUser) { + public function addsTheTagToSharedBy($taggingUser, $tagName, $fileName, $sharedOrOwnedBy, $sharingUser) { $fileId = $this->getFileIdForPath($fileName, $sharingUser); $tagId = $this->findTagIdByName($tagName); try { $this->response = $this->client->put( - $this->baseUrl.'/remote.php/dav/systemtags-relations/files/'.$fileId.'/'.$tagId, + $this->baseUrl . '/remote.php/dav/systemtags-relations/files/' . $fileId . '/' . $tagId, [ 'auth' => [ $taggingUser, @@ -381,23 +523,23 @@ class TagsContext implements \Behat\Behat\Context\Context { } /** - * @Then :fileName shared by :sharingUser has the following tags + * @Then /^"([^"]*)" (shared|owned) by "([^"]*)" has the following tags$/ * @param string $fileName * @param string $sharingUser * @param TableNode $table * @throws \Exception */ - public function sharedByHasTheFollowingTags($fileName, $sharingUser, TableNode $table) { + public function sharedByHasTheFollowingTags($fileName, $sharedOrOwnedBy, $sharingUser, TableNode $table) { $loadedExpectedTags = $table->getTable(); $expectedTags = []; - foreach($loadedExpectedTags as $expected) { + foreach ($loadedExpectedTags as $expected) { $expectedTags[] = $expected[0]; } // Get the real tags - $request = $this->client->createRequest( + $response = $this->client->request( 'PROPFIND', - $this->baseUrl.'/remote.php/dav/systemtags-relations/files/'.$this->getFileIdForPath($fileName, $sharingUser), + $this->baseUrl . '/remote.php/dav/systemtags-relations/files/' . $this->getFileIdForPath($fileName, $sharingUser), [ 'auth' => [ $sharingUser, @@ -413,19 +555,18 @@ class TagsContext implements \Behat\Behat\Context\Context { </d:prop> </d:propfind>', ] - ); - $response = $this->client->send($request)->getBody()->getContents(); - preg_match_all('/\<oc:display-name\>(.*)\<\/oc:display-name\>/', $response, $realTags); + )->getBody()->getContents(); + preg_match_all('/\<oc:display-name\>(.*?)\<\/oc:display-name\>/', $response, $realTags); - foreach($expectedTags as $key => $row) { - foreach($realTags as $tag) { - if($tag[0] === $row) { + foreach ($expectedTags as $key => $row) { + foreach ($realTags as $tag) { + if ($tag[0] === $row) { unset($expectedTags[$key]); } } } - if(count($expectedTags) !== 0) { + if (count($expectedTags) !== 0) { throw new \Exception('Not all tags found.'); } } @@ -441,13 +582,13 @@ class TagsContext implements \Behat\Behat\Context\Context { public function sharedByHasTheFollowingTagsFor($fileName, $sharingUser, $user, TableNode $table) { $loadedExpectedTags = $table->getTable(); $expectedTags = []; - foreach($loadedExpectedTags as $expected) { + foreach ($loadedExpectedTags as $expected) { $expectedTags[] = $expected[0]; } // Get the real tags try { - $request = $this->client->createRequest( + $this->response = $this->client->request( 'PROPFIND', $this->baseUrl . '/remote.php/dav/systemtags-relations/files/' . $this->getFileIdForPath($fileName, $sharingUser), [ @@ -466,25 +607,24 @@ class TagsContext implements \Behat\Behat\Context\Context { </d:propfind>', ] ); - $this->response = $this->client->send($request)->getBody()->getContents(); } catch (\GuzzleHttp\Exception\ClientException $e) { $this->response = $e->getResponse(); } - preg_match_all('/\<oc:display-name\>(.*)\<\/oc:display-name\>/', $this->response, $realTags); + preg_match_all('/\<oc:display-name\>(.*?)\<\/oc:display-name\>/', $this->response->getBody()->getContents(), $realTags); $realTags = array_filter($realTags); $expectedTags = array_filter($expectedTags); - foreach($expectedTags as $key => $row) { - foreach($realTags as $tag) { - foreach($tag as $index => $foo) { - if($tag[$index] === $row) { + foreach ($expectedTags as $key => $row) { + foreach ($realTags as $tag) { + foreach ($tag as $index => $foo) { + if ($tag[$index] === $row) { unset($expectedTags[$key]); } } } } - if(count($expectedTags) !== 0) { + if (count($expectedTags) !== 0) { throw new \Exception('Not all tags found.'); } } @@ -502,7 +642,7 @@ class TagsContext implements \Behat\Behat\Context\Context { try { $this->response = $this->client->delete( - $this->baseUrl.'/remote.php/dav/systemtags-relations/files/'.$fileId.'/'.$tagId, + $this->baseUrl . '/remote.php/dav/systemtags-relations/files/' . $fileId . '/' . $tagId, [ 'auth' => [ $user, diff --git a/build/integration/features/bootstrap/TalkContext.php b/build/integration/features/bootstrap/TalkContext.php new file mode 100644 index 00000000000..6f351c30ccf --- /dev/null +++ b/build/integration/features/bootstrap/TalkContext.php @@ -0,0 +1,54 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +use Behat\Behat\Context\Context; + +class TalkContext implements Context { + /** + * @BeforeFeature @Talk + * @BeforeScenario @Talk + */ + public static function skipTestsIfTalkIsNotInstalled() { + if (!TalkContext::isTalkInstalled()) { + throw new Exception('Talk needs to be installed to run features or scenarios tagged with @Talk'); + } + } + + /** + * @AfterScenario @Talk + */ + public static function disableTalk() { + TalkContext::runOcc(['app:disable', 'spreed']); + } + + private static function isTalkInstalled(): bool { + $appList = TalkContext::runOcc(['app:list']); + + return strpos($appList, 'spreed') !== false; + } + + private static function runOcc(array $args): string { + // Based on "runOcc" from CommandLine trait (which can not be used due + // to not being static and being already used in other sibling + // contexts). + $args = array_map(function ($arg) { + return escapeshellarg($arg); + }, $args); + $args[] = '--no-ansi --no-warnings'; + $args = implode(' ', $args); + + $descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open('php console.php ' . $args, $descriptor, $pipes, $ocPath = '../..'); + $lastStdOut = stream_get_contents($pipes[1]); + proc_close($process); + + return $lastStdOut; + } +} diff --git a/build/integration/features/bootstrap/Theming.php b/build/integration/features/bootstrap/Theming.php new file mode 100644 index 00000000000..f44a6533a1b --- /dev/null +++ b/build/integration/features/bootstrap/Theming.php @@ -0,0 +1,49 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +require __DIR__ . '/../../vendor/autoload.php'; + +trait Theming { + + private bool $undoAllThemingChangesAfterScenario = false; + + /** + * @AfterScenario + */ + public function undoAllThemingChanges() { + if (!$this->undoAllThemingChangesAfterScenario) { + return; + } + + $this->loggingInUsingWebAs('admin'); + $this->sendingAToWithRequesttoken('POST', '/index.php/apps/theming/ajax/undoAllChanges'); + + $this->undoAllThemingChangesAfterScenario = false; + } + + /** + * @When logged in admin uploads theming image for :key from file :source + * + * @param string $key + * @param string $source + */ + public function loggedInAdminUploadsThemingImageForFromFile(string $key, string $source) { + $this->undoAllThemingChangesAfterScenario = true; + + $file = \GuzzleHttp\Psr7\Utils::streamFor(fopen($source, 'r')); + + $this->sendingAToWithRequesttoken('POST', '/index.php/apps/theming/ajax/uploadImage?key=' . $key, + [ + 'multipart' => [ + [ + 'name' => 'image', + 'contents' => $file + ] + ] + ]); + $this->theHTTPStatusCodeShouldBe('200'); + } +} diff --git a/build/integration/features/bootstrap/Trashbin.php b/build/integration/features/bootstrap/Trashbin.php new file mode 100644 index 00000000000..dfcc23289a7 --- /dev/null +++ b/build/integration/features/bootstrap/Trashbin.php @@ -0,0 +1,153 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +use DMS\PHPUnitExtensions\ArraySubset\Assert as AssertArraySubset; +use PHPUnit\Framework\Assert; + +require __DIR__ . '/../../vendor/autoload.php'; + +/** + * Trashbin functions + */ +trait Trashbin { + // WebDav trait is expected to be used in the class that uses this trait. + + /** + * @When User :user empties trashbin + * @param string $user user + */ + public function emptyTrashbin($user) { + $client = $this->getSabreClient($user); + $response = $client->request('DELETE', $this->makeSabrePath($user, 'trash', 'trashbin')); + Assert::assertEquals(204, $response['statusCode']); + } + + private function findFullTrashname($user, $name) { + $rootListing = $this->listTrashbinFolder($user, '/'); + + foreach ($rootListing as $href => $rootItem) { + if ($rootItem['{http://nextcloud.org/ns}trashbin-filename'] === $name) { + return basename($href); + } + } + + return null; + } + + /** + * Get the full /startofpath.dxxxx/rest/of/path from /startofpath/rest/of/path + */ + private function getFullTrashPath($user, $path) { + if ($path !== '' && $path !== '/') { + $parts = explode('/', $path); + $fullName = $this->findFullTrashname($user, $parts[1]); + if ($fullName === null) { + Assert::fail("cant find $path in trash"); + return '/dummy_full_path_not_found'; + } + $parts[1] = $fullName; + + $path = implode('/', $parts); + } + return $path; + } + + /** + * List trashbin folder + * + * @param string $user user + * @param string $path path + * @return array response + */ + public function listTrashbinFolder($user, $path) { + $path = $this->getFullTrashPath($user, $path); + $client = $this->getSabreClient($user); + + $results = $client->propfind($this->makeSabrePath($user, 'trash' . $path, 'trashbin'), [ + '{http://nextcloud.org/ns}trashbin-filename', + '{http://nextcloud.org/ns}trashbin-original-location', + '{http://nextcloud.org/ns}trashbin-deletion-time' + ], 1); + $results = array_filter($results, function (array $item) { + return isset($item['{http://nextcloud.org/ns}trashbin-filename']); + }); + if ($path !== '' && $path !== '/') { + array_shift($results); + } + return $results; + } + + /** + * @Then /^user "([^"]*)" in trash folder "([^"]*)" should have the following elements$/ + * @param string $user + * @param string $folder + * @param \Behat\Gherkin\Node\TableNode|null $expectedElements + */ + public function checkTrashContents($user, $folder, $expectedElements) { + $elementList = $this->listTrashbinFolder($user, $folder); + $trashContent = array_filter(array_map(function (array $item) { + return $item['{http://nextcloud.org/ns}trashbin-filename']; + }, $elementList)); + if ($expectedElements instanceof \Behat\Gherkin\Node\TableNode) { + $elementRows = $expectedElements->getRows(); + $elementsSimplified = $this->simplifyArray($elementRows); + foreach ($elementsSimplified as $expectedElement) { + $expectedElement = ltrim($expectedElement, '/'); + if (array_search($expectedElement, $trashContent) === false) { + Assert::fail("$expectedElement" . ' is not in trash listing'); + } + } + } + } + + /** + * @Then /^as "([^"]*)" the (file|folder) "([^"]*)" exists in trash$/ + * @param string $user + * @param string $type + * @param string $file + */ + public function checkTrashContains($user, $type, $file) { + $parent = dirname($file); + if ($parent === '.') { + $parent = '/'; + } + $name = basename($file); + $elementList = $this->listTrashbinFolder($user, $parent); + $trashContent = array_filter(array_map(function (array $item) { + return $item['{http://nextcloud.org/ns}trashbin-filename']; + }, $elementList)); + + AssertArraySubset::assertArraySubset([$name], array_values($trashContent)); + } + + /** + * @Then /^user "([^"]*)" in trash folder "([^"]*)" should have (\d+) elements?$/ + * @param string $user + * @param string $folder + * @param \Behat\Gherkin\Node\TableNode|null $expectedElements + */ + public function checkTrashSize($user, $folder, $expectedCount) { + $elementList = $this->listTrashbinFolder($user, $folder); + Assert::assertEquals($expectedCount, count($elementList)); + } + + /** + * @When /^user "([^"]*)" in restores "([^"]*)" from trash$/ + * @param string $user + * @param string $file + */ + public function restoreFromTrash($user, $file) { + $file = $this->getFullTrashPath($user, $file); + $url = $this->makeSabrePath($user, 'trash' . $file, 'trashbin'); + $client = $this->getSabreClient($user); + $response = $client->request('MOVE', $url, null, [ + 'Destination' => $this->makeSabrePath($user, 'restore/' . basename($file), 'trashbin'), + ]); + Assert::assertEquals(201, $response['statusCode']); + return; + } +} diff --git a/build/integration/features/bootstrap/WebDav.php b/build/integration/features/bootstrap/WebDav.php index 2ef5f252f11..2cb37002ac0 100644 --- a/build/integration/features/bootstrap/WebDav.php +++ b/build/integration/features/bootstrap/WebDav.php @@ -1,8 +1,16 @@ <?php +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + use GuzzleHttp\Client as GClient; -use GuzzleHttp\Message\ResponseInterface; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; use Sabre\DAV\Client as SClient; +use Sabre\DAV\Xml\Property\ResourceType; require __DIR__ . '/../../vendor/autoload.php'; @@ -10,10 +18,17 @@ require __DIR__ . '/../../vendor/autoload.php'; trait WebDav { use Sharing; - /** @var string*/ - private $davPath = "remote.php/webdav"; + private string $davPath = 'remote.php/webdav'; + private bool $usingOldDavPath = true; + private ?array $storedETAG = null; // map with user as key and another map as value, which has path as key and etag as value + private ?int $storedFileID = null; /** @var ResponseInterface */ private $response; + private array $parsedResponse = []; + private string $s3MultipartDestination; + private string $uploadId; + /** @var string[] */ + private array $parts = []; /** * @Given /^using dav path "([^"]*)"$/ @@ -22,52 +37,103 @@ trait WebDav { $this->davPath = $davPath; } - public function makeDavRequest($user, $method, $path, $headers, $body = null){ - $fullUrl = substr($this->baseUrl, 0, -4) . $this->davPath . "$path"; + /** + * @Given /^using old dav path$/ + */ + public function usingOldDavPath() { + $this->davPath = 'remote.php/webdav'; + $this->usingOldDavPath = true; + } + + /** + * @Given /^using new dav path$/ + */ + public function usingNewDavPath() { + $this->davPath = 'remote.php/dav'; + $this->usingOldDavPath = false; + } + + /** + * @Given /^using new public dav path$/ + */ + public function usingNewPublicDavPath() { + $this->davPath = 'public.php/dav'; + $this->usingOldDavPath = false; + } + + public function getDavFilesPath($user) { + if ($this->usingOldDavPath === true) { + return $this->davPath; + } else { + return $this->davPath . '/files/' . $user; + } + } + + public function makeDavRequest($user, $method, $path, $headers, $body = null, $type = 'files') { + if ($type === 'files') { + $fullUrl = substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user) . "$path"; + } elseif ($type === 'uploads') { + $fullUrl = substr($this->baseUrl, 0, -4) . $this->davPath . "$path"; + } else { + $fullUrl = substr($this->baseUrl, 0, -4) . $this->davPath . '/' . $type . "$path"; + } $client = new GClient(); - $options = []; + $options = [ + 'headers' => $headers, + 'body' => $body + ]; if ($user === 'admin') { $options['auth'] = $this->adminUser; - } else { + } elseif ($user !== '') { $options['auth'] = [$user, $this->regularUser]; } - $request = $client->createRequest($method, $fullUrl, $options); - if (!is_null($headers)){ - foreach ($headers as $key => $value) { - $request->addHeader($key, $value); - } - } - - if (!is_null($body)) { - $request->setBody($body); - } + return $client->request($method, $fullUrl, $options); + } - return $client->send($request); + /** + * @Given /^User "([^"]*)" moved (file|folder|entry) "([^"]*)" to "([^"]*)"$/ + * @param string $user + * @param string $fileSource + * @param string $fileDestination + */ + public function userMovedFile($user, $entry, $fileSource, $fileDestination) { + $fullUrl = substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user); + $headers['Destination'] = $fullUrl . $fileDestination; + $this->response = $this->makeDavRequest($user, 'MOVE', $fileSource, $headers); + Assert::assertEquals(201, $this->response->getStatusCode()); } /** - * @Given /^User "([^"]*)" moved file "([^"]*)" to "([^"]*)"$/ + * @When /^User "([^"]*)" moves (file|folder|entry) "([^"]*)" to "([^"]*)"$/ * @param string $user * @param string $fileSource * @param string $fileDestination */ - public function userMovedFile($user, $fileSource, $fileDestination){ - $fullUrl = substr($this->baseUrl, 0, -4) . $this->davPath; + public function userMovesFile($user, $entry, $fileSource, $fileDestination) { + $fullUrl = substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user); $headers['Destination'] = $fullUrl . $fileDestination; - $this->response = $this->makeDavRequest($user, "MOVE", $fileSource, $headers); - PHPUnit_Framework_Assert::assertEquals(201, $this->response->getStatusCode()); + try { + $this->response = $this->makeDavRequest($user, 'MOVE', $fileSource, $headers); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } } /** - * @When /^User "([^"]*)" moves file "([^"]*)" to "([^"]*)"$/ + * @When /^User "([^"]*)" copies file "([^"]*)" to "([^"]*)"$/ * @param string $user * @param string $fileSource * @param string $fileDestination */ - public function userMovesFile($user, $fileSource, $fileDestination){ - $fullUrl = substr($this->baseUrl, 0, -4) . $this->davPath; + public function userCopiesFileTo($user, $fileSource, $fileDestination) { + $fullUrl = substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user); $headers['Destination'] = $fullUrl . $fileDestination; - $this->response = $this->makeDavRequest($user, "MOVE", $fileSource, $headers); + try { + $this->response = $this->makeDavRequest($user, 'COPY', $fileSource, $headers); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx and 5xx responses cause an exception + $this->response = $e->getResponse(); + } } /** @@ -75,36 +141,91 @@ trait WebDav { * @param string $fileSource * @param string $range */ - public function downloadFileWithRange($fileSource, $range){ + public function downloadFileWithRange($fileSource, $range) { $headers['Range'] = $range; - $this->response = $this->makeDavRequest($this->currentUser, "GET", $fileSource, $headers); + $this->response = $this->makeDavRequest($this->currentUser, 'GET', $fileSource, $headers); } /** * @When /^Downloading last public shared file with range "([^"]*)"$/ * @param string $range */ - public function downloadPublicFileWithRange($range){ + public function downloadPublicFileWithRange($range) { $token = $this->lastShareData->data->token; - $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/webdav"; - $headers['Range'] = $range; + $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token"; $client = new GClient(); $options = []; - $options['auth'] = [$token, ""]; + $options['headers'] = [ + 'Range' => $range + ]; + + $this->response = $client->request('GET', $fullUrl, $options); + } + + /** + * @When /^Downloading last public shared file inside a folder "([^"]*)" with range "([^"]*)"$/ + * @param string $range + */ + public function downloadPublicFileInsideAFolderWithRange($path, $range) { + $token = $this->lastShareData->data->token; + $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token/$path"; - $request = $client->createRequest("GET", $fullUrl, $options); - $request->addHeader('Range', $range); + $client = new GClient(); + $options = [ + 'headers' => [ + 'Range' => $range + ] + ]; - $this->response = $client->send($request); + $this->response = $client->request('GET', $fullUrl, $options); } /** * @Then /^Downloaded content should be "([^"]*)"$/ * @param string $content */ - public function downloadedContentShouldBe($content){ - PHPUnit_Framework_Assert::assertEquals($content, (string)$this->response->getBody()); + public function downloadedContentShouldBe($content) { + Assert::assertEquals($content, (string)$this->response->getBody()); + } + + /** + * @Then /^File "([^"]*)" should have prop "([^"]*):([^"]*)" equal to "([^"]*)"$/ + * @param string $file + * @param string $prefix + * @param string $prop + * @param string $value + */ + public function checkPropForFile($file, $prefix, $prop, $value) { + $elementList = $this->propfindFile($this->currentUser, $file, "<$prefix:$prop/>"); + $property = $elementList['/' . $this->getDavFilesPath($this->currentUser) . $file][200]["{DAV:}$prop"]; + Assert::assertEquals($property, $value); + } + + /** + * @Then /^Image search should work$/ + */ + public function search(): void { + $this->searchFile($this->currentUser); + Assert::assertEquals(207, $this->response->getStatusCode()); + } + + /** + * @Then /^Favorite search should work$/ + */ + public function searchFavorite(): void { + $this->searchFile( + $this->currentUser, + '<oc:favorite/>', + null, + '<d:eq> + <d:prop> + <oc:favorite/> + </d:prop> + <d:literal>yes</d:literal> + </d:eq>' + ); + Assert::assertEquals(207, $this->response->getStatusCode()); } /** @@ -113,39 +234,87 @@ trait WebDav { * @param string $range * @param string $content */ - public function downloadedContentWhenDownloadindShouldBe($fileSource, $range, $content){ + public function downloadedContentWhenDownloadindShouldBe($fileSource, $range, $content) { $this->downloadFileWithRange($fileSource, $range); $this->downloadedContentShouldBe($content); } /** + * @When Downloading folder :folderName + */ + public function downloadingFolder(string $folderName) { + try { + $this->response = $this->makeDavRequest($this->currentUser, 'GET', $folderName, ['Accept' => 'application/zip']); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @When Downloading public folder :folderName + */ + public function downloadPublicFolder(string $folderName) { + $token = $this->lastShareData->data->token; + $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token/$folderName"; + + $client = new GClient(); + $options = []; + $options['headers'] = [ + 'Accept' => 'application/zip' + ]; + + try { + $this->response = $client->request('GET', $fullUrl, $options); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** * @When Downloading file :fileName * @param string $fileName */ public function downloadingFile($fileName) { - $this->response = $this->makeDavRequest($this->currentUser, 'GET', $fileName, []); + try { + $this->response = $this->makeDavRequest($this->currentUser, 'GET', $fileName, []); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } } /** - * @Then The following headers should be set - * @param \Behat\Gherkin\Node\TableNode $table - * @throws \Exception + * @When Downloading public file :filename */ - public function theFollowingHeadersShouldBeSet(\Behat\Gherkin\Node\TableNode $table) { - foreach($table->getTable() as $header) { - $headerName = $header[0]; - $expectedHeaderValue = $header[1]; - $returnedHeader = $this->response->getHeader($headerName); - if($returnedHeader !== $expectedHeaderValue) { - throw new \Exception( - sprintf( - "Expected value '%s' for header '%s', got '%s'", - $expectedHeaderValue, - $headerName, - $returnedHeader - ) - ); - } + public function downloadingPublicFile(string $filename) { + $token = $this->lastShareData->data->token; + $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token/$filename"; + + $client = new GClient(); + $options = [ + 'headers' => [ + 'X-Requested-With' => 'XMLHttpRequest', + ] + ]; + + try { + $this->response = $client->request('GET', $fullUrl, $options); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @When Downloading public file :filename without ajax header + */ + public function downloadingPublicFileWithoutHeader(string $filename) { + $token = $this->lastShareData->data->token; + $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token/$filename"; + + $client = new GClient(); + try { + $this->response = $client->request('GET', $fullUrl); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); } } @@ -155,7 +324,7 @@ trait WebDav { * @throws \Exception */ public function downloadedContentShouldStartWith($start) { - if(strpos($this->response->getBody()->getContents(), $start) !== 0) { + if (strpos($this->response->getBody()->getContents(), $start) !== 0) { throw new \Exception( sprintf( "Expected '%s', got '%s'", @@ -167,12 +336,13 @@ trait WebDav { } /** - * @Then /^as "([^"]*)" gets properties of folder "([^"]*)" with$/ + * @Then /^as "([^"]*)" gets properties of (file|folder|entry) "([^"]*)" with$/ * @param string $user + * @param string $elementType * @param string $path * @param \Behat\Gherkin\Node\TableNode|null $propertiesTable */ - public function asGetsPropertiesOfFolderWith($user, $path, $propertiesTable) { + public function asGetsPropertiesOfFolderWith($user, $elementType, $path, $propertiesTable) { $properties = null; if ($propertiesTable instanceof \Behat\Gherkin\Node\TableNode) { foreach ($propertiesTable->getRows() as $row) { @@ -183,19 +353,67 @@ trait WebDav { } /** + * @Then /^as "([^"]*)" the (file|folder|entry) "([^"]*)" does not exist$/ + * @param string $user + * @param string $entry + * @param string $path + * @param \Behat\Gherkin\Node\TableNode|null $propertiesTable + */ + public function asTheFileOrFolderDoesNotExist($user, $entry, $path) { + $client = $this->getSabreClient($user); + $response = $client->request('HEAD', $this->makeSabrePath($user, $path)); + if ($response['statusCode'] !== 404) { + throw new \Exception($entry . ' "' . $path . '" expected to not exist (status code ' . $response['statusCode'] . ', expected 404)'); + } + + return $response; + } + + /** + * @Then /^as "([^"]*)" the (file|folder|entry) "([^"]*)" exists$/ + * @param string $user + * @param string $entry + * @param string $path + */ + public function asTheFileOrFolderExists($user, $entry, $path) { + $this->response = $this->listFolder($user, $path, 0); + } + + /** + * @Then the response should be empty + * @throws \Exception + */ + public function theResponseShouldBeEmpty(): void { + $response = ($this->response instanceof ResponseInterface) ? $this->convertResponseToDavEntries() : $this->response; + if ($response === []) { + return; + } + + throw new \Exception('response is not empty'); + } + + /** * @Then the single response should contain a property :key with value :value * @param string $key * @param string $expectedValue * @throws \Exception */ public function theSingleResponseShouldContainAPropertyWithValue($key, $expectedValue) { - $keys = $this->response; - if (!array_key_exists($key, $keys)) { + $response = ($this->response instanceof ResponseInterface) ? $this->convertResponseToDavSingleEntry() : $this->response; + if (!array_key_exists($key, $response)) { throw new \Exception("Cannot find property \"$key\" with \"$expectedValue\""); } - $value = $keys[$key]; - if ($value !== $expectedValue) { + $value = $response[$key]; + if ($value instanceof ResourceType) { + $value = $value->getValue(); + if (empty($value)) { + $value = ''; + } else { + $value = $value[0]; + } + } + if ($value != $expectedValue) { throw new \Exception("Property \"$key\" found with value \"$value\", expected \"$expectedValue\""); } } @@ -203,11 +421,10 @@ trait WebDav { /** * @Then the response should contain a share-types property with */ - public function theResponseShouldContainAShareTypesPropertyWith($table) - { + public function theResponseShouldContainAShareTypesPropertyWith($table) { $keys = $this->response; if (!array_key_exists('{http://owncloud.org/ns}share-types', $keys)) { - throw new \Exception("Cannot find property \"{http://owncloud.org/ns}share-types\""); + throw new \Exception('Cannot find property "{http://owncloud.org/ns}share-types"'); } $foundTypes = []; @@ -250,33 +467,190 @@ trait WebDav { } } - /*Returns the elements of a propfind, $folderDepth requires 1 to see elements without children*/ - public function listFolder($user, $path, $folderDepth, $properties = null){ + public function listFolder($user, $path, $folderDepth, $properties = null) { + $client = $this->getSabreClient($user); + if (!$properties) { + $properties = [ + '{DAV:}getetag' + ]; + } + + $response = $client->propfind($this->makeSabrePath($user, $path), $properties, $folderDepth); + + return $response; + } + + /** + * Returns the elements of a profind command + * @param string $properties properties which needs to be included in the report + * @param string $filterRules filter-rules to choose what needs to appear in the report + */ + public function propfindFile(string $user, string $path, string $properties = '') { + $client = $this->getSabreClient($user); + + $body = '<?xml version="1.0" encoding="utf-8" ?> + <d:propfind xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:prop> + ' . $properties . ' + </d:prop> + </d:propfind>'; + + $response = $client->request('PROPFIND', $this->makeSabrePath($user, $path), $body); + $parsedResponse = $client->parseMultistatus($response['body']); + return $parsedResponse; + } + + /** + * Returns the elements of a searc command + * @param string $properties properties which needs to be included in the report + * @param string $filterRules filter-rules to choose what needs to appear in the report + */ + public function searchFile(string $user, ?string $properties = null, ?string $scope = null, ?string $condition = null) { + $client = $this->getSabreClient($user); + + if ($properties === null) { + $properties = '<oc:fileid /> <d:getlastmodified /> <d:getetag /> <d:getcontenttype /> <d:getcontentlength /> <nc:has-preview /> <oc:favorite /> <d:resourcetype />'; + } + + if ($condition === null) { + $condition = '<d:and> + <d:or> + <d:eq> + <d:prop> + <d:getcontenttype/> + </d:prop> + <d:literal>image/png</d:literal> + </d:eq> + + <d:eq> + <d:prop> + <d:getcontenttype/> + </d:prop> + <d:literal>image/jpeg</d:literal> + </d:eq> + + <d:eq> + <d:prop> + <d:getcontenttype/> + </d:prop> + <d:literal>image/heic</d:literal> + </d:eq> + + <d:eq> + <d:prop> + <d:getcontenttype/> + </d:prop> + <d:literal>video/mp4</d:literal> + </d:eq> + + <d:eq> + <d:prop> + <d:getcontenttype/> + </d:prop> + <d:literal>video/quicktime</d:literal> + </d:eq> + </d:or> + <d:eq> + <d:prop> + <oc:owner-id/> + </d:prop> + <d:literal>' . $user . '</d:literal> + </d:eq> +</d:and>'; + } + + if ($scope === null) { + $scope = '<d:href>/files/' . $user . '</d:href><d:depth>infinity</d:depth>'; + } + + $body = '<?xml version="1.0" encoding="UTF-8"?> +<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns" xmlns:ns="https://github.com/icewind1991/SearchDAV/ns" xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:basicsearch> + <d:select> + <d:prop>' . $properties . '</d:prop> + </d:select> + <d:from><d:scope>' . $scope . '</d:scope></d:from> + <d:where>' . $condition . '</d:where> + <d:orderby> + <d:order> + <d:prop><d:getlastmodified/></d:prop> + <d:descending/> + </d:order> + </d:orderby> + <d:limit> + <d:nresults>35</d:nresults> + <ns:firstresult>0</ns:firstresult> + </d:limit> + </d:basicsearch> +</d:searchrequest>'; + + try { + $this->response = $this->makeDavRequest($user, 'SEARCH', '', [ + 'Content-Type' => 'text/xml' + ], $body, ''); + + var_dump((string)$this->response->getBody()); + } catch (\GuzzleHttp\Exception\ServerException $e) { + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception + $this->response = $e->getResponse(); + } + } + + /* Returns the elements of a report command + * @param string $user + * @param string $path + * @param string $properties properties which needs to be included in the report + * @param string $filterRules filter-rules to choose what needs to appear in the report + */ + public function reportFolder($user, $path, $properties, $filterRules) { + $client = $this->getSabreClient($user); + + $body = '<?xml version="1.0" encoding="utf-8" ?> + <oc:filter-files xmlns:a="DAV:" xmlns:oc="http://owncloud.org/ns" > + <a:prop> + ' . $properties . ' + </a:prop> + <oc:filter-rules> + ' . $filterRules . ' + </oc:filter-rules> + </oc:filter-files>'; + + $response = $client->request('REPORT', $this->makeSabrePath($user, $path), $body); + $parsedResponse = $client->parseMultistatus($response['body']); + return $parsedResponse; + } + + public function makeSabrePath($user, $path, $type = 'files') { + if ($type === 'files') { + return $this->encodePath($this->getDavFilesPath($user) . $path); + } else { + return $this->encodePath($this->davPath . '/' . $type . '/' . $user . '/' . $path); + } + } + + public function getSabreClient($user) { $fullUrl = substr($this->baseUrl, 0, -4); - $settings = array( + $settings = [ 'baseUri' => $fullUrl, 'userName' => $user, - ); + ]; if ($user === 'admin') { $settings['password'] = $this->adminUser[1]; } else { $settings['password'] = $this->regularUser; } + $settings['authType'] = SClient::AUTH_BASIC; - $client = new SClient($settings); - - if (!$properties) { - $properties = [ - '{DAV:}getetag' - ]; - } - - $response = $client->propfind($this->davPath . '/' . ltrim($path, '/'), $properties, $folderDepth); - - return $response; + return new SClient($settings); } /** @@ -284,15 +658,15 @@ trait WebDav { * @param string $user * @param \Behat\Gherkin\Node\TableNode|null $expectedElements */ - public function checkElementList($user, $expectedElements){ + public function checkElementList($user, $expectedElements) { $elementList = $this->listFolder($user, '/', 3); if ($expectedElements instanceof \Behat\Gherkin\Node\TableNode) { $elementRows = $expectedElements->getRows(); $elementsSimplified = $this->simplifyArray($elementRows); - foreach($elementsSimplified as $expectedElement) { - $webdavPath = "/" . $this->davPath . $expectedElement; - if (!array_key_exists($webdavPath,$elementList)){ - PHPUnit_Framework_Assert::fail("$webdavPath" . " is not in propfind answer"); + foreach ($elementsSimplified as $expectedElement) { + $webdavPath = '/' . $this->getDavFilesPath($user) . $expectedElement; + if (!array_key_exists($webdavPath, $elementList)) { + Assert::fail("$webdavPath" . ' is not in propfind answer'); } } } @@ -304,41 +678,65 @@ trait WebDav { * @param string $source * @param string $destination */ - public function userUploadsAFileTo($user, $source, $destination) - { - $file = \GuzzleHttp\Stream\Stream::factory(fopen($source, 'r')); + public function userUploadsAFileTo($user, $source, $destination) { + $file = \GuzzleHttp\Psr7\Utils::streamFor(fopen($source, 'r')); try { - $this->response = $this->makeDavRequest($user, "PUT", $destination, [], $file); + $this->response = $this->makeDavRequest($user, 'PUT', $destination, [], $file); } catch (\GuzzleHttp\Exception\ServerException $e) { - // 4xx and 5xx responses cause an exception + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception $this->response = $e->getResponse(); } } /** + * @When User :user adds a file of :bytes bytes to :destination + * @param string $user + * @param string $bytes + * @param string $destination + */ + public function userAddsAFileTo($user, $bytes, $destination) { + $filename = 'filespecificSize.txt'; + $this->createFileSpecificSize($filename, $bytes); + Assert::assertEquals(1, file_exists("work/$filename")); + $this->userUploadsAFileTo($user, "work/$filename", $destination); + $this->removeFile('work/', $filename); + $expectedElements = new \Behat\Gherkin\Node\TableNode([["$destination"]]); + $this->checkElementList($user, $expectedElements); + } + + /** * @When User :user uploads file with content :content to :destination */ - public function userUploadsAFileWithContentTo($user, $content, $destination) - { - $file = \GuzzleHttp\Stream\Stream::factory($content); + public function userUploadsAFileWithContentTo($user, $content, $destination) { + $file = \GuzzleHttp\Psr7\Utils::streamFor($content); try { - $this->response = $this->makeDavRequest($user, "PUT", $destination, [], $file); + $this->response = $this->makeDavRequest($user, 'PUT', $destination, [], $file); } catch (\GuzzleHttp\Exception\ServerException $e) { - // 4xx and 5xx responses cause an exception + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception $this->response = $e->getResponse(); } } /** - * @When User :user deletes file :file + * @When /^User "([^"]*)" deletes (file|folder) "([^"]*)"$/ * @param string $user + * @param string $type * @param string $file */ - public function userDeletesFile($user, $file) { + public function userDeletesFile($user, $type, $file) { try { $this->response = $this->makeDavRequest($user, 'DELETE', $file, []); } catch (\GuzzleHttp\Exception\ServerException $e) { - // 4xx and 5xx responses cause an exception + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception $this->response = $e->getResponse(); } } @@ -348,30 +746,460 @@ trait WebDav { * @param string $user * @param string $destination */ - public function userCreatedAFolder($user, $destination){ + public function userCreatedAFolder($user, $destination) { try { - $this->response = $this->makeDavRequest($user, "MKCOL", $destination, []); + $destination = '/' . ltrim($destination, '/'); + $this->response = $this->makeDavRequest($user, 'MKCOL', $destination, []); } catch (\GuzzleHttp\Exception\ServerException $e) { - // 4xx and 5xx responses cause an exception + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception $this->response = $e->getResponse(); } } /** - * @Given user :user uploads chunk file :num of :total with :data to :destination + * @Given user :user uploads bulked files :name1 with :content1 and :name2 with :content2 and :name3 with :content3 * @param string $user - * @param int $num - * @param int $total - * @param string $data - * @param string $destination + * @param string $name1 + * @param string $content1 + * @param string $name2 + * @param string $content2 + * @param string $name3 + * @param string $content3 */ - public function userUploadsChunkFileOfWithToWithChecksum($user, $num, $total, $data, $destination) - { - $num -= 1; - $data = \GuzzleHttp\Stream\Stream::factory($data); - $file = $destination . '-chunking-42-'.$total.'-'.$num; - $this->makeDavRequest($user, 'PUT', $file, ['OC-Chunked' => '1'], $data); + public function userUploadsBulkedFiles($user, $name1, $content1, $name2, $content2, $name3, $content3) { + $boundary = 'boundary_azertyuiop'; + + $body = ''; + $body .= '--' . $boundary . "\r\n"; + $body .= 'X-File-Path: ' . $name1 . "\r\n"; + $body .= "X-File-MD5: f6a6263167c92de8644ac998b3c4e4d1\r\n"; + $body .= "X-OC-Mtime: 1111111111\r\n"; + $body .= 'Content-Length: ' . strlen($content1) . "\r\n"; + $body .= "\r\n"; + $body .= $content1 . "\r\n"; + $body .= '--' . $boundary . "\r\n"; + $body .= 'X-File-Path: ' . $name2 . "\r\n"; + $body .= "X-File-MD5: 87c7d4068be07d390a1fffd21bf1e944\r\n"; + $body .= "X-OC-Mtime: 2222222222\r\n"; + $body .= 'Content-Length: ' . strlen($content2) . "\r\n"; + $body .= "\r\n"; + $body .= $content2 . "\r\n"; + $body .= '--' . $boundary . "\r\n"; + $body .= 'X-File-Path: ' . $name3 . "\r\n"; + $body .= "X-File-MD5: e86a1cf0678099986a901c79086f5617\r\n"; + $body .= "X-File-Mtime: 3333333333\r\n"; + $body .= 'Content-Length: ' . strlen($content3) . "\r\n"; + $body .= "\r\n"; + $body .= $content3 . "\r\n"; + $body .= '--' . $boundary . "--\r\n"; + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $body); + rewind($stream); + + $client = new GClient(); + $options = [ + 'auth' => [$user, $this->regularUser], + 'headers' => [ + 'Content-Type' => 'multipart/related; boundary=' . $boundary, + 'Content-Length' => (string)strlen($body), + ], + 'body' => $body + ]; + + return $client->request('POST', substr($this->baseUrl, 0, -4) . 'remote.php/dav/bulk', $options); } -} + /** + * @Given user :user creates a new chunking upload with id :id + */ + public function userCreatesANewChunkingUploadWithId($user, $id) { + $this->parts = []; + $destination = '/uploads/' . $user . '/' . $id; + $this->makeDavRequest($user, 'MKCOL', $destination, [], null, 'uploads'); + } + + /** + * @Given user :user uploads new chunk file :num with :data to id :id + */ + public function userUploadsNewChunkFileOfWithToId($user, $num, $data, $id) { + $data = \GuzzleHttp\Psr7\Utils::streamFor($data); + $destination = '/uploads/' . $user . '/' . $id . '/' . $num; + $this->makeDavRequest($user, 'PUT', $destination, [], $data, 'uploads'); + } + + /** + * @Given user :user moves new chunk file with id :id to :dest + */ + public function userMovesNewChunkFileWithIdToMychunkedfile($user, $id, $dest) { + $source = '/uploads/' . $user . '/' . $id . '/.file'; + $destination = substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user) . $dest; + $this->makeDavRequest($user, 'MOVE', $source, [ + 'Destination' => $destination + ], null, 'uploads'); + } + + /** + * @Then user :user moves new chunk file with id :id to :dest with size :size + */ + public function userMovesNewChunkFileWithIdToMychunkedfileWithSize($user, $id, $dest, $size) { + $source = '/uploads/' . $user . '/' . $id . '/.file'; + $destination = substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user) . $dest; + + try { + $this->response = $this->makeDavRequest($user, 'MOVE', $source, [ + 'Destination' => $destination, + 'OC-Total-Length' => $size + ], null, 'uploads'); + } catch (\GuzzleHttp\Exception\BadResponseException $ex) { + $this->response = $ex->getResponse(); + } + } + + + /** + * @Given user :user creates a new chunking v2 upload with id :id and destination :targetDestination + */ + public function userCreatesANewChunkingv2UploadWithIdAndDestination($user, $id, $targetDestination) { + $this->s3MultipartDestination = $this->getTargetDestination($user, $targetDestination); + $this->newUploadId(); + $destination = '/uploads/' . $user . '/' . $this->getUploadId($id); + $this->response = $this->makeDavRequest($user, 'MKCOL', $destination, [ + 'Destination' => $this->s3MultipartDestination, + ], null, 'uploads'); + } + + /** + * @Given user :user uploads new chunk v2 file :num to id :id + */ + public function userUploadsNewChunkv2FileToIdAndDestination($user, $num, $id) { + $data = \GuzzleHttp\Psr7\Utils::streamFor(fopen('/tmp/part-upload-' . $num, 'r')); + $destination = '/uploads/' . $user . '/' . $this->getUploadId($id) . '/' . $num; + $this->response = $this->makeDavRequest($user, 'PUT', $destination, [ + 'Destination' => $this->s3MultipartDestination + ], $data, 'uploads'); + } + + /** + * @Given user :user moves new chunk v2 file with id :id + */ + public function userMovesNewChunkv2FileWithIdToMychunkedfileAndDestination($user, $id) { + $source = '/uploads/' . $user . '/' . $this->getUploadId($id) . '/.file'; + try { + $this->response = $this->makeDavRequest($user, 'MOVE', $source, [ + 'Destination' => $this->s3MultipartDestination, + ], null, 'uploads'); + } catch (\GuzzleHttp\Exception\ServerException $e) { + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception + $this->response = $e->getResponse(); + } + } + + private function getTargetDestination(string $user, string $destination): string { + return substr($this->baseUrl, 0, -4) . $this->getDavFilesPath($user) . $destination; + } + + private function getUploadId(string $id): string { + return $id . '-' . $this->uploadId; + } + private function newUploadId() { + $this->uploadId = (string)time(); + } + + /** + * @Given /^Downloading file "([^"]*)" as "([^"]*)"$/ + */ + public function downloadingFileAs($fileName, $user) { + try { + $this->response = $this->makeDavRequest($user, 'GET', $fileName, []); + } catch (\GuzzleHttp\Exception\ServerException $e) { + // 5xx responses cause a server exception + $this->response = $e->getResponse(); + } catch (\GuzzleHttp\Exception\ClientException $e) { + // 4xx responses cause a client exception + $this->response = $e->getResponse(); + } + } + + /** + * URL encodes the given path but keeps the slashes + * + * @param string $path to encode + * @return string encoded path + */ + private function encodePath($path) { + // slashes need to stay + return str_replace('%2F', '/', rawurlencode($path)); + } + + /** + * @When user :user favorites element :path + */ + public function userFavoritesElement($user, $path) { + $this->response = $this->changeFavStateOfAnElement($user, $path, 1, 0, null); + } + + /** + * @When user :user unfavorites element :path + */ + public function userUnfavoritesElement($user, $path) { + $this->response = $this->changeFavStateOfAnElement($user, $path, 0, 0, null); + } + + /*Set the elements of a proppatch, $folderDepth requires 1 to see elements without children*/ + public function changeFavStateOfAnElement($user, $path, $favOrUnfav, $folderDepth, $properties = null) { + $fullUrl = substr($this->baseUrl, 0, -4); + $settings = [ + 'baseUri' => $fullUrl, + 'userName' => $user, + ]; + if ($user === 'admin') { + $settings['password'] = $this->adminUser[1]; + } else { + $settings['password'] = $this->regularUser; + } + $settings['authType'] = SClient::AUTH_BASIC; + + $client = new SClient($settings); + if (!$properties) { + $properties = [ + '{http://owncloud.org/ns}favorite' => $favOrUnfav + ]; + } + + $response = $client->proppatch($this->getDavFilesPath($user) . $path, $properties, $folderDepth); + return $response; + } + + /** + * @Given user :user stores etag of element :path + */ + public function userStoresEtagOfElement($user, $path) { + $propertiesTable = new \Behat\Gherkin\Node\TableNode([['{DAV:}getetag']]); + $this->asGetsPropertiesOfFolderWith($user, 'entry', $path, $propertiesTable); + $pathETAG[$path] = $this->response['{DAV:}getetag']; + $this->storedETAG[$user] = $pathETAG; + } + + /** + * @Then etag of element :path of user :user has not changed + */ + public function checkIfETAGHasNotChanged($path, $user) { + $propertiesTable = new \Behat\Gherkin\Node\TableNode([['{DAV:}getetag']]); + $this->asGetsPropertiesOfFolderWith($user, 'entry', $path, $propertiesTable); + Assert::assertEquals($this->response['{DAV:}getetag'], $this->storedETAG[$user][$path]); + } + + /** + * @Then etag of element :path of user :user has changed + */ + public function checkIfETAGHasChanged($path, $user) { + $propertiesTable = new \Behat\Gherkin\Node\TableNode([['{DAV:}getetag']]); + $this->asGetsPropertiesOfFolderWith($user, 'entry', $path, $propertiesTable); + Assert::assertNotEquals($this->response['{DAV:}getetag'], $this->storedETAG[$user][$path]); + } + + /** + * @When Connecting to dav endpoint + */ + public function connectingToDavEndpoint() { + try { + $this->response = $this->makeDavRequest(null, 'PROPFIND', '', []); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @When Requesting share note on dav endpoint + */ + public function requestingShareNote() { + $propfind = '<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"><d:prop><nc:note /></d:prop></d:propfind>'; + if (count($this->lastShareData->data->element) > 0) { + $token = $this->lastShareData->data[0]->token; + } else { + $token = $this->lastShareData->data->token; + } + try { + $this->response = $this->makeDavRequest('', 'PROPFIND', $token, [], $propfind); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->response = $e->getResponse(); + } + } + + /** + * @Then there are no duplicate headers + */ + public function thereAreNoDuplicateHeaders() { + $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 /^user "([^"]*)" in folder "([^"]*)" should have favorited the following elements$/ + * @param string $user + * @param string $folder + * @param \Behat\Gherkin\Node\TableNode|null $expectedElements + */ + public function checkFavoritedElements($user, $folder, $expectedElements) { + $elementList = $this->reportFolder($user, + $folder, + '<oc:favorite/>', + '<oc:favorite>1</oc:favorite>'); + if ($expectedElements instanceof \Behat\Gherkin\Node\TableNode) { + $elementRows = $expectedElements->getRows(); + $elementsSimplified = $this->simplifyArray($elementRows); + foreach ($elementsSimplified as $expectedElement) { + $webdavPath = '/' . $this->getDavFilesPath($user) . $expectedElement; + if (!array_key_exists($webdavPath, $elementList)) { + Assert::fail("$webdavPath" . ' is not in report answer'); + } + } + } + } + + /** + * @When /^User "([^"]*)" deletes everything from folder "([^"]*)"$/ + * @param string $user + * @param string $folder + */ + public function userDeletesEverythingInFolder($user, $folder) { + $elementList = $this->listFolder($user, $folder, 1); + $elementListKeys = array_keys($elementList); + array_shift($elementListKeys); + $davPrefix = '/' . $this->getDavFilesPath($user); + foreach ($elementListKeys as $element) { + if (substr($element, 0, strlen($davPrefix)) == $davPrefix) { + $element = substr($element, strlen($davPrefix)); + } + $this->userDeletesFile($user, 'element', $element); + } + } + + + /** + * @param string $user + * @param string $path + * @return int + */ + private function getFileIdForPath($user, $path) { + $propertiesTable = new \Behat\Gherkin\Node\TableNode([['{http://owncloud.org/ns}fileid']]); + $this->asGetsPropertiesOfFolderWith($user, 'file', $path, $propertiesTable); + return (int)$this->response['{http://owncloud.org/ns}fileid']; + } + + /** + * @Given /^User "([^"]*)" stores id of file "([^"]*)"$/ + * @param string $user + * @param string $path + */ + public function userStoresFileIdForPath($user, $path) { + $this->storedFileID = $this->getFileIdForPath($user, $path); + } + + /** + * @Given /^User "([^"]*)" checks id of file "([^"]*)"$/ + * @param string $user + * @param string $path + */ + public function userChecksFileIdForPath($user, $path) { + $currentFileID = $this->getFileIdForPath($user, $path); + Assert::assertEquals($currentFileID, $this->storedFileID); + } + + /** + * @Given /^user "([^"]*)" creates a file locally with "([^"]*)" x 5 MB chunks$/ + */ + public function userCreatesAFileLocallyWithChunks($arg1, $chunks) { + $this->parts = []; + for ($i = 1;$i <= (int)$chunks;$i++) { + $randomletter = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 1); + file_put_contents('/tmp/part-upload-' . $i, str_repeat($randomletter, 5 * 1024 * 1024)); + $this->parts[] = '/tmp/part-upload-' . $i; + } + } + + /** + * @Given user :user creates the chunk :id with a size of :size MB + */ + public function userCreatesAChunk($user, $id, $size) { + $randomletter = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 1); + file_put_contents('/tmp/part-upload-' . $id, str_repeat($randomletter, (int)$size * 1024 * 1024)); + $this->parts[] = '/tmp/part-upload-' . $id; + } + + /** + * @Then /^Downloaded content should be the created file$/ + */ + public function downloadedContentShouldBeTheCreatedFile() { + $content = ''; + sort($this->parts); + foreach ($this->parts as $part) { + $content .= file_get_contents($part); + } + Assert::assertEquals($content, (string)$this->response->getBody()); + } + + /** + * @Then /^the S3 multipart upload was successful with status "([^"]*)"$/ + */ + public function theSmultipartUploadWasSuccessful($status) { + Assert::assertEquals((int)$status, $this->response->getStatusCode()); + } + + /** + * @Then /^the upload should fail on object storage$/ + */ + public function theUploadShouldFailOnObjectStorage() { + $descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open('php occ config:system:get objectstore --no-ansi', $descriptor, $pipes, '../../'); + $lastCode = proc_close($process); + if ($lastCode === 0) { + $this->theHTTPStatusCodeShouldBe(500); + } + } + + /** + * @return array + * @throws Exception + */ + private function convertResponseToDavSingleEntry(): array { + $results = $this->convertResponseToDavEntries(); + if (count($results) > 1) { + throw new \Exception('result is empty or contain more than one (1) entry'); + } + + return array_shift($results); + } + + /** + * @return array + */ + private function convertResponseToDavEntries(): array { + $client = $this->getSabreClient($this->currentUser); + $parsedResponse = $client->parseMultiStatus((string)$this->response->getBody()); + + $results = []; + foreach ($parsedResponse as $href => $statusList) { + $results[$href] = $statusList[200] ?? []; + } + + return $results; + } +} diff --git a/build/integration/features/caldav.feature b/build/integration/features/caldav.feature deleted file mode 100644 index 948151485db..00000000000 --- a/build/integration/features/caldav.feature +++ /dev/null @@ -1,31 +0,0 @@ -Feature: caldav - Scenario: Accessing a not existing calendar of another user - Given user "user0" exists - When "admin" requests calendar "user0/MyCalendar" - Then The CalDAV HTTP status code should be "404" - And The exception is "Sabre\DAV\Exception\NotFound" - And The error message is "Node with name 'MyCalendar' could not be found" - - # Blocked by https://github.com/php/php-src/pull/1417 - #Scenario: Accessing a not shared calendar of another user - # Given user "user0" exists - # Given "admin" creates a calendar named "MyCalendar" - # Given The CalDAV HTTP status code should be "201" - # When "user0" requests calendar "admin/MyCalendar" - # Then The CalDAV HTTP status code should be "404" - # And The exception is "Sabre\DAV\Exception\NotFound" - # And The error message is "Node with name 'MyCalendar' could not be found" - - Scenario: Accessing a not existing calendar of myself - Given user "user0" exists - When "user0" requests calendar "admin/MyCalendar" - Then The CalDAV HTTP status code should be "404" - And The exception is "Sabre\DAV\Exception\NotFound" - And The error message is "Node with name 'MyCalendar' could not be found" - - # Blocked by https://github.com/php/php-src/pull/1417 - #Scenario: Creating a new calendar - # When "admin" creates a calendar named "MyCalendar" - # Then The CalDAV HTTP status code should be "201" - # And "admin" requests calendar "admin/MyCalendar" - # Then The CalDAV HTTP status code should be "200" diff --git a/build/integration/features/carddav.feature b/build/integration/features/carddav.feature deleted file mode 100644 index ee9d877085d..00000000000 --- a/build/integration/features/carddav.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: carddav - Scenario: Accessing a not existing addressbook of another user - Given user "user0" exists - When "admin" requests addressbook "user0/MyAddressbook" with statuscode "404" - And The CardDAV exception is "Sabre\DAV\Exception\NotFound" - And The CardDAV error message is "Addressbook with name 'MyAddressbook' could not be found" - - Scenario: Accessing a not shared addressbook of another user - Given user "user0" exists - Given "admin" creates an addressbook named "MyAddressbook" with statuscode "201" - When "user0" requests addressbook "admin/MyAddressbook" with statuscode "404" - And The CardDAV exception is "Sabre\DAV\Exception\NotFound" - And The CardDAV error message is "Addressbook with name 'MyAddressbook' could not be found" - - Scenario: Accessing a not existing addressbook of myself - Given user "user0" exists - When "user0" requests addressbook "admin/MyAddressbook" with statuscode "404" - And The CardDAV exception is "Sabre\DAV\Exception\NotFound" - And The CardDAV error message is "Addressbook with name 'MyAddressbook' could not be found" - - Scenario: Creating a new addressbook - When "admin" creates an addressbook named "MyAddressbook" with statuscode "201" - Then "admin" requests addressbook "admin/MyAddressbook" with statuscode "200" diff --git a/build/integration/features/checksums.feature b/build/integration/features/checksums.feature deleted file mode 100644 index d391e93afe8..00000000000 --- a/build/integration/features/checksums.feature +++ /dev/null @@ -1,76 +0,0 @@ -Feature: checksums - - Scenario: Uploading a file with checksum should work - Given user "user0" exists - When user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - Then The webdav response should have a status code "201" - - Scenario: Uploading a file with checksum should return the checksum in the propfind - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When user "user0" request the checksum of "/myChecksumFile.txt" via propfind - Then The webdav checksum should match "MD5:d70b40f177b14b470d1756a3c12b963a" - - Scenario: Uploading a file with checksum should return the checksum in the download header - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When user "user0" downloads the file "/myChecksumFile.txt" - Then The header checksum should match "MD5:d70b40f177b14b470d1756a3c12b963a" - - Scenario: Moving a file with checksum should return the checksum in the propfind - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When User "user0" moved file "/myChecksumFile.txt" to "/myMovedChecksumFile.txt" - And user "user0" request the checksum of "/myMovedChecksumFile.txt" via propfind - Then The webdav checksum should match "MD5:d70b40f177b14b470d1756a3c12b963a" - - Scenario: Moving file with checksum should return the checksum in the download header - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When User "user0" moved file "/myChecksumFile.txt" to "/myMovedChecksumFile.txt" - And user "user0" downloads the file "/myMovedChecksumFile.txt" - Then The header checksum should match "MD5:d70b40f177b14b470d1756a3c12b963a" - - Scenario: Copying a file with checksum should return the checksum in the propfind - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When User "user0" copied file "/myChecksumFile.txt" to "/myChecksumFileCopy.txt" - And user "user0" request the checksum of "/myChecksumFileCopy.txt" via propfind - Then The webdav checksum should match "MD5:d70b40f177b14b470d1756a3c12b963a" - - Scenario: Copying file with checksum should return the checksum in the download header - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When User "user0" copied file "/myChecksumFile.txt" to "/myChecksumFileCopy.txt" - And user "user0" downloads the file "/myChecksumFileCopy.txt" - Then The header checksum should match "MD5:d70b40f177b14b470d1756a3c12b963a" - - Scenario: Overwriting a file with checksum should remove the checksum and not return it in the propfind - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" - And user "user0" request the checksum of "/myChecksumFile.txt" via propfind - Then The webdav checksum should be empty - - Scenario: Overwriting a file with checksum should remove the checksum and not return it in the download header - Given user "user0" exists - And user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" with checksum "MD5:d70b40f177b14b470d1756a3c12b963a" - When user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt" - And user "user0" downloads the file "/myChecksumFile.txt" - Then The OC-Checksum header should not be there - - Scenario: Uploading a chunked file with checksum should return the checksum in the propfind - Given user "user0" exists - And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88" - And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88" - And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88" - When user "user0" request the checksum of "/myChecksumFile.txt" via propfind - Then The webdav checksum should match "MD5:e892fdd61a74bc89cd05673cc2e22f88" - - Scenario: Uploading a chunked file with checksum should return the checksum in the download header - Given user "user0" exists - And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88" - And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88" - And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88" - When user "user0" downloads the file "/myChecksumFile.txt" - Then The header checksum should match "MD5:e892fdd61a74bc89cd05673cc2e22f88" diff --git a/build/integration/features/comments.feature b/build/integration/features/comments.feature deleted file mode 100644 index 135bb016527..00000000000 --- a/build/integration/features/comments.feature +++ /dev/null @@ -1,209 +0,0 @@ -Feature: comments - Scenario: Creating a comment on a file belonging to myself - Given user "user0" exists - Given As an "user0" - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - When "user0" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - Then As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user0" - And the response should contain only "1" comments - - Scenario: Creating a comment on a shared file belonging to another user - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToComment.txt | - | shareWith | user1 | - | shareType | 0 | - When "user1" posts a comment with content "A comment from another user" on the file named "/myFileToComment.txt" it should return "201" - Then As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "A comment from another user" - And the response should contain a property "oc:actorDisplayName" with value "user1" - And the response should contain only "1" comments - - Scenario: Creating a comment on a non-shared file belonging to another user - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Then "user1" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "404" - - Scenario: Reading comments on a non-shared file belonging to another user - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Then As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "404" - - Scenario: Deleting my own comments on a file belonging to myself - Given user "user0" exists - Given As an "user0" - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given "user0" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - When As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - Then the response should contain a property "oc:parentId" with value "0" - Then the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user0" - And the response should contain only "1" comments - And As "user0" delete the created comment it should return "204" - And As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain only "0" comments - - Scenario: Deleting my own comments on a file shared by somebody else - Given user "user0" exists - Given user "user1" exists - Given As an "user0" - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToComment.txt | - | shareWith | user1 | - | shareType | 0 | - Given "user1" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - When As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "207" - Then the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - And the response should contain only "1" comments - And As "user1" delete the created comment it should return "204" - And As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain only "0" comments - - Scenario: Deleting my own comments on a file unshared by someone else - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToComment.txt | - | shareWith | user1 | - | shareType | 0 | - Given "user1" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - When As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "207" - Then the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - And the response should contain only "1" comments - And As "user0" remove all shares from the file named "/myFileToComment.txt" - And As "user1" delete the created comment it should return "404" - And As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "404" - - Scenario: Edit my own comments on a file belonging to myself - Given user "user0" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given "user0" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - When As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - Then the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user0" - And the response should contain only "1" comments - When As "user0" edit the last created comment and set text to "My edited comment" it should return "207" - Then As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My edited comment" - And the response should contain a property "oc:actorDisplayName" with value "user0" - - Scenario: Edit my own comments on a file shared by someone with me - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToComment.txt | - | shareWith | user1 | - | shareType | 0 | - Given "user1" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - When As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - Then the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - And the response should contain only "1" comments - Given As "user1" edit the last created comment and set text to "My edited comment" it should return "207" - Then As "user1" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My edited comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - - Scenario: Edit my own comments on a file unshared by someone with me - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToComment.txt | - | shareWith | user1 | - | shareType | 0 | - When "user1" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - Then As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - And the response should contain only "1" comments - And As "user0" remove all shares from the file named "/myFileToComment.txt" - When As "user1" edit the last created comment and set text to "My edited comment" it should return "404" - Then As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - And the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - - Scenario: Edit comments of other users should not be possible - Given user "user0" exists - Given user "user1" exists - Given User "user0" uploads file "data/textfile.txt" to "/myFileToComment.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToComment.txt | - | shareWith | user1 | - | shareType | 0 | - Given "user1" posts a comment with content "My first comment" on the file named "/myFileToComment.txt" it should return "201" - When As "user0" load all the comments of the file named "/myFileToComment.txt" it should return "207" - Then the response should contain a property "oc:parentId" with value "0" - And the response should contain a property "oc:childrenCount" with value "0" - And the response should contain a property "oc:verb" with value "comment" - And the response should contain a property "oc:actorType" with value "users" - And the response should contain a property "oc:objectType" with value "files" - And the response should contain a property "oc:message" with value "My first comment" - And the response should contain a property "oc:actorDisplayName" with value "user1" - And the response should contain only "1" comments - Then As "user0" edit the last created comment and set text to "My edited comment" it should return "403"
\ No newline at end of file diff --git a/build/integration/features/contacts-menu.feature b/build/integration/features/contacts-menu.feature new file mode 100644 index 00000000000..772c0e5405c --- /dev/null +++ b/build/integration/features/contacts-menu.feature @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +Feature: contacts-menu + + Scenario: users can be searched by display name + Given user "user0" exists + And user "user1" exists + And As an "admin" + And sending "PUT" to "/cloud/users/user1" with + | key | displayname | + | value | Test name | + When Logging in using web as "user0" + And searching for contacts matching with "test" + Then the list of searched contacts has "1" contacts + And searched contact "0" is named "Test name" + + Scenario: users can be searched by email + Given user "user0" exists + And user "user1" exists + And As an "admin" + And sending "PUT" to "/cloud/users/user1" with + | key | email | + | value | test@example.com | + When Logging in using web as "user0" + And searching for contacts matching with "test" + Then the list of searched contacts has "1" contacts + And searched contact "0" is named "user1" + + Scenario: users can not be searched by id + Given user "user0" exists + And user "user1" exists + And As an "admin" + And sending "PUT" to "/cloud/users/user1" with + | key | displayname | + | value | Test name | + When Logging in using web as "user0" + And searching for contacts matching with "user" + Then the list of searched contacts has "0" contacts + + Scenario: search several users + Given user "user0" exists + And user "user1" exists + And user "user2" exists + And user "user3" exists + And user "user4" exists + And user "user5" exists + And As an "admin" + And sending "PUT" to "/cloud/users/user1" with + | key | displayname | + | value | Test name | + And sending "PUT" to "/cloud/users/user2" with + | key | email | + | value | test@example.com | + And sending "PUT" to "/cloud/users/user3" with + | key | displayname | + | value | Unmatched name | + And sending "PUT" to "/cloud/users/user4" with + | key | email | + | value | unmatched@example.com | + And sending "PUT" to "/cloud/users/user5" with + | key | displayname | + | value | Another test name | + And sending "PUT" to "/cloud/users/user5" with + | key | email | + | value | another_test@example.com | + When Logging in using web as "user0" + And searching for contacts matching with "test" + Then the list of searched contacts has "3" contacts + # Results are sorted alphabetically + And searched contact "0" is named "Another test name" + And searched contact "1" is named "Test name" + And searched contact "2" is named "user2" + + Scenario: users can not be found by display name if visibility is private + Given user "user0" exists + And user "user1" exists + And user "user2" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | displayname | Test name | + | displaynameScope | v2-private | + And Logging in using web as "user2" + And Sending a "PUT" to "/settings/users/user2/settings" with requesttoken + | displayname | Another test name | + | displaynameScope | v2-federated | + When Logging in using web as "user0" + And searching for contacts matching with "test" + # Disabled because it regularly fails on drone: + # Then the list of searched contacts has "1" contacts + # And searched contact "0" is named "Another test name" + + Scenario: users can not be found by email if visibility is private + Given user "user0" exists + And user "user1" exists + And user "user2" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | email | test@example.com | + | emailScope | v2-private | + And Logging in using web as "user2" + And Sending a "PUT" to "/settings/users/user2/settings" with requesttoken + | email | another_test@example.com | + | emailScope | v2-federated | + # Disabled because it regularly fails on drone: + # When Logging in using web as "user0" + # And searching for contacts matching with "test" + # Then the list of searched contacts has "1" contacts + # And searched contact "0" is named "user2" + + Scenario: users can be found by other properties if the visibility of one is private + Given user "user0" exists + And user "user1" exists + And user "user2" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | displayname | Test name | + | displaynameScope | v2-federated | + | email | test@example.com | + | emailScope | v2-private | + And Logging in using web as "user2" + And Sending a "PUT" to "/settings/users/user2/settings" with requesttoken + | displayname | Another test name | + | displaynameScope | v2-private | + | email | another_test@example.com | + | emailScope | v2-federated | + When Logging in using web as "user0" + And searching for contacts matching with "test" + Then the list of searched contacts has "2" contacts + # Disabled because it regularly fails on drone: + # And searched contact "0" is named "" + And searched contact "1" is named "Test name" + + + + Scenario: users can be searched by display name if visibility is increased again + Given user "user0" exists + And user "user1" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | displayname | Test name | + | displaynameScope | v2-private | + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | displaynameScope | v2-federated | + When Logging in using web as "user0" + And searching for contacts matching with "test" + Then the list of searched contacts has "1" contacts + And searched contact "0" is named "Test name" + + Scenario: users can be searched by email if visibility is increased again + Given user "user0" exists + And user "user1" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | email | test@example.com | + | emailScope | v2-private | + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | emailScope | v2-federated | + # Disabled because it regularly fails on drone: + # When Logging in using web as "user0" + # And searching for contacts matching with "test" + # Then the list of searched contacts has "1" contacts + # And searched contact "0" is named "user1" + + + + Scenario: users can not be searched by display name if visibility is private even if updated with provisioning + Given user "user0" exists + And user "user1" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | displaynameScope | v2-private | + And As an "admin" + And sending "PUT" to "/cloud/users/user1" with + | key | displayname | + | value | Test name | + When Logging in using web as "user0" + And searching for contacts matching with "test" + # Disabled because it regularly fails on drone: + # Then the list of searched contacts has "0" contacts + + Scenario: users can not be searched by email if visibility is private even if updated with provisioning + Given user "user0" exists + And user "user1" exists + And Logging in using web as "user1" + And Sending a "PUT" to "/settings/users/user1/settings" with requesttoken + | emailScope | v2-private | + And As an "admin" + And sending "PUT" to "/cloud/users/user1" with + | key | email | + | value | test@example.com | + When Logging in using web as "user0" + And searching for contacts matching with "test" + # Disabled because it regularly fails on drone: + # Then the list of searched contacts has "0" contacts diff --git a/build/integration/features/log-condition.feature b/build/integration/features/log-condition.feature new file mode 100644 index 00000000000..4059db1ebf3 --- /dev/null +++ b/build/integration/features/log-condition.feature @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +Feature: log-condition + + Background: + Given invoking occ with "config:system:set log.condition matches 0 users 0 --value admin" + Then the command was successful + + Scenario: Accessing /status.php with log.condition + When requesting "/status.php" with "GET" + Then the HTTP status code should be "200" + + Scenario: Accessing /index.php with log.condition + When requesting "/index.php" with "GET" + Then the HTTP status code should be "200" + + Scenario: Accessing /remote.php/webdav with log.condition + When requesting "/remote.php/webdav" with "GET" + Then the HTTP status code should be "401" + + Scenario: Accessing /remote.php/dav with log.condition + When requesting "/remote.php/dav" with "GET" + Then the HTTP status code should be "401" + + Scenario: Accessing /ocs/v1.php with log.condition + When requesting "/ocs/v1.php" with "GET" + Then the HTTP status code should be "200" + + Scenario: Accessing /ocs/v2.php with log.condition + When requesting "/ocs/v2.php" with "GET" + Then the HTTP status code should be "404" + + Scenario: Accessing /public.php/webdav with log.condition + When requesting "/public.php/webdav" with "GET" + Then the HTTP status code should be "401" + + Scenario: Accessing /public.php/dav with log.condition + When requesting "/public.php/dav" with "GET" + Then the HTTP status code should be "503" diff --git a/build/integration/features/maintenance-mode.feature b/build/integration/features/maintenance-mode.feature new file mode 100644 index 00000000000..72af31f193f --- /dev/null +++ b/build/integration/features/maintenance-mode.feature @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +Feature: maintenance-mode + + Background: + Given Maintenance mode is enabled + Then the command was successful + + Scenario: Accessing /index.php with maintenance mode enabled + When requesting "/index.php" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful + + Scenario: Accessing /remote.php/webdav with maintenance mode enabled + When requesting "/remote.php/webdav" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful + + Scenario: Accessing /remote.php/dav with maintenance mode enabled + When requesting "/remote.php/dav" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful + + Scenario: Accessing /ocs/v1.php with maintenance mode enabled + When requesting "/ocs/v1.php" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful + + Scenario: Accessing /ocs/v2.php with maintenance mode enabled + When requesting "/ocs/v2.php" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful + + Scenario: Accessing /public.php/webdav with maintenance mode enabled + When requesting "/public.php/webdav" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful + + Scenario: Accessing /public.php/dav with maintenance mode enabled + When requesting "/public.php/dav" with "GET" + Then the HTTP status code should be "503" + Then Maintenance mode is disabled + And the command was successful diff --git a/build/integration/features/ocs-v1.feature b/build/integration/features/ocs-v1.feature new file mode 100644 index 00000000000..26907580aee --- /dev/null +++ b/build/integration/features/ocs-v1.feature @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +Feature: ocs + Background: + Given using api version "1" + + Scenario: Default output is xml + Given user "user0" exists + And As an "user0" + When sending "GET" to "/cloud/config" + And the HTTP status code should be "200" + And the Content-Type should be "text/xml; charset=UTF-8" + + Scenario: Get XML when requesting XML + Given user "user0" exists + And As an "user0" + When sending "GET" to "/cloud/config?format=xml" + And the HTTP status code should be "200" + And the Content-Type should be "text/xml; charset=UTF-8" + + Scenario: Get JSON when requesting JSON + Given user "user0" exists + And As an "user0" + When sending "GET" to "/cloud/config?format=json" + And the HTTP status code should be "200" + And the Content-Type should be "application/json; charset=utf-8" diff --git a/build/integration/features/provisioning-v1.feature b/build/integration/features/provisioning-v1.feature index 8c32c04523c..8fcfb076497 100644 --- a/build/integration/features/provisioning-v1.feature +++ b/build/integration/features/provisioning-v1.feature @@ -1,317 +1,892 @@ +# SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2015-2016 ownCloud, Inc. +# SPDX-License-Identifier: AGPL-3.0-only Feature: provisioning - Background: - Given using api version "1" - - Scenario: Getting an not existing user - Given As an "admin" - When sending "GET" to "/cloud/users/test" - Then the OCS status code should be "998" - And the HTTP status code should be "200" - - Scenario: Listing all users - Given As an "admin" - When sending "GET" to "/cloud/users" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Create a user - Given As an "admin" - And user "brand-new-user" does not exist - When sending "POST" to "/cloud/users" with - | userid | brand-new-user | - | password | 123456 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And user "brand-new-user" exists - - Scenario: Create an existing user - Given As an "admin" - And user "brand-new-user" exists - When sending "POST" to "/cloud/users" with - | userid | brand-new-user | - | password | 123456 | - Then the OCS status code should be "102" - And the HTTP status code should be "200" - - Scenario: Get an existing user - Given As an "admin" - When sending "GET" to "/cloud/users/brand-new-user" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Getting all users - Given As an "admin" - And user "brand-new-user" exists - And user "admin" exists - When sending "GET" to "/cloud/users" - Then users returned are - | brand-new-user | - | admin | - - Scenario: Edit a user - Given As an "admin" - And user "brand-new-user" exists - When sending "PUT" to "/cloud/users/brand-new-user" with - | key | quota | - | value | 12MB | - | key | email | - | value | brand-new-user@gmail.com | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And user "brand-new-user" exists - - Scenario: Create a group - Given As an "admin" - And group "new-group" does not exist - When sending "POST" to "/cloud/groups" with - | groupid | new-group | - | password | 123456 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And group "new-group" exists - - Scenario: Create a group with special characters - Given As an "admin" - And group "España" does not exist - When sending "POST" to "/cloud/groups" with - | groupid | España | - | password | 123456 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And group "España" exists - - Scenario: adding user to a group without sending the group - Given As an "admin" - And user "brand-new-user" exists - When sending "POST" to "/cloud/users/brand-new-user/groups" with - | groupid | | - Then the OCS status code should be "101" - And the HTTP status code should be "200" - - Scenario: adding user to a group which doesn't exist - Given As an "admin" - And user "brand-new-user" exists - And group "not-group" does not exist - When sending "POST" to "/cloud/users/brand-new-user/groups" with - | groupid | not-group | - Then the OCS status code should be "102" - And the HTTP status code should be "200" - - Scenario: adding user to a group without privileges - Given As an "brand-new-user" - When sending "POST" to "/cloud/users/brand-new-user/groups" with - | groupid | new-group | - Then the OCS status code should be "997" - And the HTTP status code should be "401" - - Scenario: adding user to a group - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - When sending "POST" to "/cloud/users/brand-new-user/groups" with - | groupid | new-group | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: getting groups of an user - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - When sending "GET" to "/cloud/users/brand-new-user/groups" - Then groups returned are - | new-group | - And the OCS status code should be "100" - - Scenario: adding a user which doesn't exist to a group - Given As an "admin" - And user "not-user" does not exist - And group "new-group" exists - When sending "POST" to "/cloud/users/not-user/groups" with - | groupid | new-group | - Then the OCS status code should be "103" - And the HTTP status code should be "200" - - Scenario: getting a group - Given As an "admin" - And group "new-group" exists - When sending "GET" to "/cloud/groups/new-group" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Getting all groups - Given As an "admin" - And group "new-group" exists - And group "admin" exists - When sending "GET" to "/cloud/groups" - Then groups returned are - | España | - | admin | - | new-group | - - Scenario: create a subadmin - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - When sending "POST" to "/cloud/users/brand-new-user/subadmins" with - | groupid | new-group | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: get users using a subadmin - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - And user "brand-new-user" belongs to group "new-group" - And user "brand-new-user" is subadmin of group "new-group" - And As an "brand-new-user" - When sending "GET" to "/cloud/users" - Then users returned are - | brand-new-user | - And the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: removing a user from a group which doesn't exists - Given As an "admin" - And user "brand-new-user" exists - And group "not-group" does not exist - When sending "DELETE" to "/cloud/users/brand-new-user/groups" with - | groupid | not-group | - Then the OCS status code should be "102" - - Scenario: removing a user from a group - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - And user "brand-new-user" belongs to group "new-group" - When sending "DELETE" to "/cloud/users/brand-new-user/groups" with - | groupid | new-group | - Then the OCS status code should be "100" - And user "brand-new-user" does not belong to group "new-group" - - Scenario: create a subadmin using a user which not exist - Given As an "admin" - And user "not-user" does not exist - And group "new-group" exists - When sending "POST" to "/cloud/users/not-user/subadmins" with - | groupid | new-group | - Then the OCS status code should be "101" - And the HTTP status code should be "200" - - Scenario: create a subadmin using a group which not exist - Given As an "admin" - And user "brand-new-user" exists - And group "not-group" does not exist - When sending "POST" to "/cloud/users/brand-new-user/subadmins" with - | groupid | not-group | - Then the OCS status code should be "102" - And the HTTP status code should be "200" - - Scenario: Getting subadmin groups - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - When sending "GET" to "/cloud/users/brand-new-user/subadmins" - Then subadmin groups returned are - | new-group | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Getting subadmin groups of a user which not exist - Given As an "admin" - And user "not-user" does not exist - And group "new-group" exists - When sending "GET" to "/cloud/users/not-user/subadmins" - Then the OCS status code should be "101" - And the HTTP status code should be "200" - - Scenario: Getting subadmin users of a group - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - When sending "GET" to "/cloud/groups/new-group/subadmins" - Then subadmin users returned are - | brand-new-user | - And the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Getting subadmin users of a group which doesn't exist - Given As an "admin" - And user "brand-new-user" exists - And group "not-group" does not exist - When sending "GET" to "/cloud/groups/not-group/subadmins" - Then the OCS status code should be "101" - And the HTTP status code should be "200" - - Scenario: Removing subadmin from a group - Given As an "admin" - And user "brand-new-user" exists - And group "new-group" exists - And user "brand-new-user" is subadmin of group "new-group" - When sending "DELETE" to "/cloud/users/brand-new-user/subadmins" with - | groupid | new-group | - And the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Delete a user - Given As an "admin" - And user "brand-new-user" exists - When sending "DELETE" to "/cloud/users/brand-new-user" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And user "brand-new-user" does not exist - - Scenario: Delete a group - Given As an "admin" - And group "new-group" exists - When sending "DELETE" to "/cloud/groups/new-group" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And group "new-group" does not exist - - Scenario: Delete a group with special characters - Given As an "admin" - And group "España" exists - When sending "DELETE" to "/cloud/groups/España" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And group "España" does not exist - - Scenario: get enabled apps - Given As an "admin" - When sending "GET" to "/cloud/apps?filter=enabled" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And apps returned are - | comments | - | dav | - | federatedfilesharing | - | federation | - | files | - | files_sharing | - | files_trashbin | - | files_versions | - | provisioning_api | - | systemtags | - | updatenotification | - - Scenario: get app info - Given As an "admin" - When sending "GET" to "/cloud/apps/files" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: enable an app - Given As an "admin" - And app "files_external" is disabled - When sending "POST" to "/cloud/apps/files_external" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And app "files_external" is enabled - - Scenario: disable an app - Given As an "admin" - And app "files_external" is enabled - When sending "DELETE" to "/cloud/apps/files_external" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And app "files_external" is disabled + Background: + Given using api version "1" + Given parameter "whitelist_0" of app "bruteForce" is set to "127.0.0.1" + Given parameter "whitelist_1" of app "bruteForce" is set to "::1" + Given parameter "apply_allowlist_to_ratelimit" of app "bruteforcesettings" is set to "true" + + Scenario: Getting an not existing user + Given As an "admin" + When sending "GET" to "/cloud/users/test" + Then the OCS status code should be "404" + And the HTTP status code should be "200" + + Scenario: Listing all users + Given As an "admin" + When sending "GET" to "/cloud/users" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: Create a user + Given As an "admin" + And user "brand-new-user" does not exist + When sending "POST" to "/cloud/users" with + | userid | brand-new-user | + | password | 123456 | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And user "brand-new-user" exists + + Scenario: Create an existing user + Given As an "admin" + And user "brand-new-user" exists + When sending "POST" to "/cloud/users" with + | userid | brand-new-user | + | password | 123456 | + Then the OCS status code should be "102" + And the HTTP status code should be "200" + And user "brand-new-user" has + | id | brand-new-user | + | displayname | brand-new-user | + | email | | + | phone | | + | address | | + | website | | + | twitter | | + + Scenario: Get an existing user + Given As an "admin" + When sending "GET" to "/cloud/users/brand-new-user" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: Getting all users + Given As an "admin" + And user "brand-new-user" exists + And user "admin" exists + When sending "GET" to "/cloud/users" + Then users returned are + | brand-new-user | + | admin | + + Scenario: Get editable fields + Given As an "admin" + And user "brand-new-user" exists + Then user "brand-new-user" has editable fields + | displayname | + | email | + | additional_mail | + | phone | + | address | + | website | + | twitter | + | bluesky | + | fediverse | + | organisation | + | role | + | headline | + | biography | + | profile_enabled | + | pronouns | + Given As an "brand-new-user" + Then user "brand-new-user" has editable fields + | displayname | + | email | + | additional_mail | + | phone | + | address | + | website | + | twitter | + | bluesky | + | fediverse | + | organisation | + | role | + | headline | + | biography | + | profile_enabled | + | pronouns | + Then user "self" has editable fields + | displayname | + | email | + | additional_mail | + | phone | + | address | + | website | + | twitter | + | bluesky | + | fediverse | + | organisation | + | role | + | headline | + | biography | + | profile_enabled | + | pronouns | + + Scenario: Edit a user + Given As an "admin" + And user "brand-new-user" exists + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | displayname | + | value | Brand New User | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | quota | + | value | 12MB | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | email | + | value | no-reply@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no.reply@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | noreply@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | phone | + | value | +49 711 / 25 24 28-90 | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | address | + | value | Foo Bar Town | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | website | + | value | https://nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | twitter | + | value | Nextcloud | + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | bluesky | + | value | nextcloud.bsky.social | + And the OCS status code should be "100" + And the HTTP status code should be "200" + Then user "brand-new-user" has + | id | brand-new-user | + | displayname | Brand New User | + | email | no-reply@nextcloud.com | + | additional_mail | no.reply@nextcloud.com;noreply@nextcloud.com | + | phone | +4971125242890 | + | address | Foo Bar Town | + | website | https://nextcloud.com | + | twitter | Nextcloud | + | bluesky | nextcloud.bsky.social | + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | organisation | + | value | Nextcloud GmbH | + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | role | + | value | Engineer | + And the OCS status code should be "100" + And the HTTP status code should be "200" + Then user "brand-new-user" has the following profile data + | userId | brand-new-user | + | displayname | Brand New User | + | organisation | Nextcloud GmbH | + | role | Engineer | + | address | Foo Bar Town | + | timezone | UTC | + | timezoneOffset | 0 | + | pronouns | NULL | + + Scenario: Edit a user with mixed case emails + Given As an "admin" + And user "brand-new-user" exists + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | email | + | value | mixed-CASE@Nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + Then user "brand-new-user" has + | id | brand-new-user | + | email | mixed-case@nextcloud.com | + + Scenario: Edit a user account properties scopes + Given user "brand-new-user" exists + And As an "brand-new-user" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | phoneScope | + | value | v2-private | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | twitterScope | + | value | v2-local | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | blueskyScope | + | value | v2-local | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | addressScope | + | value | v2-federated | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | emailScope | + | value | v2-published | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | email | + | value | no-reply@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + # Duplicating primary address + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no-reply@nextcloud.com | + And the OCS status code should be "101" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no.reply2@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + # Duplicating another additional address + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no.reply2@nextcloud.com | + And the OCS status code should be "101" + And the HTTP status code should be "200" + Then user "brand-new-user" has + | id | brand-new-user | + | phoneScope | v2-private | + | twitterScope | v2-local | + | blueskyScope | v2-local | + | addressScope | v2-federated | + | emailScope | v2-published | + + Scenario: Edit a user account multivalue property scopes + Given user "brand-new-user" exists + And As an "brand-new-user" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no.reply3@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | noreply4@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user/additional_mailScope" with + | key | no.reply3@nextcloud.com | + | value | v2-federated | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user/additional_mailScope" with + | key | noreply4@nextcloud.com | + | value | v2-published | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + Then user "brand-new-user" has + | id | brand-new-user | + | additional_mailScope | v2-federated;v2-published | + + Scenario: Edit a user account properties scopes with invalid or unsupported value + Given user "brand-new-user" exists + And As an "brand-new-user" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | phoneScope | + | value | invalid | + Then the OCS status code should be "101" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | displaynameScope | + | value | v2-private | + Then the OCS status code should be "101" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | emailScope | + | value | v2-private | + Then the OCS status code should be "101" + And the HTTP status code should be "200" + + Scenario: Edit a user account multi-value property scopes with invalid or unsupported value + Given user "brand-new-user" exists + And As an "brand-new-user" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no.reply5@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user/additional_mailScope" with + | key | no.reply5@nextcloud.com | + | value | invalid | + Then the OCS status code should be "102" + And the HTTP status code should be "200" + + Scenario: Delete a user account multi-value property value + Given user "brand-new-user" exists + And As an "brand-new-user" + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | no.reply6@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + And sending "PUT" to "/cloud/users/brand-new-user" with + | key | additional_mail | + | value | noreply7@nextcloud.com | + And the OCS status code should be "100" + And the HTTP status code should be "200" + When sending "PUT" to "/cloud/users/brand-new-user/additional_mail" with + | key | no.reply6@nextcloud.com | + | value | | + And the OCS status code should be "100" + And the HTTP status code should be "200" + Then user "brand-new-user" has + | additional_mail | noreply7@nextcloud.com | + Then user "brand-new-user" has not + | additional_mail | no.reply6@nextcloud.com | + + Scenario: An admin cannot edit user account property scopes + Given As an "admin" + And user "brand-new-user" exists + When sending "PUT" to "/cloud/users/brand-new-user" with + | key | phoneScope | + | value | v2-private | + Then the OCS status code should be "113" + And the HTTP status code should be "200" + + Scenario: Search by phone number + Given As an "admin" + And user "phone-user" exists + And sending "PUT" to "/cloud/users/phone-user" with + | key | phone | + | value | +49 711 / 25 24 28-90 | + And the OCS status code should be "100" + And the HTTP status code should be "200" + Then search users by phone for region "DE" with + | random-string1 | 0711 / 123 456 78 | + | random-string1 | 0711 / 252 428-90 | + | random-string2 | 0711 / 90-824 252 | + And the OCS status code should be "100" + And the HTTP status code should be "200" + Then phone matches returned are + | random-string1 | phone-user@localhost:8080 | + + Scenario: Create a group + Given As an "admin" + And group "new-group" does not exist + When sending "POST" to "/cloud/groups" with + | groupid | new-group | + | password | 123456 | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And group "new-group" exists + And group "new-group" has + | displayname | new-group | + + Scenario: Create a group with custom display name + Given As an "admin" + And group "new-group" does not exist + When sending "POST" to "/cloud/groups" with + | groupid | new-group | + | password | 123456 | + | displayname | new-group-displayname | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And group "new-group" exists + And group "new-group" has + | displayname | new-group-displayname | + + Scenario: Create a group with special characters + Given As an "admin" + And group "España" does not exist + When sending "POST" to "/cloud/groups" with + | groupid | España | + | password | 123456 | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And group "España" exists + And group "España" has + | displayname | España | + + Scenario: adding user to a group without sending the group + Given As an "admin" + And user "brand-new-user" exists + When sending "POST" to "/cloud/users/brand-new-user/groups" with + | groupid | | + Then the OCS status code should be "101" + And the HTTP status code should be "200" + + Scenario: adding user to a group which doesn't exist + Given As an "admin" + And user "brand-new-user" exists + And group "not-group" does not exist + When sending "POST" to "/cloud/users/brand-new-user/groups" with + | groupid | not-group | + Then the OCS status code should be "102" + And the HTTP status code should be "200" + + Scenario: adding user to a group without privileges + Given user "brand-new-user" exists + And As an "brand-new-user" + When sending "POST" to "/cloud/users/brand-new-user/groups" with + | groupid | new-group | + Then the OCS status code should be "403" + And the HTTP status code should be "200" + + Scenario: adding user to a group + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + When sending "POST" to "/cloud/users/brand-new-user/groups" with + | groupid | new-group | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: getting groups of an user + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + When sending "GET" to "/cloud/users/brand-new-user/groups" + Then groups returned are + | new-group | + And the OCS status code should be "100" + + Scenario: adding a user which doesn't exist to a group + Given As an "admin" + And user "not-user" does not exist + And group "new-group" exists + When sending "POST" to "/cloud/users/not-user/groups" with + | groupid | new-group | + Then the OCS status code should be "103" + And the HTTP status code should be "200" + + Scenario: getting a group + Given As an "admin" + And group "new-group" exists + When sending "GET" to "/cloud/groups/new-group" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: Getting all groups + Given As an "admin" + And group "new-group" exists + And group "admin" exists + When sending "GET" to "/cloud/groups" + Then groups returned are + | España | + | admin | + | hidden_group | + | new-group | + + Scenario: create a subadmin + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + When sending "POST" to "/cloud/users/brand-new-user/subadmins" with + | groupid | new-group | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: get users using a subadmin + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + And user "brand-new-user" belongs to group "new-group" + And user "brand-new-user" is subadmin of group "new-group" + And As an "brand-new-user" + When sending "GET" to "/cloud/users" + Then users returned are + | brand-new-user | + And the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: removing a user from a group which doesn't exists + Given As an "admin" + And user "brand-new-user" exists + And group "not-group" does not exist + When sending "DELETE" to "/cloud/users/brand-new-user/groups" with + | groupid | not-group | + Then the OCS status code should be "102" + + Scenario: removing a user from a group + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + And user "brand-new-user" belongs to group "new-group" + When sending "DELETE" to "/cloud/users/brand-new-user/groups" with + | groupid | new-group | + Then the OCS status code should be "100" + And user "brand-new-user" does not belong to group "new-group" + + Scenario: create a subadmin using a user which not exist + Given As an "admin" + And user "not-user" does not exist + And group "new-group" exists + When sending "POST" to "/cloud/users/not-user/subadmins" with + | groupid | new-group | + Then the OCS status code should be "101" + And the HTTP status code should be "200" + + Scenario: create a subadmin using a group which not exist + Given As an "admin" + And user "brand-new-user" exists + And group "not-group" does not exist + When sending "POST" to "/cloud/users/brand-new-user/subadmins" with + | groupid | not-group | + Then the OCS status code should be "102" + And the HTTP status code should be "200" + + Scenario: Getting subadmin groups + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + When sending "GET" to "/cloud/users/brand-new-user/subadmins" + Then subadmin groups returned are + | new-group | + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: Getting subadmin groups of a user which not exist + Given As an "admin" + And user "not-user" does not exist + And group "new-group" exists + When sending "GET" to "/cloud/users/not-user/subadmins" + Then the OCS status code should be "404" + And the HTTP status code should be "200" + + Scenario: Getting subadmin users of a group + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + When sending "GET" to "/cloud/groups/new-group/subadmins" + Then subadmin users returned are + | brand-new-user | + And the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: Getting subadmin users of a group which doesn't exist + Given As an "admin" + And user "brand-new-user" exists + And group "not-group" does not exist + When sending "GET" to "/cloud/groups/not-group/subadmins" + Then the OCS status code should be "101" + And the HTTP status code should be "200" + + Scenario: Removing subadmin from a group + Given As an "admin" + And user "brand-new-user" exists + And group "new-group" exists + And user "brand-new-user" is subadmin of group "new-group" + When sending "DELETE" to "/cloud/users/brand-new-user/subadmins" with + | groupid | new-group | + And the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: Delete a user + Given As an "admin" + And user "brand-new-user" exists + When sending "DELETE" to "/cloud/users/brand-new-user" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And user "brand-new-user" does not exist + + Scenario: Delete a group + Given As an "admin" + And group "new-group" exists + When sending "DELETE" to "/cloud/groups/new-group" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And group "new-group" does not exist + + Scenario: Delete a group with special characters + Given As an "admin" + And group "España" exists + When sending "DELETE" to "/cloud/groups/España" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And group "España" does not exist + + Scenario: get enabled apps + Given As an "admin" + When sending "GET" to "/cloud/apps?filter=enabled" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And apps returned are + | cloud_federation_api | + | comments | + | contactsinteraction | + | dashboard | + | dav | + | federatedfilesharing | + | federation | + | files | + | files_reminders | + | files_sharing | + | files_trashbin | + | files_versions | + | lookup_server_connector | + | profile | + | provisioning_api | + | settings | + | sharebymail | + | systemtags | + | testing | + | theming | + | twofactor_backupcodes | + | updatenotification | + | user_ldap | + | user_status | + | viewer | + | workflowengine | + | webhook_listeners | + | weather_status | + | files_external | + | oauth2 | + + Scenario: get app info + Given As an "admin" + When sending "GET" to "/cloud/apps/files" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + + Scenario: get app info from app that does not exist + Given As an "admin" + When sending "GET" to "/cloud/apps/this_app_should_never_exist" + Then the OCS status code should be "998" + And the HTTP status code should be "200" + + Scenario: enable an app + Given invoking occ with "app:disable testing" + Given As an "admin" + And app "testing" is disabled + When sending "POST" to "/cloud/apps/testing" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And app "testing" is enabled + + Scenario: enable an app that does not exist + Given As an "admin" + When sending "POST" to "/cloud/apps/this_app_should_never_exist" + Then the OCS status code should be "998" + And the HTTP status code should be "200" + + Scenario: disable an app + Given invoking occ with "app:enable testing" + Given As an "admin" + And app "testing" is enabled + When sending "DELETE" to "/cloud/apps/testing" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And app "testing" is disabled + Given invoking occ with "app:enable testing" + + Scenario: disable an user + Given As an "admin" + And user "user1" exists + When sending "PUT" to "/cloud/users/user1/disable" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And user "user1" is disabled + + Scenario: enable an user + Given As an "admin" + And user "user1" exists + And assure user "user1" is disabled + When sending "PUT" to "/cloud/users/user1/enable" + Then the OCS status code should be "100" + And the HTTP status code should be "200" + And user "user1" is enabled + + Scenario: Subadmin should be able to enable or disable an user in their group + Given As an "admin" + And user "subadmin" exists + And user "user1" exists + And group "new-group" exists + And user "subadmin" belongs to group "new-group" + And user "user1" belongs to group "new-group" + And Assure user "subadmin" is subadmin of group "new-group" + And As an "subadmin" + When sending "PUT" to "/cloud/users/user1/disable" + Then the OCS status code should be "100" + Then the HTTP status code should be "200" + And As an "admin" + And user "user1" is disabled + + Scenario: Subadmin should not be able to enable or disable an user not in their group + Given As an "admin" + And user "subadmin" exists + And user "user1" exists + And group "new-group" exists + And group "another-group" exists + And user "subadmin" belongs to group "new-group" + And user "user1" belongs to group "another-group" + And Assure user "subadmin" is subadmin of group "new-group" + And As an "subadmin" + When sending "PUT" to "/cloud/users/user1/disable" + Then the OCS status code should be "998" + Then the HTTP status code should be "200" + And As an "admin" + And user "user1" is enabled + + Scenario: Subadmins should not be able to disable users that have admin permissions in their group + Given As an "admin" + And user "another-admin" exists + And user "subadmin" exists + And group "new-group" exists + And user "another-admin" belongs to group "admin" + And user "subadmin" belongs to group "new-group" + And user "another-admin" belongs to group "new-group" + And Assure user "subadmin" is subadmin of group "new-group" + And As an "subadmin" + When sending "PUT" to "/cloud/users/another-admin/disable" + Then the OCS status code should be "998" + Then the HTTP status code should be "200" + And As an "admin" + And user "another-admin" is enabled + + Scenario: Admin can disable another admin user + Given As an "admin" + And user "another-admin" exists + And user "another-admin" belongs to group "admin" + When sending "PUT" to "/cloud/users/another-admin/disable" + Then the OCS status code should be "100" + Then the HTTP status code should be "200" + And user "another-admin" is disabled + + Scenario: Admin can enable another admin user + Given As an "admin" + And user "another-admin" exists + And user "another-admin" belongs to group "admin" + And assure user "another-admin" is disabled + When sending "PUT" to "/cloud/users/another-admin/enable" + Then the OCS status code should be "100" + Then the HTTP status code should be "200" + And user "another-admin" is enabled + + Scenario: Admin can disable subadmins in the same group + Given As an "admin" + And user "subadmin" exists + And group "new-group" exists + And user "subadmin" belongs to group "new-group" + And user "admin" belongs to group "new-group" + And Assure user "subadmin" is subadmin of group "new-group" + When sending "PUT" to "/cloud/users/subadmin/disable" + Then the OCS status code should be "100" + Then the HTTP status code should be "200" + And user "subadmin" is disabled + + Scenario: Admin can enable subadmins in the same group + Given As an "admin" + And user "subadmin" exists + And group "new-group" exists + And user "subadmin" belongs to group "new-group" + And user "admin" belongs to group "new-group" + And Assure user "subadmin" is subadmin of group "new-group" + And assure user "another-admin" is disabled + When sending "PUT" to "/cloud/users/subadmin/disable" + Then the OCS status code should be "100" + Then the HTTP status code should be "200" + And user "subadmin" is disabled + + Scenario: Admin user cannot disable himself + Given As an "admin" + And user "another-admin" exists + And user "another-admin" belongs to group "admin" + And As an "another-admin" + When sending "PUT" to "/cloud/users/another-admin/disable" + Then the OCS status code should be "101" + And the HTTP status code should be "200" + And As an "admin" + And user "another-admin" is enabled + + Scenario:Admin user cannot enable himself + Given As an "admin" + And user "another-admin" exists + And user "another-admin" belongs to group "admin" + And assure user "another-admin" is disabled + And As an "another-admin" + When sending "PUT" to "/cloud/users/another-admin/enable" + And As an "admin" + Then user "another-admin" is disabled + + Scenario: disable an user with a regular user + Given As an "admin" + And user "user1" exists + And user "user2" exists + And As an "user1" + When sending "PUT" to "/cloud/users/user2/disable" + Then the OCS status code should be "403" + And the HTTP status code should be "200" + And As an "admin" + And user "user2" is enabled + + Scenario: enable an user with a regular user + Given As an "admin" + And user "user1" exists + And user "user2" exists + And assure user "user2" is disabled + And As an "user1" + When sending "PUT" to "/cloud/users/user2/enable" + Then the OCS status code should be "403" + And the HTTP status code should be "200" + And As an "admin" + And user "user2" is disabled + + Scenario: Subadmin should not be able to disable himself + Given As an "admin" + And user "subadmin" exists + And group "new-group" exists + And user "subadmin" belongs to group "new-group" + And Assure user "subadmin" is subadmin of group "new-group" + And As an "subadmin" + When sending "PUT" to "/cloud/users/subadmin/disable" + Then the OCS status code should be "101" + Then the HTTP status code should be "200" + And As an "admin" + And user "subadmin" is enabled + + Scenario: Subadmin should not be able to enable himself + Given As an "admin" + And user "subadmin" exists + And group "new-group" exists + And user "subadmin" belongs to group "new-group" + And Assure user "subadmin" is subadmin of group "new-group" + And assure user "subadmin" is disabled + And As an "subadmin" + When sending "PUT" to "/cloud/users/subadmin/enabled" + And As an "admin" + And user "subadmin" is disabled + + Scenario: Making a ocs request with an enabled user + Given As an "admin" + And user "user0" exists + And As an "user0" + When sending "GET" to "/cloud/capabilities" + Then the HTTP status code should be "200" + And the OCS status code should be "100" + + Scenario: Making a web request with an enabled user + Given As an "admin" + And user "user0" exists + And As an "user0" + When sending "GET" with exact url to "/index.php/apps/files" + Then the HTTP status code should be "200" + + Scenario: Making a ocs request with a disabled user + Given As an "admin" + And user "user0" exists + And assure user "user0" is disabled + And As an "user0" + When sending "GET" to "/cloud/capabilities" + Then the OCS status code should be "997" + And the HTTP status code should be "401" + + Scenario: Making a web request with a disabled user + Given As an "admin" + And user "user0" exists + And assure user "user0" is disabled + And As an "user0" + When sending "GET" with exact url to "/index.php/apps/files" + And the HTTP status code should be "401" diff --git a/build/integration/features/provisioning-v2.feature b/build/integration/features/provisioning-v2.feature index 6140128684d..1169dc04b5f 100644 --- a/build/integration/features/provisioning-v2.feature +++ b/build/integration/features/provisioning-v2.feature @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2015 ownCloud, Inc. +# SPDX-License-Identifier: AGPL-3.0-only Feature: provisioning Background: Given using api version "2" @@ -7,3 +10,29 @@ Feature: provisioning When sending "GET" to "/cloud/users/test" Then the HTTP status code should be "404" + Scenario: get app info from app that does not exist + Given As an "admin" + When sending "GET" to "/cloud/apps/this_app_should_never_exist" + Then the OCS status code should be "998" + And the HTTP status code should be "404" + + Scenario: enable an app that does not exist + Given As an "admin" + When sending "POST" to "/cloud/apps/this_app_should_never_exist" + Then the OCS status code should be "998" + And the HTTP status code should be "404" + + Scenario: Searching by displayname in groups + Given As an "admin" + And user "user-in-group" with displayname "specific-name" exists + And user "user-in-group2" with displayname "another-name" exists + And user "user-not-in-group" with displayname "specific-name" exists + And user "user-not-in-group2" with displayname "another-name" exists + And group "group-search" exists + And user "user-in-group" belongs to group "group-search" + And user "user-in-group2" belongs to group "group-search" + When sending "GET" to "/cloud/groups/group-search/users/details?offset=0&limit=25&search=ifi" + Then the OCS status code should be "200" + And the HTTP status code should be "200" + And detailed users returned are + | user-in-group | diff --git a/build/integration/features/sharing-v1.feature b/build/integration/features/sharing-v1.feature deleted file mode 100644 index b9d77120b9c..00000000000 --- a/build/integration/features/sharing-v1.feature +++ /dev/null @@ -1,672 +0,0 @@ -Feature: sharing - Background: - Given using api version "1" - Given using dav path "remote.php/webdav" - - Scenario: Creating a new share with user - Given user "user0" exists - And user "user1" exists - And As an "user0" - When sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | welcome.txt | - | shareWith | user1 | - | shareType | 0 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Creating a share with a group - Given user "user0" exists - And user "user1" exists - And group "sharing-group" exists - And As an "user0" - When sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | welcome.txt | - | shareWith | sharing-group | - | shareType | 1 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Creating a new public share - Given user "user0" exists - And As an "user0" - When creating a share with - | path | welcome.txt | - | shareType | 3 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Public shared file "welcome.txt" can be downloaded - - Scenario: Creating a new public share with password - Given user "user0" exists - And As an "user0" - When creating a share with - | path | welcome.txt | - | shareType | 3 | - | password | publicpw | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Public shared file "welcome.txt" with password "publicpw" can be downloaded - - Scenario: Creating a new public share of a folder - Given user "user0" exists - And As an "user0" - When creating a share with - | path | FOLDER | - | shareType | 3 | - | password | publicpw | - | expireDate | +3 days | - | publicUpload | true | - | permissions | 7 | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | permissions | 7 | - | expiration | +3 days | - | url | AN_URL | - | token | A_TOKEN | - | mimetype | httpd/unix-directory | - - Scenario: Creating a new public share with password and adding an expiration date - Given user "user0" exists - And As an "user0" - When creating a share with - | path | welcome.txt | - | shareType | 3 | - | password | publicpw | - And Updating last share with - | expireDate | +3 days | - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Public shared file "welcome.txt" with password "publicpw" can be downloaded - - Scenario: Creating a new public share, updating its expiration date and getting its info - Given user "user0" exists - And As an "user0" - When creating a share with - | path | FOLDER | - | shareType | 3 | - And Updating last share with - | expireDate | +3 days | - And Getting info of last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | item_type | folder | - | item_source | A_NUMBER | - | share_type | 3 | - | file_source | A_NUMBER | - | file_target | /FOLDER | - | permissions | 1 | - | stime | A_NUMBER | - | expiration | +3 days | - | token | A_TOKEN | - | storage | A_NUMBER | - | mail_send | 0 | - | uid_owner | user0 | - | storage_id | home::user0 | - | file_parent | A_NUMBER | - | displayname_owner | user0 | - | url | AN_URL | - | mimetype | httpd/unix-directory | - - Scenario: Creating a new public share, updating its password and getting its info - Given user "user0" exists - And As an "user0" - When creating a share with - | path | FOLDER | - | shareType | 3 | - And Updating last share with - | password | publicpw | - And Getting info of last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | item_type | folder | - | item_source | A_NUMBER | - | share_type | 3 | - | file_source | A_NUMBER | - | file_target | /FOLDER | - | permissions | 1 | - | stime | A_NUMBER | - | token | A_TOKEN | - | storage | A_NUMBER | - | mail_send | 0 | - | uid_owner | user0 | - | storage_id | home::user0 | - | file_parent | A_NUMBER | - | displayname_owner | user0 | - | url | AN_URL | - | mimetype | httpd/unix-directory | - - Scenario: Creating a new public share, updating its permissions and getting its info - Given user "user0" exists - And As an "user0" - When creating a share with - | path | FOLDER | - | shareType | 3 | - And Updating last share with - | permissions | 7 | - And Getting info of last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | item_type | folder | - | item_source | A_NUMBER | - | share_type | 3 | - | file_source | A_NUMBER | - | file_target | /FOLDER | - | permissions | 7 | - | stime | A_NUMBER | - | token | A_TOKEN | - | storage | A_NUMBER | - | mail_send | 0 | - | uid_owner | user0 | - | storage_id | home::user0 | - | file_parent | A_NUMBER | - | displayname_owner | user0 | - | url | AN_URL | - | mimetype | httpd/unix-directory | - - Scenario: Creating a new public share, updating publicUpload option and getting its info - Given user "user0" exists - And As an "user0" - When creating a share with - | path | FOLDER | - | shareType | 3 | - And Updating last share with - | publicUpload | true | - And Getting info of last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | item_type | folder | - | item_source | A_NUMBER | - | share_type | 3 | - | file_source | A_NUMBER | - | file_target | /FOLDER | - | permissions | 7 | - | stime | A_NUMBER | - | token | A_TOKEN | - | storage | A_NUMBER | - | mail_send | 0 | - | uid_owner | user0 | - | storage_id | home::user0 | - | file_parent | A_NUMBER | - | displayname_owner | user0 | - | url | AN_URL | - | mimetype | httpd/unix-directory | - - Scenario: getting all shares of a user using that user - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And As an "user0" - When sending "GET" to "/apps/files_sharing/api/v1/shares" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And File "textfile0 (2).txt" should be included in the response - - Scenario: getting all shares of a user using another user - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And As an "admin" - When sending "GET" to "/apps/files_sharing/api/v1/shares" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And File "textfile0.txt" should not be included in the response - - Scenario: getting all shares of a file - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And user "user3" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And file "textfile0.txt" of user "user0" is shared with user "user2" - And As an "user0" - When sending "GET" to "/apps/files_sharing/api/v1/shares?path=textfile0.txt" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And User "user1" should be included in the response - And User "user2" should be included in the response - And User "user3" should not be included in the response - - Scenario: getting all shares of a file with reshares - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And user "user3" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And file "textfile0 (2).txt" of user "user1" is shared with user "user2" - And As an "user0" - When sending "GET" to "/apps/files_sharing/api/v1/shares?reshares=true&path=textfile0.txt" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And User "user1" should be included in the response - And User "user2" should be included in the response - And User "user3" should not be included in the response - - Scenario: getting share info of a share - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And As an "user0" - When Getting info of last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | item_type | file | - | item_source | A_NUMBER | - | share_type | 0 | - | share_with | user1 | - | file_source | A_NUMBER | - | file_target | /textfile0 (2).txt | - | path | /textfile0.txt | - | permissions | 19 | - | stime | A_NUMBER | - | storage | A_NUMBER | - | mail_send | 0 | - | uid_owner | user0 | - | storage_id | home::user0 | - | file_parent | A_NUMBER | - | share_with_displayname | user1 | - | displayname_owner | user0 | - | mimetype | text/plain | - - Scenario: keep group permissions in sync - Given As an "admin" - Given user "user0" exists - And user "user1" exists - And group "group1" exists - And user "user1" belongs to group "group1" - And file "textfile0.txt" of user "user0" is shared with group "group1" - And User "user1" moved file "/textfile0.txt" to "/FOLDER/textfile0.txt" - And As an "user0" - When Updating last share with - | permissions | 1 | - And Getting info of last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And Share fields of last share match with - | id | A_NUMBER | - | item_type | file | - | item_source | A_NUMBER | - | share_type | 1 | - | file_source | A_NUMBER | - | file_target | /textfile0.txt | - | permissions | 1 | - | stime | A_NUMBER | - | storage | A_NUMBER | - | mail_send | 0 | - | uid_owner | user0 | - | storage_id | home::user0 | - | file_parent | A_NUMBER | - | displayname_owner | user0 | - | mimetype | text/plain | - - Scenario: Sharee can see the share - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And As an "user1" - When sending "GET" to "/apps/files_sharing/api/v1/shares?shared_with_me=true" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And last share_id is included in the answer - - Scenario: Sharee can see the filtered share - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And file "textfile1.txt" of user "user0" is shared with user "user1" - And As an "user1" - When sending "GET" to "/apps/files_sharing/api/v1/shares?shared_with_me=true&path=textfile1 (2).txt" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And last share_id is included in the answer - - Scenario: Sharee can't see the share that is filtered out - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And file "textfile1.txt" of user "user0" is shared with user "user1" - And As an "user1" - When sending "GET" to "/apps/files_sharing/api/v1/shares?shared_with_me=true&path=textfile0 (2).txt" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And last share_id is not included in the answer - - Scenario: Sharee can see the group share - Given As an "admin" - And user "user0" exists - And user "user1" exists - And group "group0" exists - And user "user1" belongs to group "group0" - And file "textfile0.txt" of user "user0" is shared with group "group0" - And As an "user1" - When sending "GET" to "/apps/files_sharing/api/v1/shares?shared_with_me=true" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And last share_id is included in the answer - - Scenario: User is not allowed to reshare file - As an "admin" - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And As an "user0" - And creating a share with - | path | /textfile0.txt | - | shareType | 0 | - | shareWith | user1 | - | permissions | 8 | - And As an "user1" - When creating a share with - | path | /textfile0 (2).txt | - | shareType | 0 | - | shareWith | user2 | - | permissions | 31 | - Then the OCS status code should be "404" - And the HTTP status code should be "200" - - Scenario: User is not allowed to reshare file with more permissions - As an "admin" - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And As an "user0" - And creating a share with - | path | /textfile0.txt | - | shareType | 0 | - | shareWith | user1 | - | permissions | 16 | - And As an "user1" - When creating a share with - | path | /textfile0 (2).txt | - | shareType | 0 | - | shareWith | user2 | - | permissions | 31 | - Then the OCS status code should be "404" - And the HTTP status code should be "200" - - Scenario: Get a share with a user which didn't received the share - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And As an "user2" - When Getting info of last share - Then the OCS status code should be "404" - And the HTTP status code should be "200" - - Scenario: Share of folder and sub-folder to same user - core#20645 - Given As an "admin" - And user "user0" exists - And user "user1" exists - And group "group0" exists - And user "user1" belongs to group "group0" - And file "/PARENT" of user "user0" is shared with user "user1" - When file "/PARENT/CHILD" of user "user0" is shared with group "group0" - Then user "user1" should see following elements - | /FOLDER/ | - | /PARENT/ | - | /CHILD/ | - | /PARENT/parent.txt | - | /CHILD/child.txt | - And the HTTP status code should be "200" - - Scenario: Share a file by multiple channels - Given As an "admin" - And user "user0" exists - And user "user1" exists - And user "user2" exists - And group "group0" exists - And user "user1" belongs to group "group0" - And user "user2" belongs to group "group0" - And user "user0" created a folder "/common" - And user "user0" created a folder "/common/sub" - And file "common" of user "user0" is shared with group "group0" - And file "textfile0.txt" of user "user1" is shared with user "user2" - And User "user1" moved file "/textfile0.txt" to "/common/textfile0.txt" - And User "user1" moved file "/common/textfile0.txt" to "/common/sub/textfile0.txt" - And As an "user2" - When Downloading file "/common/sub/textfile0.txt" with range "bytes=9-17" - Then Downloaded content should be "test text" - And Downloaded content when downloading file "/textfile0.txt" with range "bytes=9-17" should be "test text" - And user "user2" should see following elements - | /common/sub/textfile0.txt | - - Scenario: Share a file by multiple channels - Given As an "admin" - And user "user0" exists - And user "user1" exists - And user "user2" exists - And group "group0" exists - And user "user1" belongs to group "group0" - And user "user2" belongs to group "group0" - And user "user0" created a folder "/common" - And user "user0" created a folder "/common/sub" - And file "common" of user "user0" is shared with group "group0" - And file "textfile0.txt" of user "user1" is shared with user "user2" - And User "user1" moved file "/textfile0.txt" to "/common/textfile0.txt" - And User "user1" moved file "/common/textfile0.txt" to "/common/sub/textfile0.txt" - And As an "user2" - When Downloading file "/textfile0.txt" with range "bytes=9-17" - Then Downloaded content should be "test text" - And user "user2" should see following elements - | /common/sub/textfile0.txt | - - Scenario: Delete all group shares - Given As an "admin" - And user "user0" exists - And user "user1" exists - And group "group1" exists - And user "user1" belongs to group "group1" - And file "textfile0.txt" of user "user0" is shared with group "group1" - And User "user1" moved file "/textfile0.txt" to "/FOLDER/textfile0.txt" - And As an "user0" - And Deleting last share - And As an "user1" - When sending "GET" to "/apps/files_sharing/api/v1/shares?shared_with_me=true" - Then the OCS status code should be "100" - And the HTTP status code should be "200" - And last share_id is not included in the answer - - Scenario: delete a share - Given user "user0" exists - And user "user1" exists - And file "textfile0.txt" of user "user0" is shared with user "user1" - And As an "user0" - When Deleting last share - Then the OCS status code should be "100" - And the HTTP status code should be "200" - - Scenario: Keep usergroup shares (#22143) - Given As an "admin" - And user "user0" exists - And user "user1" exists - And user "user2" exists - And group "group" exists - And user "user1" belongs to group "group" - And user "user2" belongs to group "group" - And user "user0" created a folder "/TMP" - And file "TMP" of user "user0" is shared with group "group" - And user "user1" created a folder "/myFOLDER" - And User "user1" moves file "/TMP" to "/myFOLDER/myTMP" - And user "user2" does not exist - And user "user1" should see following elements - | /myFOLDER/myTMP/ | - - Scenario: Check quota of owners parent directory of a shared file - Given using dav path "remote.php/webdav" - And As an "admin" - And user "user0" exists - And user "user1" exists - And user "user1" has a quota of "0" - And User "user0" moved file "/welcome.txt" to "/myfile.txt" - And file "myfile.txt" of user "user0" is shared with user "user1" - When User "user1" uploads file "data/textfile.txt" to "/myfile.txt" - Then the HTTP status code should be "204" - - Scenario: Don't allow sharing of the root - Given user "user0" exists - And As an "user0" - When creating a share with - | path | / | - | shareType | 3 | - Then the OCS status code should be "403" - - Scenario: Allow modification of reshare - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And user "user0" created a folder "/TMP" - And file "TMP" of user "user0" is shared with user "user1" - And file "TMP" of user "user1" is shared with user "user2" - And As an "user1" - When Updating last share with - | permissions | 1 | - Then the OCS status code should be "100" - - Scenario: Do not allow reshare to exceed permissions - Given user "user0" exists - And user "user1" exists - And user "user2" exists - And user "user0" created a folder "/TMP" - And As an "user0" - And creating a share with - | path | /TMP | - | shareType | 0 | - | shareWith | user1 | - | permissions | 21 | - And As an "user1" - And creating a share with - | path | /TMP | - | shareType | 0 | - | shareWith | user2 | - | permissions | 21 | - When Updating last share with - | permissions | 31 | - Then the OCS status code should be "404" - - Scenario: Only allow 1 link share per file/folder - Given user "user0" exists - And As an "user0" - And creating a share with - | path | welcome.txt | - | shareType | 3 | - When save last share id - And creating a share with - | path | welcome.txt | - | shareType | 3 | - Then share ids should match - - Scenario: Correct webdav share-permissions for owned file - Given user "user0" exists - And User "user0" uploads file with content "foo" to "/tmp.txt" - When as "user0" gets properties of folder "/tmp.txt" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "19" - - Scenario: Correct webdav share-permissions for received file with edit and reshare permissions - Given user "user0" exists - And user "user1" exists - And User "user0" uploads file with content "foo" to "/tmp.txt" - And file "tmp.txt" of user "user0" is shared with user "user1" - When as "user1" gets properties of folder "/tmp.txt" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "19" - - Scenario: Correct webdav share-permissions for received file with edit permissions but no reshare permissions - Given user "user0" exists - And user "user1" exists - And User "user0" uploads file with content "foo" to "/tmp.txt" - And file "tmp.txt" of user "user0" is shared with user "user1" - And As an "user0" - And Updating last share with - | permissions | 3 | - When as "user1" gets properties of folder "/tmp.txt" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "0" - - Scenario: Correct webdav share-permissions for received file with reshare permissions but no edit permissions - Given user "user0" exists - And user "user1" exists - And User "user0" uploads file with content "foo" to "/tmp.txt" - And file "tmp.txt" of user "user0" is shared with user "user1" - And As an "user0" - And Updating last share with - | permissions | 17 | - When as "user1" gets properties of folder "/tmp.txt" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "17" - - Scenario: Correct webdav share-permissions for owned folder - Given user "user0" exists - And user "user0" created a folder "/tmp" - When as "user0" gets properties of folder "/" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "31" - - Scenario: Correct webdav share-permissions for received folder with all permissions - Given user "user0" exists - And user "user1" exists - And user "user0" created a folder "/tmp" - And file "/tmp" of user "user0" is shared with user "user1" - When as "user1" gets properties of folder "/tmp" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "31" - - Scenario: Correct webdav share-permissions for received folder with all permissions but edit - Given user "user0" exists - And user "user1" exists - And user "user0" created a folder "/tmp" - And file "/tmp" of user "user0" is shared with user "user1" - And As an "user0" - And Updating last share with - | permissions | 29 | - When as "user1" gets properties of folder "/tmp" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "29" - - Scenario: Correct webdav share-permissions for received folder with all permissions but create - Given user "user0" exists - And user "user1" exists - And user "user0" created a folder "/tmp" - And file "/tmp" of user "user0" is shared with user "user1" - And As an "user0" - And Updating last share with - | permissions | 27 | - When as "user1" gets properties of folder "/tmp" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "27" - - Scenario: Correct webdav share-permissions for received folder with all permissions but delete - Given user "user0" exists - And user "user1" exists - And user "user0" created a folder "/tmp" - And file "/tmp" of user "user0" is shared with user "user1" - And As an "user0" - And Updating last share with - | permissions | 23 | - When as "user1" gets properties of folder "/tmp" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "23" - - Scenario: Correct webdav share-permissions for received folder with all permissions but share - Given user "user0" exists - And user "user1" exists - And user "user0" created a folder "/tmp" - And file "/tmp" of user "user0" is shared with user "user1" - And As an "user0" - And Updating last share with - | permissions | 15 | - When as "user1" gets properties of folder "/tmp" with - |{http://owncloud.org/ns}share-permissions| - Then the single response should contain a property "{http://owncloud.org/ns}share-permissions" with value "0" diff --git a/build/integration/features/tags.feature b/build/integration/features/tags.feature deleted file mode 100644 index 286fb62bf42..00000000000 --- a/build/integration/features/tags.feature +++ /dev/null @@ -1,370 +0,0 @@ -Feature: tags - - Scenario: Creating a normal tag as regular user should work - Given user "user0" exists - When "user0" creates a "normal" tag with name "MySuperAwesomeTagName" - Then The response should have a status code "201" - And The following tags should exist for "admin" - |MySuperAwesomeTagName|true|true| - And The following tags should exist for "user0" - |MySuperAwesomeTagName|true|true| - - Scenario: Creating a not user-assignable tag as regular user should fail - Given user "user0" exists - When "user0" creates a "not user-assignable" tag with name "MySuperAwesomeTagName" - Then The response should have a status code "400" - And "0" tags should exist for "admin" - - Scenario: Creating a not user-visible tag as regular user should fail - Given user "user0" exists - When "user0" creates a "not user-visible" tag with name "MySuperAwesomeTagName" - Then The response should have a status code "400" - And "0" tags should exist for "admin" - - Scenario: Renaming a normal tag as regular user should work - Given user "user0" exists - Given "admin" creates a "normal" tag with name "MySuperAwesomeTagName" - When "user0" edits the tag with name "MySuperAwesomeTagName" and sets its name to "AnotherTagName" - Then The response should have a status code "207" - And The following tags should exist for "admin" - |AnotherTagName|true|true| - - Scenario: Renaming a not user-assignable tag as regular user should fail - Given user "user0" exists - Given "admin" creates a "not user-assignable" tag with name "MySuperAwesomeTagName" - When "user0" edits the tag with name "MySuperAwesomeTagName" and sets its name to "AnotherTagName" - Then The response should have a status code "403" - And The following tags should exist for "admin" - |MySuperAwesomeTagName|true|false| - - Scenario: Renaming a not user-visible tag as regular user should fail - Given user "user0" exists - Given "admin" creates a "not user-visible" tag with name "MySuperAwesomeTagName" - When "user0" edits the tag with name "MySuperAwesomeTagName" and sets its name to "AnotherTagName" - Then The response should have a status code "404" - And The following tags should exist for "admin" - |MySuperAwesomeTagName|false|true| - - Scenario: Deleting a normal tag as regular user should work - Given user "user0" exists - Given "admin" creates a "normal" tag with name "MySuperAwesomeTagName" - When "user0" deletes the tag with name "MySuperAwesomeTagName" - Then The response should have a status code "204" - And "0" tags should exist for "admin" - - Scenario: Deleting a not user-assignable tag as regular user should fail - Given user "user0" exists - Given "admin" creates a "not user-assignable" tag with name "MySuperAwesomeTagName" - When "user0" deletes the tag with name "MySuperAwesomeTagName" - Then The response should have a status code "403" - And The following tags should exist for "admin" - |MySuperAwesomeTagName|true|false| - - Scenario: Deleting a not user-visible tag as regular user should fail - Given user "user0" exists - Given "admin" creates a "not user-visible" tag with name "MySuperAwesomeTagName" - When "user0" deletes the tag with name "MySuperAwesomeTagName" - Then The response should have a status code "404" - And The following tags should exist for "admin" - |MySuperAwesomeTagName|false|true| - - Scenario: Deleting a not user-assignable tag as admin should work - Given "admin" creates a "not user-assignable" tag with name "MySuperAwesomeTagName" - When "admin" deletes the tag with name "MySuperAwesomeTagName" - Then The response should have a status code "204" - And "0" tags should exist for "admin" - - Scenario: Deleting a not user-visible tag as admin should work - Given "admin" creates a "not user-visible" tag with name "MySuperAwesomeTagName" - When "admin" deletes the tag with name "MySuperAwesomeTagName" - Then The response should have a status code "204" - And "0" tags should exist for "admin" - - Scenario: Assigning a normal tag to a file shared by someone else as regular user should work - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MySuperAwesomeTagName" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - When "user1" adds the tag "MySuperAwesomeTagName" to "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "201" - And "/myFileToTag.txt" shared by "user0" has the following tags - |MySuperAwesomeTagName| - - Scenario: Assigning a normal tag to a file belonging to someone else as regular user should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - When "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - When "user1" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "404" - And "/myFileToTag.txt" shared by "user0" has the following tags - |MyFirstTag| - - Scenario: Assigning a not user-assignable tag to a file shared by someone else as regular user should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "not user-assignable" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - When "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - When "user1" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "403" - And "/myFileToTag.txt" shared by "user0" has the following tags - |MyFirstTag| - - Scenario: Assigning a not user-visible tag to a file shared by someone else as regular user should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "not user-visible" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - When "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - When "user1" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "412" - And "/myFileToTag.txt" shared by "user0" has the following tags - |MyFirstTag| - - Scenario: Assigning a not user-visible tag to a file shared by someone else as admin user should work - Given user "user0" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "not user-visible" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - When "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - When "admin" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "201" - And "/myFileToTag.txt" shared by "user0" has the following tags for "admin" - |MyFirstTag| - |MySecondTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MyFirstTag| - - Scenario: Assigning a not user-assignable tag to a file shared by someone else as admin user should worj - Given user "user0" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "not user-assignable" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - When "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - When "admin" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "201" - And "/myFileToTag.txt" shared by "user0" has the following tags for "admin" - |MyFirstTag| - |MySecondTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MyFirstTag| - |MySecondTag| - - Scenario: Unassigning a normal tag from a file shared by someone else as regular user should work - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - When "user1" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "204" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MySecondTag| - - Scenario: Unassigning a normal tag from a file unshared by someone else as regular user should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - When "user1" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "404" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MyFirstTag| - |MySecondTag| - - Scenario: Unassigning a not user-visible tag from a file shared by someone else as regular user should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "not user-visible" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - Given "admin" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - When "user1" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "404" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MySecondTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "admin" - |MyFirstTag| - |MySecondTag| - - Scenario: Unassigning a not user-visible tag from a file shared by someone else as admin should work - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "not user-visible" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - Given "admin" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - When "admin" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "204" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MySecondTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "admin" - |MySecondTag| - - Scenario: Unassigning a not user-visible tag from a file unshared by someone else should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "not user-visible" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - Given "admin" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Given As "user0" remove all shares from the file named "/myFileToTag.txt" - When "admin" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "404" - - Scenario: Unassigning a not user-assignable tag from a file shared by someone else as regular user should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "not user-assignable" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - Given "admin" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - When "user1" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "403" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MyFirstTag| - |MySecondTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "admin" - |MyFirstTag| - |MySecondTag| - - Scenario: Unassigning a not user-assignable tag from a file shared by someone else as admin should work - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "not user-assignable" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - Given "admin" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - When "admin" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "204" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MySecondTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "admin" - |MySecondTag| - - Scenario: Unassigning a not user-assignable tag from a file unshared by someone else should fail - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "not user-assignable" tag with name "MyFirstTag" - Given "admin" creates a "normal" tag with name "MySecondTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | user1 | - | shareType | 0 | - Given As "user0" sending "POST" to "/apps/files_sharing/api/v1/shares" with - | path | myFileToTag.txt | - | shareWith | admin | - | shareType | 0 | - Given "admin" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - Given "user0" adds the tag "MySecondTag" to "/myFileToTag.txt" shared by "user0" - Given As "user0" remove all shares from the file named "/myFileToTag.txt" - When "admin" removes the tag "MyFirstTag" from "/myFileToTag.txt" shared by "user0" - Then The response should have a status code "404" - - Scenario: Overwriting existing normal tags should fail - Given user "user0" exists - Given "user0" creates a "normal" tag with name "MyFirstTag" - When "user0" creates a "normal" tag with name "MyFirstTag" - Then The response should have a status code "409" - - Scenario: Overwriting existing not user-assignable tags should fail - Given "admin" creates a "not user-assignable" tag with name "MyFirstTag" - When "admin" creates a "not user-assignable" tag with name "MyFirstTag" - Then The response should have a status code "409" - - Scenario: Overwriting existing not user-visible tags should fail - Given "admin" creates a "not user-visible" tag with name "MyFirstTag" - When "admin" creates a "not user-visible" tag with name "MyFirstTag" - Then The response should have a status code "409" - - Scenario: Getting tags only works with access to the file - Given user "user0" exists - Given user "user1" exists - Given "admin" creates a "normal" tag with name "MyFirstTag" - Given user "user0" uploads file "data/textfile.txt" to "/myFileToTag.txt" - When "user0" adds the tag "MyFirstTag" to "/myFileToTag.txt" shared by "user0" - And "/myFileToTag.txt" shared by "user0" has the following tags for "user0" - |MyFirstTag| - And "/myFileToTag.txt" shared by "user0" has the following tags for "user1" - || - And The response should have a status code "404" diff --git a/build/integration/features/webdav-related.feature b/build/integration/features/webdav-related.feature deleted file mode 100644 index ee841f9eb5b..00000000000 --- a/build/integration/features/webdav-related.feature +++ /dev/null @@ -1,243 +0,0 @@ -Feature: webdav-related - Background: - Given using api version "1" - - Scenario: moving a file old way - Given using dav path "remote.php/webdav" - And As an "admin" - And user "user0" exists - When User "user0" moves file "/textfile0.txt" to "/FOLDER/textfile0.txt" - Then the HTTP status code should be "201" - - Scenario: download a file with range - Given using dav path "remote.php/webdav" - And As an "admin" - When Downloading file "/welcome.txt" with range "bytes=51-77" - Then Downloaded content should be "example file for developers" - - Scenario: Upload forbidden if quota is 0 - Given using dav path "remote.php/webdav" - And As an "admin" - And user "user0" exists - And user "user0" has a quota of "0" - When User "user0" uploads file "data/textfile.txt" to "/asdf.txt" - Then the HTTP status code should be "507" - - Scenario: Retrieving folder quota when no quota is set - Given using dav path "remote.php/webdav" - And As an "admin" - And user "user0" exists - When user "user0" has unlimited quota - Then as "user0" gets properties of folder "/" with - |{DAV:}quota-available-bytes| - And the single response should contain a property "{DAV:}quota-available-bytes" with value "-3" - - Scenario: Retrieving folder quota when quota is set - Given using dav path "remote.php/webdav" - And As an "admin" - And user "user0" exists - When user "user0" has a quota of "10 MB" - Then as "user0" gets properties of folder "/" with - |{DAV:}quota-available-bytes| - And the single response should contain a property "{DAV:}quota-available-bytes" with value "10485429" - - Scenario: Retrieving folder quota of shared folder with quota when no quota is set for recipient - Given using dav path "remote.php/webdav" - And As an "admin" - And user "user0" exists - And user "user1" exists - And user "user0" has unlimited quota - And user "user1" has a quota of "10 MB" - And As an "user1" - And user "user1" created a folder "/testquota" - And as "user1" creating a share with - | path | testquota | - | shareType | 0 | - | permissions | 31 | - | shareWith | user0 | - Then as "user0" gets properties of folder "/testquota" with - |{DAV:}quota-available-bytes| - And the single response should contain a property "{DAV:}quota-available-bytes" with value "10485429" - - Scenario: download a public shared file with range - Given user "user0" exists - And As an "user0" - When creating a share with - | path | welcome.txt | - | shareType | 3 | - And Downloading last public shared file with range "bytes=51-77" - Then Downloaded content should be "example file for developers" - - Scenario: Downloading a file on the old endpoint should serve security headers - Given using dav path "remote.php/webdav" - And As an "admin" - When Downloading file "/welcome.txt" - Then The following headers should be set - |Content-Disposition|attachment| - |Content-Security-Policy|default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src *; img-src * data: blob:; font-src 'self' data:; media-src *; connect-src *| - |X-Content-Type-Options |nosniff| - |X-Download-Options|noopen| - |X-Frame-Options|Sameorigin| - |X-Permitted-Cross-Domain-Policies|none| - |X-Robots-Tag|none| - |X-XSS-Protection|1; mode=block| - And Downloaded content should start with "Welcome to your ownCloud account!" - - Scenario: Downloading a file on the new endpoint should serve security headers - Given using dav path "remote.php/dav/files/admin/" - And As an "admin" - When Downloading file "/welcome.txt" - Then The following headers should be set - |Content-Disposition|attachment| - |Content-Security-Policy|default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src *; img-src * data: blob:; font-src 'self' data:; media-src *; connect-src *| - |X-Content-Type-Options |nosniff| - |X-Download-Options|noopen| - |X-Frame-Options|Sameorigin| - |X-Permitted-Cross-Domain-Policies|none| - |X-Robots-Tag|none| - |X-XSS-Protection|1; mode=block| - And Downloaded content should start with "Welcome to your ownCloud account!" - - Scenario: Doing a GET with a web login should work without CSRF token on the new backend - Given Logging in using web as "admin" - When Sending a "GET" to "/remote.php/dav/files/admin/welcome.txt" without requesttoken - Then Downloaded content should start with "Welcome to your ownCloud account!" - Then the HTTP status code should be "200" - - Scenario: Doing a GET with a web login should work with CSRF token on the new backend - Given Logging in using web as "admin" - When Sending a "GET" to "/remote.php/dav/files/admin/welcome.txt" with requesttoken - Then Downloaded content should start with "Welcome to your ownCloud account!" - Then the HTTP status code should be "200" - - Scenario: Doing a PROPFIND with a web login should not work without CSRF token on the new backend - Given Logging in using web as "admin" - When Sending a "PROPFIND" to "/remote.php/dav/files/admin/welcome.txt" without requesttoken - Then the HTTP status code should be "401" - - Scenario: Doing a PROPFIND with a web login should work with CSRF token on the new backend - Given Logging in using web as "admin" - When Sending a "PROPFIND" to "/remote.php/dav/files/admin/welcome.txt" with requesttoken - Then the HTTP status code should be "207" - - Scenario: Doing a GET with a web login should work without CSRF token on the old backend - Given Logging in using web as "admin" - When Sending a "GET" to "/remote.php/webdav/welcome.txt" without requesttoken - Then Downloaded content should start with "Welcome to your ownCloud account!" - Then the HTTP status code should be "200" - - Scenario: Doing a GET with a web login should work with CSRF token on the old backend - Given Logging in using web as "admin" - When Sending a "GET" to "/remote.php/webdav/welcome.txt" with requesttoken - Then Downloaded content should start with "Welcome to your ownCloud account!" - Then the HTTP status code should be "200" - - Scenario: Doing a PROPFIND with a web login should not work without CSRF token on the old backend - Given Logging in using web as "admin" - When Sending a "PROPFIND" to "/remote.php/webdav/welcome.txt" without requesttoken - Then the HTTP status code should be "401" - - Scenario: Doing a PROPFIND with a web login should work with CSRF token on the old backend - Given Logging in using web as "admin" - When Sending a "PROPFIND" to "/remote.php/webdav/welcome.txt" with requesttoken - Then the HTTP status code should be "207" - - Scenario: Upload chunked file asc - Given user "user0" exists - And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChunkedFile.txt" - And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChunkedFile.txt" - And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChunkedFile.txt" - When As an "user0" - And Downloading file "/myChunkedFile.txt" - Then Downloaded content should be "AAAAABBBBBCCCCC" - - Scenario: Upload chunked file desc - Given user "user0" exists - And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChunkedFile.txt" - And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChunkedFile.txt" - And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChunkedFile.txt" - When As an "user0" - And Downloading file "/myChunkedFile.txt" - Then Downloaded content should be "AAAAABBBBBCCCCC" - - Scenario: Upload chunked file random - Given user "user0" exists - And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChunkedFile.txt" - And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChunkedFile.txt" - And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChunkedFile.txt" - When As an "user0" - And Downloading file "/myChunkedFile.txt" - Then Downloaded content should be "AAAAABBBBBCCCCC" - - Scenario: A file that is not shared does not have a share-types property - Given user "user0" exists - And user "user0" created a folder "/test" - When as "user0" gets properties of folder "/test" with - |{http://owncloud.org/ns}share-types| - Then the response should contain an empty property "{http://owncloud.org/ns}share-types" - - Scenario: A file that is shared to a user has a share-types property - Given user "user0" exists - And user "user1" exists - And user "user0" created a folder "/test" - And as "user0" creating a share with - | path | test | - | shareType | 0 | - | permissions | 31 | - | shareWith | user1 | - When as "user0" gets properties of folder "/test" with - |{http://owncloud.org/ns}share-types| - Then the response should contain a share-types property with - | 0 | - - Scenario: A file that is shared to a group has a share-types property - Given user "user0" exists - And group "group1" exists - And user "user0" created a folder "/test" - And as "user0" creating a share with - | path | test | - | shareType | 1 | - | permissions | 31 | - | shareWith | group1 | - When as "user0" gets properties of folder "/test" with - |{http://owncloud.org/ns}share-types| - Then the response should contain a share-types property with - | 1 | - - Scenario: A file that is shared by link has a share-types property - Given user "user0" exists - And user "user0" created a folder "/test" - And as "user0" creating a share with - | path | test | - | shareType | 3 | - | permissions | 31 | - When as "user0" gets properties of folder "/test" with - |{http://owncloud.org/ns}share-types| - Then the response should contain a share-types property with - | 3 | - - Scenario: A file that is shared by user,group and link has a share-types property - Given user "user0" exists - And user "user1" exists - And group "group2" exists - And user "user0" created a folder "/test" - And as "user0" creating a share with - | path | test | - | shareType | 0 | - | permissions | 31 | - | shareWith | user1 | - And as "user0" creating a share with - | path | test | - | shareType | 1 | - | permissions | 31 | - | shareWith | group2 | - And as "user0" creating a share with - | path | test | - | shareType | 3 | - | permissions | 31 | - When as "user0" gets properties of folder "/test" with - |{http://owncloud.org/ns}share-types| - Then the response should contain a share-types property with - | 0 | - | 1 | - | 3 | |