Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/files/src/components/TransferOwnershipDialogue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
136 changes: 136 additions & 0 deletions tests/playwright/e2e/files/transfer-ownership.spec.ts
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()
})
})
86 changes: 86 additions & 0 deletions tests/playwright/support/fixtures/transfer-ownership-page.ts
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'
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.getRow(name).click()
}

/** Select a file row. */
async selectFile(name: string): Promise<void> {
await this.getRow(name).click()
}

export class BackgroundFilePickerDialogPage extends FilePickerDialogPage {
/** Confirm the current selection as the new background. */
async confirm(): Promise<void> {
await this.dialog().getByRole('button', { name: 'Select background', exact: true }).click()
override async confirm(): Promise<void> {
await super.confirm('Select background')
}
}
74 changes: 74 additions & 0 deletions tests/playwright/support/sections/FilePickerDialogPage.ts
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()
}
}
Loading
Loading