From 6741fcf6e05a869863303256a51245cf15b0964a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 27 Jul 2026 15:29:21 +0200 Subject: [PATCH 1/4] test: make PlayWright tests less flaky by waiting for expected UI states Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Ferdinand Thiessen --- .../e2e/files_sharing/files-copy-move.spec.ts | 12 +-- .../support/sections/CopyMoveDialogPage.ts | 80 ++++++++++++++++++- .../support/sections/FilesListPage.ts | 38 ++++++++- 3 files changed, 115 insertions(+), 15 deletions(-) diff --git a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts index f666653aa2f03..da2ded67437d9 100644 --- a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts +++ b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts @@ -52,14 +52,10 @@ test.describe('files_sharing: Move or copy files', () => { await filesListPage.open() await expect(filesListPage.getRowForFile('folder')).toBeVisible() await filesListPage.triggerActionForFile('file.txt', 'move-copy') - const propfind = page.waitForResponse((r) => r.request().method() === 'PROPFIND' - && r.url().includes('/remote.php/dav/files/') - && !!r.url().match(/\/folder\/?$/)) + // navigateTo waits for the folder's listing, so the button is past the + // picker's own loading state and only the missing permission can disable it await copyMoveDialog.navigateTo('folder') - await propfind // wait for the PROPFIND to finish - // see the content of the folde = loading finished await expect(copyMoveDialog.dialog().getByText(/inner-folder/)).toBeVisible() - // now the button should be disabled await expect(copyMoveDialog.confirmButton('Copy to folder')).toBeDisabled() }) @@ -77,12 +73,8 @@ test.describe('files_sharing: Move or copy files', () => { await filesListPage.open() await filesListPage.navigateToFolder('folder') await filesListPage.triggerActionForFile('file.txt', 'move-copy') - const propfind = page.waitForResponse((r) => r.request().method() === 'PROPFIND' - && r.url().includes('/remote.php/dav/files/') - && !!r.url().match(/\/owned-folder\/?$/)) await copyMoveDialog.goToAllFiles() await copyMoveDialog.navigateTo('owned-folder') - await propfind // wait for the PROPFIND to finish // can copy but not move await expect(copyMoveDialog.confirmButton('Copy to owned-folder')).toBeVisible() diff --git a/tests/playwright/support/sections/CopyMoveDialogPage.ts b/tests/playwright/support/sections/CopyMoveDialogPage.ts index 1b008a0225865..5e54b14df09ad 100644 --- a/tests/playwright/support/sections/CopyMoveDialogPage.ts +++ b/tests/playwright/support/sections/CopyMoveDialogPage.ts @@ -5,8 +5,12 @@ import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' import { escapeAttributeValue } from '../utils/css.ts' +/** The DAV endpoint all of the picker's and the action's requests go to. */ +const DAV_FILES_ENDPOINT = /\/(remote|public)\.php\/dav\/files\// + /** * The file-picker dialog opened by the files "Move or copy" action * (the NcDialog-based FilePicker from @nextcloud/dialogs). @@ -50,25 +54,97 @@ export class CopyMoveDialogPage { return this.dialog().getByRole('button', { name: label, exact: true }) } + /** + * The skeleton rows the picker renders while it loads a folder listing + * (library-owned markup, the rows are `aria-hidden` so they have no role to + * address them by). + */ + private loadingRows(): Locator { + return this.dialog().locator('.loading-row') + } + + /** + * Whether `url` is the picker's listing request for `folder` — or for the + * user's root if `folder` is undefined. + * + * @param url - The request URL to check. + * @param folder - The folder name to check for, or undefined to check for the root listing. + */ + private isListingUrl(url: string, folder?: string): boolean { + const path = decodeURIComponent(new URL(url).pathname).replace(/\/+$/, '') + // Capture what follows the DAV root ("/remote.php/dav/files/"), so + // the root listing is the match with nothing left over. + const match = path.match(/\/(?:remote|public)\.php\/dav\/files\/[^/]+(?\/.*)?$/) + if (!match) { + return false + } + const relative = match.groups?.path ?? '' + return folder === undefined ? relative === '' : relative.endsWith(`/${folder}`) + } + + /** + * Register the wait for the listing the picker loads when its destination folder changes + * + * @param folder - The folder name to wait for, or undefined to wait for the root listing. + */ + private async listingLoaded(folder?: string): Promise { + await this.page.waitForResponse((r) => r.request().method() === 'PROPFIND' + && this.isListingUrl(r.url(), folder)) + await expect(this.loadingRows()).toHaveCount(0) + } + /** Navigate into a (possibly nested) folder inside the picker; returns the leaf folder name. */ async navigateTo(dirPath: string): Promise { const segments = dirPath.split('/').filter(Boolean) for (const dir of segments) { + const loaded = this.listingLoaded(dir) await this.getDestination(dir).click() + await loaded } return segments.at(-1) } /** Navigate the destination back to the user's root via the breadcrumb. */ async goToAllFiles(): Promise { + const loaded = this.listingLoaded() await this.breadcrumbs().getByRole('button', { name: 'All files' }).click() + await loaded } private async confirm(label: string, method: 'COPY' | 'MOVE'): Promise { + const button = this.confirmButton(label) + // The picker disables its buttons while a listing loads. Get past that + // before arming the response wait: its timeout starts when it is + // registered, so an unsettled picker would eat the operation's budget and + // surface as a bogus "waiting for response" timeout. The timeout is raised + // over the 5s default because this also covers the picker's initial + // listing when the caller confirms without navigating first. + await expect(button).toBeEnabled({ timeout: 15000 }) + const done = this.page.waitForResponse((r) => r.request().method() === method - && /\/(remote|public)\.php\/dav\/files\//.test(r.url())) - await this.confirmButton(label).click() + && DAV_FILES_ENDPOINT.test(r.url())) + await button.click() + + // The picker resolves and closes, then the action checks the destination + // for conflicts and finally issues the COPY/MOVE. + await this.dialog().waitFor({ state: 'hidden' }) await done + await this.actionSettled() + } + + /** + * Wait for the move/copy action to be fully done. The action shows a + * permanent loading toast up front and only hides it once every node has been + * transferred and the file list has caught up — a copy into the current + * folder stats the new node after the COPY to insert its row. So the toast + * going away, not the DAV response, is what says "settled"; it also keeps the + * toast from covering the list for whatever the test does next. + * + * This cannot pass too early: the toast is shown before the COPY/MOVE request + * that {@link confirm} has already awaited. + */ + private async actionSettled(): Promise { + await expect(this.page.locator('.toastify.toast-loading')).toHaveCount(0, { timeout: 15000 }) } /** Copy into the folder currently shown in the picker. */ diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index 1a3e711b81a7e..767c4146a1317 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -29,6 +29,35 @@ export class FilesListPage { await this.getFilesList().waitFor({ state: 'visible' }) } + /** + * The list's loading indicator. + * + * Every view renders the same spinner while it fetches updated conent. + * In the list header ("File list is reloading") when the folder + * already has contents to keep showing. In place of the list + * ("Loading current folder") when it does not. + * The two spinners are mutually exclusive. + */ + getLoadingIndicator(): Locator { + return this.page.getByRole('img', { name: /^(File list is reloading|Loading current folder)$/ }) + } + + /** + * Wait for a pending list fetch to settle, i.e. for the loading indicator to + * come and go. + * + * The indicator is only waited for briefly: a fetch that resolves before the + * first poll is never observed as visible, and since the app raises the + * loading state synchronously with the action that triggers the fetch, an + * absent indicator means the fetch has already finished — so missing its + * appearance is not an error. Waiting for it to be gone is what matters. + */ + async waitForListLoaded(): Promise { + const indicator = this.getLoadingIndicator() + await indicator.waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) + await indicator.waitFor({ state: 'hidden' }) + } + /** * Switch the list to grid view and wait for the preference to persist. The * toggle button is labelled "Switch to grid view" (only present in list view). @@ -67,9 +96,11 @@ export class FilesListPage { * still (re-)mounting — notably in the search view on slower (CI) machines. Only * click while the item is hidden so an already-open menu is never toggled shut. * - * This is fire-and-forget: the reload request differs by view (PROPFIND for a - * folder, SEARCH for the search view, REPORT for favorites), so callers that - * need to await the refetch should wait on the specific response themselves. + * Returns once the refetch has settled. The request to await differs by view + * (PROPFIND for a folder, SEARCH for the search view, REPORT for favorites), + * so this waits on the view-independent loading indicator instead — see + * {@link waitForListLoaded}. Callers therefore never act on a list that is + * still being replaced. */ async reloadCurrentFolder(): Promise { const toggle = this.getBreadcrumbs().locator('button[aria-haspopup="menu"]').last() @@ -83,6 +114,7 @@ export class FilesListPage { }).toPass({ timeout: 15000 }) await reloadItem.click() + await this.waitForListLoaded() } getRowForFileId(fileid: number): Locator { From e7b83b4e125fb567fa0d35b22b05600366f5fb95 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 27 Jul 2026 21:03:32 +0200 Subject: [PATCH 2/4] test(files_sharing): migrate end-to-end tests to PlayWright Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Ferdinand Thiessen --- .github/workflows/playwright.yml | 4 +- .../e2e/files_sharing/FilesSharingUtils.ts | 216 ------------- cypress/e2e/files_sharing/ShareOptionsType.ts | 18 -- cypress/e2e/files_sharing/expiry-date.cy.ts | 130 -------- cypress/e2e/files_sharing/file-request.cy.ts | 84 ----- .../e2e/files_sharing/files-download.cy.ts | 103 ------ .../e2e/files_sharing/files-shares-view.cy.ts | 60 ---- .../files_sharing/limit_to_same_group.cy.ts | 112 ------- .../e2e/files_sharing/note-to-recipient.cy.ts | 92 ------ .../public-share/PublicShareUtils.ts | 188 ----------- .../public-share/copy-move-files.cy.ts | 48 --- .../public-share/default-view.cy.ts | 103 ------ .../files_sharing/public-share/download.cy.ts | 272 ---------------- .../public-share/header-avatar.cy.ts | 194 ------------ .../public-share/header-menu.cy.ts | 201 ------------ .../public-share/rename-files.cy.ts | 31 -- .../public-share/required-before-create.cy.ts | 191 ------------ .../public-share/sidebar-tab.cy.ts | 46 --- .../public-share/view_file-drop.cy.ts | 173 ---------- .../view_view-only-no-download.cy.ts | 100 ------ .../public-share/view_view-only.cy.ts | 102 ------ .../share-permissions-bundle.cy.ts | 111 ------- .../files_sharing/share-status-action.cy.ts | 124 -------- .../e2e/files/files-download.spec.ts | 6 +- .../admin-settings-download-forbidden.spec.ts | 67 ++++ .../admin-settings-expiry-date.spec.ts | 135 ++++++++ ...admin-settings-limit-to-same-group.spec.ts | 95 ++++++ .../admin-settings-permissions-bundle.spec.ts | 90 ++++++ .../e2e/files_sharing/file-request.spec.ts | 79 +++++ .../files_sharing/files-shares-view.spec.ts | 40 +++ .../files_sharing/note-to-recipient.spec.ts | 55 ++++ .../admin-settings-file-drop-terms.spec.ts | 44 +++ ...in-settings-required-before-create.spec.ts | 176 +++++++++++ .../copy-move-rename-files.spec.ts | 67 ++++ .../public-share/default-view.spec.ts | 50 +++ .../public-share/download.spec.ts | 123 ++++++++ .../public-share/file-drop.spec.ts | 98 ++++++ .../public-share/header-avatar.spec.ts | 56 ++++ .../public-share/header-menu.spec.ts | 117 +++++++ .../public-share/share-editor.spec.ts | 82 +++++ .../public-share/view-only.spec.ts | 90 ++++++ .../files_sharing/share-status-action.spec.ts | 91 ++++++ .../support/fixtures/public-share-page.ts | 59 ++++ .../support/fixtures/sharing-page.ts | 62 ++++ .../support/sections/CopyMoveDialogPage.ts | 4 +- .../support/sections/FilesListPage.ts | 16 +- .../support/sections/PublicSharePage.ts | 156 +++++++++ .../playwright/support/sections/SharingTab.ts | 295 ++++++++++++++++++ tests/playwright/support/utils/dav.ts | 27 ++ tests/playwright/support/utils/sharing.ts | 249 ++++++++++++--- 50 files changed, 2373 insertions(+), 2759 deletions(-) delete mode 100644 cypress/e2e/files_sharing/FilesSharingUtils.ts delete mode 100644 cypress/e2e/files_sharing/ShareOptionsType.ts delete mode 100644 cypress/e2e/files_sharing/expiry-date.cy.ts delete mode 100644 cypress/e2e/files_sharing/file-request.cy.ts delete mode 100644 cypress/e2e/files_sharing/files-download.cy.ts delete mode 100644 cypress/e2e/files_sharing/files-shares-view.cy.ts delete mode 100644 cypress/e2e/files_sharing/limit_to_same_group.cy.ts delete mode 100644 cypress/e2e/files_sharing/note-to-recipient.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/PublicShareUtils.ts delete mode 100644 cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/default-view.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/download.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/header-avatar.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/header-menu.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/rename-files.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/required-before-create.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts delete mode 100644 cypress/e2e/files_sharing/public-share/view_view-only.cy.ts delete mode 100644 cypress/e2e/files_sharing/share-permissions-bundle.cy.ts delete mode 100644 cypress/e2e/files_sharing/share-status-action.cy.ts create mode 100644 tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/file-request.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/files-shares-view.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/download.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts create mode 100644 tests/playwright/e2e/files_sharing/share-status-action.spec.ts create mode 100644 tests/playwright/support/fixtures/public-share-page.ts create mode 100644 tests/playwright/support/fixtures/sharing-page.ts create mode 100644 tests/playwright/support/sections/PublicSharePage.ts create mode 100644 tests/playwright/support/sections/SharingTab.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index fc5aac80f9017..1ff86fad47828 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -82,8 +82,8 @@ jobs: strategy: fail-fast: false matrix: - shardIndex: [1, 2, 3, 4, 5, 6, 7] - shardTotal: [7] + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] + shardTotal: [8] outputs: node-version: ${{ steps.versions.outputs.node-version }} package-manager-version: ${{ steps.versions.outputs.package-manager-version }} diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts deleted file mode 100644 index 432b8b5ddbfab..0000000000000 --- a/cypress/e2e/files_sharing/FilesSharingUtils.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { closeSidebar, triggerActionForFile } from '../files/FilesUtils.ts' - -export interface ShareSetting { - read: boolean - update: boolean - delete: boolean - create: boolean - share: boolean - download: boolean - note: string - expiryDate: Date -} - -export function createShare(fileName: string, username: string, shareSettings: Partial = {}) { - openSharingPanel(fileName) - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - - cy.get('#app-sidebar-vue').within(() => { - cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch') - cy.findByRole('combobox', { name: /Search for internal recipients/i }) - .type(`{selectAll}${username}`) - cy.wait('@userSearch') - }) - - cy.get(`[user="${username}"]`).click() - - // HACK: Save the share and then update it, as permissions changes are currently not saved for new share. - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - cy.wait('@createShare') - closeSidebar() - - updateShare(fileName, 0, shareSettings) -} - -export function openSharingDetails(index: number) { - cy.get('#app-sidebar-vue').within(() => { - cy.findAllByRole('button', { name: /open sharing details/i }) - .should('have.length.at.least', index + 1) - .eq(index) - .click({ force: true }) - cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]') - .click() - }) -} - -export function updateShare(fileName: string, index: number, shareSettings: Partial = {}) { - openSharingPanel(fileName) - openSharingDetails(index) - - cy.intercept({ times: 1, method: 'PUT', url: '**/apps/files_sharing/api/v1/shares/*' }).as('updateShare') - - cy.get('#app-sidebar-vue').within(() => { - if (shareSettings.download !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox') - if (shareSettings.download) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox') - .check({ force: true, scrollBehavior: 'nearest' }) - cy.get('@downloadCheckbox') - .should('be.checked') - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox') - .uncheck({ force: true, scrollBehavior: 'nearest' }) - cy.get('@downloadCheckbox') - .should('not.be.checked') - } - } - - if (shareSettings.read !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox') - if (shareSettings.read) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.update !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox') - if (shareSettings.update) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.create !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="create"]').find('input').as('createCheckbox') - if (shareSettings.create) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@createCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@createCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.delete !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox') - if (shareSettings.delete) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.note !== undefined) { - cy.findByRole('checkbox', { name: /note to recipient/i }).check({ force: true, scrollBehavior: 'nearest' }) - cy.findByRole('textbox', { name: /note to recipient/i }).type(shareSettings.note) - } - - if (shareSettings.expiryDate !== undefined) { - cy.findByRole('checkbox', { name: /expiration date/i }) - .check({ force: true, scrollBehavior: 'nearest' }) - cy.get('#share-date-picker') - .type(`${shareSettings.expiryDate.getFullYear()}-${String(shareSettings.expiryDate.getMonth() + 1).padStart(2, '0')}-${String(shareSettings.expiryDate.getDate()).padStart(2, '0')}`) - } - - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - - cy.wait('@updateShare') - }) - closeSidebar() -} - -export function openSharingPanel(fileName: string) { - triggerActionForFile(fileName, 'details') - - cy.get('[data-cy-sidebar]') - .as('sidebar') - .should('be.visible') - cy.get('@sidebar') - .find('[aria-controls="tab-sharing"]') - .click() -} - -type FileRequestOptions = { - label?: string - note?: string - password?: string - /* YYYY-MM-DD format */ - expiration?: string -} - -/** - * Create a file request for a folder - * - * @param path The path of the folder, leading slash is required - * @param options The options for the file request - */ -export function createFileRequest(path: string, options: FileRequestOptions = {}) { - if (!path.startsWith('/')) { - throw new Error('Path must start with a slash') - } - - // Navigate to the folder - cy.visit('/apps/files/files?dir=' + path) - - // Open the file request dialog - cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click() - cy.contains('.upload-picker__menu-entry button', 'Create file request').click() - cy.get('[data-cy-file-request-dialog]').should('be.visible') - - // Check and fill the first page options - cy.get('[data-cy-file-request-dialog-fieldset="label"]').should('be.visible') - cy.get('[data-cy-file-request-dialog-fieldset="destination"]').should('be.visible') - cy.get('[data-cy-file-request-dialog-fieldset="note"]').should('be.visible') - - cy.get('[data-cy-file-request-dialog-fieldset="destination"] input').should('contain.value', path) - if (options.label) { - cy.get('[data-cy-file-request-dialog-fieldset="label"] input').type(`{selectall}${options.label}`) - } - if (options.note) { - cy.get('[data-cy-file-request-dialog-fieldset="note"] textarea').type(`{selectall}${options.note}`) - } - - // Go to the next page - cy.get('[data-cy-file-request-dialog-controls="next"]').click() - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').should('exist') - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').should('not.exist') - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').should('exist') - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').should('not.exist') - if (options.expiration) { - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').check({ force: true }) - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').type(`{selectall}${options.expiration}`) - } - if (options.password) { - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').check({ force: true }) - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').type(`{selectall}${options.password}`) - } - - // Create the file request - cy.get('[data-cy-file-request-dialog-controls="next"]').click() - - // Get the file request URL - cy.get('[data-cy-file-request-dialog-fieldset="link"]').then(($link) => { - const url = $link.val() - cy.log(`File request URL: ${url}`) - cy.wrap(url).as('fileRequestUrl') - }) - - // Close - cy.get('[data-cy-file-request-dialog-controls="finish"]').click() -} diff --git a/cypress/e2e/files_sharing/ShareOptionsType.ts b/cypress/e2e/files_sharing/ShareOptionsType.ts deleted file mode 100644 index 6771590f24429..0000000000000 --- a/cypress/e2e/files_sharing/ShareOptionsType.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export type ShareOptions = { - enforcePassword?: boolean - enforceExpirationDate?: boolean - alwaysAskForPassword?: boolean - defaultExpirationDateSet?: boolean -} - -export const defaultShareOptions: ShareOptions = { - enforcePassword: false, - enforceExpirationDate: false, - alwaysAskForPassword: false, - defaultExpirationDateSet: false, -} diff --git a/cypress/e2e/files_sharing/expiry-date.cy.ts b/cypress/e2e/files_sharing/expiry-date.cy.ts deleted file mode 100644 index 0055b499d38d8..0000000000000 --- a/cypress/e2e/files_sharing/expiry-date.cy.ts +++ /dev/null @@ -1,130 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { closeSidebar } from '../files/FilesUtils.ts' -import { createShare, openSharingDetails, openSharingPanel, updateShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Expiry date', () => { - const expectedDefaultDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000) - const expectedDefaultDateString = `${expectedDefaultDate.getFullYear()}-${String(expectedDefaultDate.getMonth() + 1).padStart(2, '0')}-${String(expectedDefaultDate.getDate()).padStart(2, '0')}` - const fortnight = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) - const fortnightString = `${fortnight.getFullYear()}-${String(fortnight.getMonth() + 1).padStart(2, '0')}-${String(fortnight.getDate()).padStart(2, '0')}` - - let alice: User - let bob: User - - before(() => { - // Ensure we have the admin setting setup for default dates with 2 days in the future - cy.runOccCommand('config:app:set --value yes core shareapi_default_internal_expire_date') - cy.runOccCommand('config:app:set --value 2 core shareapi_internal_expire_after_n_days') - - cy.createRandomUser().then((user) => { - alice = user - cy.login(alice) - }) - cy.createRandomUser().then((user) => { - bob = user - }) - }) - - after(() => { - cy.runOccCommand('config:app:delete core shareapi_default_internal_expire_date') - cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date') - cy.runOccCommand('config:app:delete core shareapi_internal_expire_after_n_days') - }) - - beforeEach(() => { - cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date') - }) - - it('See default expiry date is set and enforced', () => { - // Enforce the date - cy.runOccCommand('config:app:set --value yes core shareapi_enforce_internal_expire_date') - const dir = 'defaultExpiryDateEnforced' - prepareDirectory(dir) - - validateExpiryDate(dir, expectedDefaultDateString) - cy.findByRole('checkbox', { name: /expiration date/i }) - .should('be.checked') - .and('be.disabled') - }) - - it('See default expiry date is set also if not enforced', () => { - const dir = 'defaultExpiryDate' - prepareDirectory(dir) - - validateExpiryDate(dir, expectedDefaultDateString) - cy.findByRole('checkbox', { name: /expiration date/i }) - .should('be.checked') - .and('not.be.disabled') - .check({ force: true, scrollBehavior: 'nearest' }) - }) - - it('Can set custom expiry date', () => { - const dir = 'customExpiryDate' - prepareDirectory(dir) - updateShare(dir, 0, { expiryDate: fortnight }) - validateExpiryDate(dir, fortnightString) - }) - - it('Custom expiry date survives reload', () => { - const dir = 'customExpiryDateReload' - prepareDirectory(dir) - updateShare(dir, 0, { expiryDate: fortnight }) - validateExpiryDate(dir, fortnightString) - - cy.visit('/apps/files') - validateExpiryDate(dir, fortnightString) - }) - - /** - * Regression test for https://github.com/nextcloud/server/pull/50192 - * Ensure that admin default settings do not always override the user set value. - */ - it('Custom expiry date survives unrelated update', () => { - const dir = 'customExpiryUnrelatedChanges' - prepareDirectory(dir) - updateShare(dir, 0, { expiryDate: fortnight }) - validateExpiryDate(dir, fortnightString) - closeSidebar() - - cy.log('Upadate share and validate expiry date is kept') - updateShare(dir, 0, { note: 'Only note changed' }) - validateExpiryDate(dir, fortnightString) - - cy.log('Reload page and validate expiry date is kept') - cy.visit('/apps/files') - validateExpiryDate(dir, fortnightString) - }) - - /** - * Prepare directory, login and share to bob - * - * @param name The directory name - */ - function prepareDirectory(name: string) { - cy.mkdir(alice, `/${name}`) - cy.login(alice) - cy.visit('/apps/files') - createShare(name, bob.userId) - } - - /** - * Validate expiry date on a share - * - * @param filename The filename to validate - * @param expectedDate The expected date in YYYY-MM-dd - */ - function validateExpiryDate(filename: string, expectedDate: string) { - openSharingPanel(filename) - openSharingDetails(0) - - cy.get('#share-date-picker') - .should('exist') - .and('have.value', expectedDate) - } -}) diff --git a/cypress/e2e/files_sharing/file-request.cy.ts b/cypress/e2e/files_sharing/file-request.cy.ts deleted file mode 100644 index 76965f320bb70..0000000000000 --- a/cypress/e2e/files_sharing/file-request.cy.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils.ts' -import { createFileRequest } from './FilesSharingUtils.ts' - -function enterGuestName(name: string) { - cy.findByRole('dialog', { name: /Upload files to/ }) - .should('be.visible') - .within(() => { - cy.findByRole('textbox', { name: 'Name' }) - .should('be.visible') - - cy.findByRole('textbox', { name: 'Name' }) - .type(`{selectall}${name}`) - - cy.findByRole('button', { name: 'Submit name' }) - .should('be.visible') - .click() - }) - - cy.findByRole('dialog', { name: /Upload files to/ }) - .should('not.exist') -} - -describe('Files', { testIsolation: true }, () => { - const folderName = 'test-folder' - let user: User - let url = '' - - it('Login with a user and create a file request', () => { - cy.createRandomUser().then((_user) => { - user = _user - cy.login(user) - }) - - cy.visit('/apps/files') - createFolder(folderName) - - createFileRequest(`/${folderName}`) - cy.get('@fileRequestUrl').should('contain', '/s/').then((_url: string) => { - cy.logout() - url = _url - }) - }) - - it('Open the file request as a guest', () => { - cy.visit(url) - enterGuestName('Guest') - - // Check various elements on the page - cy.contains(`Upload files to ${folderName}`) - .should('be.visible') - cy.findByRole('button', { name: 'Upload' }) - .should('be.visible') - - cy.intercept('PUT', '/public.php/dav/files/*/*').as('uploadFile') - - // Upload a file - cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') - .should('exist') - .selectFile({ - contents: Cypress.Buffer.from('abcdef'), - fileName: 'file.txt', - mimeType: 'text/plain', - lastModified: Date.now(), - }, { force: true }) - - cy.wait('@uploadFile').its('response.statusCode').should('eq', 201) - }) - - it('Check the uploaded file', () => { - cy.login(user) - cy.visit(`/apps/files/files?dir=/${folderName}`) - getRowForFile('Guest') - .should('be.visible') - navigateToFolder('Guest') - getRowForFile('file.txt').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/files-download.cy.ts b/cypress/e2e/files_sharing/files-download.cy.ts deleted file mode 100644 index 9ee3bda069996..0000000000000 --- a/cypress/e2e/files_sharing/files-download.cy.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { - getActionButtonForFile, - getActionEntryForFile, - getRowForFile, -} from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Download forbidden', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.runOccCommand('config:app:set --value yes core shareapi_allow_view_without_download') - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - after(() => { - cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download') - }) - - it('cannot download a folder if disabled', () => { - // share the folder - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - createShare('folder', sharee.userId, { read: true, download: false }) - cy.logout() - - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getActionButtonForFile('folder') - .should('be.visible') - // open the action menu - .click({ force: true }) - // see no download action - getActionEntryForFile('folder', 'download') - .should('not.exist') - - // Disable view without download option - cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('folder').should('be.visible') - getActionButtonForFile('folder') - .should('be.visible') - // open the action menu - .click({ force: true }) - getActionEntryForFile('folder', 'download').should('not.exist') - }) - - it('cannot download a file if disabled', () => { - // share the folder - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - createShare('file.txt', sharee.userId, { read: true, download: false }) - cy.logout() - - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getActionButtonForFile('file.txt') - .should('be.visible') - // open the action menu - .click({ force: true }) - // see no download action - getActionEntryForFile('file.txt', 'download') - .should('not.exist') - - // Disable view without download option - cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('file.txt').should('be.visible') - getActionButtonForFile('file.txt') - .should('be.visible') - // open the action menu - .click({ force: true }) - getActionEntryForFile('file.txt', 'download').should('not.exist') - }) -}) diff --git a/cypress/e2e/files_sharing/files-shares-view.cy.ts b/cypress/e2e/files_sharing/files-shares-view.cy.ts deleted file mode 100644 index c20fad87a67ba..0000000000000 --- a/cypress/e2e/files_sharing/files-shares-view.cy.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Files view', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/46108 - */ - it('opens a shared folder when clicking on it', () => { - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true }) - // visit the own shares - cy.visit('/apps/files/sharingout') - // see the shared folder - getRowForFile('folder').should('be.visible') - // click on the folder should open it in files - getRowForFile('folder').findByRole('button', { name: /open in files/i }).click() - // See the URL has changed - cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/) - // Content of the shared folder - getRowForFile('file').should('be.visible') - - cy.logout() - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files/sharingin') - // see the shared folder - getRowForFile('folder').should('be.visible') - // click on the folder should open it in files - getRowForFile('folder').findByRole('button', { name: /open in files/i }).click() - // See the URL has changed - cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/) - // Content of the shared folder - getRowForFile('file').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts deleted file mode 100644 index 21e37ae745ef0..0000000000000 --- a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('Limit to sharing to people in the same group', () => { - let alice: User - let bob: User - let randomFileName1 = '' - let randomFileName2 = '' - let randomGroupName = '' - let randomGroupName2 = '' - let randomGroupName3 = '' - - before(() => { - randomFileName1 = randomString(10) + '.txt' - randomFileName2 = randomString(10) + '.txt' - randomGroupName = randomString(10) - randomGroupName2 = randomString(10) - randomGroupName3 = randomString(10) - - cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value yes') - - cy.createRandomUser() - .then((user) => { - alice = user - }) - cy.createRandomUser() - .then((user) => { - bob = user - - cy.runOccCommand(`group:add ${randomGroupName}`) - cy.runOccCommand(`group:add ${randomGroupName2}`) - cy.runOccCommand(`group:add ${randomGroupName3}`) - cy.runOccCommand(`group:adduser ${randomGroupName} ${alice.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName} ${bob.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName2} ${alice.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName2} ${bob.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName3} ${bob.userId}`) - - cy.uploadContent(alice, new Blob(['share to bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName1}`) - cy.uploadContent(bob, new Blob(['share by bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName2}`) - - cy.login(alice) - cy.visit('/apps/files') - createShare(randomFileName1, bob.userId) - cy.logout() - - cy.login(bob) - cy.visit('/apps/files') - createShare(randomFileName2, alice.userId) - cy.logout() - }) - }) - - after(() => { - cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value no') - }) - - it('Alice can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist') - }) - - it('Bob can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist') - }) - - context('Bob is removed from the first group', () => { - before(() => { - cy.runOccCommand(`group:removeuser ${randomGroupName} ${bob.userId}`) - }) - - it('Alice can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist') - }) - - it('Bob can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist') - }) - }) - - context('Bob is removed from the second group', () => { - before(() => { - cy.runOccCommand(`group:removeuser ${randomGroupName2} ${bob.userId}`) - }) - - it('Alice cannot see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('not.exist') - }) - - it('Bob cannot see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/note-to-recipient.cy.ts b/cypress/e2e/files_sharing/note-to-recipient.cy.ts deleted file mode 100644 index 989155a6608f9..0000000000000 --- a/cypress/e2e/files_sharing/note-to-recipient.cy.ts +++ /dev/null @@ -1,92 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { navigateToFolder } from '../files/FilesUtils.ts' -import { createShare, openSharingPanel } from './FilesSharingUtils.ts' - -describe('files_sharing: Note to recipient', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - it('displays the note to the sharee', () => { - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) - - cy.logout() - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - navigateToFolder('folder') - cy.get('.note-to-recipient') - .should('be.visible') - .and('contain.text', 'Hello, this is the note.') - }) - - it('displays the note to the sharee even if the file list is empty', () => { - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) - - cy.logout() - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - navigateToFolder('folder') - cy.get('.note-to-recipient') - .should('be.visible') - .and('contain.text', 'Hello, this is the note.') - }) - - /** - * Regression test for https://github.com/nextcloud/server/issues/46188 - */ - it('shows an existing note when editing a share', () => { - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) - - // reload just to be sure - cy.visit('/apps/files') - - // open the sharing tab - openSharingPanel('folder') - - cy.get('[data-cy-sidebar]').within(() => { - // Open the share - cy.get('[data-cy-files-sharing-share-actions]').first().click({ force: true }) - - cy.findByRole('checkbox', { name: /note to recipient/i }) - .and('be.checked') - cy.findByRole('textbox', { name: /note to recipient/i }) - .should('be.visible') - .and('have.value', 'Hello, this is the note.') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts deleted file mode 100644 index 0577c0120fede..0000000000000 --- a/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' -import type { ShareOptions } from '../ShareOptionsType.ts' - -import { openSharingPanel } from '../FilesSharingUtils.ts' - -export interface ShareContext { - user: User - url?: string -} - -const defaultShareContext: ShareContext = { - user: {} as User, - url: undefined, -} - -/** - * Retrieves the URL of the share. - * Throws an error if the share context is not initialized properly. - * - * @param context The current share context (defaults to `defaultShareContext` if not provided). - * @return The share URL. - * @throws {Error} if the share context has no URL. - */ -export function getShareUrl(context: ShareContext = defaultShareContext): string { - if (!context.url) { - throw new Error('You need to setup the share first!') - } - return context.url -} - -/** - * Setup the available data - * - * @param user The current share context - * @param shareName The name of the shared folder - */ -export function setupData(user: User, shareName: string): void { - cy.mkdir(user, `/${shareName}`) - cy.mkdir(user, `/${shareName}/subfolder`) - cy.uploadContent(user, new Blob(['foo']), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent(user, new Blob(['bar']), 'text/plain', `/${shareName}/subfolder/bar.txt`) -} - -/** - * Check the password state based on enforcement and default presence. - * - * @param enforced Whether the password is enforced. - * @param alwaysAskForPassword Wether the password should always be asked for. - */ -function checkPasswordState(enforced: boolean, alwaysAskForPassword: boolean) { - if (enforced) { - cy.contains('Password protection (enforced)').should('exist') - } else if (alwaysAskForPassword) { - cy.contains('Password protection').should('exist') - } - cy.contains('Enter a password') - .should('exist') - .and('not.be.disabled') -} - -/** - * Check the expiration date state based on enforcement and default presence. - * - * @param enforced Whether the expiration date is enforced. - * @param hasDefault Whether a default expiration date is set. - */ -function checkExpirationDateState(enforced: boolean, hasDefault: boolean) { - if (enforced) { - cy.contains('Enable link expiration (enforced)').should('exist') - } else if (hasDefault) { - cy.contains('Enable link expiration').should('exist') - } - cy.contains('Enter expiration date') - .should('exist') - .and('not.be.disabled') - cy.get('input[data-cy-files-sharing-expiration-date-input]').should('exist') - cy.get('input[data-cy-files-sharing-expiration-date-input]') - .invoke('val') - .then((val) => { - expect(val).to.not.be.undefined - - const inputDate = new Date(typeof val === 'number' ? val : String(val)) - const expectedDate = new Date() - expectedDate.setDate(expectedDate.getDate() + 2) - expect(inputDate.toDateString()).to.eq(expectedDate.toDateString()) - }) -} - -/** - * Create a public link share - * - * @param context The current share context - * @param shareName The name of the shared folder - * @param options The share options - */ -export function createLinkShare(context: ShareContext, shareName: string, options: ShareOptions | null = null): Cypress.Chainable { - cy.login(context.user) - cy.visit('/apps/files') - openSharingPanel(shareName) - - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createLinkShare') - cy.findByRole('button', { name: 'Create a new share link' }).click() - // Conduct optional checks based on the provided options - if (options) { - cy.get('.sharing-entry__actions').should('be.visible') // Wait for the dialog to open - checkPasswordState(options.enforcePassword ?? false, options.alwaysAskForPassword ?? false) - checkExpirationDateState(options.enforceExpirationDate ?? false, options.defaultExpirationDateSet ?? false) - cy.findByRole('button', { name: 'Create share' }).click() - } - - return cy.wait('@createLinkShare') - .should(({ response }) => { - expect(response?.statusCode).to.eq(200) - const url = response?.body?.ocs?.data?.url - expect(url).to.match(/^https?:\/\//) - context.url = url - }) - .then(() => cy.wrap(context.url as string)) -} - -/** - * open link share details for specific index - * - * @param index - */ -export function openLinkShareDetails(index: number) { - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .eq(index) - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }).click() -} - -/** - * Adjust share permissions to be editable - */ -function adjustSharePermission(): void { - openLinkShareDetails(0) - - cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click() - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }).click() - cy.wait('@updateShare').its('response.statusCode').should('eq', 200) -} - -/** - * Setup a public share and backup the state. - * If the setup was already done in another run, the state will be restored. - * - * @param shareName The name of the shared folder - * @return The URL of the share - */ -export function setupPublicShare(shareName = 'shared'): Cypress.Chainable { - return cy.task('getVariable', { key: `public-share-data--${shareName}` }) - .then((data) => { - const { dataSnapshot, shareUrl } = data as any || {} - if (dataSnapshot) { - cy.restoreState(dataSnapshot) - defaultShareContext.url = shareUrl - return cy.wrap(shareUrl as string) - } else { - const shareData: Record = {} - return cy.createRandomUser() - .then((user) => { - defaultShareContext.user = user - }) - .then(() => setupData(defaultShareContext.user, shareName)) - .then(() => createLinkShare(defaultShareContext, shareName)) - .then((url) => { - shareData.shareUrl = url - }) - .then(() => adjustSharePermission()) - .then(() => cy.saveState().then((snapshot) => { - shareData.dataSnapshot = snapshot - })) - .then(() => cy.task('setVariable', { key: `public-share-data--${shareName}`, value: shareData })) - .then(() => cy.log(`Public share setup, URL: ${shareData.shareUrl}`)) - .then(() => cy.wrap(defaultShareContext.url)) - } - }) -} diff --git a/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts deleted file mode 100644 index 524cf3d3f8acd..0000000000000 --- a/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { copyFile, getRowForFile, moveFile, navigateToFolder } from '../../files/FilesUtils.ts' -import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - copy and move files', { testIsolation: true }, () => { - beforeEach(() => { - setupPublicShare() - .then(() => cy.logout()) - .then(() => cy.visit(getShareUrl())) - }) - - it('Can copy a file to new folder', () => { - getRowForFile('foo.txt').should('be.visible') - getRowForFile('subfolder').should('be.visible') - - copyFile('foo.txt', 'subfolder') - - // still visible - getRowForFile('foo.txt').should('be.visible') - navigateToFolder('subfolder') - - cy.url().should('contain', 'dir=/subfolder') - getRowForFile('foo.txt').should('be.visible') - getRowForFile('bar.txt').should('be.visible') - getRowForFile('subfolder').should('not.exist') - }) - - it('Can move a file to new folder', () => { - getRowForFile('foo.txt').should('be.visible') - getRowForFile('subfolder').should('be.visible') - - moveFile('foo.txt', 'subfolder') - - // wait until visible again - getRowForFile('subfolder').should('be.visible') - - // file should be moved -> not exist anymore - getRowForFile('foo.txt').should('not.exist') - navigateToFolder('subfolder') - - cy.url().should('contain', 'dir=/subfolder') - getRowForFile('foo.txt').should('be.visible') - getRowForFile('subfolder').should('not.exist') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/default-view.cy.ts b/cypress/e2e/files_sharing/public-share/default-view.cy.ts deleted file mode 100644 index c09c6de5085c0..0000000000000 --- a/cypress/e2e/files_sharing/public-share/default-view.cy.ts +++ /dev/null @@ -1,103 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from '../../files/FilesUtils.ts' -import { createLinkShare, setupData } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - setting the default view mode', () => { - let user: User - - beforeEach(() => { - cy.createRandomUser() - .then(($user) => (user = $user)) - .then(() => setupData(user, 'shared')) - }) - - it('is by default in list view', () => { - const context = { user } - createLinkShare(context, 'shared') - .then((url) => { - cy.logout() - cy.visit(url!) - - // See file is visible - getRowForFile('foo.txt').should('be.visible') - // See we are in list view - cy.findByRole('button', { name: 'Switch to grid view' }) - .should('be.visible') - .and('not.be.disabled') - }) - }) - - it('can be toggled by user', () => { - const context = { user } - createLinkShare(context, 'shared') - .then((url) => { - cy.logout() - cy.visit(url!) - - // See file is visible - getRowForFile('foo.txt') - .should('be.visible') - // See we are in list view - .find('.files-list__row-icon') - .should(($el) => expect($el.outerWidth()).to.be.lessThan(99)) - - // See the grid view toggle - cy.findByRole('button', { name: 'Switch to grid view' }) - .should('be.visible') - .and('not.be.disabled') - // And can change to grid view - .click() - - // See we are in grid view - getRowForFile('foo.txt') - .find('.files-list__row-icon') - .should(($el) => expect($el.outerWidth()).to.be.greaterThan(99)) - - // See the grid view toggle is now the list view toggle - cy.findByRole('button', { name: 'Switch to list view' }) - .should('be.visible') - .and('not.be.disabled') - }) - }) - - it('can be changed to default grid view', () => { - const context = { user } - createLinkShare(context, 'shared') - .then((url) => { - // Can set the "grid" view checkbox - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }).click() - cy.findByRole('button', { name: /Advanced settings/i }).click() - cy.findByRole('checkbox', { name: /Show files in grid view/i }) - .scrollIntoView() - cy.findByRole('checkbox', { name: /Show files in grid view/i }) - .should('not.be.checked') - .check({ force: true }) - - // Wait for the share update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }).click() - cy.wait('@updateShare').its('response.statusCode').should('eq', 200) - - // Logout and visit the share - cy.logout() - cy.visit(url!) - - // See file is visible - getRowForFile('foo.txt').should('be.visible') - // See we are in list view - cy.findByRole('button', { name: 'Switch to list view' }) - .should('be.visible') - .and('not.be.disabled') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/download.cy.ts b/cypress/e2e/files_sharing/public-share/download.cy.ts deleted file mode 100644 index 5c9c0ed7302bf..0000000000000 --- a/cypress/e2e/files_sharing/public-share/download.cy.ts +++ /dev/null @@ -1,272 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' -import type { ShareContext } from './PublicShareUtils.ts' - -import { zipFileContains } from '../../../support/utils/assertions.ts' -import { deleteDownloadsFolderBeforeEach } from '../../../support/utils/deleteDownloadsFolder.ts' -import { getRowForFile, getRowForFileId, triggerActionForFile, triggerActionForFileId } from '../../files/FilesUtils.ts' -import { createLinkShare, getShareUrl, openLinkShareDetails, setupPublicShare } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - downloading files', { testIsolation: true }, () => { - // in general there is no difference except downloading - // as file shares have the source of the share token but a different displayname - describe('file share', () => { - let fileId: number - - before(() => { - cy.createRandomUser().then((user) => { - const context: ShareContext = { user } - cy.uploadContent(user, new Blob(['foo']), 'text/plain', '/file.txt') - .then(({ headers }) => { fileId = Number.parseInt(headers['oc-fileid']) }) - cy.login(user) - createLinkShare(context, 'file.txt') - .then(() => cy.logout()) - .then(() => cy.visit(context.url!)) - }) - }) - - it('can download the file', () => { - getRowForFileId(fileId) - .should('be.visible') - getRowForFileId(fileId) - .find('[data-cy-files-list-row-name]') - .should((el) => expect(el.text()).to.match(/file\s*\.txt/)) // extension is sparated so there might be a space between - triggerActionForFileId(fileId, 'download') - // check a file is downloaded with the correct name - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'foo') - }) - }) - - describe('folder share', () => { - const shareName = 'a-folder-share' - - before(() => setupPublicShare(shareName)) - - deleteDownloadsFolderBeforeEach() - - beforeEach(() => { - cy.logout() - cy.visit(getShareUrl()) - }) - - it('Can download all files', () => { - getRowForFile('foo.txt').should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - cy.findByRole('checkbox', { name: /Toggle selection for all files/i }) - .should('exist') - .check({ force: true }) - - // see that two files are selected - cy.contains('2 selected').should('be.visible') - - // click download - cy.findByRole('button', { name: 'Download (selected)' }) - .should('be.visible') - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'foo.txt', - 'subfolder/', - 'subfolder/bar.txt', - ])) - }) - }) - - it('Can download selected files', () => { - getRowForFile('subfolder') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - getRowForFile('subfolder') - .findByRole('checkbox') - .check({ force: true }) - - // see that two files are selected - cy.contains('1 selected').should('be.visible') - - // click download - cy.findByRole('button', { name: 'Download (selected)' }) - .should('be.visible') - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'subfolder/', - 'subfolder/bar.txt', - ])) - }) - }) - - it('Can download folder by action', () => { - getRowForFile('subfolder') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - triggerActionForFile('subfolder', 'download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'subfolder/', - 'subfolder/bar.txt', - ])) - }) - }) - - it('Can download file by action', () => { - getRowForFile('foo.txt') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - triggerActionForFile('foo.txt', 'download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'foo') - }) - }) - - it('Can download file by selection', () => { - getRowForFile('foo.txt') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - getRowForFile('foo.txt') - .findByRole('checkbox') - .check({ force: true }) - - cy.findByRole('button', { name: 'Download (selected)' }) - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'foo') - }) - }) - }) - - describe('download permission - link share', () => { - let context: ShareContext - beforeEach(() => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/test') - - context = { user } - createLinkShare(context, 'test') - cy.login(context.user) - cy.visit('/apps/files') - }) - }) - - deleteDownloadsFolderBeforeEach() - - it('download permission is retained', () => { - getRowForFile('test').should('be.visible') - triggerActionForFile('test', 'details') - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update') - - cy.findByRole('checkbox', { name: /hide download/i }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - cy.findByRole('checkbox', { name: /hide download/i }) - .should('be.checked') - cy.findByRole('button', { name: /update share/i }) - .click() - - cy.wait('@update') - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('checkbox', { name: /hide download/i }) - .should('be.checked') - - cy.reload() - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('checkbox', { name: /hide download/i }) - .should('be.checked') - }) - }) - - describe('download permission - mail share', () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/test') - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('download permission is retained', () => { - getRowForFile('test').should('be.visible') - triggerActionForFile('test', 'details') - - cy.findByRole('combobox', { name: /Enter external recipients/i }) - .type('test@example.com') - - cy.get('.option[sharetype="4"][user="test@example.com"]') - .parent('li') - .click() - cy.findByRole('button', { name: /advanced settings/i }) - .should('be.visible') - .click() - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update') - - cy.findByRole('checkbox', { name: /hide download/i }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - cy.findByRole('button', { name: /save share/i }) - .click() - - cy.wait('@update') - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }) - .click() - cy.findByRole('checkbox', { name: /hide download/i }) - .should('exist') - .and('be.checked') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts deleted file mode 100644 index c986ce634ac56..0000000000000 --- a/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts +++ /dev/null @@ -1,194 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { ShareContext } from './PublicShareUtils.ts' - -import { createLinkShare, setupData } from './PublicShareUtils.ts' - -/** - * This tests ensures that on public shares the header avatar menu correctly works - */ -describe('files_sharing: Public share - header avatar menu', { testIsolation: true }, () => { - let context: ShareContext - let firstPublicShareUrl = '' - let secondPublicShareUrl = '' - - before(() => { - cy.createRandomUser() - .then((user) => { - context = { - user, - url: undefined, - } - setupData(context.user, 'public1') - setupData(context.user, 'public2') - createLinkShare(context, 'public1').then((shareUrl) => { - firstPublicShareUrl = shareUrl - cy.log(`Created first share with URL: ${shareUrl}`) - }) - createLinkShare(context, 'public2').then((shareUrl) => { - secondPublicShareUrl = shareUrl - cy.log(`Created second share with URL: ${shareUrl}`) - }) - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(firstPublicShareUrl) - }) - - it('See the undefined avatar menu', () => { - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - // Note that current guest user is not identified - cy.get('@headerMenu') - .should('be.visible') - .findByRole('note') - .should('be.visible') - .should('contain', 'not identified') - - // Button to set guest name - cy.get('@headerMenu') - .findByRole('link', { name: /Set public name/i }) - .should('be.visible') - }) - - it('Can set public name', () => { - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .as('userMenuButton') - - // Open the user menu - cy.get('@userMenuButton').click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - cy.get('@headerMenu') - .findByRole('link', { name: /Set public name/i }) - .should('be.visible') - .click() - - // Check the dialog is visible - cy.findByRole('dialog', { name: /Guest identification/i }) - .should('be.visible') - .as('guestIdentificationDialog') - - // Check the note is visible - cy.get('@guestIdentificationDialog') - .findByRole('note') - .should('contain', 'not identified') - - // Check the input is visible - cy.get('@guestIdentificationDialog') - .findByRole('textbox', { name: /Name/i }) - .should('be.visible') - .type('{selectAll}John Doe{enter}') - - // Check that the dialog is closed - cy.get('@guestIdentificationDialog') - .should('not.exist') - - // Check that the avatar changed - cy.get('@userMenuButton') - .find('img') - .invoke('attr', 'src') - .should('include', 'avatar/guest/John%20Doe') - }) - - it('Guest name us persistent and can be changed', () => { - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .as('userMenuButton') - - // Open the user menu - cy.get('@userMenuButton').click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - cy.get('@headerMenu') - .findByRole('link', { name: /Set public name/i }) - .should('be.visible') - .click() - - // Check the dialog is visible - cy.findByRole('dialog', { name: /Guest identification/i }) - .should('be.visible') - .as('guestIdentificationDialog') - - // Set the name - cy.get('@guestIdentificationDialog') - .findByRole('textbox', { name: /Name/i }) - .should('be.visible') - .type('{selectAll}Jane Doe{enter}') - - // Check that the dialog is closed - cy.get('@guestIdentificationDialog') - .should('not.exist') - - // Create another share - cy.visit(secondPublicShareUrl) - - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .as('userMenuButton') - - // Open the user menu - cy.get('@userMenuButton').click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - // See the note with the current name - cy.get('@headerMenu') - .findByRole('note') - .should('contain', 'Your guest name: Jane Doe') - - cy.get('@headerMenu') - .findByRole('link', { name: /Change public name/i }) - .should('be.visible') - .click() - - // Check the dialog is visible - cy.findByRole('dialog', { name: /Guest identification/i }) - .should('be.visible') - .as('guestIdentificationDialog') - - // Check that the note states the current name - // cy.get('@guestIdentificationDialog') - // .findByRole('note') - // .should('contain', 'are currently identified as Jane Doe') - - // Change the name - cy.get('@guestIdentificationDialog') - .findByRole('textbox', { name: /Name/i }) - .should('be.visible') - .type('{selectAll}Foo Bar{enter}') - - // Check that the dialog is closed - cy.get('@guestIdentificationDialog') - .should('not.exist') - - // Check that the avatar changed with the second name - cy.get('@userMenuButton') - .find('img') - .invoke('attr', 'src') - .should('include', 'avatar/guest/Foo%20Bar') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/header-menu.cy.ts b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts deleted file mode 100644 index 56da2d2e2e000..0000000000000 --- a/cypress/e2e/files_sharing/public-share/header-menu.cy.ts +++ /dev/null @@ -1,201 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { haveValidity, zipFileContains } from '../../../support/utils/assertions.ts' -import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' - -/** - * This tests ensures that on public shares the header actions menu correctly works - */ -describe('files_sharing: Public share - header actions menu', { testIsolation: true }, () => { - before(() => setupPublicShare()) - beforeEach(() => { - cy.logout() - cy.visit(getShareUrl()) - }) - - it('Can download all files', () => { - cy.get('header') - .findByRole('button', { name: 'Download' }) - .should('be.visible') - cy.get('header') - .findByRole('button', { name: 'Download' }) - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/shared.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'shared/', - 'shared/foo.txt', - 'shared/subfolder/', - 'shared/subfolder/bar.txt', - ])) - }) - - it('Can copy direct link', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .click() - // See the menu - cy.findByRole('menu', { name: /More action/i }) - .should('be.visible') - // see correct link in item - cy.findByRole('menuitem', { name: 'Direct link' }) - .should('be.visible') - .and('have.attr', 'href') - .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`))) - // see menu closes on click - cy.findByRole('menuitem', { name: 'Direct link' }) - .click() - cy.findByRole('menu', { name: /More actions/i }) - .should('not.exist') - }) - - it('Can create federated share', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .click() - // See the menu - cy.findByRole('menu', { name: /More action/i }) - .should('be.visible') - // see correct button - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - // see the dialog - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }) - .should('be.visible') - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).within(() => { - cy.findByRole('textbox') - .type('user@nextcloud.local') - // create share - cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare') - .as('createFederatedShare') - cy.findByRole('button', { name: 'Create share' }) - .click() - cy.wait('@createFederatedShare') - }) - }) - - it('Has user feedback while creating federated share', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - // see correct button - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - // see the dialog - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => { - cy.findByRole('textbox') - .type('user@nextcloud.local') - // intercept request, the request is continued when the promise is resolved - const { promise, resolve } = Promise.withResolvers() - cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare', (request) => { - // we need to wait in the onResponse handler as the intercept handler times out otherwise - request.on('response', async (response) => { - await promise - response.statusCode = 503 - }) - }).as('createFederatedShare') - - // create the share - cy.findByRole('button', { name: 'Create share' }) - .click() - // see that while the share is created the button is disabled - cy.findByRole('button', { name: 'Create share' }) - .should('be.disabled') - .then(() => { - // continue the request - resolve(null) - }) - cy.wait('@createFederatedShare') - // see that the button is no longer disabled - cy.findByRole('button', { name: 'Create share' }) - .should('not.be.disabled') - }) - }) - - it('Has input validation for federated share', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - // see correct button - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - // see the dialog - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => { - // Check domain only - cy.findByRole('textbox') - .type('nextcloud.local') - cy.findByRole('textbox') - .should(haveValidity(/user/i)) - // Check no valid domain - cy.findByRole('textbox') - .type('{selectAll}user@invalid') - cy.findByRole('textbox') - .should(haveValidity(/invalid.+url/i)) - }) - }) - - it('See primary action is moved to menu on small screens', () => { - cy.viewport(490, 490) - // Check the button does not exist - cy.get('header').within(() => { - cy.findByRole('button', { name: 'Direct link' }) - .should('not.exist') - cy.findByRole('button', { name: 'Download' }) - .should('not.exist') - cy.findByRole('button', { name: /Add to your/i }) - .should('not.exist') - // Open the menu - cy.findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - }) - - // See correct number of menu item - cy.findByRole('menu', { name: 'More actions' }) - .findAllByRole('menuitem') - .should('have.length', 3) - cy.findByRole('menu', { name: 'More actions' }) - .within(() => { - // See that download, federated share and direct link are moved to the menu - cy.findByRole('menuitem', { name: /^Download/ }) - .should('be.visible') - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - cy.findByRole('menuitem', { name: 'Direct link' }) - .should('be.visible') - - // See that direct link works - cy.findByRole('menuitem', { name: 'Direct link' }) - .should('be.visible') - .and('have.attr', 'href') - .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`))) - // See remote share works - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - }) - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/rename-files.cy.ts b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts deleted file mode 100644 index a0fa5492be571..0000000000000 --- a/cypress/e2e/files_sharing/public-share/rename-files.cy.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getRowForFile, haveValidity, triggerActionForFile } from '../../files/FilesUtils.ts' -import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - renaming files', { testIsolation: true }, () => { - beforeEach(() => { - setupPublicShare() - .then(() => cy.logout()) - .then(() => cy.visit(getShareUrl())) - }) - - it('can rename a file', () => { - // All are visible by default - getRowForFile('foo.txt').should('be.visible') - - triggerActionForFile('foo.txt', 'rename') - - getRowForFile('foo.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{enter}') - - // See it is renamed - getRowForFile('other.txt').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts deleted file mode 100644 index dd11bd0b2cfac..0000000000000 --- a/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts +++ /dev/null @@ -1,191 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { ShareOptions } from '../ShareOptionsType.ts' -import type { ShareContext } from './PublicShareUtils.ts' - -import { defaultShareOptions } from '../ShareOptionsType.ts' -import { createLinkShare, setupData } from './PublicShareUtils.ts' - -describe('files_sharing: Before create checks', () => { - let shareContext: ShareContext - - before(() => { - // Setup data for the shared folder once before all tests - cy.createRandomUser().then((randomUser) => { - shareContext = { - user: randomUser, - } - }) - }) - - afterEach(() => { - cy.runOccCommand('config:app:delete core shareapi_enable_link_password_by_default') - cy.runOccCommand('config:app:delete core shareapi_enforce_links_password') - cy.runOccCommand('config:app:delete core shareapi_default_expire_date') - cy.runOccCommand('config:app:delete core shareapi_enforce_expire_date') - cy.runOccCommand('config:app:delete core shareapi_expire_after_n_days') - }) - - const applyShareOptions = (options: ShareOptions = defaultShareOptions): void => { - cy.runOccCommand(`config:app:set --value ${options.alwaysAskForPassword ? 'yes' : 'no'} core shareapi_enable_link_password_by_default`) - cy.runOccCommand(`config:app:set --value ${options.enforcePassword ? 'yes' : 'no'} core shareapi_enforce_links_password`) - cy.runOccCommand(`config:app:set --value ${options.enforceExpirationDate ? 'yes' : 'no'} core shareapi_enforce_expire_date`) - cy.runOccCommand(`config:app:set --value ${options.defaultExpirationDateSet ? 'yes' : 'no'} core shareapi_default_expire_date`) - if (options.defaultExpirationDateSet) { - cy.runOccCommand('config:app:set --value 2 core shareapi_expire_after_n_days') - } - } - - it('Checks if user can create share when both password and expiration date are enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: true, - enforceExpirationDate: true, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - const shareName = 'passwordAndExpireEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share when password is enforced and expiration date has a default set', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: true, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - const shareName = 'passwordEnforcedDefaultExpire' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share when password is optionally requested and expiration date is enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - defaultExpirationDateSet: true, - enforceExpirationDate: true, - } - applyShareOptions(shareOptions) - const shareName = 'defaultPasswordExpireEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share when password is optionally requested and expiration date have defaults set', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - const shareName = 'defaultPasswordAndExpire' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password enforced and expiration date set but not enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: true, - defaultExpirationDateSet: true, - enforceExpirationDate: false, - } - applyShareOptions(shareOptions) - const shareName = 'passwordEnforcedExpireSetNotEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create a share when both password and expiration date have default values but are both not enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - enforceExpirationDate: false, - } - applyShareOptions(shareOptions) - const shareName = 'defaultPasswordAndExpirationNotEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password not enforced but expiration date enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - enforceExpirationDate: true, - } - applyShareOptions(shareOptions) - const shareName = 'noPasswordExpireEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password not enforced and expiration date has a default set', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - enforceExpirationDate: false, - } - applyShareOptions(shareOptions) - const shareName = 'defaultExpireNoPasswordEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with expiration date set and password not enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - - const shareName = 'noPasswordExpireDefault' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password not enforced, expiration date not enforced, and no defaults set', () => { - applyShareOptions() - const shareName = 'noPasswordNoExpireNoDefaults' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, null).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts deleted file mode 100644 index 0430ca9b2d186..0000000000000 --- a/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { createLinkShare, openLinkShareDetails } from './PublicShareUtils.ts' - -describe('files_sharing: sidebar tab', () => { - let alice: User - - beforeEach(() => { - cy.createRandomUser() - .then((user) => { - alice = user - cy.mkdir(user, '/test') - cy.login(user) - cy.visit('/apps/files') - }) - }) - - /** - * Regression tests of https://github.com/nextcloud/server/issues/53566 - * Where the ' char was shown as ' - */ - it('correctly lists shares by label with special characters', () => { - createLinkShare({ user: alice }, 'test') - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('textbox', { name: /share label/i }) - .should('be.visible') - .type('Alice\' share') - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('PUT') - cy.findByRole('button', { name: /update share/i }).click() - cy.wait('@PUT') - - // see the label is shown correctly - cy.findByRole('list', { name: /link shares/i }) - .findAllByRole('listitem') - .should('have.length', 1) - .first() - .should('contain.text', 'Share link (Alice\' share)') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts deleted file mode 100644 index c3c289774ccb2..0000000000000 --- a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts +++ /dev/null @@ -1,173 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getRowForFile } from '../../files/FilesUtils.ts' -import { openSharingPanel } from '../FilesSharingUtils.ts' - -describe('files_sharing: Public share - File drop', { testIsolation: true }, () => { - let shareUrl: string - let user: string - const shareName = 'shared' - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user.userId - cy.mkdir($user, `/${shareName}`) - cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - - // Update the share to be a file drop - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }) - .should('be.visible') - .click() - cy.get('[data-cy-files-sharing-share-permissions-bundle]') - .should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="file-drop"]') - .click() - - // save the update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }) - .click() - cy.wait('@updateShare') - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(shareUrl) - }) - - it('Cannot see share content', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - // foo exists - cy.userFileExists(user, `${shareName}/foo.txt`).should('be.gt', 0) - // but is not visible - getRowForFile('foo.txt') - .should('not.exist') - }) - - it('Can only see upload files and upload folders menu entries', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - cy.findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // See upload actions - cy.findByRole('menuitem', { name: 'Upload files' }) - .should('be.visible') - cy.findByRole('menuitem', { name: 'Upload folders' }) - .should('be.visible') - // But no other - cy.findByRole('menu') - .findAllByRole('menuitem') - .should('have.length', 2) - }) - - it('Can only see dedicated upload button', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - cy.findByRole('button', { name: 'Upload' }) - .should('be.visible') - .click() - // See upload actions - cy.findByRole('menuitem', { name: 'Upload files' }) - .should('be.visible') - cy.findByRole('menuitem', { name: 'Upload folders' }) - .should('be.visible') - // But no other - cy.findByRole('menu') - .findAllByRole('menuitem') - .should('have.length', 2) - }) - - it('Can upload files', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - const { promise, resolve } = Promise.withResolvers() - cy.intercept('PUT', '**/public.php/dav/files/**', (request) => { - if (request.url.includes('first.txt')) { - // just continue the first one - request.continue() - } else { - // We delay the second one until we checked that the progress bar is visible - request.on('response', async () => { - await promise - }) - } - }).as('uploadFile') - - cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') - .should('exist') - .selectFile([ - { fileName: 'first.txt', contents: Buffer.from('8 bytes!') }, - { fileName: 'second.md', contents: Buffer.from('x'.repeat(128)) }, - ], { force: true }) - - cy.wait('@uploadFile') - - cy.findByRole('progressbar') - .should('be.visible') - .and((el) => { expect(Number.parseInt(el.attr('value') ?? '0')).be.gte(50) }) - // continue second request - .then(() => resolve(null)) - - cy.wait('@uploadFile') - - // Check files uploaded - cy.userFileExists(user, `${shareName}/first.txt`).should('eql', 8) - cy.userFileExists(user, `${shareName}/second.md`).should('eql', 128) - }) - - describe('Terms of service', { testIsolation: true }, () => { - before(() => cy.runOccCommand('config:app:set --value \'TEST: Some disclaimer text\' --type string core shareapi_public_link_disclaimertext')) - beforeEach(() => cy.visit(shareUrl)) - after(() => cy.runOccCommand('config:app:delete core shareapi_public_link_disclaimertext')) - - it('shows ToS on file-drop view', () => { - cy.get('[data-cy-files-sharing-file-drop]') - .contains(`Upload files to ${shareName}`) - .should('be.visible') - cy.get('[data-cy-files-sharing-file-drop]') - .contains('agree to the terms of service') - .should('be.visible') - cy.findByRole('button', { name: /Terms of service/i }) - .should('be.visible') - .click() - - cy.findByRole('dialog', { name: 'Terms of service' }) - .should('contain.text', 'TEST: Some disclaimer text') - // close - .findByRole('button', { name: 'Close' }) - .click() - - cy.findByRole('dialog', { name: 'Terms of service' }) - .should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts deleted file mode 100644 index 0545e7d88db6d..0000000000000 --- a/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts +++ /dev/null @@ -1,100 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' -import { openSharingPanel } from '../FilesSharingUtils.ts' - -describe('files_sharing: Public share - View only', { testIsolation: true }, () => { - let shareUrl: string - const shareName = 'shared' - - before(() => { - cy.createRandomUser().then(($user) => { - cy.mkdir($user, `/${shareName}`) - cy.mkdir($user, `/${shareName}/subfolder`) - cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/subfolder/bar.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - - // Update the share to be a view-only-no-download share - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }) - .should('be.visible') - .click() - cy.get('[data-cy-files-sharing-share-permissions-bundle]') - .should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') - .click() - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('checkbox', { name: 'Hide download' }) - .check({ force: true }) - // save the update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }) - .click() - cy.wait('@updateShare') - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(shareUrl) - }) - - it('Can see the files list', () => { - // foo exists - getRowForFile('foo.txt') - .should('be.visible') - }) - - it('But no actions available', () => { - // foo exists - getRowForFile('foo.txt') - .should('be.visible') - // but no actions - getActionButtonForFile('foo.txt') - .should('not.exist') - - // TODO: We really need Viewer in the server repo. - // So we could at least test viewing images - }) - - it('Can navigate to subfolder', () => { - getRowForFile('subfolder') - .should('be.visible') - - navigateToFolder('subfolder') - - getRowForFile('bar.txt') - .should('be.visible') - - // but also no actions - getActionButtonForFile('bar.txt') - .should('not.exist') - }) - - it('Cannot upload files', () => { - // wait for file list to be ready - getRowForFile('foo.txt') - .should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts deleted file mode 100644 index f2363defcee82..0000000000000 --- a/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts +++ /dev/null @@ -1,102 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' -import { openSharingPanel } from '../FilesSharingUtils.ts' - -describe('files_sharing: Public share - View only', { testIsolation: true }, () => { - let shareUrl: string - const shareName = 'shared' - - before(() => { - cy.createRandomUser().then(($user) => { - cy.mkdir($user, `/${shareName}`) - cy.mkdir($user, `/${shareName}/subfolder`) - cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/subfolder/bar.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - - // Update the share to be a view-only-no-download share - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }) - .should('be.visible') - .click() - cy.get('[data-cy-files-sharing-share-permissions-bundle]') - .should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') - .click() - // save the update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }) - .click() - cy.wait('@updateShare') - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(shareUrl) - }) - - it('Can see the files list', () => { - // foo exists - getRowForFile('foo.txt') - .should('be.visible') - }) - - it('Can navigate to subfolder', () => { - getRowForFile('subfolder') - .should('be.visible') - - navigateToFolder('subfolder') - - getRowForFile('bar.txt') - .should('be.visible') - }) - - it('Cannot upload files', () => { - // wait for file list to be ready - getRowForFile('foo.txt') - .should('be.visible') - }) - - it('Only download action is actions available', () => { - getActionButtonForFile('foo.txt') - .should('be.visible') - .click() - - // Only the download action - cy.findByRole('menuitem', { name: 'Download' }) - .should('be.visible') - cy.findAllByRole('menuitem') - .should('have.length', 1) - - // Can download - cy.findByRole('menuitem', { name: 'Download' }).click() - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'content') - }) -}) diff --git a/cypress/e2e/files_sharing/share-permissions-bundle.cy.ts b/cypress/e2e/files_sharing/share-permissions-bundle.cy.ts deleted file mode 100644 index dec8173c4a9c1..0000000000000 --- a/cypress/e2e/files_sharing/share-permissions-bundle.cy.ts +++ /dev/null @@ -1,111 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { openSharingPanel } from './FilesSharingUtils.ts' - -describe('files_sharing: Share permissions bundle configuration', () => { - let alice: User - let bob: User - - before(() => { - cy.createRandomUser().then(($user) => { - alice = $user - }) - cy.createRandomUser().then(($user) => { - bob = $user - }) - }) - - beforeEach(() => { - cy.runOccCommand('config:app:delete files_sharing shareapi_exclude_reshare_from_edit') - }) - - after(() => { - cy.runOccCommand('config:app:delete files_sharing shareapi_exclude_reshare_from_edit') - }) - - /** - * Helper to create a user share and select "Allow editing" - */ - function createUserShareWithEdit(itemName: string) { - openSharingPanel(itemName) - - cy.get('#app-sidebar-vue').within(() => { - cy.intercept('GET', '**/apps/files_sharing/api/v1/sharees?*').as('shareeSearch') - cy.findByRole('combobox', { name: /Search for internal recipients/i }) - .type(`{selectAll}${bob.userId}`) - cy.wait('@shareeSearch') - }) - - cy.get(`[user="${bob.userId}"]`).click() - - // Select "Allow editing" permission bundle - cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click() - - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Save share' }).click() - - return cy.wait('@createShare') - } - - describe('Default behavior (SHARE included in edit)', () => { - it('Creates user share with "Allow editing" with SHARE permission for folders', () => { - const folderName = 'test-folder-with-share' - cy.mkdir(alice, `/${folderName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(folderName).should(({ response }) => { - // Verify permission value is 31 (ALL with SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8 + SHARE=16) - expect(response?.body?.ocs?.data?.permissions).to.equal(31) - }) - }) - - it('Creates user share with "Allow editing" with SHARE permission for files', () => { - const fileName = 'test-file-with-share.txt' - cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(fileName).should(({ response }) => { - // Verify permission value is 19 (ALL_FILE with SHARE: READ=1 + UPDATE=2 + SHARE=16) - expect(response?.body?.ocs?.data?.permissions).to.equal(19) - }) - }) - }) - - describe('With SHARE excluded from edit (config enabled)', () => { - beforeEach(() => { - cy.runOccCommand('config:app:set --value yes files_sharing shareapi_exclude_reshare_from_edit') - }) - - it('Creates user share with "Allow editing" without SHARE permission for folders', () => { - const folderName = 'test-folder-no-share' - cy.mkdir(alice, `/${folderName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(folderName).should(({ response }) => { - // Verify permission value is 15 (ALL without SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8) - expect(response?.body?.ocs?.data?.permissions).to.equal(15) - }) - }) - - it('Creates user share with "Allow editing" without SHARE permission for files', () => { - const fileName = 'test-file-no-share.txt' - cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(fileName).should(({ response }) => { - // Verify permission value is 3 (ALL_FILE without SHARE: READ=1 + UPDATE=2) - expect(response?.body?.ocs?.data?.permissions).to.equal(3) - }) - }) - }) -}) diff --git a/cypress/e2e/files_sharing/share-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts deleted file mode 100644 index 30a76334b091c..0000000000000 --- a/cypress/e2e/files_sharing/share-status-action.cy.ts +++ /dev/null @@ -1,124 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { closeSidebar, enableGridMode, getActionButtonForFile, getActionsForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Sharing status action', { testIsolation: true }, () => { - /** - * Regression test of https://github.com/nextcloud/server/issues/45723 - */ - it('No "shared" tag when user ID is purely numerical but there are no shares', () => { - const user = { - language: 'en', - password: 'test1234', - userId: String(Math.floor(Math.random() * 1000)), - } as User - cy.createUser(user) - cy.mkdir(user, '/folder') - cy.login(user) - - cy.visit('/apps/files') - - getRowForFile('folder').should('be.visible') - getActionsForFile('folder') - .findByRole('button', { name: 'Shared' }) - .should('not.exist') - }) - - it('Render quick option for sharing', () => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/folder') - cy.login(user) - - cy.visit('/apps/files') - }) - - getRowForFile('folder').should('be.visible') - getActionsForFile('folder') - .findByRole('button', { name: /Sharing options/ }) - .should('be.visible') - .click({ force: true }) - - // check the click opened the sidebar - cy.get('[data-cy-sidebar]') - .should('be.visible') - // and ensure the sharing tab is selected - .findByRole('tab', { name: 'Sharing', selected: true }) - .should('exist') - }) - - describe('Sharing inline status action handling', () => { - let user: User - let sharee: User - - before(() => { - cy.createRandomUser().then(($user) => { - sharee = $user - }) - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/folder') - cy.login(user) - - cy.visit('/apps/files') - getRowForFile('folder').should('be.visible') - - createShare('folder', sharee.userId) - closeSidebar() - }) - cy.logout() - }) - - it('Render inline status action for sharer', () => { - cy.login(user) - cy.visit('/apps/files') - - getInlineActionEntryForFile('folder', 'sharing-status') - .should('have.attr', 'aria-label', `Shared with ${sharee.userId}`) - .should('have.attr', 'title', `Shared with ${sharee.userId}`) - .should('be.visible') - }) - - it('Render status action in gridview for sharer', () => { - cy.login(user) - cy.visit('/apps/files') - enableGridMode() - - getRowForFile('folder') - .should('be.visible') - getActionButtonForFile('folder') - .click() - cy.findByRole('menu') - .findByRole('menuitem', { name: /shared with/i }) - .should('be.visible') - }) - - it('Render inline status action for sharee', () => { - cy.login(sharee) - cy.visit('/apps/files') - - getInlineActionEntryForFile('folder', 'sharing-status') - .should('have.attr', 'aria-label', `Shared by ${user.userId}`) - .should('be.visible') - }) - - it('Render status action in grid view for sharee', () => { - cy.login(sharee) - cy.visit('/apps/files') - - enableGridMode() - - getRowForFile('folder') - .should('be.visible') - getActionButtonForFile('folder') - .click() - cy.findByRole('menu') - .findByRole('menuitem', { name: `Shared by ${user.userId}` }) - .should('be.visible') - }) - }) -}) diff --git a/tests/playwright/e2e/files/files-download.spec.ts b/tests/playwright/e2e/files/files-download.spec.ts index 6137271127fab..e88a493253d7d 100644 --- a/tests/playwright/e2e/files/files-download.spec.ts +++ b/tests/playwright/e2e/files/files-download.spec.ts @@ -217,8 +217,10 @@ test.describe('Files: Download files using selection', () => { * user via the docker helper and logs in at the API level. */ test('can download selected files with email uid', async ({ page, filesListPage }) => { - const randomString = (length: number) => Math.random().toString(36).slice(2, 2 + length) - const uid = `${randomString(5)}@${randomString(3)}` + const uid = crypto.randomUUID() + .split('-', 2) + .reverse() + .join('@') const emailUser = new User(uid, uid, 'en') await addUser(emailUser) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts new file mode 100644 index 0000000000000..f231d3d216c87 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../support/fixtures/files-sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createShare, DOWNLOAD_DISABLED_ATTRIBUTE, SharePermission, waitForShare } from '../../support/utils/sharing.ts' + +/** + * A share whose download is switched off must not offer a download action to the + * recipient — neither while view-without-download is allowed instance-wide (the + * file is then viewable but not downloadable) nor when it is off. + * + * The browser is logged in as the recipient here; `owner` seeds the share. + */ +test.describe('files_sharing: Download forbidden', () => { + test.beforeEach(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_allow_view_without_download']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_allow_view_without_download']) + }) + + test('offers no download action for a folder', async ({ page, user, owner, ownerRequest, filesListPage }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId, { + permissions: SharePermission.READ, + attributes: DOWNLOAD_DISABLED_ATTRIBUTE, + }) + await waitForShare(page.request, user, '', 'folder') + + await filesListPage.open() + let menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + + // Also with view-without-download disabled the action stays away + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_allow_view_without_download']) + + await filesListPage.open() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + }) + + test('offers no download action for a file', async ({ page, user, owner, ownerRequest, filesListPage }) => { + await uploadContent(ownerRequest, owner, Buffer.alloc(0), 'text/plain', '/file.txt') + await createShare(ownerRequest, '/file.txt', user.userId, { + permissions: SharePermission.READ, + attributes: DOWNLOAD_DISABLED_ATTRIBUTE, + }) + await waitForShare(page.request, user, '', 'file.txt') + + await filesListPage.open() + let menu = await filesListPage.openActionsMenuForFile('file.txt') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_allow_view_without_download']) + + await filesListPage.open() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + menu = await filesListPage.openActionsMenuForFile('file.txt') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts new file mode 100644 index 0000000000000..98d89a8952108 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { Page } from '@playwright/test' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' +import type { SharingTab } from '../../support/sections/SharingTab.ts' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, openSharingPanel } from '../../support/utils/sharing.ts' + +/** The fixtures a test hands to {@link shareAndOpenEditor}. */ +interface SharingContext { + page: Page + user: User + recipient: User + filesListPage: FilesListPage + sharingTab: SharingTab +} + +/** The instance-wide default: internal shares expire two days after creation. */ +const EXPIRE_AFTER_DAYS = 2 + +/** `YYYY-MM-DD` of today plus `days`, the format the date input holds. */ +function dateInDays(days: number): string { + const date = new Date() + date.setDate(date.getDate() + days) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +test.describe('files_sharing: Expiry date of internal shares', () => { + test.beforeAll(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_default_internal_expire_date']) + await runOcc(['config:app:set', '--value', String(EXPIRE_AFTER_DAYS), 'core', 'shareapi_internal_expire_after_n_days']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_default_internal_expire_date']) + await runOcc(['config:app:delete', 'core', 'shareapi_internal_expire_after_n_days']) + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_internal_expire_date']) + }) + + test.beforeEach(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_internal_expire_date']) + }) + + /** + * Share `folder` with the recipient and open the share editor's advanced + * section, where the expiration date lives. + * + * @param folder - The folder to create and share + * @param context - The fixtures to drive + */ + async function shareAndOpenEditor( + folder: string, + { page, user, recipient, filesListPage, sharingTab }: SharingContext, + ): Promise { + await mkdir(page.request, user, `/${folder}`) + await createShare(page.request, `/${folder}`, recipient.userId) + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, folder) + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + } + + test('applies the default expiry date and enforces it', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_enforce_internal_expire_date']) + await shareAndOpenEditor('default-expiry-enforced', { page, user, recipient, filesListPage, sharingTab }) + + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(EXPIRE_AFTER_DAYS)) + // Enforced means the recipient's share cannot outlive the policy + await expect(sharingTab.checkbox(/expiration date/i)).toBeChecked() + await expect(sharingTab.checkbox(/expiration date/i)).toBeDisabled() + }) + + test('applies the default expiry date without enforcing it', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('default-expiry', { page, user, recipient, filesListPage, sharingTab }) + + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(EXPIRE_AFTER_DAYS)) + await expect(sharingTab.checkbox(/expiration date/i)).toBeChecked() + await expect(sharingTab.checkbox(/expiration date/i)).toBeEnabled() + }) + + test('can be set to a custom date', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('custom-expiry', { page, user, recipient, filesListPage, sharingTab }) + + await sharingTab.expirationDateInput().fill(dateInDays(14)) + await sharingTab.save() + + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(14)) + }) + + test('keeps a custom date across a reload', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('custom-expiry-reload', { page, user, recipient, filesListPage, sharingTab }) + + await sharingTab.expirationDateInput().fill(dateInDays(14)) + await sharingTab.save() + + await page.reload() + await openSharingPanel(filesListPage, sharingTab, 'custom-expiry-reload') + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(14)) + }) + + /** + * Regression test of https://github.com/nextcloud/server/pull/50192: an + * unrelated update must not reset the expiry date to the admin default. + */ + test('keeps a custom date when an unrelated field is updated', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('custom-expiry-unrelated', { page, user, recipient, filesListPage, sharingTab }) + + await sharingTab.expirationDateInput().fill(dateInDays(14)) + await sharingTab.save() + + // Change only the note … + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await sharingTab.setCheckbox('Note to recipient', true) + await sharingTab.noteInput().fill('Only the note changed') + await sharingTab.save() + + // … and the date is still the custom one + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(14)) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts new file mode 100644 index 0000000000000..1695a6c2c91e4 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' + +/** + * With `shareapi_only_share_with_group_members` an existing share stays visible + * only while the two accounts still have a group in common. These tests share + * both ways, then remove the recipient from the shared groups one by one. + * + * The Cypress original logged in as the same user in both assertions of every + * step, so it never actually checked the second direction; both directions are + * verified here. + */ +test.describe('files_sharing: Sharing limited to members of the same group', () => { + const groups = [`group-a-${crypto.randomUUID()}`, `group-b-${crypto.randomUUID()}`] + + test.beforeAll(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_only_share_with_group_members']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_only_share_with_group_members']) + for (const group of groups) { + await runOcc(['group:delete', group], { failOnError: false }) + } + }) + + /** + * Put both accounts in two shared groups, then share a file each way. + * + * @returns the file names, `fromUser` shared by `user`, `fromRecipient` by `recipient` + */ + async function seedMutualShares( + user: User, + recipient: User, + userRequest: Parameters[0], + recipientRequest: Parameters[0], + ): Promise<{ fromUser: string, fromRecipient: string }> { + for (const group of groups) { + await runOcc(['group:add', group], { failOnError: false }) + await runOcc(['group:adduser', group, user.userId]) + await runOcc(['group:adduser', group, recipient.userId]) + } + + const fromUser = 'shared-by-user.txt' + const fromRecipient = 'shared-by-recipient.txt' + await uploadContent(userRequest, user, 'share to recipient', 'text/plain', `/${fromUser}`) + await uploadContent(recipientRequest, recipient, 'share to user', 'text/plain', `/${fromRecipient}`) + await createShare(userRequest, `/${fromUser}`, recipient.userId) + await createShare(recipientRequest, `/${fromRecipient}`, user.userId) + + await waitForShare(recipientRequest, recipient, '', fromUser) + await waitForShare(userRequest, user, '', fromRecipient) + + return { fromUser, fromRecipient } + } + + test('keeps the shares while one common group is left', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + const { fromUser, fromRecipient } = await seedMutualShares(user, recipient, page.request, recipientRequest) + + // Leaving one of the two groups is not enough to lose the shares + await runOcc(['group:removeuser', groups[0]!, recipient.userId]) + + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromRecipient)).toBeVisible() + + await login(page.request, recipient) + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromUser)).toBeVisible() + }) + + test('hides the shares once no common group is left', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + const { fromUser, fromRecipient } = await seedMutualShares(user, recipient, page.request, recipientRequest) + + for (const group of groups) { + await runOcc(['group:removeuser', group, recipient.userId]) + } + + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromRecipient)).toHaveCount(0) + + await login(page.request, recipient) + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromUser)).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts new file mode 100644 index 0000000000000..9c52adab46515 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' +import type { SharingTab } from '../../support/sections/SharingTab.ts' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { openSharingPanel, SharePermission } from '../../support/utils/sharing.ts' + +const { READ, UPDATE, CREATE, DELETE, SHARE } = SharePermission + +test.describe('files_sharing: "Allow editing" permission bundle', () => { + test.beforeEach(async () => { + await runOcc(['config:app:delete', 'files_sharing', 'shareapi_exclude_reshare_from_edit']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:delete', 'files_sharing', 'shareapi_exclude_reshare_from_edit']) + }) + + /** + * Share an entry with the recipient, picking the "Allow editing" bundle, and + * return the permissions the server stored. + * + * @param name - The entry to share + * @param context - The fixtures to drive + */ + async function shareWithEditingBundle( + name: string, + { filesListPage, sharingTab, recipient }: { + filesListPage: FilesListPage + sharingTab: SharingTab + recipient: User + }, + ): Promise { + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, name) + await sharingTab.pickRecipient(recipient.userId) + await sharingTab.selectPermissionBundle('upload-edit') + + const share = await sharingTab.save() + return share.permissions + } + + test.describe('by default resharing is part of editing', () => { + test('grants a folder share everything including resharing', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/folder-with-share') + + const permissions = await shareWithEditingBundle('folder-with-share', { filesListPage, sharingTab, recipient }) + + expect(permissions).toBe(READ | UPDATE | CREATE | DELETE | SHARE) + }) + + test('grants a file share what a file can carry including resharing', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await uploadContent(page.request, user, 'content', 'text/plain', '/file-with-share.txt') + + const permissions = await shareWithEditingBundle('file-with-share.txt', { filesListPage, sharingTab, recipient }) + + // A file share has neither CREATE nor DELETE + expect(permissions).toBe(READ | UPDATE | SHARE) + }) + }) + + test.describe('with resharing excluded from editing', () => { + test.beforeEach(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'files_sharing', 'shareapi_exclude_reshare_from_edit']) + }) + + test('grants a folder share everything but resharing', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/folder-no-share') + + const permissions = await shareWithEditingBundle('folder-no-share', { filesListPage, sharingTab, recipient }) + + expect(permissions).toBe(READ | UPDATE | CREATE | DELETE) + }) + + test('grants a file share only read and update', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await uploadContent(page.request, user, 'content', 'text/plain', '/file-no-share.txt') + + const permissions = await shareWithEditingBundle('file-no-share.txt', { filesListPage, sharingTab, recipient }) + + expect(permissions).toBe(READ | UPDATE) + }) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/file-request.spec.ts b/tests/playwright/e2e/files_sharing/file-request.spec.ts new file mode 100644 index 0000000000000..fcc583ed5b829 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/file-request.spec.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { PublicSharePage } from '../../support/sections/PublicSharePage.ts' +import { getFileContent, mkdir } from '../../support/utils/dav.ts' + +const FOLDER = 'test-folder' +const GUEST = 'Guest' + +/** + * The whole file-request round trip: the owner creates the request through the + * "New" menu, a guest identifies itself and uploads, and the upload shows up in + * the owner's folder under the guest's name. + * + * The Cypress original split this across three tests that shared the share URL + * through a closure; it is one flow, so it is one test here. + */ +test('files_sharing: a guest can upload through a file request', async ({ page, browser, user, filesListPage }) => { + await mkdir(page.request, user, `/${FOLDER}`) + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER) + + // The owner creates the file request + await page.locator('[data-cy-upload-picker]').getByRole('button', { name: 'New' }).first().click() + await page.getByRole('menuitem', { name: 'Create file request' }).click() + + const dialog = page.getByRole('dialog', { name: 'Create a file request' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('textbox', { name: 'Upload destination' })).toHaveValue(new RegExp(FOLDER)) + await dialog.getByRole('textbox', { name: 'Request subject' }).fill('Please upload') + await dialog.getByRole('button', { name: 'Continue' }).click() + + // Neither an expiration date nor a password is asked for by default + await expect(dialog.getByRole('checkbox', { name: 'Set a submission expiration date' })).not.toBeChecked() + await expect(dialog.getByRole('checkbox', { name: 'Set a password' })).not.toBeChecked() + await dialog.getByRole('button', { name: 'Continue' }).click() + + const created = page.getByRole('dialog', { name: 'File request created' }) + const shareUrl = await created.getByRole('textbox', { name: 'Share link' }).inputValue() + expect(shareUrl).toContain('/s/') + + // The dialog's own close control, not the dialog chrome's "Close" button + await created.getByRole('button', { name: 'Close', exact: true }).last().click() + await expect(created).toBeHidden() + + // A guest — a separate, anonymous browser context — uploads a file + const guestContext = await browser.newContext() + try { + const guestPage = await guestContext.newPage() + const publicShare = new PublicSharePage(guestPage) + + await publicShare.open(shareUrl) + await publicShare.submitGuestName(GUEST) + + await expect(guestPage.getByText(`Upload files to ${FOLDER}`).first()).toBeVisible() + + await guestPage.getByRole('button', { name: 'Upload', exact: true }).click() + await publicShare.uploadFiles('Upload files', [ + { name: 'file.txt', mimeType: 'text/plain', buffer: Buffer.from('abcdef') }, + ]) + + // The upload lands in a folder named after the guest + await expect.poll(() => getFileContent(page.request, user, `/${FOLDER}/${GUEST}/file.txt`).catch(() => '')) + .toBe('abcdef') + } finally { + await guestContext.close() + } + + // And the owner sees it there + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER) + await expect(filesListPage.getRowForFile(GUEST)).toBeVisible() + + await filesListPage.navigateToFolder(GUEST) + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() +}) diff --git a/tests/playwright/e2e/files_sharing/files-shares-view.spec.ts b/tests/playwright/e2e/files_sharing/files-shares-view.spec.ts new file mode 100644 index 0000000000000..5c1ca33e4320a --- /dev/null +++ b/tests/playwright/e2e/files_sharing/files-shares-view.spec.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' + +/** + * Regression test of https://github.com/nextcloud/server/issues/46108: the + * shares views list shared entries with an "Open in files" action, which has to + * land in the folder itself. + */ +test('files_sharing: opens a shared folder from the shares views', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/file') + await createShare(page.request, '/folder', recipient.userId) + await waitForShare(recipientRequest, recipient, '', 'folder') + + // The sharer sees it in "Shared with others" + await filesListPage.open('sharingout') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + await filesListPage.getRowForFile('folder').getByRole('button', { name: /open in files/i }).click() + + await expect(page).toHaveURL(/apps\/files\/files\/.+dir=\/folder/) + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + // And the recipient the same in "Shared with you" + await login(page.request, recipient) + await filesListPage.open('sharingin') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + await filesListPage.getRowForFile('folder').getByRole('button', { name: /open in files/i }).click() + + await expect(page).toHaveURL(/apps\/files\/files\/.+dir=\/folder/) + await expect(filesListPage.getRowForFile('file')).toBeVisible() +}) diff --git a/tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts b/tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts new file mode 100644 index 0000000000000..c9383394de027 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createShare, openSharingPanel, waitForShare } from '../../support/utils/sharing.ts' + +const NOTE = 'Hello, this is the note.' + +test.describe('files_sharing: Note to recipient', () => { + test('is shown to the recipient inside the shared folder', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/file') + await createShare(page.request, '/folder', recipient.userId, { note: NOTE }) + await waitForShare(recipientRequest, recipient, '', 'folder') + + await login(page.request, recipient) + await filesListPage.open() + await filesListPage.navigateToFolder('folder') + + await expect(page.getByText(NOTE)).toBeVisible() + }) + + test('is shown to the recipient even when the folder is empty', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await createShare(page.request, '/folder', recipient.userId, { note: NOTE }) + await waitForShare(recipientRequest, recipient, '', 'folder') + + await login(page.request, recipient) + await filesListPage.open() + await filesListPage.navigateToFolder('folder') + + await expect(page.getByText(NOTE)).toBeVisible() + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/46188, where + * re-opening a share hid the note it already had. + */ + test('is filled in when the share is edited again', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/folder') + await createShare(page.request, '/folder', recipient.userId, { note: NOTE }) + await filesListPage.open() + + await openSharingPanel(filesListPage, sharingTab, 'folder') + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + + await expect(sharingTab.checkbox('Note to recipient')).toBeChecked() + await expect(sharingTab.noteInput()).toHaveValue(NOTE) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts b/tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts new file mode 100644 index 0000000000000..925b44ba2cdd4 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { mkdir } from '../../../support/utils/dav.ts' +import { BUNDLED_PERMISSIONS, createLinkShare } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' +const DISCLAIMER = 'TEST: Some disclaimer text' + +/** + * The disclaimer is an instance-wide setting, so this lives in the serial + * `admin-settings` project rather than next to the other file-drop tests. + */ +test.beforeAll(async () => { + await runOcc(['config:app:set', '--value', DISCLAIMER, '--type', 'string', 'core', 'shareapi_public_link_disclaimertext']) +}) + +test.afterAll(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_public_link_disclaimertext']) +}) + +test('files_sharing: a file drop shows the terms of service', async ({ page, user, ownerRequest, publicShare }) => { + await mkdir(ownerRequest, user, `/${SHARE_NAME}`) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.FILE_DROP, + }) + await publicShare.open(share.url) + + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + await expect(page.getByText('agree to the terms of service')).toBeVisible() + + await page.getByRole('button', { name: /terms of service/i }).click() + + const dialog = page.getByRole('dialog', { name: 'Terms of service' }) + await expect(dialog).toContainText(DISCLAIMER) + + await dialog.getByRole('button', { name: 'Close' }).click() + + await expect(dialog).toBeHidden() +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts b/tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts new file mode 100644 index 0000000000000..29c24a4602f2d --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { Page } from '@playwright/test' +import type { FilesListPage } from '../../../support/sections/FilesListPage.ts' +import type { SharingTab } from '../../../support/sections/SharingTab.ts' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../../support/fixtures/sharing-page.ts' +import { openSharingPanel, seedSharedFolder } from '../../../support/utils/sharing.ts' + +/** + * The instance-wide link-share defaults. Each is either off, defaulted or + * enforced, and the share editor has to ask for whatever is missing *before* it + * creates the share. + */ +interface LinkShareDefaults { + /** `shareapi_enable_link_password_by_default` — offer a password field up front. */ + askForPassword?: boolean + /** `shareapi_enforce_links_password` — a password is mandatory. */ + enforcePassword?: boolean + /** `shareapi_default_expire_date` (+ `shareapi_expire_after_n_days`). */ + defaultExpirationDate?: boolean + /** `shareapi_enforce_expire_date` — the expiration date cannot be removed. */ + enforceExpirationDate?: boolean +} + +/** The page objects a test hands to {@link createLinkShareWithDefaults}. */ +interface SharingContext { + page: Page + user: User + filesListPage: FilesListPage + sharingTab: SharingTab +} + +/** The number of days the default expiration date is configured to. */ +const EXPIRE_AFTER_DAYS = 2 + +/** `YYYY-MM-DD` of today plus `days`, i.e. what the editor should pre-fill. */ +function dateInDays(days: number): string { + const date = new Date() + date.setDate(date.getDate() + days) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +async function applyDefaults(defaults: LinkShareDefaults): Promise { + const flag = (value?: boolean) => value ? 'yes' : 'no' + await runOcc(['config:app:set', '--value', flag(defaults.askForPassword), 'core', 'shareapi_enable_link_password_by_default']) + await runOcc(['config:app:set', '--value', flag(defaults.enforcePassword), 'core', 'shareapi_enforce_links_password']) + await runOcc(['config:app:set', '--value', flag(defaults.defaultExpirationDate), 'core', 'shareapi_default_expire_date']) + await runOcc(['config:app:set', '--value', flag(defaults.enforceExpirationDate), 'core', 'shareapi_enforce_expire_date']) + if (defaults.defaultExpirationDate) { + await runOcc(['config:app:set', '--value', String(EXPIRE_AFTER_DAYS), 'core', 'shareapi_expire_after_n_days']) + } +} + +/** + * Create a link share through the editor under the given defaults, asserting on + * the way that the editor asks for exactly what the configuration demands, and + * return the created share's URL. + * + * @param defaults - The instance defaults to apply first + * @param shareName - The folder to share + * @param context - The page objects to drive + */ +async function createLinkShareWithDefaults( + defaults: LinkShareDefaults, + shareName: string, + { page, user, filesListPage, sharingTab }: SharingContext, +): Promise { + await applyDefaults(defaults) + await seedSharedFolder(page.request, user, shareName) + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, shareName) + + // With something still missing, the button opens the "required information" + // dialog instead of creating the share right away. + await sharingTab.panel().getByRole('button', { name: 'Create a new share link' }).click() + const pending = sharingTab.pendingShareDialog() + + if (defaults.enforcePassword) { + await expect(pending.getByRole('checkbox', { name: 'Password protection (enforced)' })).toBeVisible() + } else if (defaults.askForPassword) { + await expect(pending.getByRole('checkbox', { name: 'Password protection' })).toBeVisible() + } + const password = pending.getByRole('textbox', { name: 'Enter a password' }) + await expect(password).toBeVisible() + await expect(password).toBeEnabled() + if (defaults.enforcePassword) { + // An enforced password has to be filled in before the share can be created + await password.fill(`s3cret-${shareName}`) + } + + if (defaults.enforceExpirationDate) { + await expect(pending.getByRole('checkbox', { name: 'Enable link expiration (enforced)' })).toBeVisible() + } else if (defaults.defaultExpirationDate) { + await expect(pending.getByRole('checkbox', { name: 'Enable link expiration' })).toBeVisible() + } + if (defaults.defaultExpirationDate || defaults.enforceExpirationDate) { + // The date comes pre-filled with the configured default + await expect(sharingTab.pendingExpirationDateInput()).toHaveValue(dateInDays(EXPIRE_AFTER_DAYS)) + } + + return sharingTab.confirmPendingLinkShare() +} + +/** + * The Cypress original ran ten cases, but only these five configurations are + * actually distinct — the others repeat one of them with the same effective + * settings (e.g. "not enforced" spelled out rather than left off). + */ +test.describe('files_sharing: Link share defaults asked for before creating', () => { + test.afterEach(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_enable_link_password_by_default']) + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_links_password']) + await runOcc(['config:app:delete', 'core', 'shareapi_default_expire_date']) + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_expire_date']) + await runOcc(['config:app:delete', 'core', 'shareapi_expire_after_n_days']) + }) + + test('password and expiration date both enforced', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + enforcePassword: true, + defaultExpirationDate: true, + enforceExpirationDate: true, + }, 'password-and-expire-enforced', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('password enforced, expiration date defaulted', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + enforcePassword: true, + defaultExpirationDate: true, + }, 'password-enforced-default-expire', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('password optional, expiration date enforced', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + defaultExpirationDate: true, + enforceExpirationDate: true, + }, 'default-password-expire-enforced', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('password and expiration date both only defaulted', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + defaultExpirationDate: true, + }, 'default-password-and-expire', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('nothing defaulted or enforced creates the share directly', async ({ page, user, filesListPage, sharingTab }) => { + await applyDefaults({}) + await seedSharedFolder(page.request, user, 'no-defaults') + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, 'no-defaults') + + // Nothing to ask for, so the button creates the share straight away + const url = await sharingTab.createLinkShare() + + expect(url).toMatch(/\/s\//) + await expect(sharingTab.linkShareEntries()).toHaveCount(1) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts b/tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts new file mode 100644 index 0000000000000..be4f7de657b7f --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { BUNDLED_PERMISSIONS, createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +/** + * A public share that allows uploading and editing — the permission bundle the + * editor calls "Allow upload and editing" — so its content can be reorganized + * by a guest. + */ +test.describe('files_sharing: Public share - copy, move and rename files', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE, + }) + await publicShare.open(share.url) + }) + + test('can copy a file to another folder', async ({ page, filesListPage, copyMoveDialog }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.triggerActionForFile('foo.txt', 'move-copy') + await copyMoveDialog.copyToFolder('subfolder') + + // The copy source stays where it is + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.navigateToFolder('subfolder') + + await expect(page).toHaveURL(/dir=\/subfolder/) + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('bar.txt')).toBeVisible() + }) + + test('can move a file to another folder', async ({ page, filesListPage, copyMoveDialog }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.triggerActionForFile('foo.txt', 'move-copy') + await copyMoveDialog.moveToFolder('subfolder') + + // Moved out of the current folder + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + await expect(filesListPage.getRowForFile('foo.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('subfolder') + + await expect(page).toHaveURL(/dir=\/subfolder/) + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + }) + + test('can rename a file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.renameFile('foo.txt', 'other.txt') + + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('foo.txt')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts b/tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts new file mode 100644 index 0000000000000..2a549aba85a9c --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { createLinkShare, GRID_VIEW_ATTRIBUTE, seedSharedFolder } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +/** + * Which view mode a public share opens in. The view is identified by the toggle + * the header offers: "Switch to grid view" means we are in list view, and the + * other way round. + */ +test.describe('files_sharing: Public share - default view mode', () => { + test.beforeEach(async ({ user, ownerRequest }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + }) + + test('opens in list view by default', async ({ page, ownerRequest, publicShare, filesListPage }) => { + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(page.getByRole('button', { name: 'Switch to grid view' })).toBeEnabled() + }) + + test('can be toggled by the visitor', async ({ page, ownerRequest, publicShare, filesListPage }) => { + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await page.getByRole('button', { name: 'Switch to grid view' }).click() + + // The toggle now offers the way back, i.e. we are in grid view + await expect(page.getByRole('button', { name: 'Switch to list view' })).toBeEnabled() + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + }) + + test('opens in grid view when the share asks for it', async ({ page, ownerRequest, publicShare, filesListPage }) => { + // "Show files in grid view" in the share editor stores this attribute + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { attributes: GRID_VIEW_ATTRIBUTE }) + await publicShare.open(share.url) + + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(page.getByRole('button', { name: 'Switch to list view' })).toBeEnabled() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/download.spec.ts b/tests/playwright/e2e/files_sharing/public-share/download.spec.ts new file mode 100644 index 0000000000000..985ab1dc912c2 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/download.spec.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Download, Page } from '@playwright/test' + +import { readFile } from 'node:fs/promises' +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { uploadContent } from '../../../support/utils/dav.ts' +import { createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' +import { getZipEntries } from '../../../support/utils/zip.ts' + +const SHARE_NAME = 'a-folder-share' + +/** + * Register the download listener before the action that triggers it — Playwright + * needs `waitForEvent('download')` to be pending first. + */ +async function triggerDownload(page: Page, action: () => Promise): Promise { + const downloadPromise = page.waitForEvent('download') + await action() + return downloadPromise +} + +/** Read a download's body as UTF-8 text. */ +async function readDownloadText(download: Download): Promise { + return readFile(await download.path(), 'utf-8') +} + +test.describe('files_sharing: Public share - downloading a shared file', () => { + /** + * A file share behaves like a folder share except for the download: its source + * is the share token, so the displayed name comes from the share itself. + */ + test('can download the shared file', async ({ page, user, ownerRequest, publicShare, filesListPage }) => { + const fileId = await uploadContent(ownerRequest, user, 'foo', 'text/plain', '/file.txt') + const share = await createLinkShare(ownerRequest, '/file.txt') + await publicShare.open(share.url) + + const row = filesListPage.getRowForFileId(Number(fileId)) + await expect(row).toBeVisible() + // The extension is rendered in its own element, so allow whitespace in between + await expect(row.locator('[data-cy-files-list-row-name]')).toHaveText(/file\s*\.txt/) + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFileId(Number(fileId), 'download')) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('foo') + }) +}) + +test.describe('files_sharing: Public share - downloading from a shared folder', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + }) + + test('can download everything by selecting all', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.selectAll() + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe(`${SHARE_NAME}.zip`) + expect(await getZipEntries(download)).toEqual([ + 'foo.txt', + 'subfolder/', + 'subfolder/bar.txt', + ]) + }) + + test('can download a selected folder', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.selectRowForFile('subfolder') + await expect(page.getByText('1 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/bar.txt', + ]) + }) + + test('can download a folder by its row action', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('subfolder', 'download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/bar.txt', + ]) + }) + + test('can download a file by its row action', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('foo.txt', 'download')) + + expect(download.suggestedFilename()).toBe('foo.txt') + expect(await readDownloadText(download)).toBe('foo') + }) + + test('can download a selected file', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.selectRowForFile('foo.txt') + await expect(page.getByText('1 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('foo.txt') + expect(await readDownloadText(download)).toBe('foo') + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts b/tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts new file mode 100644 index 0000000000000..045df14aca7fb --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { getFileContent, mkdir, uploadContent } from '../../../support/utils/dav.ts' +import { BUNDLED_PERMISSIONS, createLinkShare } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +/** + * A file-drop share ("File request" in the share editor): visitors may upload + * but never see what is already there. + */ +test.describe('files_sharing: Public share - file drop', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await mkdir(ownerRequest, user, `/${SHARE_NAME}`) + await uploadContent(ownerRequest, user, 'content', 'text/plain', `/${SHARE_NAME}/foo.txt`) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.FILE_DROP, + }) + await publicShare.open(share.url) + }) + + test('cannot see the share content', async ({ user, ownerRequest, publicShare, filesListPage }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + // The file is there … + expect(await getFileContent(ownerRequest, user, `/${SHARE_NAME}/foo.txt`)).toBe('content') + // … but never listed for the visitor + await expect(filesListPage.getRowForFile('foo.txt')).toHaveCount(0) + }) + + test('offers uploading as the only action of the new-content menu', async ({ page, publicShare }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + await page.getByRole('button', { name: 'New' }).click() + + const menu = page.getByRole('menu') + await expect(menu.getByRole('menuitem')).toHaveCount(2) + await expect(menu.getByRole('menuitem', { name: 'Upload files' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Upload folders' })).toBeVisible() + }) + + test('offers the same options on the dedicated upload button', async ({ page, publicShare }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + await page.getByRole('button', { name: 'Upload', exact: true }).click() + + const menu = page.getByRole('menu') + await expect(menu.getByRole('menuitem')).toHaveCount(2) + await expect(menu.getByRole('menuitem', { name: 'Upload files' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Upload folders' })).toBeVisible() + }) + + test('can upload files and reports the progress', async ({ page, user, ownerRequest, publicShare }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + // Hold the second upload's *response* back so the progress bar can be + // observed mid-flight: the bytes are sent (which is what progress counts) + // but the upload is not finished yet. Delaying the request instead would + // stall the transfer and never move the bar. + const { promise: held, resolve: release } = Promise.withResolvers() + await page.route(/\/public\.php\/dav\/files\//, async (route) => { + if (route.request().url().includes('first.txt')) { + await route.continue() + return + } + const response = await route.fetch() + await held + await route.fulfill({ response }) + }) + + await page.getByRole('button', { name: 'Upload', exact: true }).click() + await publicShare.uploadFiles('Upload files', [ + { name: 'first.txt', mimeType: 'text/plain', buffer: Buffer.from('8 bytes!') }, + { name: 'second.md', mimeType: 'text/markdown', buffer: Buffer.from('x'.repeat(128)) }, + ]) + + // While the second file is still in flight the bar reports the first one as + // done — a partial value, not a finished upload. The exact percentage is + // the uploader's own byte accounting, so only the range is asserted. + const progress = page.getByRole('progressbar') + await expect(progress).toBeVisible() + await expect.poll(async () => Number(await progress.getAttribute('value') ?? 0), { + message: 'the progress bar should report partial progress', + }).toBeGreaterThan(0) + expect(Number(await progress.getAttribute('value'))).toBeLessThan(100) + + release() + + await expect.poll(() => getFileContent(ownerRequest, user, `/${SHARE_NAME}/first.txt`).catch(() => '')) + .toBe('8 bytes!') + await expect.poll(() => getFileContent(ownerRequest, user, `/${SHARE_NAME}/second.md`).catch(() => '')) + .toBe('x'.repeat(128)) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts b/tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts new file mode 100644 index 0000000000000..ee38032520c8e --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' + +/** + * The guest identification menu of a public share: a visitor is anonymous until + * they set a public name, which is then remembered across shares. + */ +test.describe('files_sharing: Public share - guest identification', () => { + test('shows the anonymous guest menu', async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, 'public1') + const share = await createLinkShare(ownerRequest, '/public1') + await publicShare.open(share.url) + + const menu = await publicShare.openUserMenu() + + await expect(menu.getByRole('note')).toContainText('not identified') + await expect(menu.getByRole('link', { name: /Set public name/i })).toBeVisible() + }) + + test('can set a public name', async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, 'public1') + const share = await createLinkShare(ownerRequest, '/public1') + await publicShare.open(share.url) + + await publicShare.setPublicName('John Doe') + + // The avatar is now the one generated for that guest name + await expect(publicShare.userMenuButton().locator('img')) + .toHaveAttribute('src', /avatar\/guest\/John%20Doe/) + }) + + test('keeps the public name across shares and allows changing it', async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, 'public1') + await seedSharedFolder(ownerRequest, user, 'public2') + const first = await createLinkShare(ownerRequest, '/public1') + const second = await createLinkShare(ownerRequest, '/public2') + + await publicShare.open(first.url) + await publicShare.setPublicName('Jane Doe') + + // The name travels to another share of the same visitor + await publicShare.open(second.url) + const menu = await publicShare.openUserMenu() + await expect(menu.getByRole('note')).toContainText('Your guest name: Jane Doe') + + await publicShare.setPublicName('Foo Bar') + + await expect(publicShare.userMenuButton().locator('img')) + .toHaveAttribute('src', /avatar\/guest\/Foo%20Bar/) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts b/tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts new file mode 100644 index 0000000000000..cdf2a81d78e68 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' +import { getZipEntries } from '../../../support/utils/zip.ts' + +const SHARE_NAME = 'shared' +const FEDERATED_SHARE_API = '/apps/federatedfilesharing/createFederatedShare' + +/** The direct link points at the share's DAV endpoint and downloads it as a zip. */ +const DIRECT_LINK = /\/public\.php\/dav\/files\/.+\/?accept=zip$/ + +test.describe('files_sharing: Public share - header actions menu', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + }) + + test('can download all files', async ({ page, publicShare }) => { + const downloadPromise = page.waitForEvent('download') + await publicShare.primaryAction('Download').click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe(`${SHARE_NAME}.zip`) + expect(await getZipEntries(download)).toEqual([ + `${SHARE_NAME}/`, + `${SHARE_NAME}/foo.txt`, + `${SHARE_NAME}/subfolder/`, + `${SHARE_NAME}/subfolder/bar.txt`, + ]) + }) + + test('offers a direct link and closes the menu when it is used', async ({ publicShare }) => { + await publicShare.openActionsMenu() + + const directLink = publicShare.actionsMenuEntry('Direct link') + await expect(directLink).toHaveAttribute('href', DIRECT_LINK) + + await directLink.click() + + await expect(publicShare.actionsMenu()).toBeHidden() + }) + + test('can create a federated share', async ({ page, publicShare }) => { + await publicShare.openActionsMenu() + await publicShare.actionsMenuEntry(/Add to your/i).click() + + const dialog = publicShare.federatedShareDialog() + await expect(dialog).toBeVisible() + + await dialog.getByRole('textbox').fill('user@nextcloud.local') + + const created = page.waitForResponse((r) => r.url().includes(FEDERATED_SHARE_API)) + await dialog.getByRole('button', { name: 'Create share' }).click() + await created + }) + + test('disables the submit button while the federated share is created', async ({ page, publicShare }) => { + // Hold the response back so the in-flight state can be observed + const { promise: held, resolve: release } = Promise.withResolvers() + await page.route(`**${FEDERATED_SHARE_API}`, async (route) => { + await held + await route.fulfill({ status: 503, body: '' }) + }) + + await publicShare.openActionsMenu() + await publicShare.actionsMenuEntry(/Add to your/i).click() + + const dialog = publicShare.federatedShareDialog() + await dialog.getByRole('textbox').fill('user@nextcloud.local') + + const submit = dialog.getByRole('button', { name: 'Create share' }) + await submit.click() + await expect(submit).toBeDisabled() + + release() + + await expect(submit).toBeEnabled() + }) + + test('validates the federated share input', async ({ publicShare }) => { + await publicShare.openActionsMenu() + await publicShare.actionsMenuEntry(/Add to your/i).click() + + const input = publicShare.federatedShareDialog().getByRole('textbox') + + // A bare domain is missing the user part + await input.fill('nextcloud.local') + await expect(input).toHaveValidationMessage(/user/i) + + // And the domain itself has to be a URL + await input.fill('user@invalid') + await expect(input).toHaveValidationMessage(/invalid.+url/i) + }) + + test('moves the primary action into the menu on small screens', async ({ page, publicShare }) => { + await page.setViewportSize({ width: 490, height: 490 }) + + // Nothing is rendered next to the menu any more + await expect(publicShare.primaryAction('Download')).toHaveCount(0) + await expect(publicShare.primaryAction('Direct link')).toHaveCount(0) + await expect(publicShare.primaryAction(/Add to your/i)).toHaveCount(0) + + const menu = await publicShare.openActionsMenu() + await expect(menu.getByRole('menuitem')).toHaveCount(3) + await expect(publicShare.actionsMenuEntry(/^Download/)).toBeVisible() + await expect(publicShare.actionsMenuEntry('Direct link')).toHaveAttribute('href', DIRECT_LINK) + + await publicShare.actionsMenuEntry(/Add to your/i).click() + + await expect(publicShare.federatedShareDialog()).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts b/tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts new file mode 100644 index 0000000000000..be6ac110649d7 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../../support/utils/dav.ts' +import { createLinkShare, openSharingPanel } from '../../../support/utils/sharing.ts' + +/** The link-share side of the share editor, driven by the share owner. */ +test.describe('files_sharing: Link share editor', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/test') + await createLinkShare(page.request, '/test') + await filesListPage.open() + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/53566, where + * an apostrophe in the label was rendered as `'`. + */ + test('lists a share label with special characters as typed', async ({ filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await sharingTab.labelInput().fill('Alice\' share') + await sharingTab.save() + + await expect(sharingTab.linkShareEntries()).toHaveCount(1) + await expect(sharingTab.linkShareEntries().first()).toContainText('Share link (Alice\' share)') + }) + + /** + * Regression test: "Hide download" must survive both re-opening the editor and + * a page reload — the checkbox used to fall back to its default. + */ + test('keeps "Hide download" after saving and reloading', async ({ page, filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await sharingTab.setCheckbox('Hide download', true, { persists: true }) + await sharingTab.closeDetails() + + // Still set when the editor is opened again … + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).toBeChecked() + + // … and after a reload, i.e. it was really stored + await page.reload() + await openSharingPanel(filesListPage, sharingTab, 'test') + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).toBeChecked() + }) +}) + +test.describe('files_sharing: Email share editor', () => { + /** + * A brand new email share must keep the "Hide download" option that was set + * before it was ever saved. + */ + test('keeps "Hide download" set while creating the share', async ({ page, user, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/test') + await filesListPage.open() + + await openSharingPanel(filesListPage, sharingTab, 'test') + await sharingTab.pickRecipient('test@example.com', { external: true }) + + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await sharingTab.setCheckbox('Hide download', true) + await sharingTab.save() + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).toBeChecked() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts b/tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts new file mode 100644 index 0000000000000..87bcc31cc6cef --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { BUNDLED_PERMISSIONS, createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +test.describe('files_sharing: Public share - view only', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.READ_ONLY, + }) + await publicShare.open(share.url) + }) + + test('can see the files list', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + }) + + test('can navigate to a subfolder', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.navigateToFolder('subfolder') + + await expect(filesListPage.getRowForFile('bar.txt')).toBeVisible() + }) + + test('cannot upload files', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + // Without CREATE permission the share offers no way to add content + await expect(page.getByRole('button', { name: 'New' })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Upload/ })).toHaveCount(0) + }) + + test('offers downloading as the only file action', async ({ page, filesListPage }) => { + const menu = await filesListPage.openActionsMenuForFile('foo.txt') + + await expect(menu.getByRole('menuitem')).toHaveCount(1) + await expect(menu.getByRole('menuitem', { name: 'Download' })).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await menu.getByRole('menuitem', { name: 'Download' }).click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('foo.txt') + }) +}) + +test.describe('files_sharing: Public share - view only without download', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.READ_ONLY, + hideDownload: true, + }) + await publicShare.open(share.url) + }) + + test('can see the files list', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + }) + + test('offers no file actions at all', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await expect(filesListPage.getRowForFile('foo.txt').getByRole('button', { name: 'Actions' })).toHaveCount(0) + }) + + test('can navigate to a subfolder, which also has no actions', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.navigateToFolder('subfolder') + + await expect(filesListPage.getRowForFile('bar.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('bar.txt').getByRole('button', { name: 'Actions' })).toHaveCount(0) + }) + + test('cannot upload files', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await expect(page.getByRole('button', { name: 'New' })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Upload/ })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/share-status-action.spec.ts b/tests/playwright/e2e/files_sharing/share-status-action.spec.ts new file mode 100644 index 0000000000000..c889b2d1d7c11 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/share-status-action.spec.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/e2e-test-server' +import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' + +test.describe('files_sharing: Sharing status action', () => { + /** + * Regression test of https://github.com/nextcloud/server/issues/45723: a + * purely numerical user id used to be mistaken for a share, so an unshared + * folder was flagged as shared. + */ + test('shows no sharing status for a numerical user id without shares', async ({ page, filesListPage }) => { + const uid = crypto.getRandomValues(new Uint32Array(1))[0].toString() + const numericalUser = new User(uid, uid, 'en') + await addUser(numericalUser) + + try { + await login(page.request, numericalUser) + await mkdir(page.request, numericalUser, '/folder') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('folder').getByRole('button', { name: 'Shared' })).toHaveCount(0) + } finally { + await runOcc(['user:delete', uid], { failOnError: false }) + } + }) + + test('offers a quick sharing action that opens the sharing tab', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await filesListPage.open() + + await filesListPage.getRowForFile('folder').hover() + await filesListPage.getRowForFile('folder') + .getByRole('button', { name: /Sharing options/ }) + .click({ force: true }) + + // The sidebar opens straight on the Sharing tab + await expect(page.getByRole('tab', { name: 'Sharing', selected: true })).toBeVisible() + }) + + test.describe('for a shared folder', () => { + test.beforeEach(async ({ page, user, recipient, recipientRequest }) => { + await mkdir(page.request, user, '/folder') + await createShare(page.request, '/folder', recipient.userId) + await waitForShare(recipientRequest, recipient, '', 'folder') + }) + + test('names the recipient for the sharer', async ({ filesListPage }) => { + await filesListPage.open() + + const status = filesListPage.getInlineActionEntryForFile('folder', 'sharing-status') + await expect(status).toBeVisible() + await expect(status).toHaveAttribute('aria-label', /^Shared with /) + await expect(status).toHaveAttribute('title', /^Shared with /) + }) + + test('names the recipient for the sharer in grid view', async ({ filesListPage }) => { + await filesListPage.open() + await filesListPage.enableGridView() + + const menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: /shared with/i })).toBeVisible() + }) + + test('names the owner for the recipient', async ({ page, user, recipient, filesListPage }) => { + await login(page.request, recipient) + await filesListPage.open() + + const status = filesListPage.getInlineActionEntryForFile('folder', 'sharing-status') + await expect(status).toBeVisible() + await expect(status).toHaveAttribute('aria-label', `Shared by ${user.userId}`) + }) + + test('names the owner for the recipient in grid view', async ({ page, user, recipient, filesListPage }) => { + await login(page.request, recipient) + await filesListPage.open() + await filesListPage.enableGridView() + + const menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: `Shared by ${user.userId}` })).toBeVisible() + }) + }) +}) diff --git a/tests/playwright/support/fixtures/public-share-page.ts b/tests/playwright/support/fixtures/public-share-page.ts new file mode 100644 index 0000000000000..ca9bee50ce630 --- /dev/null +++ b/tests/playwright/support/fixtures/public-share-page.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' + +import { CopyMoveDialogPage } from '../sections/CopyMoveDialogPage.ts' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { PublicSharePage } from '../sections/PublicSharePage.ts' +import { test as randomUserTest } from './random-user.ts' + +type PublicShareFixtures = { + /** + * A request context authenticated as the share `owner` via basic auth. Seed + * the shared content and the share itself with this — the browser page stays + * a guest, so it must never carry the owner's session. + */ + ownerRequest: APIRequestContext + /** The public share page (header actions, guest identification, file drop). */ + publicShare: PublicSharePage + /** The files list as rendered on the public share. */ + filesListPage: FilesListPage + /** The file picker of the list's "Move or copy" action. */ + copyMoveDialog: CopyMoveDialogPage +} + +/** + * Fixtures for public (link) shares. Unlike the other files fixtures the `page` + * here is a plain, **not logged in** browser context — that is what a guest + * visiting a share link is. The share owner exists as `user` and is only acted + * on through {@link PublicShareFixtures.ownerRequest}. + */ +export const test = randomUserTest.extend({ + ownerRequest: async ({ playwright, user, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: user.userId, password: user.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, + + publicShare: async ({ page }, use) => { + await use(new PublicSharePage(page)) + }, + + filesListPage: async ({ page }, use) => { + await use(new FilesListPage(page)) + }, + + copyMoveDialog: async ({ page }, use) => { + await use(new CopyMoveDialogPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/sharing-page.ts b/tests/playwright/support/fixtures/sharing-page.ts new file mode 100644 index 0000000000000..823772a740bee --- /dev/null +++ b/tests/playwright/support/fixtures/sharing-page.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { SharingTab } from '../sections/SharingTab.ts' +import { test as filesTest } from './files-page.ts' + +type SharingFixtures = { + /** + * A second account to share with. It is never logged into the browser — use + * {@link recipientRequest} to act as it, or log in with the harness `login()` + * on the page's request context to swap sessions mid-test. + */ + recipient: User + /** + * A request context authenticated as `recipient` via basic auth, with no + * browser session cookies — cookies would otherwise win over basic auth and + * the request would run as the logged-in sharer instead. + */ + recipientRequest: APIRequestContext + /** The share editor in the files sidebar. */ + sharingTab: SharingTab +} + +/** + * Files fixtures for driving the share editor: the browser is logged in as + * `user` (the sharer) and `recipient` is a second account to share with. + * + * This mirrors `files-sharing-page.ts`, which is the other way round (the + * browser is the recipient of a share seeded by `owner`) — pick whichever side + * the spec drives through the UI. + */ +export const test = filesTest.extend({ + recipient: async ({}, use) => { + const recipient = await createRandomUser() + await use(recipient) + await runOcc(['user:delete', recipient.userId], { failOnError: false }) + }, + + recipientRequest: async ({ playwright, recipient, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: recipient.userId, password: recipient.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, + + sharingTab: async ({ page }, use) => { + await use(new SharingTab(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/CopyMoveDialogPage.ts b/tests/playwright/support/sections/CopyMoveDialogPage.ts index 5e54b14df09ad..bdb54d9a58bc2 100644 --- a/tests/playwright/support/sections/CopyMoveDialogPage.ts +++ b/tests/playwright/support/sections/CopyMoveDialogPage.ts @@ -7,9 +7,7 @@ import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' import { escapeAttributeValue } from '../utils/css.ts' - -/** The DAV endpoint all of the picker's and the action's requests go to. */ -const DAV_FILES_ENDPOINT = /\/(remote|public)\.php\/dav\/files\// +import { DAV_FILES_ENDPOINT } from '../utils/dav.ts' /** * The file-picker dialog opened by the files "Move or copy" action diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index 767c4146a1317..797e65a2b9dd9 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -7,6 +7,7 @@ import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' import { escapeAttributeValue } from '../utils/css.ts' +import { DAV_FILES_ENDPOINT } from '../utils/dav.ts' export class FilesListPage { constructor(protected readonly page: Page) {} @@ -285,7 +286,8 @@ export class FilesListPage { * replaces the whole name, then Enter commits the rename. */ async renameFile(oldName: string, newName: string): Promise { - const moved = this.page.waitForResponse((r) => r.request().method() === 'MOVE' && r.url().includes('/remote.php/dav/files/')) + // Matches public.php too, so a rename on a public share is awaited as well + const moved = this.page.waitForResponse((r) => r.request().method() === 'MOVE' && DAV_FILES_ENDPOINT.test(r.url())) await this.triggerActionForFile(oldName, 'rename') const input = this.getRenameInputForFile(oldName) @@ -388,6 +390,16 @@ export class FilesListPage { } async triggerSelectionAction(actionId: string): Promise { + // The toolbar renders the first actions inline and moves the rest into the + // overflow menu — with only one available action (e.g. "Download" on a + // public share) there is no menu toggle at all, so only open the menu when + // the entry is not already on screen. + const inlineButton = this.getSelectionActionEntry(actionId) + if (await inlineButton.isVisible()) { + await inlineButton.click() + return + } + await this.openSelectionActionsMenu() // NcActionButton renders as