diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index c8aa55224db9f..0bda9e68d631e 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -31,7 +31,7 @@ const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to trans .allowDirectories() .setMultiSelect(false) .setButtonFactory(([node]) => { - const canPick = !!node?.path && node.path !== '/' && node.owner === getCurrentUser()!.uid + const canPick = !!node?.path && node.owner === getCurrentUser()!.uid return [{ label: canPick ? t('files', 'Transfer "{path}"', { path: node.displayname }) diff --git a/tests/playwright/e2e/files/transfer-ownership.spec.ts b/tests/playwright/e2e/files/transfer-ownership.spec.ts new file mode 100644 index 0000000000000..fed78012840b7 --- /dev/null +++ b/tests/playwright/e2e/files/transfer-ownership.spec.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' + +import { expect, test } from '../../support/fixtures/transfer-ownership-page.ts' +import { getFileContent, mkdir, rm, uploadContent } from '../../support/utils/dav.ts' +import { getToast } from '../../support/utils/toast.ts' +import { completeOwnershipTransfer, transferFolderPattern } from '../../support/utils/transferOwnership.ts' + +/** + * Assert that the only entry in the current list is the folder a transfer from + * `source` created, and return its name — it carries the time of the transfer. + * + * @param filesList - The files list of the new owner, showing their root + * @param source - The user the files were transferred from + */ +async function expectSingleTransferFolder(filesList: FilesListPage, source: User): Promise { + await expect.poll(() => filesList.getRowNames()) + .toEqual([expect.stringMatching(transferFolderPattern(source))]) + + const [name] = await filesList.getRowNames() + return name +} + +test.describe('Files: Transfer ownership', () => { + // Accepting the transfer and running its background job shell out to occ, + // which takes considerably longer than the browser interaction itself + test.slow() + + test.beforeEach(async ({ page, user, recipient, recipientPage }) => { + // Both accounts start with a welcome.txt — remove it so the transferred + // files are the only content of either account + await rm(page.request, user, '/welcome.txt') + await rm(recipientPage.request, recipient, '/welcome.txt') + }) + + test('transfers a single file', async ({ page, user, filesListPage, recipient, recipientFilesList, recipientPage, recipientRequest, transferOwnershipPage }) => { + await uploadContent(page.request, user, 'transferred content', 'text/plain', '/document.txt') + await uploadContent(page.request, user, 'kept content', 'text/plain', '/other.txt') + + await transferOwnershipPage.open() + await expect(transferOwnershipPage.getSubmitButton()).toBeDisabled() + await expect(transferOwnershipPage.getMissingNodeHint()).toHaveCount(1) + await expect(transferOwnershipPage.getMissingOwnerHint()).toHaveCount(1) + + await transferOwnershipPage.selectFile('document.txt') + await expect(transferOwnershipPage.getMissingNodeHint()).toHaveCount(0) + + await transferOwnershipPage.selectNewOwner(recipient) + await expect(transferOwnershipPage.getMissingOwnerHint()).toHaveCount(0) + + await expect(transferOwnershipPage.getSubmitButton()) + .toHaveAccessibleName(`Transfer document.txt to ${recipient.userId}`) + await transferOwnershipPage.submit() + + await expect(getToast(page, 'Ownership transfer request sent')).toBeVisible() + // The form is reset, ready for the next transfer + await expect(transferOwnershipPage.getSubmitButton()).toBeDisabled() + await expect(transferOwnershipPage.getMissingNodeHint()).toHaveCount(1) + await expect(transferOwnershipPage.getMissingOwnerHint()).toHaveCount(1) + + await completeOwnershipTransfer(recipientRequest, user, recipient) + + // The previous owner keeps everything but the transferred file + await filesListPage.open() + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('document.txt')).toHaveCount(0) + + // The new owner received it, with its content, in the transfer folder + await recipientFilesList.open() + const transferFolder = await expectSingleTransferFolder(recipientFilesList, user) + await recipientFilesList.navigateToFolder(transferFolder) + await expect(recipientFilesList.getRowForFile('document.txt')).toBeVisible() + expect(await getFileContent(recipientPage.request, recipient, `${transferFolder}/document.txt`)) + .toBe('transferred content') + }) + + test('transfers a folder with all of its content', async ({ page, user, filesListPage, recipient, recipientFilesList, recipientRequest, transferOwnershipPage }) => { + await mkdir(page.request, user, '/project') + await uploadContent(page.request, user, 'readme', 'text/plain', '/project/readme.md') + await mkdir(page.request, user, '/project/notes') + await uploadContent(page.request, user, 'todo', 'text/plain', '/project/notes/todo.md') + + await transferOwnershipPage.open() + await transferOwnershipPage.selectFolder('project') + await transferOwnershipPage.selectNewOwner(recipient) + await transferOwnershipPage.submit() + await expect(getToast(page, 'Ownership transfer request sent')).toBeVisible() + + await completeOwnershipTransfer(recipientRequest, user, recipient) + + // The folder is gone for the previous owner + await filesListPage.open() + await expect(filesListPage.getRows()).toHaveCount(0) + + // The new owner received the folder with its whole tree + await recipientFilesList.open() + const transferFolder = await expectSingleTransferFolder(recipientFilesList, user) + await recipientFilesList.navigateToFolder(`${transferFolder}/project`) + await expect(recipientFilesList.getRowForFile('readme.md')).toBeVisible() + + await recipientFilesList.navigateToFolder('notes') + await expect(recipientFilesList.getRowForFile('todo.md')).toBeVisible() + }) + + test('transfers all files at once', async ({ page, user, filesListPage, recipient, recipientFilesList, recipientRequest, transferOwnershipPage }) => { + await uploadContent(page.request, user, 'text', 'text/plain', '/document.txt') + await mkdir(page.request, user, '/pictures') + await uploadContent(page.request, user, 'image', 'image/png', '/pictures/image.png') + + await transferOwnershipPage.open() + await transferOwnershipPage.selectAllFiles(user) + await transferOwnershipPage.selectNewOwner(recipient) + await transferOwnershipPage.submit() + await expect(getToast(page, 'Ownership transfer request sent')).toBeVisible() + + await completeOwnershipTransfer(recipientRequest, user, recipient) + + // The previous owner is left with an empty account + await filesListPage.open() + await expect(filesListPage.getRows()).toHaveCount(0) + + // Everything they owned is now in the new owners transfer folder + await recipientFilesList.open() + const transferFolder = await expectSingleTransferFolder(recipientFilesList, user) + await recipientFilesList.navigateToFolder(transferFolder) + await expect(recipientFilesList.getRowForFile('document.txt')).toBeVisible() + + await recipientFilesList.navigateToFolder('pictures') + await expect(recipientFilesList.getRowForFile('image.png')).toBeVisible() + }) +}) diff --git a/tests/playwright/support/fixtures/transfer-ownership-page.ts b/tests/playwright/support/fixtures/transfer-ownership-page.ts new file mode 100644 index 0000000000000..6db18102daf0d --- /dev/null +++ b/tests/playwright/support/fixtures/transfer-ownership-page.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext, Page } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { TransferOwnershipPage } from '../sections/TransferOwnershipPage.ts' +import { test as filesTest } from './files-page.ts' + +type TransferOwnershipFixtures = { + /** A second account, receiving the ownership of the files of `user`. */ + recipient: User + /** + * A request context authenticated as `recipient` via basic auth, with no + * browser session cookies — cookies would otherwise win over basic auth and + * the request would run as the user logged into `page` instead. + */ + recipientRequest: APIRequestContext + /** A second browser session, logged in as `recipient`. */ + recipientPage: Page + /** The files list as seen by `recipient`. */ + recipientFilesList: FilesListPage + /** The ownership transfer form in the personal settings of `user`. */ + transferOwnershipPage: TransferOwnershipPage +} + +/** + * Files fixtures for the ownership transfer: the browser is logged in as `user`, + * who owns the files and requests the transfer, and `recipient` is the account + * receiving them. + */ +export const test = filesTest.extend({ + recipient: async ({}, use) => { + let recipient: User + try { + recipient = await createRandomUser() + } catch { + // Retry once on transient failure, as the `user` fixture does + await new Promise((resolve) => setTimeout(resolve, 800)) + recipient = await createRandomUser() + } + await use(recipient) + await runOcc(['user:delete', recipient.userId], { failOnError: false }) + }, + + recipientRequest: async ({ playwright, recipient, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: recipient.userId, password: recipient.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, + + recipientPage: async ({ browser, recipient }, use) => { + const context = await browser.newContext() + const recipientPage = await context.newPage() + try { + await login(recipientPage.request, recipient) + } catch (error) { + // Same transient failure the session of `user` is retried for + console.info('Failed to authenticate as recipient, retrying', error) + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(recipientPage.request, recipient) + } + await use(recipientPage) + await context.close() + }, + + recipientFilesList: async ({ recipientPage }, use) => { + await use(new FilesListPage(recipientPage)) + }, + + transferOwnershipPage: async ({ page }, use) => { + await use(new TransferOwnershipPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts b/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts index bbeec863414ef..ca494ef06669c 100644 --- a/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts +++ b/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts @@ -3,39 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page } from '@playwright/test' +import { FilePickerDialogPage } from './FilePickerDialogPage.ts' /** * The file-picker dialog opened by the "Custom background" card/button on * Personal settings > Appearance and accessibility > Background and color */ -export class BackgroundFilePickerDialogPage { - constructor(private readonly page: Page) {} - - /** The open file-picker dialog. */ - dialog(): Locator { - return this.page.getByRole('dialog') - } - - /** - * Returns a row (file or folder) from inside the picker. - */ - getRow(name: string): Locator { - return this.dialog().getByTestId('row-name').filter({ hasText: name }) - } - - /** Navigate into a folder. */ - async openFolder(name: string): Promise { - await this.getRow(name).click() - } - - /** Select a file row. */ - async selectFile(name: string): Promise { - await this.getRow(name).click() - } - +export class BackgroundFilePickerDialogPage extends FilePickerDialogPage { /** Confirm the current selection as the new background. */ - async confirm(): Promise { - await this.dialog().getByRole('button', { name: 'Select background', exact: true }).click() + override async confirm(): Promise { + await super.confirm('Select background') } } diff --git a/tests/playwright/support/sections/FilePickerDialogPage.ts b/tests/playwright/support/sections/FilePickerDialogPage.ts new file mode 100644 index 0000000000000..67d3b400765d9 --- /dev/null +++ b/tests/playwright/support/sections/FilePickerDialogPage.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { DAV_FILES_ENDPOINT } from '../utils/dav.ts' + +/** + * The file picker dialog of `@nextcloud/dialogs`, used by every feature that + * lets the user choose a file or folder (custom background, ownership + * transfer, …). + * + * The confirm button is provided by the feature opening the picker, so its + * label is passed to {@link confirm} instead of being hardcoded here. + */ +export class FilePickerDialogPage { + constructor(protected readonly page: Page) {} + + /** The open file picker dialog. */ + dialog(): Locator { + return this.page.getByRole('dialog') + } + + /** + * A file or folder entry of the directory currently listed. + * + * Rows are matched by their text rather than by accessible name: the picker + * renders the base name and the extension of a file as two elements, which + * the accessible name computation joins with a space ("file .txt"). + * + * @param name - The name of the file or folder + */ + getRow(name: string): Locator { + return this.dialog().getByRole('row').filter({ hasText: name }) + } + + /** + * Navigate into a folder and wait for its content to be listed. + * + * Clicking a folder always navigates into it — a folder cannot be selected, + * it is picked by navigating into it and confirming with no selection. + * + * @param name - The name of the folder to enter + */ + async openFolder(name: string): Promise { + const listed = this.page.waitForResponse((r) => r.request().method() === 'PROPFIND' && DAV_FILES_ENDPOINT.test(r.url())) + await this.getRow(name).click() + await listed + } + + /** + * Select a file row (only files can be selected, see {@link openFolder}). + * + * @param name - The name of the file to select + */ + async selectFile(name: string): Promise { + const row = this.getRow(name) + await row.click() + await expect(row).toHaveAttribute('aria-selected', 'true') + } + + /** + * Confirm the picker with the button carrying the given label. + * + * @param label - The label of the confirmation button + */ + async confirm(label: string | RegExp): Promise { + await this.dialog().getByRole('button', { name: label }).click() + await expect(this.dialog()).toBeHidden() + } +} diff --git a/tests/playwright/support/sections/TransferOwnershipPage.ts b/tests/playwright/support/sections/TransferOwnershipPage.ts new file mode 100644 index 0000000000000..7f7cf2ce85c19 --- /dev/null +++ b/tests/playwright/support/sections/TransferOwnershipPage.ts @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { FilePickerDialogPage } from './FilePickerDialogPage.ts' + +/** + * The "Transfer ownership of a file or folder" form of the files app, rendered + * in the personal sharing settings. + */ +export class TransferOwnershipPage { + private readonly filePicker: FilePickerDialogPage + + constructor(private readonly page: Page) { + this.filePicker = new FilePickerDialogPage(page) + } + + /** Open the personal settings section hosting the transfer form. */ + async open(): Promise { + await this.page.goto('settings/user/sharing') + await expect(this.getSection()).toBeVisible() + } + + /** The form, rendered as a labelled fieldset. */ + getSection(): Locator { + return this.page.getByRole('group', { name: 'Transfer ownership of a file or folder' }) + } + + /** The button opening the file picker. */ + getNodeButton(): Locator { + return this.getSection().getByRole('button', { name: 'File or folder to transfer' }) + } + + /** The user search picking the account to transfer the ownership to. */ + getNewOwnerCombobox(): Locator { + return this.getSection().getByRole('combobox', { name: 'New owner' }) + } + + /** + * The submit button. Its label names both the file and the new owner as soon + * as the form is complete, which is the only accessible confirmation of what + * is about to be transferred — the description of {@link getNodeButton} is + * not part of the button. + */ + getSubmitButton(): Locator { + return this.getSection().getByRole('button', { name: /^Transfer\b/ }) + } + + /** + * The hints of the live region naming what is still missing to submit. They + * are rendered only while the respective input is empty, so assert on their + * count: being visually hidden they always count as visible. + */ + getMissingNodeHint(): Locator { + return this.getSection().getByText('You need to select a file or folder to transfer ownership.') + } + + /** See {@link getMissingNodeHint}. */ + getMissingOwnerHint(): Locator { + return this.getSection().getByText('You need to select a new owner for the file or folder.') + } + + /** + * Pick a file of the users root folder for transfer. + * + * @param name - The name of the file to transfer + */ + async selectFile(name: string): Promise { + await this.openFilePicker() + await this.filePicker.selectFile(name) + await this.filePicker.confirm(`Transfer "${name}"`) + } + + /** + * Pick a folder for transfer. + * + * The picker has no way to select a folder, so the folder is navigated into + * and then confirmed as the current directory. + * + * @param path - The path of the folder, relative to the users root folder + */ + async selectFolder(path: string): Promise { + const segments = path.split('/').filter(Boolean) + await this.openFilePicker() + for (const segment of segments) { + await this.filePicker.openFolder(segment) + } + await this.filePicker.confirm(`Transfer "${segments.at(-1)}"`) + } + + /** + * Pick the users root folder — transferring all of their files at once. + * It is confirmed as the picker's initial directory, without a selection. + * + * @param user - The owner of the files, whose id names their root folder + */ + async selectAllFiles(user: User): Promise { + await this.openFilePicker() + await this.filePicker.confirm(`Transfer "${user.userId}"`) + } + + /** + * Search for a user and pick them as the new owner. The suggestions are + * fetched from the sharees API, debounced, so the request is awaited before + * the matching option is picked. + * + * @param user - The user to receive the ownership + */ + async selectNewOwner(user: User): Promise { + const suggestions = this.page.waitForResponse((r) => r.url().includes('/apps/files_sharing/api/v1/sharees') + && new URL(r.url()).searchParams.get('search') === user.userId) + + await this.getNewOwnerCombobox().fill(user.userId) + await suggestions + + await this.page.getByRole('option', { name: user.userId }).click() + } + + /** Submit the form and wait for the transfer request to be created. */ + async submit(): Promise { + const requested = this.page.waitForResponse((r) => r.request().method() === 'POST' + && r.url().includes('/apps/files/api/v1/transferownership')) + + await this.getSubmitButton().click() + + expect((await requested).status()).toBe(200) + } + + private async openFilePicker(): Promise { + await this.getNodeButton().click() + await expect(this.filePicker.dialog()).toBeVisible() + } +} diff --git a/tests/playwright/support/utils/transferOwnership.ts b/tests/playwright/support/utils/transferOwnership.ts new file mode 100644 index 0000000000000..6f114f48ec80d --- /dev/null +++ b/tests/playwright/support/utils/transferOwnership.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { runExec } from '@nextcloud/e2e-test-server/docker' + +const TRANSFER_JOB_CLASS = 'OCA\\Files\\BackgroundJob\\TransferOwnership' + +/** + * Carry out the ownership transfer a user requested: accept it as the new owner + * and run the background job that moves the files. + * + * Returns once the files have been moved, so callers can assert on the result + * without polling. + * + * @param request - A request context authenticated as `target` + * @param source - The user who requested the transfer + * @param target - The user receiving the ownership + */ +export async function completeOwnershipTransfer( + request: APIRequestContext, + source: User, + target: User, +): Promise { + const transferId = await getPendingTransferId(source, target) + await acceptOwnershipTransfer(request, transferId) + await runTransferJob(transferId) +} + +/** + * The name of the folder an ownership transfer creates in the new owners root, + * as a pattern — the folder is suffixed with the time of the transfer. + * + * @param source - The user who transferred the files + */ +export function transferFolderPattern(source: User): RegExp { + return new RegExp(`^Transferred from ${source.userId} on `) +} + +/** + * Read the id of the ownership transfer pending between two users. + * + * A transfer is normally accepted from the notification it creates, but the + * notifications app is not installed on the test instance, so there is no API + * to look the id up with and it is read from the database instead. + * + * @param source - The user who requested the transfer + * @param target - The user receiving the ownership + */ +async function getPendingTransferId(source: User, target: User): Promise { + const script = ` + require_once "/var/www/html/lib/base.php"; + $query = \\OCP\\Server::get(\\OCP\\IDBConnection::class)->getQueryBuilder(); + $query->select("id") + ->from("user_transfer_owner") + ->where($query->expr()->eq("source_user", $query->createNamedParameter($argv[1]))) + ->andWhere($query->expr()->eq("target_user", $query->createNamedParameter($argv[2]))); + echo json_encode(array_column($query->executeQuery()->fetchAll(), "id")); + ` + const stdout = await run(['php', '-r', script, '--', source.userId, target.userId], { retry: true }) + + const ids = JSON.parse(stdout.trim()) as number[] + if (ids.length !== 1) { + throw new Error(`Expected one pending transfer from ${source.userId} to ${target.userId}, found ${ids.length}`) + } + return Number(ids[0]) +} + +/** + * Accept an ownership transfer, which queues the job doing the actual transfer. + * + * @param request - A request context authenticated as the receiving user + * @param transferId - The id of the transfer to accept + */ +async function acceptOwnershipTransfer(request: APIRequestContext, transferId: number): Promise { + const response = await request.post(`/ocs/v2.php/apps/files/api/v1/transferownership/${transferId}`, { + headers: { + Accept: 'application/json', + 'OCS-APIRequest': 'true', + }, + }) + const meta = (await response.json()).ocs?.meta + if (meta?.statuscode !== 200) { + throw new Error(`Accepting transfer ${transferId} failed: ${meta?.statuscode} ${meta?.message}`) + } +} + +/** + * Run the queued background job of a transfer. + * + * The job is addressed by its id instead of running a worker for the job class, + * so that parallel tests never execute each others transfers. + * + * @param transferId - The id of the accepted transfer + */ +async function runTransferJob(transferId: number): Promise { + const stdout = await run(['php', 'occ', 'background-job:list', '--class', TRANSFER_JOB_CLASS, '--output', 'json'], { retry: true }) + const jobs = JSON.parse(stdout) as { id: number, argument: string }[] + const job = jobs.find(({ argument }) => JSON.parse(argument).id === transferId) + if (!job) { + throw new Error(`No background job queued for transfer ${transferId}`) + } + + await run(['php', 'occ', 'background-job:execute', String(job.id), '--force-execute']) +} + +/** + * Run a command in the Nextcloud container, reporting its output on failure. + * + * @param command - The command to run + * @param options - Whether to run the command a second time if it failed. Only + * for commands that read, they can fail spuriously when the SQLite database + * of the test instance is busy with the transfers of parallel tests. + */ +async function run(command: string[], { retry = false } = {}): Promise { + const { stdout, stderr, exitCode } = await runExec(command, { failOnError: false }) + if (exitCode === 0) { + return stdout + } + if (retry) { + return await run(command) + } + throw new Error(`"${command.join(' ')}" exited with ${exitCode}: ${stderr || stdout}`) +}