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.
This commit is contained in:
Viktor Scharf authored and GitHub committed 2026-10-19 11:39:48 +02:00
1 parent 6bb1f2e859
commit 8517f84ea3
11 files changed
+1306 -3

No files matched your search

+2
View File
@@ -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 <noreply@example.com>",
"NOTIFICATIONS_DEBUG_ADDR": "0.0.0.0:9174",
"OC_ENABLE_GUEST_LINKS": True,
},
},
"antivirus": {
+7 -1
View File
@@ -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;
@@ -0,0 +1,125 @@
<?php declare(strict_types=1);
/**
* @author Viktor Scharf OpenCloud GmbH <v.scharf@opencloud.eu>
* @copyright Copyright (c) 2026 OpenCloud GmbH <v.scharf@opencloud.eu>
*
* 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 <http://www.gnu.org/licenses/>
*
*/
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.<share id hash>.<secret>"
*/
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<string, string> $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<string, string>
*/
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;
}
}
@@ -0,0 +1,487 @@
<?php declare(strict_types=1);
/**
* @author Viktor Scharf OpenCloud GmbH <v.scharf@opencloud.eu>
* @copyright Copyright (c) 2026 OpenCloud GmbH <v.scharf@opencloud.eu>
*
* 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 <http://www.gnu.org/licenses/>
*
*/
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<string, string[]>
*/
private array $guestLinkTokens = [];
/**
* guest session cookie of each guest
*
* @var array<string, string>
*/
private array $guestSessions = [];
/**
* number of emails of each guest that the scenario has already read
*
* @var array<string, int>
*/
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'"
);
}
}
@@ -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,
+12
View File
@@ -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"
@@ -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)
@@ -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)
@@ -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 "<token>" using the Graph API
Then the HTTP status code should be "<http-status-code>"
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": "<error-type>" },
"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 | <permissions-role> |
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 "<http-status-code>"
And for user "Alice" the content of the file "textfile.txt" of the space "Personal" should be "<content>"
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 "<method>" to URL "<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" }
}
}
}
}
}
"""
@@ -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 | <resource> |
| space | Personal |
| sharee | guest@example.org |
| shareType | guest |
| permissionsRole | <permissions-role> |
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 | <sharee> |
| shareType | guest |
| permissionsRole | <permissions-role> |
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%" }
}
}
"""
@@ -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 | <permissions-role> |
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"