mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-30 10:08:39 -04:00
* tests: add acceptance tests for renewing guest links Cover renewing a guest link with and without a session, the PIN, rate limiting and the expiry of the session and the PIN. * tests: replay used PIN with its nonce in PIN-once scenario
1160 lines
34 KiB
PHP
1160 lines
34 KiB
PHP
<?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\GraphHelper;
|
|
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 or PIN and the nonce cookie set by renewing, and sends them 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 = [];
|
|
|
|
/**
|
|
* PINs received by each guest, in the order they were read from the emails
|
|
*
|
|
* @var array<string, string[]>
|
|
*/
|
|
private array $guestPins = [];
|
|
|
|
/**
|
|
* guest session cookie of each guest
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
private array $guestSessions = [];
|
|
|
|
/**
|
|
* expiry of the guest session of each guest, as returned when verifying a link or PIN
|
|
*
|
|
* @var array<string, int>
|
|
*/
|
|
private array $guestSessionExpiry = [];
|
|
|
|
/**
|
|
* nonce cookie (name => value) that binds the PIN of each guest to the "browser"
|
|
*
|
|
* @var array<string, array<string, string>>
|
|
*/
|
|
private array $guestNonces = [];
|
|
|
|
/**
|
|
* nonce cookie (name => value) of the last successful PIN verification of each guest,
|
|
* the server clears the cookie then, but a client can still replay it
|
|
*
|
|
* @var array<string, array<string, string>>
|
|
*/
|
|
private array $usedGuestNonces = [];
|
|
|
|
/**
|
|
* expiry of the PIN of each guest, taken from the max-age of the nonce cookie
|
|
*
|
|
* @var array<string, int>
|
|
*/
|
|
private array $guestPinExpiry = [];
|
|
|
|
/**
|
|
* 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 and the PIN 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];
|
|
}
|
|
}
|
|
if (\preg_match(GuestLinkHelper::PIN_IN_EMAIL_REGEX, $body, $matches)) {
|
|
$knownPins = $this->guestPins[$guest] ?? [];
|
|
if (\end($knownPins) !== $matches[1]) {
|
|
$this->guestPins[$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);
|
|
return $this->getBodyOfLastEmail($guest);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
return $this->getBodyOfLastEmail($guest);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return string
|
|
* @throws GuzzleException
|
|
*/
|
|
private function getBodyOfLastEmail(string $guest): string {
|
|
$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 {
|
|
return $this->getGuestLinkToken($guest, $this->readLastEmail($guest));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $body
|
|
*
|
|
* @return string
|
|
*/
|
|
private function getGuestLinkToken(string $guest, string $body): string {
|
|
Assert::assertMatchesRegularExpression(
|
|
GuestLinkHelper::TOKEN_IN_EMAIL_REGEX,
|
|
$body,
|
|
"The 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
|
|
* @throws GuzzleException
|
|
*/
|
|
private function getPinFromLastEmail(string $guest): string {
|
|
$body = $this->readLastEmail($guest);
|
|
Assert::assertMatchesRegularExpression(
|
|
GuestLinkHelper::PIN_IN_EMAIL_REGEX,
|
|
$body,
|
|
"The last email of guest '$guest' does not contain a PIN"
|
|
);
|
|
\preg_match(GuestLinkHelper::PIN_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);
|
|
$body = \json_decode((string)$response->getBody(), true);
|
|
if (isset($body['expirationDateTime'])) {
|
|
$this->guestSessionExpiry[$guest] = \strtotime($body['expirationDateTime']);
|
|
}
|
|
}
|
|
|
|
$nonceSetCookie = GuestLinkHelper::getNonceSetCookieHeader($response);
|
|
if ($nonceSetCookie !== null) {
|
|
$name = \trim(\explode('=', $nonceSetCookie, 2)[0]);
|
|
$value = GuestLinkHelper::getCookieValue($nonceSetCookie);
|
|
if ($value === null) {
|
|
unset($this->guestNonces[$guest]);
|
|
} else {
|
|
$this->guestNonces[$guest] = [$name => $value];
|
|
if (\preg_match('/max-age=(\d+)/i', $nonceSetCookie, $matches)) {
|
|
$this->guestPinExpiry[$guest] = \time() + (int)$matches[1];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
* @param string $pin
|
|
* @param bool $withNonce
|
|
*
|
|
* @return ResponseInterface
|
|
* @throws GuzzleException
|
|
*/
|
|
public function verifyGuestPin(string $guest, string $pin, bool $withNonce = true): ResponseInterface {
|
|
$nonce = $withNonce ? ($this->guestNonces[$guest] ?? []) : [];
|
|
$response = $this->sendVerifyPinRequest($pin, $nonce);
|
|
if ($response->getStatusCode() === 200) {
|
|
$this->usedGuestNonces[$guest] = $nonce;
|
|
}
|
|
$this->rememberCookies($guest, $response);
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* @param string $pin
|
|
* @param array<string, string> $nonce
|
|
*
|
|
* @return ResponseInterface
|
|
* @throws GuzzleException
|
|
*/
|
|
private function sendVerifyPinRequest(string $pin, array $nonce): ResponseInterface {
|
|
return GuestLinkHelper::verifyPin(
|
|
$this->featureContext->getBaseUrl(),
|
|
$this->featureContext->getStepLineRef(),
|
|
$this->featureContext->shareNgGetLastCreatedUserGroupShareID(),
|
|
$pin,
|
|
$nonce
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param bool $withSession
|
|
* @param string|null $shareId defaults to the last created share
|
|
*
|
|
* @return ResponseInterface
|
|
* @throws GuzzleException
|
|
*/
|
|
public function renewGuestLink(string $guest, bool $withSession, ?string $shareId = null): ResponseInterface {
|
|
$cookies = $withSession
|
|
? [GuestLinkHelper::SESSION_COOKIE_NAME => $this->getGuestSession($guest)]
|
|
: [];
|
|
$response = GuestLinkHelper::renew(
|
|
$this->featureContext->getBaseUrl(),
|
|
$this->featureContext->getStepLineRef(),
|
|
$shareId ?? $this->featureContext->shareNgGetLastCreatedUserGroupShareID(),
|
|
$cookies
|
|
);
|
|
$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 (and PIN) without using them
|
|
*
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[Given('guest :guest has received a new guest link')]
|
|
public function guestHasReceivedANewGuestLink(string $guest): void {
|
|
$this->getGuestLinkToken($guest, $this->readNewEmail($guest));
|
|
}
|
|
|
|
/**
|
|
* @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 verifies the PIN from the last email using the Graph API')]
|
|
public function guestVerifiesThePinFromTheLastEmail(string $guest): void {
|
|
$pin = $this->getPinFromLastEmail($guest);
|
|
$this->featureContext->setResponse($this->verifyGuestPin($guest, $pin));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest verifies the PIN from the last email without the nonce cookie using the Graph API')]
|
|
public function guestVerifiesThePinFromTheLastEmailWithoutTheNonceCookie(string $guest): void {
|
|
$pin = $this->getPinFromLastEmail($guest);
|
|
$this->featureContext->setResponse($this->verifyGuestPin($guest, $pin, false));
|
|
}
|
|
|
|
/**
|
|
* Replays the PIN and the nonce cookie of the last successful PIN verification,
|
|
* so the request fails only because they were already used
|
|
*
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest verifies the PIN from the last email again with the same nonce cookie using the Graph API')]
|
|
public function guestVerifiesThePinFromTheLastEmailAgainWithTheSameNonceCookie(string $guest): void {
|
|
Assert::assertArrayHasKey(
|
|
$guest,
|
|
$this->usedGuestNonces,
|
|
"Guest '$guest' has not verified a PIN successfully"
|
|
);
|
|
Assert::assertNotEmpty($this->usedGuestNonces[$guest], "Guest '$guest' verified the PIN without a nonce cookie");
|
|
$pin = $this->getPinFromLastEmail($guest);
|
|
$response = $this->sendVerifyPinRequest($pin, $this->usedGuestNonces[$guest]);
|
|
$this->rememberCookies($guest, $response);
|
|
$this->featureContext->setResponse($response);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[Given('guest :guest has verified the PIN from the last email')]
|
|
public function guestHasVerifiedThePinFromTheLastEmail(string $guest): void {
|
|
$pin = $this->getPinFromLastEmail($guest);
|
|
$response = $this->verifyGuestPin($guest, $pin);
|
|
$this->featureContext->theHTTPStatusCodeShouldBe(200, "", $response);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest verifies the PIN from the previous email using the Graph API')]
|
|
public function guestVerifiesThePinFromThePreviousEmail(string $guest): void {
|
|
$this->readLastEmail($guest);
|
|
$pins = $this->guestPins[$guest] ?? [];
|
|
Assert::assertGreaterThanOrEqual(2, \count($pins), "Guest '$guest' has not received a previous PIN");
|
|
$this->featureContext->setResponse($this->verifyGuestPin($guest, $pins[\count($pins) - 2]));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $pin
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest verifies the PIN :pin using the Graph API')]
|
|
public function guestVerifiesThePin(string $guest, string $pin): void {
|
|
$this->featureContext->setResponse($this->verifyGuestPin($guest, $pin));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest requests a new guest link with the guest session using the Graph API')]
|
|
public function guestRequestsANewGuestLinkWithTheGuestSession(string $guest): void {
|
|
$this->featureContext->setResponse($this->renewGuestLink($guest, true));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[Given('guest :guest has requested a new guest link with the guest session')]
|
|
public function guestHasRequestedANewGuestLinkWithTheGuestSession(string $guest): void {
|
|
$response = $this->renewGuestLink($guest, true);
|
|
$this->featureContext->theHTTPStatusCodeShouldBe(200, "", $response);
|
|
$this->guestHasReceivedANewGuestLink($guest);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $resource
|
|
* @param string $space
|
|
* @param string $owner
|
|
*
|
|
* @return string the id of the share of the resource with the guest
|
|
* @throws GuzzleException
|
|
*/
|
|
private function getGuestShareId(string $guest, string $resource, string $space, string $owner): string {
|
|
$response = GraphHelper::getPermissionsList(
|
|
$this->featureContext->getBaseUrl(),
|
|
$this->featureContext->getStepLineRef(),
|
|
$this->featureContext->getActualUsername($owner),
|
|
$this->featureContext->getPasswordForUser($owner),
|
|
$this->spacesContext->getSpaceIdByName($owner, $space),
|
|
$this->spacesContext->getResourceId($owner, $space, $resource)
|
|
);
|
|
foreach ($this->featureContext->getJsonDecodedResponse($response)['value'] ?? [] as $permission) {
|
|
if (($permission['grantedToV2']['user']['id'] ?? null) === \strtolower($guest)) {
|
|
return $permission['id'];
|
|
}
|
|
}
|
|
Assert::fail("Resource '$resource' of user '$owner' is not shared with guest '$guest'");
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $resource
|
|
* @param string $space
|
|
* @param string $owner
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest requests a new guest link for resource :resource of space :space of user :owner with the guest session using the Graph API')]
|
|
public function guestRequestsANewGuestLinkForResourceWithTheGuestSession(
|
|
string $guest,
|
|
string $resource,
|
|
string $space,
|
|
string $owner
|
|
): void {
|
|
$shareId = $this->getGuestShareId($guest, $resource, $space, $owner);
|
|
$this->featureContext->setResponse($this->renewGuestLink($guest, true, $shareId));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest requests a new guest link without a guest session using the Graph API')]
|
|
public function guestRequestsANewGuestLinkWithoutAGuestSession(string $guest): void {
|
|
$this->featureContext->setResponse($this->renewGuestLink($guest, false));
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[Given('guest :guest has requested a new guest link without a guest session')]
|
|
public function guestHasRequestedANewGuestLinkWithoutAGuestSession(string $guest): void {
|
|
$response = $this->renewGuestLink($guest, false);
|
|
$this->featureContext->theHTTPStatusCodeShouldBe(200, "", $response);
|
|
$this->guestHasReceivedANewGuestLink($guest);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $pin
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[Given('guest :guest has tried to verify the PIN :pin')]
|
|
public function guestHasTriedToVerifyThePin(string $guest, string $pin): void {
|
|
$response = $this->verifyGuestPin($guest, $pin);
|
|
$this->featureContext->theHTTPStatusCodeShouldBe(401, "", $response);
|
|
}
|
|
|
|
/**
|
|
* Waits until the guest session has expired according to the expiry returned by the server,
|
|
* plus the leeway that the guestlinks auth manager of reva accepts for clock skew
|
|
*
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
*/
|
|
#[Given('the guest session of guest :guest has expired')]
|
|
public function theGuestSessionOfGuestHasExpired(string $guest): void {
|
|
Assert::assertArrayHasKey($guest, $this->guestSessionExpiry, "Guest '$guest' has no guest session");
|
|
$this->waitUntilTimestampHasPassed(
|
|
$this->guestSessionExpiry[$guest] + GuestLinkHelper::SESSION_EXPIRY_LEEWAY_SECONDS
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Waits until the PIN has expired according to the max-age of the nonce cookie
|
|
*
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
*/
|
|
#[Given('the PIN of guest :guest has expired')]
|
|
public function thePinOfGuestHasExpired(string $guest): void {
|
|
Assert::assertArrayHasKey($guest, $this->guestPinExpiry, "Guest '$guest' has not requested a PIN");
|
|
$this->waitUntilTimestampHasPassed($this->guestPinExpiry[$guest]);
|
|
}
|
|
|
|
/**
|
|
* @param int $timestamp
|
|
*
|
|
* @return void
|
|
*/
|
|
private function waitUntilTimestampHasPassed(int $timestamp): void {
|
|
$timeoutSeconds = \max(1, $timestamp - \time() + 5);
|
|
Assert::assertLessThan(
|
|
120,
|
|
$timeoutSeconds,
|
|
"Refusing to wait $timeoutSeconds seconds, configure a shorter lifetime for this scenario"
|
|
);
|
|
WaitHelper::waitUntil(
|
|
fn () => \time(),
|
|
fn ($now) => $now > $timestamp,
|
|
500,
|
|
$timeoutSeconds
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* @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(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'GET',
|
|
'',
|
|
\array_merge($this->getSessionHeaders($guest), []),
|
|
$fileId,
|
|
$this->featureContext->getStepLineRef(),
|
|
null,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'PUT',
|
|
'',
|
|
\array_merge($this->getSessionHeaders($guest), []),
|
|
$fileId,
|
|
$this->featureContext->getStepLineRef(),
|
|
$content,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $folder
|
|
* @param string $space
|
|
* @param string $owner
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest lists the content of folder :folder of space :space of user :owner using the WebDAV API')]
|
|
public function guestListsTheContentOfFolder(
|
|
string $guest,
|
|
string $folder,
|
|
string $space,
|
|
string $owner
|
|
): void {
|
|
$folderId = $this->spacesContext->getResourceId($owner, $space, $folder);
|
|
$this->featureContext->setResponse(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'PROPFIND',
|
|
'',
|
|
\array_merge($this->getSessionHeaders($guest), ['Depth' => '1']),
|
|
$folderId,
|
|
$this->featureContext->getStepLineRef(),
|
|
null,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $content
|
|
* @param string $path
|
|
* @param string $folder
|
|
* @param string $space
|
|
* @param string $owner
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest uploads content :content to :path inside folder :folder of space :space of user :owner using the WebDAV API')]
|
|
public function guestUploadsContentInsideFolder(
|
|
string $guest,
|
|
string $content,
|
|
string $path,
|
|
string $folder,
|
|
string $space,
|
|
string $owner
|
|
): void {
|
|
$folderId = $this->spacesContext->getResourceId($owner, $space, $folder);
|
|
$this->featureContext->setResponse(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'PUT',
|
|
$path,
|
|
\array_merge($this->getSessionHeaders($guest), []),
|
|
$folderId,
|
|
$this->featureContext->getStepLineRef(),
|
|
$content,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $path
|
|
* @param string $folder
|
|
* @param string $space
|
|
* @param string $owner
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest creates folder :path inside folder :folder of space :space of user :owner using the WebDAV API')]
|
|
public function guestCreatesFolderInsideFolder(
|
|
string $guest,
|
|
string $path,
|
|
string $folder,
|
|
string $space,
|
|
string $owner
|
|
): void {
|
|
$folderId = $this->spacesContext->getResourceId($owner, $space, $folder);
|
|
$this->featureContext->setResponse(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'MKCOL',
|
|
$path,
|
|
\array_merge($this->getSessionHeaders($guest), []),
|
|
$folderId,
|
|
$this->featureContext->getStepLineRef(),
|
|
null,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $path
|
|
* @param string $folder
|
|
* @param string $space
|
|
* @param string $owner
|
|
* @param string $newName
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest renames :path inside folder :folder of space :space of user :owner to :newName using the WebDAV API')]
|
|
public function guestRenamesInsideFolder(
|
|
string $guest,
|
|
string $path,
|
|
string $folder,
|
|
string $space,
|
|
string $owner,
|
|
string $newName
|
|
): void {
|
|
$folderId = $this->spacesContext->getResourceId($owner, $space, $folder);
|
|
$this->featureContext->setResponse(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'MOVE',
|
|
$path,
|
|
\array_merge($this->getSessionHeaders($guest), ['Destination' => $this->featureContext->getBaseUrl() . '/' . WebDavHelper::getDavPath(WebDavHelper::DAV_VERSION_SPACES, $folderId) . '/' . $newName]),
|
|
$folderId,
|
|
$this->featureContext->getStepLineRef(),
|
|
null,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $path
|
|
* @param string $folder
|
|
* @param string $space
|
|
* @param string $owner
|
|
* @param string $destination
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest tries to move :path inside folder :folder of space :space of user :owner to :destination in the root of that space using the WebDAV API')]
|
|
public function guestTriesToMoveOutOfTheSharedFolder(
|
|
string $guest,
|
|
string $path,
|
|
string $folder,
|
|
string $space,
|
|
string $owner,
|
|
string $destination
|
|
): void {
|
|
$folderId = $this->spacesContext->getResourceId($owner, $space, $folder);
|
|
$spaceRootId = $this->spacesContext->getResourceId($owner, $space, '');
|
|
$this->featureContext->setResponse(
|
|
WebDavHelper::makeDavRequest(
|
|
$this->featureContext->getBaseUrl(),
|
|
null,
|
|
null,
|
|
'MOVE',
|
|
$path,
|
|
\array_merge($this->getSessionHeaders($guest), ['Destination' => $this->featureContext->getBaseUrl() . '/' . WebDavHelper::getDavPath(WebDavHelper::DAV_VERSION_SPACES, $spaceRootId) . '/' . $destination]),
|
|
$folderId,
|
|
$this->featureContext->getStepLineRef(),
|
|
null,
|
|
WebDavHelper::DAV_VERSION_SPACES
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
* @param string $method
|
|
* @param string $endpoint e.g. "invite", "createLink" or "permissions"
|
|
* @param string $resource
|
|
* @param string $space
|
|
* @param string $owner
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[When('guest :guest sends HTTP method :method to the Graph endpoint :endpoint of resource :resource of space :space of user :owner')]
|
|
public function guestSendsHttpMethodToGraphEndpointOfResource(
|
|
string $guest,
|
|
string $method,
|
|
string $endpoint,
|
|
string $resource,
|
|
string $space,
|
|
string $owner
|
|
): void {
|
|
$spaceId = $this->spacesContext->getSpaceIdByName($owner, $space);
|
|
$itemId = $this->spacesContext->getResourceId($owner, $space, $resource);
|
|
$this->featureContext->setResponse(
|
|
HttpRequestHelper::sendRequest(
|
|
GraphHelper::getBetaFullUrl(
|
|
$this->featureContext->getBaseUrl(),
|
|
"drives/$spaceId/items/$itemId/$endpoint"
|
|
),
|
|
$this->featureContext->getStepLineRef(),
|
|
$method,
|
|
null,
|
|
null,
|
|
$this->getSessionHeaders($guest),
|
|
$method === 'GET' ? null : '{}',
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $guest
|
|
*
|
|
* @return void
|
|
*/
|
|
#[Given('the signature of the guest session of guest :guest has been changed')]
|
|
public function theSignatureOfTheGuestSessionOfGuestHasBeenChanged(string $guest): void {
|
|
$session = $this->getGuestSession($guest);
|
|
// change the first character of the signature, the last one can carry only padding bits
|
|
$position = \strrpos($session, '.') + 1;
|
|
$this->guestSessions[$guest] = \substr_replace($session, $session[$position] === 'A' ? 'B' : 'A', $position, 1);
|
|
}
|
|
|
|
/**
|
|
* @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"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
#[Then('the response should contain a nonce cookie')]
|
|
public function theResponseShouldContainANonceCookie(): void {
|
|
$setCookie = GuestLinkHelper::getNonceSetCookieHeader($this->featureContext->getResponse());
|
|
Assert::assertNotNull($setCookie, "The response does not set the nonce cookie");
|
|
Assert::assertNotNull(GuestLinkHelper::getCookieValue($setCookie), "The response clears the nonce cookie");
|
|
$this->assertCookieIsHardened($setCookie);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
#[Then('the response should not contain a nonce cookie')]
|
|
public function theResponseShouldNotContainANonceCookie(): void {
|
|
$setCookie = GuestLinkHelper::getNonceSetCookieHeader($this->featureContext->getResponse());
|
|
Assert::assertTrue(
|
|
$setCookie === null || GuestLinkHelper::getCookieValue($setCookie) === null,
|
|
"The response was not expected to set the nonce cookie, but it sets '$setCookie'"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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 and the PIN are random, so they are compared as
|
|
* the placeholders %guest_link_token% and %guest_pin%
|
|
*
|
|
* @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);
|
|
$actualContent = \preg_replace(GuestLinkHelper::PIN_IN_EMAIL_REGEX, 'Your PIN: %guest_pin%', $actualContent);
|
|
Assert::assertStringContainsString(
|
|
$expectedContent,
|
|
$actualContent,
|
|
"The new email of guest '$guest' was expected to contain '$expectedContent'"
|
|
. " but the received email is '$actualContent'"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Waits a few seconds to make sure that no further email arrives
|
|
*
|
|
* @param string $guest
|
|
* @param int $count
|
|
*
|
|
* @return void
|
|
* @throws GuzzleException
|
|
*/
|
|
#[Then('/^guest "([^"]*)" should have received (\d+) emails? in total$/')]
|
|
public function guestShouldHaveReceivedEmailsInTotal(string $guest, int $count): void {
|
|
$emails = $this->waitForEmails($guest, $count + 1, 5);
|
|
Assert::assertCount($count, $emails, "Guest '$guest' was expected to have received $count email(s)");
|
|
}
|
|
}
|