-
-
Notifications
You must be signed in to change notification settings - Fork 5.2k
fix(files): bring back ownership transfer of all files #64129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> { | ||
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TransferOwnershipFixtures>({ | ||
| 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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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<void> { | ||
| 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<void> { | ||
| await this.dialog().getByRole('button', { name: label }).click() | ||
| await expect(this.dialog()).toBeHidden() | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.