diff options
Diffstat (limited to 'cypress/e2e/files_sharing')
23 files changed, 2707 insertions, 112 deletions
diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts new file mode 100644 index 00000000000..c9b30bd576c --- /dev/null +++ b/cypress/e2e/files_sharing/FilesSharingUtils.ts @@ -0,0 +1,199 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/* eslint-disable jsdoc/require-jsdoc */ +import { triggerActionForFile } from '../files/FilesUtils' + +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<ShareSetting> = {}) { + openSharingPanel(fileName) + + 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' }) + updateShare(fileName, 0, shareSettings) +} + +export function openSharingDetails(index: number) { + cy.get('#app-sidebar-vue').within(() => { + cy.get('[data-cy-files-sharing-share-actions]').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<ShareSetting> = {}) { + 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' }) + } else { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) + } + } + + 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') + }) + // close all toasts + cy.get('.toast-success').findAllByRole('button').click({ force: true, multiple: true }) +} + +export function openSharingPanel(fileName: string) { + triggerActionForFile(fileName, 'details') + + cy.get('[data-cy-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 const 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 new file mode 100644 index 00000000000..a6ce6922299 --- /dev/null +++ b/cypress/e2e/files_sharing/ShareOptionsType.ts @@ -0,0 +1,18 @@ +/*! + * 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 new file mode 100644 index 00000000000..f39a47309e2 --- /dev/null +++ b/cypress/e2e/files_sharing/expiry-date.cy.ts @@ -0,0 +1,128 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/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() + updateShare(dir, 0, { note: 'Only note changed' }) + validateExpiryDate(dir, fortnightString) + + 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 new file mode 100644 index 00000000000..578f72fa0b5 --- /dev/null +++ b/cypress/e2e/files_sharing/file-request.cy.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils' +import { createFileRequest } from './FilesSharingUtils' + +const 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-copy-move.cy.ts b/cypress/e2e/files_sharing/files-copy-move.cy.ts new file mode 100644 index 00000000000..6ad01cb2219 --- /dev/null +++ b/cypress/e2e/files_sharing/files-copy-move.cy.ts @@ -0,0 +1,150 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { + getRowForFile, + copyFile, + navigateToFolder, + triggerActionForFile, +} from '../files/FilesUtils.ts' +import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts' + +export const copyFileForbidden = (fileName: string, dirPath: string) => { + getRowForFile(fileName).should('be.visible') + triggerActionForFile(fileName, ACTION_COPY_MOVE) + + cy.get('.file-picker').within(() => { + // intercept the copy so we can wait for it + cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') + + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click() + }) + + // check copy button + cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.disabled') + }) +} + +export const moveFileForbidden = (fileName: string, dirPath: string) => { + getRowForFile(fileName).should('be.visible') + triggerActionForFile(fileName, ACTION_COPY_MOVE) + + cy.get('.file-picker').within(() => { + // intercept the copy so we can wait for it + cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') + + // select home folder + cy.get('button[title="Home"]').should('be.visible').click() + + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${directory}"]`).should('be.visible').click() + }) + + // click move + cy.contains('button', `Move to ${directories.at(-1)}`).should('not.exist') + }) +} + +describe('files_sharing: Move or copy files', { testIsolation: true }, () => { + let user: User + let sharee: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + cy.createRandomUser().then(($user) => { + sharee = $user + }) + }) + + it('can create a file in a shared folder', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true }) + cy.logout() + + // Now for the sharee + cy.uploadContent(sharee, new Blob([]), 'text/plain', '/folder/file.txt') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + navigateToFolder('folder') + // Content of the shared folder + getRowForFile('file.txt').should('be.visible') + }) + + it('can copy a file to a shared folder', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true }) + cy.logout() + + // Now for the sharee + cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + // copy file to a shared folder + copyFile('file.txt', 'folder') + // click on the folder should open it in files + navigateToFolder('folder') + // Content of the shared folder + getRowForFile('file.txt').should('be.visible') + }) + + it('can not copy a file to a shared folder with no create permissions', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true, create: false }) + cy.logout() + + // Now for the sharee + cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + copyFileForbidden('file.txt', 'folder') + }) + + it('can not move a file from a shared folder with no delete permissions', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file.txt') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true, delete: false }) + cy.logout() + + // Now for the sharee + cy.mkdir(sharee, '/folder-own') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + navigateToFolder('folder') + getRowForFile('file.txt').should('be.visible') + moveFileForbidden('file.txt', 'folder-own') + }) +}) diff --git a/cypress/e2e/files_sharing/files-download.cy.ts b/cypress/e2e/files_sharing/files-download.cy.ts new file mode 100644 index 00000000000..97ea91b7647 --- /dev/null +++ b/cypress/e2e/files_sharing/files-download.cy.ts @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { + getActionButtonForFile, + getActionEntryForFile, + getRowForFile, +} from '../files/FilesUtils.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 new file mode 100644 index 00000000000..12a67d9ee0f --- /dev/null +++ b/cypress/e2e/files_sharing/files-shares-view.cy.ts @@ -0,0 +1,59 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { getRowForFile } from '../files/FilesUtils.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/filesSharingUtils.ts b/cypress/e2e/files_sharing/filesSharingUtils.ts deleted file mode 100644 index cb407153380..00000000000 --- a/cypress/e2e/files_sharing/filesSharingUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -/** - * @copyright Copyright (c) 2024 Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { triggerActionForFile } from '../files/FilesUtils' - -export interface ShareSetting { - read: boolean - update: boolean - delete: boolean - share: boolean - download: boolean -} - -export function createShare(fileName: string, username: string, shareSettings: Partial<ShareSetting> = {}) { - openSharingPanel(fileName) - - cy.get('#app-sidebar-vue').within(() => { - cy.get('#sharing-search-input').clear() - cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch') - cy.get('#sharing-search-input').type(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' }) - updateShare(fileName, 0, shareSettings) -} - -export function updateShare(fileName: string, index: number, shareSettings: Partial<ShareSetting> = {}) { - openSharingPanel(fileName) - - cy.get('#app-sidebar-vue').within(() => { - cy.get('[data-cy-files-sharing-share-actions]').eq(index).click() - cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click() - - 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' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - 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.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' }) - } - } - - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - }) -} - -export function openSharingPanel(fileName: string) { - triggerActionForFile(fileName, 'details') - - cy.get('#app-sidebar-vue') - .get('[aria-controls="tab-sharing"]') - .click() -} diff --git a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts new file mode 100644 index 00000000000..c95efa089ff --- /dev/null +++ b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts @@ -0,0 +1,107 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from "@nextcloud/cypress" +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 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + randomFileName2 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + randomGroupName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + randomGroupName2 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + randomGroupName3 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 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.login(bob) + cy.visit('/apps/files') + createShare(randomFileName2, alice.userId) + }) + }) + + 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 new file mode 100644 index 00000000000..08fee587d9a --- /dev/null +++ b/cypress/e2e/files_sharing/note-to-recipient.cy.ts @@ -0,0 +1,92 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare, openSharingPanel } from './FilesSharingUtils.ts' +import { navigateToFolder } from '../files/FilesUtils.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 new file mode 100644 index 00000000000..e0cbd06a4c7 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts @@ -0,0 +1,191 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/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(['<content>foo</content>']), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent(user, new Blob(['<content>bar</content>']), '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) => { + // eslint-disable-next-line no-unused-expressions + 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<string> { + 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<string> { + + return cy.task('getVariable', { key: 'public-share-data' }) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { dataSnapshot, shareUrl } = data as any || {} + if (dataSnapshot) { + cy.restoreState(dataSnapshot) + defaultShareContext.url = shareUrl + return cy.wrap(shareUrl as string) + } else { + const shareData: Record<string, unknown> = {} + 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', 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 new file mode 100644 index 00000000000..87f16b01387 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts @@ -0,0 +1,49 @@ +/*! + * 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 new file mode 100644 index 00000000000..33e0a57da11 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/default-view.cy.ts @@ -0,0 +1,102 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/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('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 new file mode 100644 index 00000000000..372f553a8a0 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/download.cy.ts @@ -0,0 +1,266 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// @ts-expect-error The package is currently broken - but works... +import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' +import { createLinkShare, getShareUrl, openLinkShareDetails, setupPublicShare, type ShareContext } from './PublicShareUtils.ts' +import { getRowForFile, getRowForFileId, triggerActionForFile, triggerActionForFileId } from '../../files/FilesUtils.ts' +import { zipFileContains } from '../../../support/utils/assertions.ts' +import type { User } from '@nextcloud/cypress' + +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(['<content>foo</content>']), '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', '<content>foo</content>') + }) + }) + + describe('folder share', () => { + before(() => setupPublicShare()) + + 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}/download.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', '<content>foo</content>') + }) + }) + + 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', '<content>foo</content>') + }) + }) + }) + + 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.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('checkbox', { name: /hide download/i }) + .should('be.checked') + + cy.reload() + + openLinkShareDetails(0) + 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 new file mode 100644 index 00000000000..c7227062293 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts @@ -0,0 +1,193 @@ +/*! + * 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', 'You will be identified as 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 new file mode 100644 index 00000000000..1dd0de13477 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts @@ -0,0 +1,199 @@ +/*! + * 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 new file mode 100644 index 00000000000..adeb6e52504 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts @@ -0,0 +1,32 @@ +/*! + * 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 new file mode 100644 index 00000000000..772b7fa8380 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts @@ -0,0 +1,192 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ShareContext } from './PublicShareUtils.ts' +import type { ShareOptions } from '../ShareOptionsType.ts' +import { defaultShareOptions } from '../ShareOptionsType.ts' +import { setupData, createLinkShare } 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 new file mode 100644 index 00000000000..6b026717fd8 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts @@ -0,0 +1,45 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from "@nextcloud/cypress" +import { createShare } from "./FilesSharingUtils" +import { createLinkShare, openLinkShareDetails } from "./PublicShareUtils" + +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('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 new file mode 100644 index 00000000000..f95115ee591 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts @@ -0,0 +1,172 @@ +/*! + * 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 new file mode 100644 index 00000000000..0e2d2edab6c --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts @@ -0,0 +1,100 @@ +/*! + * 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('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 new file mode 100644 index 00000000000..511a1caeb09 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts @@ -0,0 +1,103 @@ +/*! + * 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-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts new file mode 100644 index 00000000000..f02ec676573 --- /dev/null +++ b/cypress/e2e/files_sharing/share-status-action.cy.ts @@ -0,0 +1,125 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { closeSidebar, enableGridMode, getActionButtonForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.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') + .find('[data-cy-files-list-row-actions]') + .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') + .find('[data-cy-files-list-row-actions]') + .findByRole('button', { name: /Sharing options/ }) + .should('be.visible') + .click() + + // 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') + }) + }) +}) |