From 8517f84ea3ac443cff048d76f09bc60c1fc19f60 Mon Sep 17 00:00:00 2001 From: Viktor Scharf Date: Mon, 19 Oct 2026 11:39:48 +0200 Subject: [PATCH] tests: add acceptance tests for sharing with guests via email (#3751) Share resources with an email address via the sharingNg API and access them with the guest link from the email. --- .woodpecker.star | 2 + tests/acceptance/TestHelpers/GraphHelper.php | 8 +- .../TestHelpers/GuestLinkHelper.php | 125 +++++ .../acceptance/bootstrap/GuestLinkContext.php | 487 ++++++++++++++++++ .../acceptance/bootstrap/SharingNgContext.php | 10 +- tests/acceptance/config/behat.yml | 12 + .../expected-failures-decomposed-storage.md | 4 + .../expected-failures-posix-storage.md | 4 + .../apiGuestLink/guestLinkAccess.feature | 291 +++++++++++ .../apiGuestLink/guestLinkInvitation.feature | 239 +++++++++ .../guestLinkProjectSpace.feature | 127 +++++ 11 files changed, 1306 insertions(+), 3 deletions(-) create mode 100644 tests/acceptance/TestHelpers/GuestLinkHelper.php create mode 100644 tests/acceptance/bootstrap/GuestLinkContext.php create mode 100644 tests/acceptance/features/apiGuestLink/guestLinkAccess.feature create mode 100644 tests/acceptance/features/apiGuestLink/guestLinkInvitation.feature create mode 100644 tests/acceptance/features/apiGuestLink/guestLinkProjectSpace.feature diff --git a/.woodpecker.star b/.woodpecker.star index 510b1b5bd5..d039402543 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -250,6 +250,7 @@ config = { "notification": { "suites": [ "apiNotification", + "apiGuestLink", ], "skip": False, "withRemotePhp": False, @@ -265,6 +266,7 @@ config = { "NOTIFICATIONS_SMTP_INSECURE": True, "NOTIFICATIONS_SMTP_SENDER": "ownCloud ", "NOTIFICATIONS_DEBUG_ADDR": "0.0.0.0:9174", + "OC_ENABLE_GUEST_LINKS": True, }, }, "antivirus": { diff --git a/tests/acceptance/TestHelpers/GraphHelper.php b/tests/acceptance/TestHelpers/GraphHelper.php index 4a1a6e7fa5..acee0c4ac0 100644 --- a/tests/acceptance/TestHelpers/GraphHelper.php +++ b/tests/acceptance/TestHelpers/GraphHelper.php @@ -1765,6 +1765,11 @@ class GraphHelper { foreach ($shareeIds as $index => $shareeId) { $shareType = $shareTypes[$index]; + if ($shareType === "guest") { + // guest invitation via email address + $body['recipients'][] = ["email" => $shareeId]; + continue; + } $body['recipients'][] = [ "@libre.graph.recipient.type" => $shareType, "objectId" => $shareeId @@ -1780,7 +1785,8 @@ class GraphHelper { $body['@libre.graph.permissions.actions'] = ['libre.graph/driveItem/' . $permissionsAction]; } - if ($expirationDateTime !== null) { + if ($expirationDateTime !== null || \in_array("guest", $shareTypes, true)) { + // guest invitations always send expirationDateTime, like the web client does $body['expirationDateTime'] = $expirationDateTime; } return $body; diff --git a/tests/acceptance/TestHelpers/GuestLinkHelper.php b/tests/acceptance/TestHelpers/GuestLinkHelper.php new file mode 100644 index 0000000000..cc3a92210e --- /dev/null +++ b/tests/acceptance/TestHelpers/GuestLinkHelper.php @@ -0,0 +1,125 @@ + + * @copyright Copyright (c) 2026 OpenCloud GmbH + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, + * as published by the Free Software Foundation; + * either version 3 of the License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see + * + */ + +namespace TestHelpers; + +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\ResponseInterface; + +/** + * Helper for the unauthenticated guest links endpoints of the auth-guest service + */ +class GuestLinkHelper { + public const SESSION_COOKIE_NAME = '__Host-oc_guest_session'; + + /** + * matches the guest link token in a guest link email, e.g. "/g/v1.." + */ + public const TOKEN_IN_EMAIL_REGEX = '/\/g\/(v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/'; + + /** + * @param string $baseUrl + * @param string $action verify/token|verify/pin|renew + * + * @return string + */ + public static function getUrl(string $baseUrl, string $action): string { + return GraphHelper::getBetaFullUrl($baseUrl, "extensions/org.libregraph/guestLinks/$action"); + } + + /** + * @param array $cookies cookie name => value + * + * @return array + */ + public static function getHeaders(array $cookies = []): array { + $headers = ['Content-Type' => 'application/json']; + if ($cookies !== []) { + $headers['Cookie'] = \implode( + '; ', + \array_map(fn ($name, $value) => "$name=$value", \array_keys($cookies), $cookies) + ); + } + return $headers; + } + + /** + * @param string $baseUrl + * @param string $xRequestId + * @param string $token + * + * @return ResponseInterface + * @throws GuzzleException + */ + public static function verifyToken(string $baseUrl, string $xRequestId, string $token): ResponseInterface { + return HttpRequestHelper::post( + self::getUrl($baseUrl, 'verify/token'), + $xRequestId, + null, + null, + self::getHeaders(), + \json_encode(['token' => $token]) + ); + } + + /** + * Returns the Set-Cookie headers of the response, keyed by the cookie name + * + * @param ResponseInterface $response + * + * @return array + */ + public static function getSetCookieHeaders(ResponseInterface $response): array { + $setCookies = []; + foreach ($response->getHeader('Set-Cookie') as $setCookie) { + $name = \trim(\explode('=', \explode(';', $setCookie, 2)[0], 2)[0]); + $setCookies[$name] = $setCookie; + } + return $setCookies; + } + + /** + * Returns the value of a cookie set by the response, or null when it is not set or is cleared + * + * @param string $setCookie + * + * @return string|null + */ + public static function getCookieValue(string $setCookie): ?string { + $value = \explode('=', \explode(';', $setCookie, 2)[0], 2)[1] ?? ''; + $attributes = \strtolower($setCookie); + if ($value === '' || \str_contains($attributes, 'max-age=0') || \preg_match('/max-age=-\d/', $attributes)) { + return null; + } + return $value; + } + + /** + * Returns the raw Set-Cookie header of the guest session cookie, or null + * + * @param ResponseInterface $response + * + * @return string|null + */ + public static function getSessionSetCookieHeader(ResponseInterface $response): ?string { + return self::getSetCookieHeaders($response)[self::SESSION_COOKIE_NAME] ?? null; + } + +} diff --git a/tests/acceptance/bootstrap/GuestLinkContext.php b/tests/acceptance/bootstrap/GuestLinkContext.php new file mode 100644 index 0000000000..1819d22ac1 --- /dev/null +++ b/tests/acceptance/bootstrap/GuestLinkContext.php @@ -0,0 +1,487 @@ + + * @copyright Copyright (c) 2026 OpenCloud GmbH + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, + * as published by the Free Software Foundation; + * either version 3 of the License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode; +use Behat\Step\Given; +use Behat\Step\Then; +use Behat\Step\When; +use GuzzleHttp\Exception\GuzzleException; +use PHPUnit\Framework\Assert; +use Psr\Http\Message\ResponseInterface; +use TestHelpers\BehatHelper; +use TestHelpers\EmailHelper; +use TestHelpers\GuestLinkHelper; +use TestHelpers\HttpRequestHelper; +use TestHelpers\WaitHelper; +use TestHelpers\WebDavHelper; + +require_once 'bootstrap.php'; + +/** + * Steps for guest links: shares with an email address that the guest opens + * through a link (or PIN) received by email, without an OpenCloud account. + * + * The guest is treated like a browser: it keeps the session cookie set by verifying + * a link and sends it back. + */ +class GuestLinkContext implements Context { + private FeatureContext $featureContext; + private SpacesContext $spacesContext; + + /** + * guest link tokens received by each guest, in the order they were read from the emails + * + * @var array + */ + private array $guestLinkTokens = []; + + /** + * guest session cookie of each guest + * + * @var array + */ + private array $guestSessions = []; + + /** + * number of emails of each guest that the scenario has already read + * + * @var array + */ + private array $readEmailCount = []; + + /** + * @BeforeScenario + * + * @param BeforeScenarioScope $scope + * + * @return void + */ + public function before(BeforeScenarioScope $scope): void { + $environment = $scope->getEnvironment(); + $this->featureContext = BehatHelper::getContext($scope, $environment, 'FeatureContext'); + $this->spacesContext = BehatHelper::getContext($scope, $environment, 'SpacesContext'); + } + + /** + * Delete the emails of the guests that were invited in the scenario, + * so that the next scenario does not read a stale guest link. + * + * @AfterScenario + * + * @return void + */ + public function clearGuestMailboxes(): void { + foreach ($this->featureContext->emailRecipients as $mailBox) { + try { + EmailHelper::deleteAllEmails( + EmailHelper::getLocalEmailUrl(), + $mailBox, + $this->featureContext->getStepLineRef(), + ); + } catch (Exception $e) { + echo __METHOD__ . " could not delete emails of '$mailBox', is inbucket set up?\n" . $e->getMessage(); + } + } + } + + /** + * @param string $guest + * @param int $count + * @param int $timeoutSeconds + * + * @return array the emails of the guest, after at least $count arrived or the timeout passed + * @throws GuzzleException + */ + private function waitForEmails(string $guest, int $count, int $timeoutSeconds = 10): array { + $this->featureContext->pushEmailRecipientAsMailBox($guest); + $mailBox = EmailHelper::getMailBoxFromEmail($guest); + return WaitHelper::waitUntil( + fn () => EmailHelper::getMailBoxInformation($mailBox, $this->featureContext->getStepLineRef()), + fn ($emails) => \count($emails) >= $count, + 500, + $timeoutSeconds + ); + } + + /** + * Remembers the guest link contained in an email + * + * @param string $guest + * @param string $body + * + * @return void + */ + private function rememberCredentialsFromEmail(string $guest, string $body): void { + if (\preg_match(GuestLinkHelper::TOKEN_IN_EMAIL_REGEX, $body, $matches)) { + $knownTokens = $this->guestLinkTokens[$guest] ?? []; + if (\end($knownTokens) !== $matches[1]) { + $this->guestLinkTokens[$guest][] = $matches[1]; + } + } + } + + /** + * Waits for an email that the scenario has not read yet and returns its body + * + * @param string $guest + * + * @return string + * @throws GuzzleException + */ + private function readNewEmail(string $guest): string { + $expectedCount = ($this->readEmailCount[$guest] ?? 0) + 1; + $emails = $this->waitForEmails($guest, $expectedCount); + Assert::assertGreaterThanOrEqual( + $expectedCount, + \count($emails), + "Guest '$guest' was expected to have received a new email" + ); + $this->readEmailCount[$guest] = \count($emails); + $body = EmailHelper::getBodyOfLastEmail($guest, $this->featureContext->getStepLineRef()); + $this->rememberCredentialsFromEmail($guest, $body); + return $body; + } + + /** + * Returns the body of the last email, waiting for the first one if none was read yet + * + * @param string $guest + * + * @return string + * @throws GuzzleException + */ + private function readLastEmail(string $guest): string { + if (($this->readEmailCount[$guest] ?? 0) === 0) { + return $this->readNewEmail($guest); + } + $body = EmailHelper::getBodyOfLastEmail($guest, $this->featureContext->getStepLineRef()); + $this->rememberCredentialsFromEmail($guest, $body); + return $body; + } + + /** + * @param string $guest + * + * @return string + * @throws GuzzleException + */ + private function getGuestLinkTokenFromLastEmail(string $guest): string { + $body = $this->readLastEmail($guest); + Assert::assertMatchesRegularExpression( + GuestLinkHelper::TOKEN_IN_EMAIL_REGEX, + $body, + "The last email of guest '$guest' does not contain a guest link" + ); + \preg_match(GuestLinkHelper::TOKEN_IN_EMAIL_REGEX, $body, $matches); + return $matches[1]; + } + + /** + * @param string $guest + * + * @return string + */ + private function getGuestSession(string $guest): string { + Assert::assertArrayHasKey( + $guest, + $this->guestSessions, + "Guest '$guest' has no guest session, verify a guest link or PIN first" + ); + return $this->guestSessions[$guest]; + } + + /** + * @param string $guest + * + * @return array + */ + private function getSessionHeaders(string $guest): array { + return GuestLinkHelper::getHeaders([GuestLinkHelper::SESSION_COOKIE_NAME => $this->getGuestSession($guest)]); + } + + /** + * Keeps the cookies that a guest endpoint sets, like a browser does + * + * @param string $guest + * @param ResponseInterface $response + * + * @return void + */ + private function rememberCookies(string $guest, ResponseInterface $response): void { + $sessionSetCookie = GuestLinkHelper::getSessionSetCookieHeader($response); + if ($sessionSetCookie !== null && GuestLinkHelper::getCookieValue($sessionSetCookie) !== null) { + $this->guestSessions[$guest] = GuestLinkHelper::getCookieValue($sessionSetCookie); + } + } + + /** + * @param string $guest + * @param string $token + * + * @return ResponseInterface + * @throws GuzzleException + */ + public function verifyGuestLinkToken(string $guest, string $token): ResponseInterface { + $response = GuestLinkHelper::verifyToken( + $this->featureContext->getBaseUrl(), + $this->featureContext->getStepLineRef(), + $token + ); + $this->rememberCookies($guest, $response); + return $response; + } + + /** + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest verifies the guest link from the last email using the Graph API')] + public function guestVerifiesTheGuestLinkFromTheLastEmail(string $guest): void { + $token = $this->getGuestLinkTokenFromLastEmail($guest); + $this->featureContext->setResponse($this->verifyGuestLinkToken($guest, $token)); + } + + /** + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[Given('guest :guest has verified the guest link from the last email')] + public function guestHasVerifiedTheGuestLinkFromTheLastEmail(string $guest): void { + $token = $this->getGuestLinkTokenFromLastEmail($guest); + $response = $this->verifyGuestLinkToken($guest, $token); + $this->featureContext->theHTTPStatusCodeShouldBe(200, "", $response); + } + + /** + * Waits for the next email of the guest and remembers its link without using it + * + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[Given('guest :guest has received a new guest link')] + public function guestHasReceivedANewGuestLink(string $guest): void { + $body = $this->readNewEmail($guest); + Assert::assertMatchesRegularExpression( + GuestLinkHelper::TOKEN_IN_EMAIL_REGEX, + $body, + "The new email of guest '$guest' does not contain a guest link" + ); + } + + /** + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest verifies the previous guest link using the Graph API')] + public function guestVerifiesThePreviousGuestLink(string $guest): void { + $this->readLastEmail($guest); + $tokens = $this->guestLinkTokens[$guest] ?? []; + Assert::assertGreaterThanOrEqual(2, \count($tokens), "Guest '$guest' has not received a previous guest link"); + $this->featureContext->setResponse($this->verifyGuestLinkToken($guest, $tokens[\count($tokens) - 2])); + } + + /** + * @param string $guest + * @param string $token + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest verifies the guest link token :token using the Graph API')] + public function guestVerifiesTheGuestLinkToken(string $guest, string $token): void { + $this->featureContext->setResponse($this->verifyGuestLinkToken($guest, $token)); + } + + /** + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest lists the shares shared with them using the Graph API')] + public function guestListsTheSharesSharedWithThem(string $guest): void { + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->featureContext->getBaseUrl() . '/graph/v1beta1/me/drive/sharedWithMe', + $this->featureContext->getStepLineRef(), + null, + null, + $this->getSessionHeaders($guest), + ) + ); + } + + /** + * @param string $guest + * @param string $resource + * @param string $space + * @param string $owner + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest downloads file :resource shared from space :space of user :owner using the WebDAV API')] + public function guestDownloadsSharedFile(string $guest, string $resource, string $space, string $owner): void { + $fileId = $this->spacesContext->getResourceId($owner, $space, $resource); + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->featureContext->getBaseUrl() . "/" . WebDavHelper::prefixRemotePhp("dav/spaces/$fileId"), + $this->featureContext->getStepLineRef(), + null, + null, + $this->getSessionHeaders($guest), + ) + ); + } + + /** + * @param string $guest + * @param string $content + * @param string $resource + * @param string $space + * @param string $owner + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest uploads content :content to file :resource shared from space :space of user :owner using the WebDAV API')] + public function guestUploadsContentToSharedFile( + string $guest, + string $content, + string $resource, + string $space, + string $owner + ): void { + $fileId = $this->spacesContext->getResourceId($owner, $space, $resource); + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $this->featureContext->getBaseUrl() . "/" . WebDavHelper::prefixRemotePhp("dav/spaces/$fileId"), + $this->featureContext->getStepLineRef(), + 'PUT', + null, + null, + $this->getSessionHeaders($guest), + $content, + ) + ); + } + + /** + * @param string $guest + * @param string $method + * @param string $url + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest sends HTTP method :method to URL :url with the guest session')] + public function guestSendsHttpMethodToUrlWithTheGuestSession(string $guest, string $method, string $url): void { + $url = $this->featureContext->substituteInLineCodes($url); + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $this->featureContext->getBaseUrl() . $url, + $this->featureContext->getStepLineRef(), + $method, + null, + null, + $this->getSessionHeaders($guest), + ) + ); + } + + + /** + * @return void + */ + #[Then('the response should contain a guest session cookie')] + public function theResponseShouldContainAGuestSessionCookie(): void { + $setCookie = GuestLinkHelper::getSessionSetCookieHeader($this->featureContext->getResponse()); + Assert::assertNotNull($setCookie, "The response does not set the guest session cookie"); + $this->assertCookieIsHardened($setCookie); + } + + /** + * @return void + */ + #[Then('the response should not contain a guest session cookie')] + public function theResponseShouldNotContainAGuestSessionCookie(): void { + Assert::assertNull( + GuestLinkHelper::getSessionSetCookieHeader($this->featureContext->getResponse()), + "The response was not expected to set the guest session cookie" + ); + } + + /** + * @param string $setCookie + * + * @return void + */ + private function assertCookieIsHardened(string $setCookie): void { + $attributes = \array_map('trim', \explode(';', \strtolower($setCookie))); + foreach (['path=/', 'httponly', 'secure', 'samesite=lax'] as $expectedAttribute) { + Assert::assertContains( + $expectedAttribute, + $attributes, + "The cookie '$setCookie' is missing the attribute '$expectedAttribute'" + ); + } + } + + /** + * The guest link token is random, so it is compared as the placeholder %guest_link_token% + * + * @param string $guest + * @param string $sender + * @param PyStringNode $content + * + * @return void + * @throws GuzzleException + */ + #[Then('guest :guest should have received the following email from user :sender')] + public function guestShouldHaveReceivedTheFollowingEmailFromUser( + string $guest, + string $sender, + PyStringNode $content + ): void { + $expectedContent = $this->featureContext->substituteInLineCodes( + \str_replace("\r\n", "\n", $content->getRaw()), + $sender + ); + $actualContent = $this->readNewEmail($guest); + $actualContent = \preg_replace(GuestLinkHelper::TOKEN_IN_EMAIL_REGEX, '/g/%guest_link_token%', $actualContent); + Assert::assertStringContainsString( + $expectedContent, + $actualContent, + "The new email of guest '$guest' was expected to contain '$expectedContent'" + . " but the received email is '$actualContent'" + ); + } + +} diff --git a/tests/acceptance/bootstrap/SharingNgContext.php b/tests/acceptance/bootstrap/SharingNgContext.php index 7b2fe59cf4..9479ae42a8 100644 --- a/tests/acceptance/bootstrap/SharingNgContext.php +++ b/tests/acceptance/bootstrap/SharingNgContext.php @@ -401,6 +401,9 @@ class SharingNgContext implements Context { } } elseif ($shareType === "group") { $shareeId = $this->featureContext->getAttributeOfCreatedGroup($sharee, 'id'); + } elseif ($shareType === "guest") { + $shareeId = $sharee; + $this->featureContext->pushEmailRecipientAsMailBox($sharee); } // for non-existing group or user, generate random id $shareeIds[] = $shareeId ?: WebDavHelper::generateUUIDv4(); @@ -476,6 +479,9 @@ class SharingNgContext implements Context { } } elseif ($shareType === "group") { $shareeId = $this->featureContext->getAttributeOfCreatedGroup($sharee, 'id'); + } elseif ($shareType === "guest") { + $shareeId = $sharee; + $this->featureContext->pushEmailRecipientAsMailBox($sharee); } // for non-existing group or user, generate random id $shareeIds[] = $shareeId ?: WebDavHelper::generateUUIDv4(); @@ -1131,7 +1137,7 @@ class SharingNgContext implements Context { * @throws JsonException * @throws GuzzleException */ - #[Given('/^user "([^"]*)" has removed the access of (user|group) "([^"]*)" from (?:file|folder|resource) "([^"]*)" of space "([^"]*)"$/')] + #[Given('/^user "([^"]*)" has removed the access of (user|group|guest) "([^"]*)" from (?:file|folder|resource) "([^"]*)" of space "([^"]*)"$/')] public function userHasRemovedAccessOfUserOrGroupFromResourceOfSpace( string $sharer, string $recipientType, @@ -1155,7 +1161,7 @@ class SharingNgContext implements Context { * @throws JsonException * @throws GuzzleException */ - #[When('/^user "([^"]*)" removes the access of (user|group) "([^"]*)" from (?:file|folder|resource) "([^"]*)" of space "([^"]*)" using the Graph API$/')] + #[When('/^user "([^"]*)" removes the access of (user|group|guest) "([^"]*)" from (?:file|folder|resource) "([^"]*)" of space "([^"]*)" using the Graph API$/')] public function userRemovesAccessOfUserOrGroupFromResourceOfSpaceUsingGraphAPI( string $sharer, string $recipientType, diff --git a/tests/acceptance/config/behat.yml b/tests/acceptance/config/behat.yml index 7b45b0cf21..e848c8b335 100644 --- a/tests/acceptance/config/behat.yml +++ b/tests/acceptance/config/behat.yml @@ -189,6 +189,18 @@ default: - OcmContext: - CliContext: + apiGuestLink: + paths: + - "%paths.base%/../features/apiGuestLink" + context: *common_ldap_suite_context + contexts: + - FeatureContext: *common_feature_context_params + - SpacesContext: + - GraphContext: + - SharingNgContext: + - NotificationContext: + - GuestLinkContext: + apiAntivirus: paths: - "%paths.base%/../features/apiAntivirus" diff --git a/tests/acceptance/expected-failures-decomposed-storage.md b/tests/acceptance/expected-failures-decomposed-storage.md index 955535d2da..fe9449836a 100644 --- a/tests/acceptance/expected-failures-decomposed-storage.md +++ b/tests/acceptance/expected-failures-decomposed-storage.md @@ -319,6 +319,10 @@ _ocdav: api compatibility, return correct status code_ - [coreApiWebdavPreviews/previews.feature:250](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/coreApiWebdavPreviews/previews.feature#L250) - [coreApiWebdavPreviews/previews.feature:251](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/coreApiWebdavPreviews/previews.feature#L251) +#### [Guest invited to a project space does not get a guest link email](https://github.com/opencloud-eu/opencloud/issues/3744) + +- [apiGuestLink/guestLinkProjectSpace.feature:98](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/apiGuestLink/guestLinkProjectSpace.feature#L98) + #### [Preview of text file truncated](https://github.com/opencloud-eu/opencloud/issues/1452) - [coreApiWebdavPreviews/previews.feature:263](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/coreApiWebdavPreviews/previews.feature#L263) diff --git a/tests/acceptance/expected-failures-posix-storage.md b/tests/acceptance/expected-failures-posix-storage.md index ed7d0c54f4..7097cc3471 100644 --- a/tests/acceptance/expected-failures-posix-storage.md +++ b/tests/acceptance/expected-failures-posix-storage.md @@ -322,6 +322,10 @@ _ocdav: api compatibility, return correct status code_ - [coreApiWebdavPreviews/previews.feature:250](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/coreApiWebdavPreviews/previews.feature#L250) - [coreApiWebdavPreviews/previews.feature:251](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/coreApiWebdavPreviews/previews.feature#L251) +#### [Guest invited to a project space does not get a guest link email](https://github.com/opencloud-eu/opencloud/issues/3744) + +- [apiGuestLink/guestLinkProjectSpace.feature:98](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/apiGuestLink/guestLinkProjectSpace.feature#L98) + #### [Preview of text file truncated](https://github.com/opencloud-eu/opencloud/issues/1452) - [coreApiWebdavPreviews/previews.feature:263](https://github.com/opencloud-eu/opencloud/blob/main/tests/acceptance/features/coreApiWebdavPreviews/previews.feature#L263) diff --git a/tests/acceptance/features/apiGuestLink/guestLinkAccess.feature b/tests/acceptance/features/apiGuestLink/guestLinkAccess.feature new file mode 100644 index 0000000000..b3d8176406 --- /dev/null +++ b/tests/acceptance/features/apiGuestLink/guestLinkAccess.feature @@ -0,0 +1,291 @@ +Feature: access a shared resource as a guest + As a guest without an account + I want to open the guest link I received by email + So that I can access the resource that was shared with me + + Background: + Given using SharingNG + And user "Alice" has been created with default attributes + And user "Alice" has uploaded file with content "to share" to "textfile.txt" + + @email + Scenario: guest verifies the guest link and gets a session + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + When guest "guest@example.org" verifies the guest link from the last email using the Graph API + Then the HTTP status code should be "200" + And the response should contain a guest session cookie + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["permissionId"], + "properties": { + "permissionId": { "const": "%last_share_id%" } + } + } + """ + + @email + Scenario: guest link can only be used once + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + When guest "guest@example.org" verifies the guest link from the last email using the Graph API + Then the HTTP status code should be "409" + And the response should not contain a guest session cookie + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["errorType", "permissionId"], + "properties": { + "errorType": { "const": "tokenAlreadyRedeemed" }, + "permissionId": { "const": "%last_share_id%" } + } + } + """ + + + Scenario Outline: verifying an invalid guest link token fails + When guest "guest@example.org" verifies the guest link token "" using the Graph API + Then the HTTP status code should be "" + And the response should not contain a guest session cookie + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["errorType", "permissionId"], + "properties": { + "errorType": { "const": "" }, + "permissionId": { "const": "" } + } + } + """ + Examples: + | token | http-status-code | error-type | + | | 400 | invalidRequest | + | invalid | 401 | tokenInvalid | + | v2.bJTW-HnCYqpGxkk1S1PQUOMm1vlt8W2FgZDlFSGtyu0.DXNg71vlfMV80QIp1qxl1G0MCi4RbRFSfM4rJk5Ibmw | 401 | tokenInvalid | + | v1.a.b | 401 | tokenInvalid | + | v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.DXNg71vlfMV80QIp1qxl1G0MCi4RbRFSfM4rJk5Ibmw | 404 | tokenNotFound | + + @email + Scenario: guest lists the resource shared with them + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + When guest "guest@example.org" lists the shares shared with them using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["name", "remoteItem"], + "properties": { + "name": { "const": "textfile.txt" }, + "remoteItem": { + "type": "object", + "required": ["permissions"], + "properties": { + "permissions": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["id", "grantedToV2"], + "properties": { + "id": { "const": "%last_share_id%" }, + "grantedToV2": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["@libre.graph.userType", "id"], + "properties": { + "@libre.graph.userType": { "const": "Guest" }, + "id": { "const": "guest@example.org" } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + """ + + @email + Scenario: guest downloads the shared file + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + When guest "guest@example.org" downloads file "textfile.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "200" + And the downloaded content should be "to share" + + @email + Scenario Outline: guest updates the shared file depending on the role + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | | + And guest "guest@example.org" has verified the guest link from the last email + When guest "guest@example.org" uploads content "changed by guest" to file "textfile.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "" + And for user "Alice" the content of the file "textfile.txt" of the space "Personal" should be "" + Examples: + | permissions-role | http-status-code | content | + | Viewer | 403 | to share | + | File Editor | 204 | changed by guest | + + @email + Scenario Outline: guest session only grants access to the guest endpoints + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + When guest "guest@example.org" sends HTTP method "" to URL "" with the guest session + Then the HTTP status code should be "401" + Examples: + | method | url | + | GET | /graph/v1.0/me | + | GET | /graph/v1.0/me/drives | + | GET | /graph/v1.0/users | + | GET | /ocs/v2.php/cloud/user | + | GET | /graph/v1beta1/me/drive/root | + + @email + Scenario: guest cannot access other resources of the sharer + Given user "Alice" has uploaded file with content "not shared" to "private.txt" + And user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + When guest "guest@example.org" downloads file "private.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "404" + + @email + Scenario: guest loses access when the share is removed + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + And user "Alice" has removed the access of guest "guest@example.org" from resource "textfile.txt" of space "Personal" + When guest "guest@example.org" downloads file "textfile.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "401" + When guest "guest@example.org" lists the shares shared with them using the Graph API + Then the HTTP status code should be "401" + + @email + Scenario: guest link cannot be used after the share is removed + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And user "Alice" has removed the access of guest "guest@example.org" from resource "textfile.txt" of space "Personal" + When guest "guest@example.org" verifies the guest link from the last email using the Graph API + Then the HTTP status code should be "404" + And the response should not contain a guest session cookie + + @email + Scenario: guest loses write access when the owner downgrades the role + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | File Editor | + And guest "guest@example.org" has verified the guest link from the last email + And user "Alice" has updated the last resource share with the following properties: + | permissionsRole | Viewer | + | space | Personal | + | resource | textfile.txt | + When guest "guest@example.org" uploads content "changed by guest" to file "textfile.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "403" + And for user "Alice" the content of the file "textfile.txt" of the space "Personal" should be "to share" + When guest "guest@example.org" downloads file "textfile.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "200" + And the downloaded content should be "to share" + + @email + Scenario: guest keeps access when the owner renames the shared resource + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has verified the guest link from the last email + And user "Alice" has moved file "textfile.txt" to "renamed.txt" + When guest "guest@example.org" downloads file "renamed.txt" shared from space "Personal" of user "Alice" using the WebDAV API + Then the HTTP status code should be "200" + And the downloaded content should be "to share" + When guest "guest@example.org" lists the shares shared with them using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "const": "renamed.txt" } + } + } + } + } + } + """ diff --git a/tests/acceptance/features/apiGuestLink/guestLinkInvitation.feature b/tests/acceptance/features/apiGuestLink/guestLinkInvitation.feature new file mode 100644 index 0000000000..bba9358777 --- /dev/null +++ b/tests/acceptance/features/apiGuestLink/guestLinkInvitation.feature @@ -0,0 +1,239 @@ +Feature: share a resource with a guest via email address + As a user + I want to share a resource with an email address of a person without an account + So that the person can access the resource through a guest link + + Background: + Given user "Alice" has been created with default attributes + And user "Alice" has uploaded file with content "to share" to "textfile.txt" + + + Scenario Outline: share a resource with a guest using an email address + Given user "Alice" has created folder "folderToShare" + When user "Alice" sends the following resource share invitation using the Graph API: + | resource | | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | | + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["id", "roles", "grantedToV2", "invitation"], + "properties": { + "id": { + "type": "string", + "pattern": "^%permissions_id_pattern%$" + }, + "roles": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "pattern": "^%role_id_pattern%$" + } + }, + "grantedToV2": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["@libre.graph.userType", "displayName", "id"], + "properties": { + "@libre.graph.userType": { "const": "Guest" }, + "displayName": { "const": "guest@example.org" }, + "id": { "const": "guest@example.org" } + } + } + } + }, + "invitation": { + "type": "object", + "required": ["invitedBy"], + "properties": { + "invitedBy": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["@libre.graph.userType", "displayName", "id"], + "properties": { + "@libre.graph.userType": { "const": "Member" }, + "displayName": { "const": "Alice Hansen" }, + "id": { "type": "string", "pattern": "^%user_id_pattern%$" } + } + } + } + } + } + } + } + } + } + } + } + """ + Examples: + | resource | permissions-role | + | textfile.txt | Viewer | + | textfile.txt | File Editor | + | folderToShare | Viewer | + | folderToShare | Editor | + + + Scenario: email address of the guest is stored in its canonical lowercase form + When user "Alice" sends the following resource share invitation using the Graph API: + | resource | textfile.txt | + | space | Personal | + | sharee | Guest@Example.ORG | + | shareType | guest | + | permissionsRole | Viewer | + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["grantedToV2"], + "properties": { + "grantedToV2": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "const": "guest@example.org" } + } + } + } + } + } + } + } + } + } + """ + + @email + Scenario: guest receives an email with a guest link + When user "Alice" sends the following resource share invitation using the Graph API: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + Then the HTTP status code should be "200" + And guest "guest@example.org" should have received the following email from user "Alice" + """ + Hello, + + %displayname% has shared "textfile.txt" with you. + + The link below is personal and can only be used once. Please do not forward it. + + The link is only valid for 30 minutes. + + Click here to view it: %base_url%/g/%guest_link_token% + """ + + + Scenario Outline: share the same resource with the same guest again + Given user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + When user "Alice" sends the following resource share invitation using the Graph API: + | resource | textfile.txt | + | space | Personal | + | sharee | | + | shareType | guest | + | permissionsRole | | + Then the HTTP status code should be "409" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "const": "nameAlreadyExists" } + } + } + } + } + """ + Examples: + | sharee | permissions-role | + | guest@example.org | Viewer | + | guest@example.org | File Editor | + | Guest@Example.ORG | Viewer | + + @email + Scenario: share a resource with a guest again after the previous share was removed + Given using SharingNG + And user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has received a new guest link + And user "Alice" has removed the access of guest "guest@example.org" from resource "textfile.txt" of space "Personal" + And user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + And guest "guest@example.org" has received a new guest link + When guest "guest@example.org" verifies the previous guest link using the Graph API + Then the HTTP status code should be "404" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["errorType"], + "properties": { + "errorType": { "const": "tokenNotFound" } + } + } + """ + When guest "guest@example.org" verifies the guest link from the last email using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["permissionId"], + "properties": { + "permissionId": { "const": "%last_share_id%" } + } + } + """ diff --git a/tests/acceptance/features/apiGuestLink/guestLinkProjectSpace.feature b/tests/acceptance/features/apiGuestLink/guestLinkProjectSpace.feature new file mode 100644 index 0000000000..fc41341888 --- /dev/null +++ b/tests/acceptance/features/apiGuestLink/guestLinkProjectSpace.feature @@ -0,0 +1,127 @@ +Feature: share a project space with a guest via email address + As a space manager + I want to invite a person without an account to a project space using their email address + So that the person can access the space through a guest link + + Background: + Given using spaces DAV path + And using SharingNG + And user "Alice" has been created with default attributes + And the administrator has assigned the role "Space Admin" to user "Alice" using the Graph API + And user "Alice" has created a space "new-space" with the default quota using the Graph API + And user "Alice" has uploaded a file inside space "new-space" with content "space content" to "textfile.txt" + + + Scenario Outline: invite a guest to a project space using an email address + When user "Alice" sends the following space share invitation using root endpoint of the Graph API: + | space | new-space | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | | + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["id", "roles", "grantedToV2", "invitation"], + "properties": { + "id": { + "type": "string", + "pattern": "^%permissions_id_pattern%$" + }, + "roles": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "pattern": "^%role_id_pattern%$" + } + }, + "grantedToV2": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["@libre.graph.userType", "displayName", "id"], + "properties": { + "@libre.graph.userType": { "const": "Guest" }, + "displayName": { "const": "guest@example.org" }, + "id": { "const": "guest@example.org" } + } + } + } + }, + "invitation": { + "type": "object", + "required": ["invitedBy"], + "properties": { + "invitedBy": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["@libre.graph.userType", "displayName", "id"], + "properties": { + "@libre.graph.userType": { "const": "Member" }, + "displayName": { "const": "Alice Hansen" }, + "id": { "type": "string", "pattern": "^%user_id_pattern%$" } + } + } + } + } + } + } + } + } + } + } + } + """ + Examples: + | permissions-role | + | Space Viewer | + | Space Editor | + | Manager | + + @email + Scenario: guest invited to a project space gets access through the guest link + Given user "Alice" has sent the following space share invitation: + | space | new-space | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Space Viewer | + When guest "guest@example.org" verifies the guest link from the last email using the Graph API + Then the HTTP status code should be "200" + And the response should contain a guest session cookie + When guest "guest@example.org" downloads file "textfile.txt" shared from space "new-space" of user "Alice" using the WebDAV API + Then the HTTP status code should be "200" + And the downloaded content should be "space content" + + @email + Scenario: guest gets access to a file shared from a project space + Given user "Alice" has uploaded a file inside space "new-space" with content "not shared" to "private.txt" + And user "Alice" has sent the following resource share invitation: + | resource | textfile.txt | + | space | new-space | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Viewer | + When guest "guest@example.org" verifies the guest link from the last email using the Graph API + Then the HTTP status code should be "200" + And the response should contain a guest session cookie + When guest "guest@example.org" downloads file "textfile.txt" shared from space "new-space" of user "Alice" using the WebDAV API + Then the HTTP status code should be "200" + And the downloaded content should be "space content" + When guest "guest@example.org" downloads file "private.txt" shared from space "new-space" of user "Alice" using the WebDAV API + Then the HTTP status code should be "404"