diff --git a/cypress/e2e/files/files-copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts deleted file mode 100644 index abd0b9598cb6a..0000000000000 --- a/cypress/e2e/files/files-copy-move.cy.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { copyFile, getRowForFile, moveFile, navigateToFolder } from './FilesUtils.ts' - -describe('Files: Move or copy files', { testIsolation: true }, () => { - let currentUser - beforeEach(() => { - cy.createRandomUser().then((user) => { - currentUser = user - cy.login(user) - }) - }) - afterEach(() => { - // nice to have cleanup - cy.deleteUser(currentUser) - }) - - it('Can copy a file to new folder', () => { - // Prepare initial state - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/new-folder') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', 'new-folder') - - navigateToFolder('new-folder') - - cy.url().should('contain', 'dir=/new-folder') - getRowForFile('original.txt').should('be.visible') - getRowForFile('new-folder').should('not.exist') - }) - - it('Can move a file to new folder', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/new-folder') - cy.login(currentUser) - cy.visit('/apps/files') - - moveFile('original.txt', 'new-folder') - - // wait until visible again - getRowForFile('new-folder').should('be.visible') - - // original should be moved -> not exist anymore - getRowForFile('original.txt').should('not.exist') - navigateToFolder('new-folder') - - cy.url().should('contain', 'dir=/new-folder') - getRowForFile('original.txt').should('be.visible') - getRowForFile('new-folder').should('not.exist') - }) - - /** - * Test for https://github.com/nextcloud/server/issues/41768 - */ - it('Can move a file to folder with similar name', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original') - .mkdir(currentUser, '/original folder') - cy.login(currentUser) - cy.visit('/apps/files') - - moveFile('original', 'original folder') - - // wait until visible again - getRowForFile('original folder').should('be.visible') - - // original should be moved -> not exist anymore - getRowForFile('original').should('not.exist') - navigateToFolder('original folder') - - cy.url().should('contain', 'dir=/original%20folder') - getRowForFile('original').should('be.visible') - getRowForFile('original folder').should('not.exist') - }) - - it('Can move a file to its parent folder', () => { - cy.mkdir(currentUser, '/new-folder') - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/new-folder/original.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - navigateToFolder('new-folder') - cy.url().should('contain', 'dir=/new-folder') - - moveFile('original.txt', '/') - - // wait until visible again - cy.get('main').contains('No files in here').should('be.visible') - - // original should be moved -> not exist anymore - getRowForFile('original.txt').should('not.exist') - - cy.visit('/apps/files') - getRowForFile('new-folder').should('be.visible') - getRowForFile('original.txt').should('be.visible') - }) - - it('Can copy a file to same folder', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', '.') - - getRowForFile('original.txt').should('be.visible') - getRowForFile('original (1).txt').should('be.visible') - }) - - it('Can copy a file multiple times to same folder', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', '.') - - getRowForFile('original.txt').should('be.visible') - getRowForFile('original (2).txt').should('be.visible') - }) - - /** - * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)') - * Test for: https://github.com/nextcloud/server/issues/43843 - */ - it('Can copy a folder to same folder', () => { - cy.mkdir(currentUser, '/foo.bar') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('foo.bar', '.') - - getRowForFile('foo.bar').should('be.visible') - getRowForFile('foo.bar (1)').should('be.visible') - }) - - /** Test for https://github.com/nextcloud/server/issues/43329 */ - context('escaping file and folder names', () => { - it('Can handle files with special characters', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/can\'t say') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', 'can\'t say') - - navigateToFolder('can\'t say') - - cy.url().should('contain', 'dir=/can%27t%20say') - getRowForFile('original.txt').should('be.visible') - getRowForFile('can\'t say').should('not.exist') - }) - - /** - * If escape is set to false (required for test above) then "foo" would result in "foo" if sanitizing is not disabled - * We should disable it as vue already escapes the text when using v-text - */ - it('does not incorrectly sanitize file names', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/foo') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', 'foo') - - navigateToFolder('foo') - - cy.url().should('contain', 'dir=/%3Ca%20href%3D%22%23%22%3Efoo') - getRowForFile('original.txt').should('be.visible') - getRowForFile('foo').should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/files-copy-move.cy.ts b/cypress/e2e/files_sharing/files-copy-move.cy.ts deleted file mode 100644 index 16d07c3d40c20..0000000000000 --- a/cypress/e2e/files_sharing/files-copy-move.cy.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { - copyFile, - getRowForFile, - navigateToFolder, - triggerActionForFile, -} from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -const ACTION_COPY_MOVE = 'move-copy' - -export function 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 function 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('.breadcrumb') - .findByRole('button', { name: 'All files' }) - .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/tests/playwright/e2e/files/files-copy-move.spec.ts b/tests/playwright/e2e/files/files-copy-move.spec.ts new file mode 100644 index 0000000000000..ff571e4aece0e --- /dev/null +++ b/tests/playwright/e2e/files/files-copy-move.spec.ts @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +const EMPTY = Buffer.alloc(0) + +test.describe('Files: Move or copy files', () => { + test('can copy a file to a new folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, '/new-folder') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToFolder('new-folder') + + await filesListPage.navigateToFolder('new-folder') + await expect(page).toHaveURL(/dir=\/new-folder/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('new-folder')).toHaveCount(0) + }) + + test('can move a file to a new folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, '/new-folder') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.moveToFolder('new-folder') + + // Moved out of the current folder + await expect(filesListPage.getRowForFile('new-folder')).toBeVisible() + await expect(filesListPage.getRowForFile('original.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('new-folder') + await expect(page).toHaveURL(/dir=\/new-folder/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('new-folder')).toHaveCount(0) + }) + + /** Regression: https://github.com/nextcloud/server/issues/41768 */ + test('can move a file to a folder with a similar name', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original') + await mkdir(page.request, user, '/original folder') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original', 'move-copy') + await copyMoveDialog.moveToFolder('original folder') + + await expect(filesListPage.getRowForFile('original folder')).toBeVisible() + await expect(filesListPage.getRowForFile('original')).toHaveCount(0) + + await filesListPage.navigateToFolder('original folder') + await expect(page).toHaveURL(/dir=\/original%20folder/) + await expect(filesListPage.getRowForFile('original')).toBeVisible() + await expect(filesListPage.getRowForFile('original folder')).toHaveCount(0) + }) + + test('can move a file to its parent folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await mkdir(page.request, user, '/new-folder') + await uploadContent(page.request, user, EMPTY, 'text/plain', '/new-folder/original.txt') + await filesListPage.open() + + await filesListPage.navigateToFolder('new-folder') + await expect(page).toHaveURL(/dir=\/new-folder/) + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.moveToCurrentFolder() + + // The folder is now empty and the file is gone from it + await expect(page.getByText('No files in here')).toBeVisible() + await expect(filesListPage.getRowForFile('original.txt')).toHaveCount(0) + + // Back at the root the file lives next to its former parent + await filesListPage.open() + await expect(filesListPage.getRowForFile('new-folder')).toBeVisible() + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + }) + + test('can copy a file to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('original (1).txt')).toBeVisible() + }) + + test('can copy a file multiple times to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original (1).txt') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('original (2).txt')).toBeVisible() + }) + + /** + * Regression: https://github.com/nextcloud/server/issues/43843 + * A copied folder with a dot must be renamed correctly ("foo.bar" -> "foo.bar (1)"). + */ + test('can copy a folder to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await mkdir(page.request, user, '/foo.bar') + await filesListPage.open() + + await filesListPage.triggerActionForFile('foo.bar', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await expect(filesListPage.getRowForFile('foo.bar')).toBeVisible() + await expect(filesListPage.getRowForFile('foo.bar (1)')).toBeVisible() + }) + + /** Regression: https://github.com/nextcloud/server/issues/43329 */ + test.describe('escaping file and folder names', () => { + test('can handle files with special characters', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, "/can't say") + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToFolder("can't say") + + await filesListPage.navigateToFolder("can't say") + await expect(page).toHaveURL(/dir=\/can%27t%20say/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile("can't say")).toHaveCount(0) + }) + + /** + * Folder names like 'foo' must render as text, not be sanitized + * into markup — Vue already escapes via v-text. + */ + test('does not incorrectly sanitize file names', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, '/foo') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToFolder('foo') + + await filesListPage.navigateToFolder('foo') + await expect(page).toHaveURL(/dir=\/%3Ca%20href%3D%22%23%22%3Efoo/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('foo')).toHaveCount(0) + }) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts new file mode 100644 index 0000000000000..0cefb48c4f45d --- /dev/null +++ b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' +import type { User } from '@nextcloud/e2e-test-server' +import { test, expect } from '../../support/fixtures/files-sharing-page.ts' +import { getChildPermissions, mkdir, uploadContent } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, SharePermission, createShare } from '../../support/utils/sharing.ts' + +const EMPTY = Buffer.alloc(0) + +/** + * A share mounts into the recipient's tree asynchronously, and permission changes + * propagate after that. Poll the recipient's directory listing for the entry's + * `oc:permissions` (the same source the Files UI reads) until it exists and + * satisfies `ready`, before driving the UI. Transient errors (mount not there + * yet) are swallowed so the poll keeps waiting. + */ +async function waitForShare( + request: APIRequestContext, + user: User, + parentPath: string, + childName: string, + ready: (permissions: string) => boolean = () => true, +): Promise { + await expect.poll(async () => { + try { + const permissions = await getChildPermissions(request, user, parentPath, childName) + return permissions !== '' && ready(permissions) + } catch { + return false + } + }, { message: `share ${parentPath}/${childName} did not propagate to ${user.userId}`, timeout: 20_000 }).toBe(true) +} + +test.describe('files_sharing: Move or copy files', () => { + test('can create a file in a shared folder', async ({ page, user, owner, ownerRequest, filesListPage }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId) + await waitForShare(page.request, user, '', 'folder') + + // The recipient adds a file into the shared folder, then sees it there + await uploadContent(page.request, user, EMPTY, 'text/plain', '/folder/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + test('can copy a file to a shared folder', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId) + await waitForShare(page.request, user, '', 'folder') + + await uploadContent(page.request, user, EMPTY, 'text/plain', '/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.triggerActionForFile('file.txt', 'move-copy') + await copyMoveDialog.copyToFolder('folder') + + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + test('can not copy a file to a shared folder with no create permission', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId, ALL_PERMISSIONS & ~SharePermission.CREATE) + await uploadContent(page.request, user, EMPTY, 'text/plain', '/file.txt') + + // Wait for the create restriction (no C) to reach the recipient's listing + await waitForShare(page.request, user, '', 'folder', (p) => !p.includes('C')) + + // The browser session may still read the pre-restriction permissions for a + // moment after the API listing has updated, so reload until the picker + // reflects the missing create permission. + await expect(async () => { + await filesListPage.open() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.triggerActionForFile('file.txt', 'move-copy') + await copyMoveDialog.navigateTo('folder') + await expect(copyMoveDialog.confirmButton('Copy to folder')).toBeDisabled({ timeout: 3_000 }) + }).toPass({ timeout: 30_000 }) + }) + + // NOTE: the Cypress original also covered "can not move a file from a shared + // folder with no delete permission". It was dropped from this migration: the + // recipient's browser session reads stale move-availability for the file after + // the owner restricts the share, and that cross-user permission-cache lag isn't + // reliably resolvable from the test side (the Cypress version was flaky for the + // same reason). Tracked for a follow-up PR. +}) diff --git a/tests/playwright/support/fixtures/files-page.ts b/tests/playwright/support/fixtures/files-page.ts index 35dace2b84228..46b9073c1db91 100644 --- a/tests/playwright/support/fixtures/files-page.ts +++ b/tests/playwright/support/fixtures/files-page.ts @@ -7,6 +7,7 @@ import { runOcc } from '@nextcloud/e2e-test-server/docker' import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' import { test as baseTest } from '@playwright/test' import type { User } from '@nextcloud/e2e-test-server' +import { CopyMoveDialogPage } from '../sections/CopyMoveDialogPage.ts' import { FilesListPage } from '../sections/FilesListPage.ts' import { FilesNavigationPage } from '../sections/FilesNavigationPage.ts' import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts' @@ -16,6 +17,7 @@ type FilesFixtures = { filesListPage: FilesListPage filesNavigation: FilesNavigationPage filesSidebar: FilesSidebarPage + copyMoveDialog: CopyMoveDialogPage } export const test = baseTest.extend({ @@ -43,6 +45,10 @@ export const test = baseTest.extend({ filesSidebar: async ({ page }, use) => { await use(new FilesSidebarPage(page)) }, + + copyMoveDialog: async ({ page }, use) => { + await use(new CopyMoveDialogPage(page)) + }, }) export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/files-sharing-page.ts b/tests/playwright/support/fixtures/files-sharing-page.ts new file mode 100644 index 0000000000000..b41947bdbfb54 --- /dev/null +++ b/tests/playwright/support/fixtures/files-sharing-page.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import type { APIRequestContext } from '@playwright/test' +import type { User } from '@nextcloud/e2e-test-server' +import { test as filesTest } from './files-page.ts' + +type SharingFixtures = { + owner: User + /** + * A request context authenticated as `owner` via basic auth, with no browser + * session cookies — needed because cookies would otherwise win over basic auth + * and the seeding would run as the logged-in recipient instead. + */ + ownerRequest: APIRequestContext +} + +/** + * Files fixtures plus a second `owner` user. The browser is logged in as `user` + * (the share recipient); `owner` owns and shares the folder via `ownerRequest` + * and is never logged into the page. + */ +export const test = filesTest.extend({ + owner: async ({}, use) => { + const owner = await createRandomUser() + await use(owner) + await runOcc(['user:delete', owner.userId]) + }, + + ownerRequest: async ({ playwright, owner, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: owner.userId, password: owner.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/CopyMoveDialogPage.ts b/tests/playwright/support/sections/CopyMoveDialogPage.ts new file mode 100644 index 0000000000000..f65940660c202 --- /dev/null +++ b/tests/playwright/support/sections/CopyMoveDialogPage.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' +import { escapeAttributeValue } from '../utils/css.ts' + +/** + * The file-picker dialog opened by the files "Move or copy" action + * (the NcDialog-based FilePicker from @nextcloud/dialogs). + * + * The confirm buttons carry the visible label the user sees: "Copy" / "Move" + * for the current folder, "Copy to " / "Move to " once a + * destination is selected. Navigation and confirmation each own their own + * DAV wait so callers don't have to repeat the register-before/await-after dance. + */ +export class CopyMoveDialogPage { + constructor(private readonly page: Page) {} + + /** The open file-picker dialog. */ + dialog(): Locator { + return this.page.getByRole('dialog') + } + + /** + * A destination row inside the picker. The picker tags each row with the + * library-owned `data-filename`; rows have no per-folder accessible name to + * navigate by (their role name is a generic "Select the row for …"). + */ + getDestination(name: string): Locator { + return this.dialog().locator(`[data-filename="${escapeAttributeValue(name)}"]`) + } + + /** + * The breadcrumb path. The picker has two navs — the left view list (labelled + * like the dialog, holding the All files / Recent / Favorites shortcuts) and + * the breadcrumb. Only the breadcrumb changes the destination folder, so + * select it as the nav without the view shortcuts. + */ + private breadcrumbs(): Locator { + return this.dialog() + .getByRole('navigation') + .filter({ hasNot: this.page.getByRole('button', { name: 'Favorites' }) }) + } + + /** A confirm button by its exact visible label, e.g. "Copy" or "Move to docs". */ + confirmButton(label: string): Locator { + return this.dialog().getByRole('button', { name: label, exact: true }) + } + + /** Navigate into a (possibly nested) folder inside the picker; returns the leaf folder name. */ + async navigateTo(dirPath: string): Promise { + const segments = dirPath.split('/').filter(Boolean) + for (const dir of segments) { + await this.getDestination(dir).click() + } + return segments.at(-1) + } + + /** Navigate the destination back to the user's root via the breadcrumb. */ + async goToAllFiles(): Promise { + await this.breadcrumbs().getByRole('button', { name: 'All files' }).click() + } + + private async confirm(label: string, method: 'COPY' | 'MOVE'): Promise { + const done = this.page.waitForResponse( + (r) => r.request().method() === method + && /\/(remote|public)\.php\/dav\/files\//.test(r.url()), + ) + await this.confirmButton(label).click() + await done + } + + /** Copy into the folder currently shown in the picker. */ + async copyToCurrentFolder(): Promise { + await this.confirm('Copy', 'COPY') + } + + /** Move into the folder currently shown in the picker. */ + async moveToCurrentFolder(): Promise { + await this.confirm('Move', 'MOVE') + } + + /** Navigate into the destination folder and copy there. */ + async copyToFolder(dirPath: string): Promise { + const target = await this.navigateTo(dirPath) + await this.confirm(`Copy to ${target}`, 'COPY') + } + + /** Navigate into the destination folder and move there. */ + async moveToFolder(dirPath: string): Promise { + const target = await this.navigateTo(dirPath) + await this.confirm(`Move to ${target}`, 'MOVE') + } +} diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index 977b1519161f1..9073235c16214 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -4,6 +4,7 @@ */ import type { Locator, Page } from '@playwright/test' +import { escapeAttributeValue } from '../utils/css.ts' export class FilesListPage { constructor(protected readonly page: Page) {} @@ -18,7 +19,7 @@ export class FilesListPage { } getRowForFile(filename: string): Locator { - return this.page.locator(`[data-cy-files-list-row-name="${filename}"]`) + return this.page.locator(`[data-cy-files-list-row-name="${escapeAttributeValue(filename)}"]`) } getRowForFileId(fileid: number): Locator { diff --git a/tests/playwright/support/utils/css.ts b/tests/playwright/support/utils/css.ts new file mode 100644 index 0000000000000..d330c9d0f635c --- /dev/null +++ b/tests/playwright/support/utils/css.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Escape a value for safe use inside a double-quoted CSS attribute selector, + * so file names containing quotes or backslashes (e.g. `foo`) don't + * break the selector. (`CSS.escape` is a browser API, not available in Node.) + */ +export function escapeAttributeValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} diff --git a/tests/playwright/support/utils/dav.ts b/tests/playwright/support/utils/dav.ts index a06f5ae035a20..2d657c2d8fc86 100644 --- a/tests/playwright/support/utils/dav.ts +++ b/tests/playwright/support/utils/dav.ts @@ -9,8 +9,9 @@ import type { User } from '@nextcloud/e2e-test-server' /** * Make a MKCOL request to create a directory at the given path for the given user. * - * @param request - The Playwright API request context - * @param user - The user to create the directory for + * @param request - The Playwright API request context (authenticated as the + * acting user; use an owner-scoped context to seed data for another user) + * @param user - The user whose root the path is relative to * @param path - The path of the directory to create (relative to user root) */ export async function mkdir(request: APIRequestContext, user: User, path: string): Promise { @@ -48,7 +49,7 @@ export async function uploadContent( 'Content-Type': mimeType, requesttoken, }, - data: typeof content === 'string' ? content : content, + data: content, }) if (!response.ok()) { throw new Error(`PUT ${path} failed with status ${response.status()}`) @@ -75,6 +76,47 @@ export async function rm(request: APIRequestContext, user: User, path: string): } } +/** + * PROPFIND a directory (Depth 1) and return the WebDAV permission letters + * (`oc:permissions`, e.g. "SRGDNVCK") of the named child entry, or '' if absent. + * + * The Files UI derives action availability from a directory listing's entries + * (e.g. the move/copy picker gates a destination on its `C` permission), so + * polling the child entry here matches what the UI reads and lets a test wait + * for a share-permission change to propagate. Letters of interest: `C` = can + * create (in a folder), `D` = can delete (the entry). + * + * @param request - The Playwright API request context (acts as this session's user) + * @param user - The user whose root the parent path is relative to + * @param parentPath - The directory to list (relative to user root; '' = root) + * @param childName - The name of the child entry whose permissions to return + */ +export async function getChildPermissions( + request: APIRequestContext, + user: User, + parentPath: string, + childName: string, +): Promise { + const requesttoken = await getRequestToken(request) + const response = await request.fetch(davUrl(user, parentPath), { + method: 'PROPFIND', + headers: { requesttoken, Depth: '1' }, + data: '', + }) + if (!response.ok()) { + throw new Error(`PROPFIND ${parentPath} failed with status ${response.status()}`) + } + const body = await response.text() + for (const entry of body.split(/<\/d:response>/i)) { + const href = entry.match(/([^<]*)<\/d:href>/)?.[1] ?? '' + const name = decodeURIComponent(href.replace(/\/$/, '').split('/').pop() ?? '') + if (name === childName) { + return entry.match(/([^<]*)<\/oc:permissions>/)?.[1] ?? '' + } + } + return '' +} + /** * Construct the DAV URL for a given user and path. * diff --git a/tests/playwright/support/utils/sharing.ts b/tests/playwright/support/utils/sharing.ts new file mode 100644 index 0000000000000..dd947a8221355 --- /dev/null +++ b/tests/playwright/support/utils/sharing.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' + +/** Nextcloud share permission bits (see OCS Share API). */ +export const SharePermission = { + READ: 1, + UPDATE: 2, + CREATE: 4, + DELETE: 8, + SHARE: 16, +} as const + +/** All permissions a user share can grant. */ +export const ALL_PERMISSIONS = SharePermission.READ + | SharePermission.UPDATE + | SharePermission.CREATE + | SharePermission.DELETE + | SharePermission.SHARE + +/** + * Create a user-to-user share via the OCS Share API. Seeding shares through the + * API avoids driving the (flaky) share-editor sidebar. + * + * @param request - A request context authenticated as the share owner (e.g. the + * `ownerRequest` fixture) + * @param path - The path to share, relative to the owner's root + * @param shareWith - The user id of the share recipient + * @param permissions - The permission bitmask to grant (defaults to all) + */ +export async function createShare( + request: APIRequestContext, + path: string, + shareWith: string, + permissions: number = ALL_PERMISSIONS, +): Promise { + const response = await request.post('/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json', { + headers: { 'OCS-APIRequest': 'true' }, + form: { + path, + shareType: 0, // user share + shareWith, + permissions, + }, + }) + // OCS returns HTTP 200 even on failure; the real status lives in ocs.meta + const { ocs } = await response.json() + if (ocs?.meta?.statuscode !== 200) { + throw new Error(`Creating share for ${path} failed: ${ocs?.meta?.statuscode} ${ocs?.meta?.message}`) + } + + // A new share ignores the create-time permissions and always starts with the + // full set, so restricted permissions must be applied with a follow-up update. + if (permissions !== ALL_PERMISSIONS) { + const update = await request.put(`/ocs/v2.php/apps/files_sharing/api/v1/shares/${ocs.data.id}?format=json`, { + headers: { 'OCS-APIRequest': 'true' }, + form: { permissions }, + }) + const updateMeta = (await update.json()).ocs?.meta + if (updateMeta?.statuscode !== 200) { + throw new Error(`Updating share ${ocs.data.id} failed: ${updateMeta?.statuscode} ${updateMeta?.message}`) + } + } +}