From 2ee50102bc23f10947f7305820d430d827618242 Mon Sep 17 00:00:00 2001 From: "v.scharf" Date: Mon, 2 Nov 2026 14:04:33 +0100 Subject: [PATCH] test(guest-link): guest session does not override other credentials Requests that carry a guest session cookie together with other credentials (bearer token, basic auth, public link, signed URL) must be authenticated with those credentials, or rejected with 401 if they are invalid, and never fall back to the guest. Also covers what the web client does in a guest session: downloads via the signed oc:downloadURL and TUS uploads with an unusable Authorization header. Assisted-by: Claude Code:claude-opus-5-5 --- .../acceptance/bootstrap/GuestLinkContext.php | 208 ++++++++++++++++++ .../guestLinkOtherCredentials.feature | 84 +++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/acceptance/features/apiGuestLink/guestLinkOtherCredentials.feature diff --git a/tests/acceptance/bootstrap/GuestLinkContext.php b/tests/acceptance/bootstrap/GuestLinkContext.php index eec89f2258..cd58c6d93d 100644 --- a/tests/acceptance/bootstrap/GuestLinkContext.php +++ b/tests/acceptance/bootstrap/GuestLinkContext.php @@ -728,6 +728,76 @@ public function guestListsTheSharesSharedWithThem(string $guest): void { ); } + /** + * @param string $guest + * @param string $authorization + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest lists the shares shared with them with authorization header :authorization using the Graph API')] + public function guestListsTheSharesSharedWithThemWithAuthorizationHeader( + string $guest, + string $authorization + ): void { + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->featureContext->getBaseUrl() . '/graph/v1beta1/me/drive/sharedWithMe', + $this->featureContext->getStepLineRef(), + null, + null, + \array_merge($this->getSessionHeaders($guest), ['Authorization' => $authorization]), + ) + ); + } + + /** + * @param string $user + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[When('user :user lists the shares shared with them with the guest session of guest :guest using the Graph API')] + public function userListsTheSharesSharedWithThemWithTheGuestSession(string $user, string $guest): void { + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->featureContext->getBaseUrl() . '/graph/v1beta1/me/drive/sharedWithMe', + $this->featureContext->getStepLineRef(), + $this->featureContext->getActualUsername($user), + $this->featureContext->getPasswordForUser($user), + $this->getSessionHeaders($guest), + ) + ); + } + + /** + * @param string $guest + * @param string $resource + * @param string $password + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest downloads file :resource from the last public link share with password :password and the guest session using the public WebDAV API')] + public function guestDownloadsFileFromTheLastPublicLinkShareWithTheGuestSession( + string $guest, + string $resource, + string $password + ): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $davPath = WebDavHelper::getDavPath(WebDavHelper::DAV_VERSION_NEW, $token, 'public-files'); + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->featureContext->getBaseUrl() . "/$davPath/$resource", + $this->featureContext->getStepLineRef(), + 'public', + $this->featureContext->getActualPassword($password), + \array_merge($this->getSessionHeaders($guest), ['public-token' => $token]), + ) + ); + } + /** * @param string $guest * @param string $resource @@ -1014,6 +1084,144 @@ public function guestSendsHttpMethodToGraphEndpointOfResource( ); } + /** + * @param string $fileId + * @param string|null $user + * @param array $headers + * + * @return string + * @throws GuzzleException + */ + private function getDownloadUrl(string $fileId, ?string $user, array $headers): string { + $response = WebDavHelper::makeDavRequest( + $this->featureContext->getBaseUrl(), + $user === null ? null : $this->featureContext->getActualUsername($user), + $user === null ? null : $this->featureContext->getPasswordForUser($user), + 'PROPFIND', + '', + \array_merge($headers, ['Depth' => '0']), + $fileId, + $this->featureContext->getStepLineRef(), + WebDavHelper::getBodyForPropfind(['oc:downloadURL']), + WebDavHelper::DAV_VERSION_SPACES + ); + $this->featureContext->theHTTPStatusCodeShouldBe(207, '', $response); + $match = HttpRequestHelper::getResponseXml($response, __METHOD__)->xpath('//d:prop/oc:downloadURL'); + Assert::assertNotEmpty($match, 'The response does not contain an oc:downloadURL'); + return (string)$match[0]; + } + + /** + * @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 via its signed download URL')] + public function guestDownloadsSharedFileViaItsDownloadUrl( + string $guest, + string $resource, + string $space, + string $owner + ): void { + $fileId = $this->spacesContext->getResourceId($owner, $space, $resource); + $headers = $this->getSessionHeaders($guest); + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->getDownloadUrl($fileId, null, $headers), + $this->featureContext->getStepLineRef(), + null, + null, + $headers, + ) + ); + } + + /** + * @param string $user + * @param string $resource + * @param string $guest + * + * @return void + * @throws GuzzleException + */ + #[When('user :user downloads file :resource via its signed download URL with the guest session of guest :guest')] + public function userDownloadsFileViaItsDownloadUrlWithTheGuestSession( + string $user, + string $resource, + string $guest + ): void { + $fileId = $this->spacesContext->getResourceId($user, 'Personal', $resource); + $this->featureContext->setResponse( + HttpRequestHelper::get( + $this->getDownloadUrl($fileId, $user, []), + $this->featureContext->getStepLineRef(), + null, + null, + $this->getSessionHeaders($guest), + ) + ); + } + + /** + * @param string $guest + * @param string $content + * @param string $path + * @param string $folder + * @param string $space + * @param string $owner + * @param string $authorization + * + * @return void + * @throws GuzzleException + */ + #[When('guest :guest uploads content :content to :path inside folder :folder of space :space of user :owner using the TUS protocol with authorization header :authorization')] + public function guestUploadsContentInsideFolderUsingTusWithAuthorizationHeader( + string $guest, + string $content, + string $path, + string $folder, + string $space, + string $owner, + string $authorization + ): void { + $folderId = $this->spacesContext->getResourceId($owner, $space, $folder); + $headers = \array_merge( + $this->getSessionHeaders($guest), + ['Authorization' => $authorization, 'Tus-Resumable' => '1.0.0'] + ); + $response = WebDavHelper::makeDavRequest( + $this->featureContext->getBaseUrl(), + null, + null, + 'POST', + '', + \array_merge( + $headers, + ['Upload-Length' => (string)\strlen($content), 'Upload-Metadata' => 'filename ' . \base64_encode($path)] + ), + $folderId, + $this->featureContext->getStepLineRef(), + null, + WebDavHelper::DAV_VERSION_SPACES + ); + $this->featureContext->theHTTPStatusCodeShouldBe(201, '', $response); + $this->featureContext->setResponse( + HttpRequestHelper::sendRequest( + $response->getHeader('Location')[0], + $this->featureContext->getStepLineRef(), + 'PATCH', + null, + null, + \array_merge($headers, ['Content-Type' => 'application/offset+octet-stream', 'Upload-Offset' => '0']), + $content, + ) + ); + } + /** * @param string $guest * diff --git a/tests/acceptance/features/apiGuestLink/guestLinkOtherCredentials.feature b/tests/acceptance/features/apiGuestLink/guestLinkOtherCredentials.feature new file mode 100644 index 0000000000..ffaeb6e6e4 --- /dev/null +++ b/tests/acceptance/features/apiGuestLink/guestLinkOtherCredentials.feature @@ -0,0 +1,84 @@ +@email +Feature: guest session does not override other credentials + As a user who has opened a guest link in the same browser + I want requests with other credentials to never be authenticated as the guest + So that I get my own data, or a 401 to renew invalid credentials + + Background: + Given user "Alice" has been created with default attributes + And user "Brian" has been created with default attributes + And user "Alice" has uploaded file with content "for guest" to "guestFile.txt" + And user "Alice" has sent the following resource share invitation: + | resource | guestFile.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 + + + Scenario Outline: request with invalid credentials and a guest session is rejected + When guest "guest@example.org" lists the shares shared with them with authorization header "" using the Graph API + Then the HTTP status code should be "401" + Examples: + | authorization | + | Bearer invalid-token | + | Basic aW52YWxpZDppbnZhbGlk | + + + Scenario: request with valid user credentials and a guest session is authenticated as the user + When user "Brian" lists the shares shared with them with the guest session of guest "guest@example.org" 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": 0, + "maxItems": 0 + } + } + } + """ + + + Scenario: public link request with a guest session is authenticated as the public link + Given user "Alice" has created folder "publicFolder" + And user "Alice" has uploaded file with content "public content" to "publicFolder/public.txt" + And user "Alice" has created the following resource link share: + | resource | publicFolder | + | space | Personal | + | permissionsRole | view | + | password | %public% | + When guest "guest@example.org" downloads file "public.txt" from the last public link share with password "%public%" and the guest session using the public WebDAV API + Then the HTTP status code should be "200" + And the downloaded content should be "public content" + + + Scenario: guest downloads a shared file via its signed download URL + When guest "guest@example.org" downloads file "guestFile.txt" shared from space "Personal" of user "Alice" via its signed download URL + Then the HTTP status code should be "200" + And the downloaded content should be "for guest" + + + Scenario: signed download URL of a user with a guest session is authenticated as the user + Given user "Brian" has uploaded file with content "from Brian" to "brianFile.txt" + When user "Brian" downloads file "brianFile.txt" via its signed download URL with the guest session of guest "guest@example.org" + Then the HTTP status code should be "200" + And the downloaded content should be "from Brian" + + + Scenario: guest uploads a file using TUS with an unusable authorization header + Given user "Alice" has created folder "uploadFolder" + And user "Alice" has sent the following resource share invitation: + | resource | uploadFolder | + | space | Personal | + | sharee | guest@example.org | + | shareType | guest | + | permissionsRole | Editor | + When guest "guest@example.org" uploads content "uploaded by guest" to "tus.txt" inside folder "uploadFolder" of space "Personal" of user "Alice" using the TUS protocol with authorization header "undefined" + Then the HTTP status code should be "204" + And the content of file "uploadFolder/tus.txt" for user "Alice" should be "uploaded by guest"