Merge pull request #61522 from nextcloud/test/pw-settings
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Psalm static code analysis / changes (push) Waiting to run
Psalm static code analysis / static-code-analysis (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-security (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ocp (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ncu (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-strict (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-summary (push) Blocked by required conditions

test(settings): migrate end-to-end tests from Cypress to PlayWright
This commit is contained in:
Ferdinand Thiessen 2026-06-24 12:14:37 +02:00 committed by GitHub
commit bb6717e2d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1514 additions and 1706 deletions

View file

@ -141,10 +141,10 @@ jobs:
matrix:
# Run multiple copies of the current job in parallel
# Please increase the number or runners as your tests suite grows (0 based index for e2e tests)
containers: ['setup', '0', '1', '2', '3', '4', '5']
containers: ['setup', '0', '1', '2', '3', '4']
# Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions
# Always align this number with the total of e2e runners (max. index + 1)
total-containers: [6]
total-containers: [5]
services:
mysql:

View file

@ -82,8 +82,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
shardIndex: [1, 2, 3, 4, 5]
shardTotal: [5]
outputs:
node-version: ${{ steps.versions.outputs.node-version }}
package-manager-version: ${{ steps.versions.outputs.package-manager-version }}

View file

@ -1,73 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts'
const admin = new User('admin', 'admin')
describe('Settings: Ensure only administrator can see the administration settings section', { testIsolation: true }, () => {
beforeEach(() => {
clearState()
})
it('Regular users cannot see admin-level items on the Settings page', () => {
// Given I am logged in
cy.createRandomUser().then(($user) => {
cy.login($user)
cy.visit('/')
})
// I open the settings menu
getNextcloudUserMenuToggle().click()
// I navigate to the settings panel
getNextcloudUserMenu()
.findByRole('link', { name: /settings/i })
.click()
cy.url().should('match', /\/settings\/user$/)
cy.findAllByRole('navigation')
.filter('#app-navigation-vue')
.as('appNavigation')
.findByRole('list', { name: 'Personal' })
.should('be.visible')
.findByRole('link', { name: /Personal info/i })
.should('be.visible')
.and('have.attr', 'aria-current', 'page')
cy.get('@appNavigation')
.findByRole('list', { name: 'Administration' })
.should('not.exist')
})
it('Admin users can see admin-level items on the Settings page', () => {
// Given I am logged in
cy.login(admin)
cy.visit('/')
// I open the settings menu
getNextcloudUserMenuToggle().click()
// I navigate to the settings panel
getNextcloudUserMenu()
.findByRole('link', { name: /Personal settings/i })
.click()
cy.url().should('match', /\/settings\/user$/)
cy.findAllByRole('navigation')
.filter('#app-navigation-vue')
.as('appNavigation')
.findByRole('list', { name: 'Personal' })
.should('be.visible')
.findByRole('link', { name: /Personal info/i })
.should('be.visible')
.and('have.attr', 'aria-current', 'page')
cy.get('@appNavigation')
.findByRole('list', { name: 'Administration' })
.should('be.visible')
.findByRole('link', { name: /Overview/i })
.should('be.visible')
})
})

View file

@ -1,449 +0,0 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { handlePasswordConfirmation } from '../core-utils.ts'
let user: User
enum Visibility {
Private = 'Private',
Local = 'Local',
Federated = 'Federated',
Public = 'Published',
}
const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated]
/**
* Get the input connected to a specific label
* @param label The content of the label
*/
const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`))
/**
* Get the property visibility button
* @param property The property to which to look for the button
*/
const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`)
/**
* Validate a specifiy visibility is set for a property
* @param property The property
* @param active The active visibility
*/
function validateActiveVisibility(property: string, active: Visibility) {
getVisibilityButton(property)
.should('have.attr', 'aria-label')
.and('match', new RegExp(`current scope is ${active}`, 'i'))
getVisibilityButton(property)
.click()
cy.get('ul[role="menu"]')
.contains('button', active)
.should('have.attr', 'aria-checked', 'true')
// close menu
getVisibilityButton(property)
.click()
}
/**
* Set a specific visibility for a property
* @param property The property
* @param active The visibility to set
*/
function setActiveVisibility(property: string, active: Visibility) {
getVisibilityButton(property)
.click()
cy.get('ul[role="menu"]')
.contains('button', active)
.click({ force: true })
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
}
/**
* Helper to check that setting all visibilities on a property is possible
* @param property The property to test
* @param defaultVisibility The default visibility of that property
* @param allowedVisibility Visibility that is allowed and need to be checked
*/
function checkSettingsVisibility(property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) {
getVisibilityButton(property)
.scrollIntoView()
validateActiveVisibility(property, defaultVisibility)
allowedVisibility.forEach((active) => {
setActiveVisibility(property, active)
cy.reload()
getVisibilityButton(property).scrollIntoView()
validateActiveVisibility(property, active)
})
// TODO: Fix this in vue library then enable this test again
/* // Test that not allowed options are disabled
ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => {
getVisibilityButton(property)
.click()
cy.get('ul[role="dialog"')
.contains('button', disabled)
.should('exist')
.and('have.attr', 'disabled', 'true')
}) */
}
const genericProperties = [
['Location', 'Berlin'],
['X (formerly Twitter)', 'nextclouders'],
['Fediverse', 'nextcloud@mastodon.xyz'],
]
const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About']
describe('Settings: Change personal information', { testIsolation: true }, () => {
let snapshot: string = ''
before(() => {
// make sure the fediverse check does not do http requests
cy.runOccCommand('config:system:set has_internet_connection --type bool --value false')
// ensure we can set locale and language
cy.runOccCommand('config:system:delete force_language')
cy.runOccCommand('config:system:delete force_locale')
cy.createRandomUser().then(($user) => {
user = $user
cy.modifyUser(user, 'language', 'en')
cy.modifyUser(user, 'locale', 'en_US')
// Make sure the user is logged in at least once
// before the snapshot is taken to speed up the tests
cy.login(user)
cy.visit('/settings/user')
cy.saveState().then(($snapshot) => {
snapshot = $snapshot
})
})
})
after(() => {
cy.runOccCommand('config:system:delete has_internet_connection')
cy.runOccCommand('config:system:set force_language --value en')
cy.runOccCommand('config:system:set force_locale --value en_US')
})
beforeEach(() => {
cy.login(user)
cy.visit('/settings/user')
cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting')
})
afterEach(() => {
cy.restoreState(snapshot)
})
it('Can dis- and enable the profile', () => {
cy.visit(`/u/${user.userId}`)
cy.contains('h2', user.userId).should('be.visible')
cy.visit('/settings/user')
cy.contains('Enable profile').click()
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
cy.contains('h2', 'Profile not found').should('be.visible')
cy.visit('/settings/user')
cy.contains('Enable profile').click()
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
cy.contains('h2', user.userId).should('be.visible')
})
it('Can change language', () => {
cy.intercept('GET', /settings\/user/).as('reload')
inputForLabel('Language').scrollIntoView()
inputForLabel('Language').type('Ned')
cy.contains('li[role="option"]', 'Nederlands')
.click()
cy.wait('@reload')
// expect language changed
inputForLabel('Taal').scrollIntoView()
cy.contains('section', 'Help met vertalen')
})
it('Can change locale', () => {
cy.intercept('GET', /settings\/user/).as('reload')
cy.clock(new Date(2024, 0, 10))
// Default is US
cy.contains('section', '01/10/2024')
inputForLabel('Locale').scrollIntoView()
inputForLabel('Locale').type('German')
cy.contains('li[role="option"]', 'German (Germany')
.click()
cy.wait('@reload')
// expect locale changed
inputForLabel('Locale').scrollIntoView()
cy.contains('section', '10.01.2024')
})
it('Can set primary email and change its visibility', () => {
cy.contains('label', 'Email').scrollIntoView()
// Check invalid input
inputForLabel('Email').type('foo bar')
inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
// handle valid input
inputForLabel('Email').type('{selectAll}hello@example.com')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Email').should('have.value', 'hello@example.com')
checkSettingsVisibility(
'Email',
Visibility.Federated,
// It is not possible to set it as private
ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
)
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com')
})
it('Can delete primary email', () => {
cy.contains('label', 'Email').scrollIntoView()
inputForLabel('Email').type('{selectAll}hello@example.com')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
// check after reload
cy.reload()
inputForLabel('Email').should('have.value', 'hello@example.com')
// delete email
cy.get('button[aria-label="Remove primary email"]').click({ force: true })
cy.wait('@submitSetting')
// check after reload
cy.reload()
inputForLabel('Email').should('have.value', '')
})
it('Can set and delete additional emails', () => {
cy.get('button[aria-label="Add additional email"]').should('be.disabled')
// we need a primary email first
cy.contains('label', 'Email').scrollIntoView()
inputForLabel('Email').type('{selectAll}primary@example.com')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
// add new email
cy.get('button[aria-label="Add additional email"]')
.click()
// without any value we should not be able to add a second additional
cy.get('button[aria-label="Add additional email"]').should('be.disabled')
// fill the first additional
inputForLabel('Additional email address 1')
.type('1@example.com')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
// add second additional email
cy.get('button[aria-label="Add additional email"]')
.click()
// fill the second additional
inputForLabel('Additional email address 2')
.type('2@example.com')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
// check the content is saved
cy.reload()
inputForLabel('Additional email address 1')
.should('have.value', '1@example.com')
inputForLabel('Additional email address 2')
.should('have.value', '2@example.com')
// delete the first
cy.get('button[aria-label="Options for additional email address 1"]')
.click({ force: true })
cy.contains('button[role="menuitem"]', 'Delete email')
.click({ force: true })
handlePasswordConfirmation(user.password)
cy.reload()
inputForLabel('Additional email address 1')
.should('have.value', '2@example.com')
})
it('Can set Full name and change its visibility', () => {
cy.contains('label', 'Full name').scrollIntoView()
// handle valid input
inputForLabel('Full name').type('{selectAll}Jane Doe')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Full name').should('have.value', 'Jane Doe')
checkSettingsVisibility(
'Full name',
Visibility.Federated,
// It is not possible to set it as private
ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
)
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
cy.contains('h2', 'Jane Doe').should('be.visible')
})
it('Can set Phone number and its visibility', () => {
cy.contains('label', 'Phone number').scrollIntoView()
// Check invalid input
inputForLabel('Phone number').type('foo bar')
inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
// handle valid input
inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701')
inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Phone number').should('have.value', '+498972101099701')
checkSettingsVisibility('Phone number')
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
cy.get('a[href="tel:+498972101099701"]').should('be.visible')
})
it('Can set phone number with phone region', () => {
cy.contains('label', 'Phone number').scrollIntoView()
inputForLabel('Phone number').type('{selectAll}0 40 428990')
inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
cy.runOccCommand('config:system:set default_phone_region --value DE')
cy.reload()
cy.contains('label', 'Phone number').scrollIntoView()
inputForLabel('Phone number').type('{selectAll}0 40 428990')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Phone number').should('have.value', '+4940428990')
})
it('Can reset phone number', () => {
cy.contains('label', 'Phone number').scrollIntoView()
inputForLabel('Phone number').type('{selectAll}+49 40 428990')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Phone number').should('have.value', '+4940428990')
inputForLabel('Phone number').clear()
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Phone number').should('have.value', '')
})
it('Can reset social media property', () => {
cy.contains('label', 'Fediverse').scrollIntoView()
inputForLabel('Fediverse').type('{selectAll}@nextcloud@mastodon.social')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Fediverse').should('have.value', 'nextcloud@mastodon.social')
inputForLabel('Fediverse').clear()
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Fediverse').should('have.value', '')
})
it('Can set Website and change its visibility', () => {
cy.contains('label', 'Website').scrollIntoView()
// Check invalid input
inputForLabel('Website').type('foo bar')
inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
// handle valid input
inputForLabel('Website').type('{selectAll}http://example.com')
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel('Website').should('have.value', 'http://example.com')
checkSettingsVisibility('Website')
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
cy.contains('http://example.com').should('be.visible')
})
// Check generic properties that allow any visibility and any value
genericProperties.forEach(([property, value]) => {
it(`Can set ${property} and change its visibility`, () => {
cy.contains('label', property).scrollIntoView()
inputForLabel(property).type(value)
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel(property).should('have.value', value)
checkSettingsVisibility(property)
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
cy.contains(value).should('be.visible')
})
})
// Check non federated properties - those where we need special configuration and only support local visibility
nonfederatedProperties.forEach((property) => {
it(`Can set ${property} and change its visibility`, () => {
const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
cy.contains('label', property).scrollIntoView()
inputForLabel(property).type(uniqueValue)
handlePasswordConfirmation(user.password)
cy.wait('@submitSetting')
cy.reload()
inputForLabel(property).should('have.value', uniqueValue)
checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local])
// check it is visible on the profile
cy.visit(`/u/${user.userId}`)
cy.contains(uniqueValue).should('be.visible')
})
})
})

View file

@ -1,186 +0,0 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { randomString } from '../../support/utils/randomString.ts'
import { handlePasswordConfirmation } from '../core-utils.ts'
import { getUserListRow } from './usersUtils.ts'
const admin = new User('admin', 'admin')
const john = new User('john', '123456')
/**
* Make a user subadmin of a group.
*
* @param user - The user to make subadmin
* @param group - The group the user should be subadmin of
*/
function makeSubAdmin(user: User, group: string): void {
cy.request({
url: `${Cypress.config('baseUrl')!.replace('/index.php', '')}/ocs/v2.php/cloud/users/${user.userId}/subadmins`,
method: 'POST',
auth: {
user: admin.userId,
password: admin.userId,
},
headers: {
'OCS-ApiRequest': 'true',
},
body: {
groupid: group,
},
})
}
describe('Settings: Create accounts as a group admin', function() {
let subadmin: User
let group: string
beforeEach(() => {
group = randomString(7)
cy.deleteUser(john)
cy.createRandomUser().then((user) => {
subadmin = user
cy.runOccCommand(`group:add '${group}'`)
cy.runOccCommand(`group:adduser '${group}' '${subadmin.userId}'`)
makeSubAdmin(subadmin, group)
})
})
it('Can create a user with prefilled single group', () => {
cy.login(subadmin)
// open the User settings
cy.visit('/settings/users')
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// see that the correct group is preselected
cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible')
// see that the username is ""
cy.get('input[data-test="username"]').should('exist').and('have.value', '')
// set the username to john
cy.get('input[data-test="username"]').type(john.userId)
// see that the username is john
cy.get('input[data-test="username"]').should('have.value', john.userId)
// see that the password is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password to 123456
cy.get('input[type="password"]').type(john.password)
// see that the password is 123456
cy.get('input[type="password"]').should('have.value', john.password)
})
cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
// submit the new user form
cy.get('button[type="submit"]').click({ force: true })
})
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the created user is in the list
getUserListRow(john.userId)
// see that the list of users contains the user john
.contains(john.userId).should('exist')
})
// Skiping as this crash the webengine in the CI
it.skip('Can create a new user when member of multiple groups', () => {
const group2 = randomString(7)
cy.runOccCommand(`group:add '${group2}'`)
cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`)
makeSubAdmin(subadmin, group2)
cy.login(subadmin)
// open the User settings
cy.visit('/settings/users')
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// see that no group is pre-selected
cy.get('[data-test="groups"] .vs__selected').should('not.exist')
// see both groups are available
cy.findByRole('combobox', { name: /member of the following groups/i })
.should('be.visible')
.click()
// can select both groups
cy.document().its('body')
.findByRole('listbox', { name: 'Options' })
.should('be.visible')
.as('options')
.findAllByRole('option')
.should('have.length', 2)
.get('@options')
.findByRole('option', { name: group })
.should('be.visible')
.get('@options')
.findByRole('option', { name: group2 })
.should('be.visible')
.click()
// see group is selected
cy.contains('[data-test="groups"] .vs__selected', group2).should('be.visible')
// see that the username is ""
cy.get('input[data-test="username"]').should('exist').and('have.value', '')
// set the username to john
cy.get('input[data-test="username"]').type(john.userId)
// see that the username is john
cy.get('input[data-test="username"]').should('have.value', john.userId)
// see that the password is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password to 123456
cy.get('input[type="password"]').type(john.password)
// see that the password is 123456
cy.get('input[type="password"]').should('have.value', john.password)
})
cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
// submit the new user form
cy.get('button[type="submit"]').click({ force: true })
})
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the created user is in the list
getUserListRow(john.userId)
// see that the list of users contains the user john
.contains(john.userId).should('exist')
})
it.skip('Only sees groups they are subadmin of', () => {
const group2 = randomString(7)
cy.runOccCommand(`group:add '${group2}'`)
cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`)
// not a subadmin!
cy.login(subadmin)
// open the User settings
cy.visit('/settings/users')
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// see that the subadmin group is pre-selected
cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible')
// see only the subadmin group is available
cy.findByRole('combobox', { name: /member of the following groups/i })
.should('be.visible')
.click()
// can select both groups
cy.document().its('body')
.findByRole('listbox', { name: 'Options' })
.should('be.visible')
.as('options')
.findAllByRole('option')
.should('have.length', 1)
})
})
})

View file

@ -1,131 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/// <reference types="cypress-if" />
import { User } from '@nextcloud/e2e-test-server/cypress'
import { handlePasswordConfirmation } from '../core-utils.ts'
import { getUserListRow } from './usersUtils.ts'
const admin = new User('admin', 'admin')
const john = new User('john', '123456')
describe('Settings: Create and delete accounts', function() {
beforeEach(function() {
cy.listUsers().then((users) => {
if ((users as string[]).includes(john.userId)) {
// ensure created user is deleted
cy.deleteUser(john)
}
})
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
it('Can create a user', function() {
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// see that the username is ""
cy.get('input[data-test="username"]').should('exist').and('have.value', '')
// set the username to john
cy.get('input[data-test="username"]').type(john.userId)
// see that the username is john
cy.get('input[data-test="username"]').should('have.value', john.userId)
// see that the password is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password to 123456
cy.get('input[type="password"]').type(john.password)
// see that the password is 123456
cy.get('input[type="password"]').should('have.value', john.password)
})
cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
// submit the new user form
cy.get('button[type="submit"]').click({ force: true })
})
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the created user is in the list
getUserListRow(john.userId)
// see that the list of users contains the user john
.contains(john.userId).should('exist')
})
it('Can create a user with additional field data', function() {
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// set the username
cy.get('input[data-test="username"]').should('exist').and('have.value', '')
cy.get('input[data-test="username"]').type(john.userId)
cy.get('input[data-test="username"]').should('have.value', john.userId)
// set the display name
cy.get('input[data-test="displayName"]').should('exist').and('have.value', '')
cy.get('input[data-test="displayName"]').type('John Smith')
cy.get('input[data-test="displayName"]').should('have.value', 'John Smith')
// set the email
cy.get('input[data-test="email"]').should('exist').and('have.value', '')
cy.get('input[data-test="email"]').type('john@example.org')
cy.get('input[data-test="email"]').should('have.value', 'john@example.org')
// set the password
cy.get('input[type="password"]').should('exist').and('have.value', '')
cy.get('input[type="password"]').type(john.password)
cy.get('input[type="password"]').should('have.value', john.password)
})
cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
// submit the new user form
cy.get('button[type="submit"]').click({ force: true })
})
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the created user is in the list
getUserListRow(john.userId)
// see that the list of users contains the user john
.contains(john.userId)
.should('exist')
})
it('Can delete a user', function() {
let testUser
// create user
cy.createRandomUser()
.then(($user) => {
testUser = $user
})
cy.login(admin)
// ensure created user is present
cy.reload().then(() => {
// see that the user is in the list
getUserListRow(testUser.userId).within(() => {
// see that the list of users contains the user testUser
cy.contains(testUser.userId).should('exist')
// open the actions menu for the user
cy.get('[data-cy-user-list-cell-actions]')
.find('button.action-item__menutoggle')
.click({ force: true })
})
// The "Delete account" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Delete account').should('exist').click({ force: true })
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// And confirmation dialog accepted
cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true })
// deleted clicked the user is not shown anymore
getUserListRow(testUser.userId).should('not.exist')
})
})
})

View file

@ -1,99 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { assertNotExistOrNotVisible, getUserList } from './usersUtils.js'
const admin = new User('admin', 'admin')
describe('Settings: Show and hide columns', function() {
before(function() {
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
beforeEach(function() {
// open the settings dialog
cy.contains('button', 'Account management settings').click()
// reset all visibility toggles
cy.get('.modal-container #settings-section_visibility-settings input[type="checkbox"]').uncheck({ force: true })
cy.contains('.modal-container', 'Account management settings').within(() => {
// enable the last login toggle
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').check({ force: true })
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
})
it('Can show a column', function() {
// see that the language column is not in the header
cy.get('[data-cy-user-list-header-languages]').should('not.exist')
// see that the language column is not in all user rows
cy.get('tbody.user-list__body tr').each(($row) => {
cy.wrap($row).get('[data-test="language"]').should('not.exist')
})
// open the settings dialog
cy.contains('button', 'Account management settings').click()
cy.contains('.modal-container', 'Account management settings').within(() => {
// enable the language toggle
cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('not.be.checked')
cy.get('[data-test="showLanguages"] input[type="checkbox"]').check({ force: true })
cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('be.checked')
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
// see that the language column is in the header
cy.get('[data-cy-user-list-header-languages]').should('exist')
// see that the language column is in all user rows
getUserList().find('tbody tr').each(($row) => {
cy.wrap($row).get('[data-cy-user-list-cell-language]').should('exist')
})
// Clear local storage and reload to verify user settings DB persistence
cy.clearLocalStorage()
cy.reload()
cy.get('[data-cy-user-list-header-languages]').should('exist')
})
it('Can hide a column', function() {
// see that the last login column is in the header
cy.get('[data-cy-user-list-header-last-login]').should('exist')
// see that the last login column is in all user rows
getUserList().find('tbody tr').each(($row) => {
cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('exist')
})
// open the settings dialog
cy.contains('button', 'Account management settings').click()
cy.contains('.modal-container', 'Account management settings').within(() => {
// disable the last login toggle
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('be.checked')
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').uncheck({ force: true })
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('not.be.checked')
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.contains('.modal-container', 'Account management settings').should((el) => assertNotExistOrNotVisible(el)))
// see that the last login column is not in the header
cy.get('[data-cy-user-list-header-last-login]').should('not.exist')
// see that the last login column is not in all user rows
getUserList().find('tbody tr').each(($row) => {
cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('not.exist')
})
})
})

View file

@ -1,79 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { getUserListRow } from './usersUtils.ts'
const admin = new User('admin', 'admin')
describe('Settings: Disable and enable users', function() {
let testUser: User
beforeEach(function() {
clearState()
cy.createRandomUser().then(($user) => {
testUser = $user
})
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
// Not guranteed to run but would be nice to cleanup
after(() => {
cy.deleteUser(testUser)
})
it('Can disable the user', function() {
// ensure user is enabled
cy.enableUser(testUser)
// see that the user is in the list of active users
getUserListRow(testUser.userId).within(() => {
// see that the list of users contains the user testUser
cy.contains(testUser.userId).should('exist')
// open the actions menu for the user
cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' })
})
// The "Disable account" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Disable account').should('exist').click()
// When clicked the section is not shown anymore
getUserListRow(testUser.userId).should('not.exist')
// But the disabled user section now exists
cy.get('#disabled').should('exist')
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
// The list of disabled users should now contain the user
getUserListRow(testUser.userId).should('exist')
})
it('Can enable the user', function() {
// ensure user is disabled
cy.enableUser(testUser, false).reload()
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
// see that the user is in the list of active users
getUserListRow(testUser.userId).within(() => {
// see that the list of disabled users contains the user testUser
cy.contains(testUser.userId).should('exist')
// open the actions menu for the user
cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' })
})
// The "Enable account" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Enable account').should('exist').click()
// When clicked the section is not shown anymore
cy.get('#disabled').should('not.exist')
// Make sure it is still gone after the reload reload
cy.reload().login(admin)
cy.get('#disabled').should('not.exist')
})
})

View file

@ -1,305 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { randomString } from '../../support/utils/randomString.ts'
import { handlePasswordConfirmation } from '../core-utils.ts'
import { assertNotExistOrNotVisible, getUserListRow, openEditDialog, saveEditDialog } from './usersUtils.ts'
const admin = new User('admin', 'admin')
describe('Settings: Create groups', () => {
let groupName: string
after(() => {
cy.runOccCommand(`group:delete '${groupName!}'`)
})
before(() => {
cy.login(admin)
cy.visit('/settings/users')
})
it('Can create a group', () => {
cy.intercept('POST', '**/ocs/v2.php/cloud/groups').as('createGroups')
groupName = randomString(7)
// open the Create group menu
cy.get('button[aria-label="Create group"]').click()
cy.get('li[data-cy-users-settings-new-group-name]').within(() => {
// see that the group name is ""
cy.get('input').should('exist').and('have.value', '')
// set the group name to foo
cy.get('input').type(groupName)
// see that the group name is foo
cy.get('input').should('have.value', groupName)
// submit the group name
cy.get('input ~ button').click()
})
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
cy.wait('@createGroups').its('response.statusCode').should('eq', 200)
// see that the created group is in the list
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
// see that the list of groups contains the group foo
cy.contains(groupName).should('exist')
})
})
})
describe('Settings: Assign user to a group', { testIsolation: false }, () => {
const groupName = randomString(7)
let testUser: User
after(() => {
cy.deleteUser(testUser)
cy.runOccCommand(`group:delete '${groupName}'`)
})
before(() => {
clearState()
cy.createRandomUser().then((user) => {
testUser = user
})
cy.runOccCommand(`group:add '${groupName}'`)
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
cy.wait('@loadGroups')
})
it('see that the group is in the list', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('exist')
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('.counter-bubble__counter')
.should('not.exist') // is hidden when 0
})
it('see that the user is in the list', () => {
getUserListRow(testUser.userId)
.contains(testUser.userId)
.should('exist')
.scrollIntoView()
})
it('assign the group via the edit dialog', () => {
openEditDialog(testUser)
// Type part of the group name in the groups NcSelect
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('[data-test="groups"] input[type="search"]').click({ force: true })
cy.get('[data-test="groups"] input[type="search"]').type(groupName.slice(0, 5))
})
// Select the group from the floating dropdown
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', groupName).click({ force: true })
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
})
it('see the group was successfully assigned', () => {
// see a new member
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('.counter-bubble__counter')
.should('contain', '1')
})
it('validate the user was added on backend', () => {
cy.runOccCommand(`user:info --output=json '${testUser.userId}'`).then((output) => {
cy.wrap(output.exitCode).should('eq', 0)
cy.wrap(JSON.parse(output.stdout)?.groups).should('include', groupName)
})
})
})
describe('Settings: Delete an empty group', { testIsolation: false }, () => {
const groupName = randomString(7)
after(() => {
cy.runOccCommand(`group:delete '${groupName}'`, { failOnNonZeroExit: false })
})
before(() => {
cy.runOccCommand(`group:add '${groupName}'`)
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
cy.wait('@loadGroups')
})
it('see that the group is in the list', () => {
// see that the list of groups contains the group foo
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('exist')
.scrollIntoView()
// open the actions menu for the group
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('button.action-item__menutoggle')
.click({ force: true })
})
it('can delete the group', () => {
// The "Delete group" action in the actions menu is shown and clicked
cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true })
// And confirmation dialog accepted
cy.get('.modal-container button').contains('Confirm').click({ force: true })
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
})
it('deleted group is not shown anymore', () => {
// see that the list of groups does not contain the group
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
.find('li')
.not('.app-navigation-caption')
.should('not.exist')
// and also not in database
cy.runOccCommand('group:list --output=json').then(($response) => {
const groups: string[] = Object.keys(JSON.parse($response.stdout))
expect(groups).to.not.include(groupName)
})
})
})
describe('Settings: Delete a non empty group', () => {
let testUser: User
const groupName = randomString(7)
after(() => {
cy.runOccCommand(`group:delete '${groupName}'`, { failOnNonZeroExit: false })
})
before(() => {
cy.runOccCommand(`group:add '${groupName}'`)
cy.createRandomUser().then(($user) => {
testUser = $user
cy.runOccCommand(`group:addUser '${groupName}' '${$user.userId}'`)
})
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
cy.wait('@loadGroups')
})
after(() => cy.deleteUser(testUser))
it('see that the group is in the list', () => {
// see that the list of groups contains the group
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('exist')
.scrollIntoView()
})
it('can delete the group', () => {
// open the menu
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('button.action-item__menutoggle')
.click({ force: true })
// The "Delete group" action in the actions menu is shown and clicked
cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true })
// And confirmation dialog accepted
cy.get('.modal-container button').contains('Confirm').click({ force: true })
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
})
it('deleted group is not shown anymore', () => {
// see that the list of groups does not contain the group foo
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
.find('li')
.not('.app-navigation-caption')
.should('not.exist')
// and also not in database
cy.runOccCommand('group:list --output=json').then(($response) => {
const groups: string[] = Object.keys(JSON.parse($response.stdout))
expect(groups).to.not.include(groupName)
})
})
})
describe('Settings: Sort groups in the UI', () => {
before(() => {
// Clear state
clearState()
// Add two groups and add one user to group B
cy.runOccCommand('group:add A')
cy.runOccCommand('group:add B')
cy.createRandomUser().then((user) => {
cy.runOccCommand(`group:adduser B '${user.userId}'`)
})
// Visit the settings as admin
cy.login(admin)
cy.visit('/settings/users')
})
it('Can set sort by member count', () => {
// open the settings dialog
cy.contains('button', 'Account management settings').click()
cy.contains('.modal-container', 'Account management settings').within(() => {
cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').scrollIntoView()
cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').check({ force: true })
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
})
it('See that the groups are sorted by the member count', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'B') // 1 member
cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'A') // 0 members
})
})
it('See that the order is preserved after a reload', () => {
cy.reload()
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'B') // 1 member
cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'A') // 0 members
})
})
it('Can set sort by group name', () => {
// open the settings dialog
cy.contains('button', 'Account management settings').click()
cy.contains('.modal-container', 'Account management settings').within(() => {
cy.get('[data-test="sortGroupsByName"] input[type="radio"]').scrollIntoView()
cy.get('[data-test="sortGroupsByName"] input[type="radio"]').check({ force: true })
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
})
it('See that the groups are sorted by the user count', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'A')
cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'B')
})
})
it('See that the order is preserved after a reload', () => {
cy.reload()
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'A')
cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'B')
})
})
})

View file

@ -1,82 +0,0 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { handlePasswordConfirmation } from '../core-utils.ts'
import { openEditDialog, saveEditDialog } from './usersUtils.ts'
const admin = new User('admin', 'admin')
describe('Settings: User Manager Management', function() {
let user: User
let manager: User
beforeEach(function() {
clearState()
cy.createRandomUser().then(($user) => {
manager = $user
return cy.createRandomUser()
}).then(($user) => {
user = $user
cy.login(admin)
})
})
it('Can assign a manager through the edit dialog', function() {
cy.visit('/settings/users')
openEditDialog(user)
// Open the Manager NcSelect and type manager name
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.findByRole('combobox', { name: /Manager/i }).click({ force: true })
cy.findByRole('combobox', { name: /Manager/i }).type(manager.userId)
})
// Select the manager from the floating dropdown
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', manager.userId).click({ force: true })
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.getUserData(user).then(($result) => {
expect($result.body).to.contain(`<manager>${manager.userId}</manager>`)
})
})
it('Can remove a manager through the edit dialog', function() {
// Set manager via backend first.
// User::getManagerUids() decodes this with JSON_THROW_ON_ERROR, so we
// must store a JSON array, matching what setManagerUids() writes.
// Double-quotes are escaped because runOccCommand passes the command
// through `bash -c "..."`, which would otherwise eat them.
cy.runOccCommand(`user:setting '${user.userId}' settings manager '[\\"${manager.userId}\\"]'`)
cy.visit('/settings/users')
openEditDialog(user)
// Clear the manager selection inside the dialog
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('.user-form__managers .vs__clear').click({ force: true })
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.getUserData(user).then(($result) => {
expect($result.body).to.not.contain(`<manager>${manager.userId}</manager>`)
expect($result.body).to.contain('<manager></manager>')
})
})
})

View file

@ -1,176 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { handlePasswordConfirmation } from '../core-utils.ts'
import { openEditDialog, saveEditDialog } from './usersUtils.ts'
const admin = new User('admin', 'admin')
describe('Settings: Change user properties', function() {
let user: User
beforeEach(function() {
clearState()
cy.createRandomUser().then(($user) => {
user = $user
})
cy.login(admin)
})
it('Can change the display name', function() {
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('input[data-test="displayName"]').should('have.value', user.userId)
cy.get('input[data-test="displayName"]').clear()
cy.get('input[data-test="displayName"]').type('John Doe')
cy.get('input[data-test="displayName"]').should('have.value', 'John Doe')
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
const info = JSON.parse($result.stdout)
expect(info?.display_name).to.equal('John Doe')
})
})
it('Can change the password', function() {
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('input[data-test="password"]').should('have.value', '')
cy.get('input[data-test="password"]').type('newpassword123')
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify by logging in with the new password
cy.login(new User(user.userId, 'newpassword123'))
cy.visit('/apps/dashboard')
cy.url().should('include', '/apps/dashboard')
})
it('Can change the email address', function() {
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('input[data-test="email"]').should('have.value', '')
cy.get('input[data-test="email"]').type('mymail@example.com')
cy.get('input[data-test="email"]').should('have.value', 'mymail@example.com')
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
const info = JSON.parse($result.stdout)
expect(info?.email).to.equal('mymail@example.com')
})
})
it('Can change the user quota to a predefined one', function() {
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
// Open the quota selector
cy.get('.vs__selected').contains('Unlimited').should('exist')
cy.findByRole('combobox', { name: /Quota/i }).click({ force: true })
})
// Dropdown is floating outside the form — select 5 GB
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', '5 GB').click({ force: true })
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
const info = JSON.parse($result.stdout)
expect(info?.quota).to.equal('5 GB')
})
})
it('Can change the user quota to a custom value', function() {
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
// Type a custom quota value
cy.findByRole('combobox', { name: /Quota/i }).type('4 MB{enter}')
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
// Quota value is stored as bytes, verify it was set
const info = JSON.parse($result.stdout)
expect(info?.quota).to.not.equal('none')
})
})
it('Can make user a subadmin of a group', function() {
const groupName = 'userstestgroup'
cy.runOccCommand(`group:add '${groupName}'`)
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
// Find the subadmin NcSelect by its label and open the dropdown
cy.findByRole('combobox', { name: /Admin of the following groups/i }).click({ force: true })
cy.findByRole('combobox', { name: /Admin of the following groups/i }).type('userstestgroup')
})
// Select the group from the floating dropdown
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', groupName).click({ force: true })
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.getUserData(user).then(($response) => {
expect($response.status).to.equal(200)
const dom = (new DOMParser()).parseFromString($response.body, 'text/xml')
expect(dom.querySelector('subadmin element')?.textContent).to.contain(groupName)
})
})
})

View file

@ -1,117 +0,0 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/// <reference types="cypress-if" />
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { randomString } from '../../support/utils/randomString.ts'
import { getUserList, getUserListRow } from './usersUtils.ts'
const admin = new User('admin', 'admin')
/** Scope role queries to the account management sidebar so they don't match
* unrelated elements (e.g. the global unified search bar at the top of the page).
*/
function accountNav() {
return cy.findByRole('navigation', { name: /account management/i })
}
function waitForSearchRequest(alias: string, expectedSearch: string) {
return cy.wait(alias).then(({ request }) => {
expect(new URL(request.url).searchParams.get('search')).to.equal(expectedSearch)
})
}
describe('Settings: Unified search for accounts and groups', { testIsolation: false }, () => {
// Use a stable, searchable prefix in the group name so we can match
// it independently from the random user id below.
const matchingGroup = `zzz-match-${randomString(5)}`
const otherGroup = `aaa-other-${randomString(5)}`
let alice: User
let bob: User
after(() => {
cy.deleteUser(alice)
cy.deleteUser(bob)
cy.runOccCommand(`group:delete '${matchingGroup}'`, { failOnNonZeroExit: false })
cy.runOccCommand(`group:delete '${otherGroup}'`, { failOnNonZeroExit: false })
})
before(() => {
clearState()
cy.createRandomUser().then((user) => {
alice = user
})
cy.createRandomUser().then((user) => {
bob = user
})
cy.runOccCommand(`group:add '${matchingGroup}'`)
cy.runOccCommand(`group:add '${otherGroup}'`)
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=*').as('initialLoadGroups')
cy.intercept('GET', '**/ocs/v2.php/cloud/users/details?*').as('initialLoadUsers')
cy.visit('/settings/users')
cy.wait('@initialLoadGroups')
cy.wait('@initialLoadUsers')
})
beforeEach(() => {
// Intercept aliases reset between tests even with testIsolation: false,
// so re-register them here to capture requests triggered inside each test.
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=*').as('loadGroups')
cy.intercept('GET', '**/ocs/v2.php/cloud/users/details?*').as('loadUsers')
})
it('shows the search input in the navigation sidebar', () => {
accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
.should('be.visible')
.and('have.value', '')
})
it('dispatches the query to both the users and groups API', () => {
accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
.type(alice.userId)
// A single keystroke sequence debounces once (300ms), then fans out
// to both APIs — both requests must carry the same search term.
cy.wait('@loadUsers').its('request.url').should('include', `search=${alice.userId}`)
cy.wait('@loadGroups').its('request.url').should('include', `search=${alice.userId}`)
// The user list reflects what the backend returned for this query.
getUserListRow(alice.userId).should('exist')
getUserList().should('not.contain', bob.userId)
})
it('filters the group list when the query matches a group name', () => {
accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
.clear()
.type(matchingGroup)
cy.wait('@loadGroups').its('request.url').should('include', `search=${matchingGroup}`)
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
.should('contain', matchingGroup)
.and('not.contain', otherGroup)
})
it('resets both lists when the clear button is clicked', () => {
accountNav().findByRole('button', { name: /clear search/i }).click()
accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
.should('have.value', '')
waitForSearchRequest('@loadUsers', '')
waitForSearchRequest('@loadGroups', '')
getUserListRow(alice.userId).should('exist')
getUserListRow(bob.userId).should('exist')
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
.should('contain', matchingGroup)
.and('contain', otherGroup)
})
})

View file

@ -94,7 +94,6 @@ test.describe('Header: Contacts menu', () => {
test('users from other groups are not seen when user enumeration is restricted to the same group', async ({ page, contactUser }) => {
// Enable restriction first, then open the menu.
await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_restrict_user_enumeration_to_group'])
await new Promise((resolve) => globalThis.setTimeout(resolve, 3000)) // wait for app config cache to expire
try {
await page.goto('/')
const contactsMenu = new ContactsMenuPage(page)
@ -106,9 +105,7 @@ test.describe('Header: Contacts menu', () => {
// Close, lift the restriction, reopen — the contact should reappear.
await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_restrict_user_enumeration_to_group'])
const waitForAppConfigCacheTTL = new Promise((resolve) => globalThis.setTimeout(resolve, 3000)) // wait for app config cache to expire
await contactsMenu.close()
await waitForAppConfigCacheTTL
await page.reload()
await contactsMenu.open()

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import { test as userTest } from '../../support/fixtures/random-user-session.ts'
import { test as adminTest } from '../../support/fixtures/admin-session.ts'
import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts'
userTest.describe('Settings: Access levels regular user', () => {
userTest('cannot see the Administration section in the settings navigation', async ({ page }) => {
await page.goto('/')
const accountMenu = new AccountMenuPage(page)
await accountMenu.open()
await accountMenu.entry('Settings').getByRole('link').click()
await expect(page).toHaveURL(/\/settings\/user$/)
const appNavigation = page.locator('#app-navigation-vue')
await expect(appNavigation.getByRole('list', { name: 'Personal' })).toBeVisible()
await expect(appNavigation.getByRole('link', { name: /Personal info/i })).toBeVisible()
// Regular users must not see the Administration section
await expect(appNavigation.getByRole('list', { name: 'Administration' })).toHaveCount(0)
})
})
adminTest.describe('Settings: Access levels admin user', () => {
adminTest('can see the Administration section in the settings navigation', async ({ page }) => {
await page.goto('/')
const accountMenu = new AccountMenuPage(page)
await accountMenu.open()
await accountMenu.entry('Personal settings').getByRole('link').click()
await expect(page).toHaveURL(/\/settings\/user$/)
const appNavigation = page.locator('#app-navigation-vue')
await expect(appNavigation.getByRole('list', { name: 'Personal' })).toBeVisible()
await expect(appNavigation.getByRole('link', { name: /Personal info/i })).toBeVisible()
// Admins must see the Administration section
await expect(appNavigation.getByRole('list', { name: 'Administration' })).toBeVisible()
await expect(appNavigation.getByRole('link', { name: /Overview/i })).toBeVisible()
})
})

View file

@ -0,0 +1,460 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Page, Response } from '@playwright/test'
import { expect, test as baseTest } from '@playwright/test'
import type { User } from '@nextcloud/e2e-test-server'
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
// ── Visibility scope labels exactly as rendered in the UI ─────────────────────
const Visibility = {
Private: 'Private',
Local: 'Local',
Federated: 'Federated',
Published: 'Published',
} as const
type Visibility = typeof Visibility[keyof typeof Visibility]
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Register a listener for the next personal-info PUT. Call BEFORE triggering
* the save action; await the result after the action and any password dialog.
*/
function waitForSave(page: Page): Promise<Response> {
return page.waitForResponse(
(r) => r.request().method() === 'PUT' && r.url().includes('/ocs/v2.php/cloud/users/'),
)
}
/**
* Click the scope (visibility) control for `property` and select `scope`.
* `property` is the lowercase readable name as it appears in the button's
* aria-label (e.g. "email", "full name", "phone number").
*/
async function changeVisibility(page: Page, property: string, scope: Visibility, password: string): Promise<void> {
const saved = waitForSave(page)
await page.getByRole('button', { name: new RegExp(`change scope level of ${property}`, 'i') }).click()
await page.getByRole('menuitemradio', { name: scope }).click()
await handlePasswordConfirmation(page, password)
await saved
}
// ── Fixture ───────────────────────────────────────────────────────────────────
const test = baseTest.extend<{ user: User }>({
user: async ({ context }, use) => {
const user = await createRandomUser()
// Ensure English UI language and locale so string assertions are stable
await runOcc(['user:setting', user.userId, 'core', 'lang', 'en'])
await runOcc(['user:setting', user.userId, 'core', 'locale', 'en_US'])
await login(context.request, user)
await use(user)
await runOcc(['user:delete', user.userId])
},
})
// ── Spec ──────────────────────────────────────────────────────────────────────
test.describe('Settings: Change personal information', () => {
test.beforeAll(async () => {
// Prevent the Fediverse section from making outbound HTTP requests
await runOcc(['config:system:set', 'has_internet_connection', '--type', 'bool', '--value', 'false'])
// Let each user choose their own language and locale
await runOcc(['config:system:delete', 'force_language'])
await runOcc(['config:system:delete', 'force_locale'])
})
test.afterAll(async () => {
await runOcc(['config:system:delete', 'has_internet_connection'])
// Restore English defaults so other test suites are unaffected
await runOcc(['config:system:set', 'force_language', '--value', 'en'])
await runOcc(['config:system:set', 'force_locale', '--value', 'en_US'])
})
// ── Profile ───────────────────────────────────────────────────────────────
test('can enable and disable the profile', async ({ page, user }) => {
// Profile is enabled by default: the public profile page shows the user id
await page.goto(`/u/${user.userId}`)
await expect(page.getByRole('heading', { name: user.userId })).toBeVisible()
await page.goto('/settings/user')
const saved1 = waitForSave(page)
await page.getByRole('checkbox', { name: 'Enable profile' }).uncheck({ force: true })
await handlePasswordConfirmation(page, user.password)
await saved1
// Profile is disabled: the public profile page shows a "not found" heading
await page.goto(`/u/${user.userId}`, { waitUntil: 'networkidle' })
await expect(page.getByRole('heading', { name: /Profile not found/i })).toBeVisible()
// Re-enable the profile
await page.goto('/settings/user')
const saved2 = waitForSave(page)
await page.getByRole('checkbox', { name: 'Enable profile' }).check({ force: true } )
await handlePasswordConfirmation(page, user.password)
await saved2
await page.goto(`/u/${user.userId}`)
await expect(page.getByRole('heading', { name: user.userId })).toBeVisible()
})
// ── Language ──────────────────────────────────────────────────────────────
test('can change language', async ({ page, user: _ }) => {
await page.goto('/settings/user')
// NcSelect: type to filter, click the option (teleported to <body>)
await page.getByRole('combobox', { name: 'Language' }).scrollIntoViewIfNeeded()
await page.getByRole('combobox', { name: 'Language' }).fill('Ned')
await page.getByRole('option', { name: /Neder\s?lands/ }).click()
// Language change triggers a full page reload; wait for Dutch UI
await expect(page.getByRole('combobox', { name: 'Taal' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Help met vertalen')).toBeVisible()
})
// ── Locale ────────────────────────────────────────────────────────────────
test('can change locale', async ({ page, user: _ }) => {
await page.goto('/settings/user')
await page.getByRole('combobox', { name: 'Locale' }).fill('German')
await page.getByRole('option', { name: /^German/ }).filter({ hasText: /\(Germany\)/ }).click()
// Locale change triggers a full page reload
await page.waitForLoadState('networkidle')
// After reload the German locale option is reflected in the combobox
await expect(page.getByRole('combobox', { name: 'Locale' })).toBeVisible()
await expect(page.getByText(/German \(Germany\)/)).toBeVisible()
})
// ── Primary email ─────────────────────────────────────────────────────────
test('can set primary email and change its visibility', async ({ page, user }) => {
await page.goto('/settings/user')
const emailInput = page.getByRole('textbox', { name: 'Email' })
// HTML5 email validation: 'foo bar' is not a valid address
await emailInput.fill('foo bar')
await expect(emailInput.and(page.locator(':invalid'))).toHaveCount(1)
// Set a valid email
const saved = waitForSave(page)
await emailInput.fill('hello@example.com')
await handlePasswordConfirmation(page, user.password)
await saved
await page.reload()
await expect(emailInput).toHaveValue('hello@example.com')
// Change visibility and verify it persists across a reload
await changeVisibility(page, 'email', Visibility.Local, user.password)
await page.reload()
await expect(page.getByRole('button', { name: /change scope level of email.*local/i })).toBeVisible()
// With Local visibility the address is visible on the public profile
await page.goto(`/u/${user.userId}`)
await expect(page.getByRole('link', { name: 'hello@example.com' })).toBeVisible()
})
test('can delete primary email', async ({ page, user }) => {
await page.goto('/settings/user')
const saved1 = waitForSave(page)
const emailInput = page.getByRole('textbox', { name: 'Email' })
await emailInput.fill('hello@example.com')
await handlePasswordConfirmation(page, user.password)
await saved1
await page.reload()
await expect(emailInput).toHaveValue('hello@example.com')
const saved2 = waitForSave(page)
// The "Remove primary email" button is visually inside the input row
await page.getByRole('button', { name: 'Remove primary email' }).click({ force: true })
await handlePasswordConfirmation(page, user.password)
await saved2
await page.reload()
await expect(emailInput).toHaveValue('')
})
// ── Additional emails ─────────────────────────────────────────────────────
test('can set and delete additional emails', async ({ page, user }) => {
await page.goto('/settings/user')
// "Add additional email" is disabled until a primary email exists
await expect(page.getByRole('button', { name: 'Add additional email' })).toBeDisabled()
// Set a primary email first
const emailInput = page.getByRole('textbox', { name: 'Email' })
const saved1 = waitForSave(page)
await emailInput.fill('primary@example.com')
await handlePasswordConfirmation(page, user.password)
await saved1
// Add first additional email
await page.getByRole('button', { name: 'Add additional email' }).click()
// Disabled again until the new field has a value
await expect(page.getByRole('button', { name: 'Add additional email' })).toBeDisabled()
const saved2 = waitForSave(page)
await page.getByRole('textbox', { name: 'Additional email address 1' }).fill('1@example.com')
await handlePasswordConfirmation(page, user.password)
await saved2
// Add second additional email
await page.getByRole('button', { name: 'Add additional email' }).click()
const saved3 = waitForSave(page)
await page.getByRole('textbox', { name: 'Additional email address 2' }).fill('2@example.com')
await handlePasswordConfirmation(page, user.password)
await saved3
// Both additional addresses persist across a reload
await page.reload()
await expect(page.getByRole('textbox', { name: 'Additional email address 1' })).toHaveValue('1@example.com')
await expect(page.getByRole('textbox', { name: 'Additional email address 2' })).toHaveValue('2@example.com')
// Delete the first additional email via its options menu
await page.getByRole('button', { name: 'Options for additional email address 1' }).click({ force: true })
const saved4 = waitForSave(page)
await page.getByRole('menuitem', { name: 'Delete email' }).click({ force: true })
await handlePasswordConfirmation(page, user.password)
await saved4
// After deletion the second address shifts into position 1
await page.reload()
await expect(page.getByRole('textbox', { name: 'Additional email address' })).toHaveValue('2@example.com')
})
// ── Full name ─────────────────────────────────────────────────────────────
test('can set full name and change its visibility', async ({ page, user }) => {
await page.goto('/settings/user')
const saved = waitForSave(page)
await page.getByRole('textbox', { name: 'Full name' }).fill('Jane Doe')
await handlePasswordConfirmation(page, user.password)
await saved
await page.reload()
await expect(page.getByRole('textbox', { name: 'Full name' })).toHaveValue('Jane Doe')
await changeVisibility(page, 'full name', Visibility.Local, user.password)
await page.reload()
await expect(page.getByRole('button', { name: /change scope level of full name.*local/i })).toBeVisible()
// With Local visibility the display name appears on the public profile
await page.goto(`/u/${user.userId}`)
await expect(page.getByRole('heading', { name: 'Jane Doe' })).toBeVisible()
})
// ── Phone number ──────────────────────────────────────────────────────────
test('can set phone number and its visibility', async ({ page, user }) => {
await page.goto('/settings/user')
const saved = waitForSave(page)
const phoneInput = page.getByRole('textbox', { name: 'Phone number' })
await phoneInput.fill('+49 89 721010 99701')
await handlePasswordConfirmation(page, user.password)
await saved
// Server normalises to E.164 format
await page.reload()
await expect(phoneInput).toHaveValue('+498972101099701')
await changeVisibility(page, 'phone number', Visibility.Private, user.password)
await page.reload()
await expect(page.getByRole('button', { name: /change scope level of phone number.*private/i })).toBeVisible()
})
test('can set phone number with phone region', async ({ page, user }) => {
await page.goto('/settings/user')
const phoneInput = page.getByRole('textbox', { name: 'Phone number' })
// Without a phone region, a local-format number is rejected
await phoneInput.fill('0 40 428990')
// NcTextField marks the field with an error class but we verify via the saved value
// being empty after reload (the server rejects the malformed number)
// Set the default region and reload
await runOcc(['config:system:set', 'default_phone_region', '--value', 'DE'])
await page.reload()
const saved = waitForSave(page)
await phoneInput.fill('0 40 428990')
await handlePasswordConfirmation(page, user.password)
await saved
await page.reload()
await expect(phoneInput).toHaveValue('+4940428990')
await runOcc(['config:system:delete', 'default_phone_region'])
})
test('can reset phone number', async ({ page, user }) => {
await page.goto('/settings/user')
const phoneInput = page.getByRole('textbox', { name: 'Phone number' })
const saved1 = waitForSave(page)
await phoneInput.fill('+49 40 428990')
await handlePasswordConfirmation(page, user.password)
await saved1
await page.reload()
await expect(phoneInput).toHaveValue('+4940428990')
const saved2 = waitForSave(page)
await phoneInput.clear()
await handlePasswordConfirmation(page, user.password)
await saved2
await page.reload()
await expect(phoneInput).toHaveValue('')
})
// ── Social media ──────────────────────────────────────────────────────────
test('can reset a social media property', async ({ page, user }) => {
await page.goto('/settings/user')
const fediverseInput = page.getByRole('textbox', { name: 'Fediverse (e.g. Mastodon)' })
const saved1 = waitForSave(page)
await fediverseInput.fill('@nextcloud@mastodon.social')
await handlePasswordConfirmation(page, user.password)
await saved1
// The server strips the leading '@'
await page.reload()
await expect(fediverseInput).toHaveValue('nextcloud@mastodon.social')
const saved2 = waitForSave(page)
await fediverseInput.clear()
await handlePasswordConfirmation(page, user.password)
await saved2
await page.reload()
await expect(fediverseInput).toHaveValue('')
})
// ── Website ───────────────────────────────────────────────────────────────
test('can set website and change its visibility', async ({ page, user }) => {
await page.goto('/settings/user')
const websiteInput = page.getByRole('textbox', { name: 'Website' })
// HTML5 URL validation: 'foo bar' is not a valid URL
await websiteInput.fill('foo bar')
await expect(websiteInput.and(page.locator(':invalid'))).toHaveCount(1)
const saved = waitForSave(page)
await websiteInput.fill('http://example.com')
await handlePasswordConfirmation(page, user.password)
await saved
await page.reload()
await expect(websiteInput).toHaveValue('http://example.com')
await changeVisibility(page, 'website', Visibility.Private, user.password)
await page.reload()
await expect(page.getByRole('button', { name: /change scope level of website.*private/i })).toBeVisible()
// Change to Local so the URL appears on the public profile
await changeVisibility(page, 'website', Visibility.Local, user.password)
await page.goto(`/u/${user.userId}`)
await expect(page.getByText('http://example.com')).toBeVisible()
})
// ── Generic properties (any value, all visibility levels) ─────────────────
// Each property is tested in its own test so failures are isolated.
const genericProperties = [
{ label: 'Location', scopeProperty: 'location', value: 'Berlin' },
{ label: 'Fediverse (e.g. Mastodon)', scopeProperty: 'fediverse', value: 'nextcloud@mastodon.xyz' },
] as const
for (const { label, scopeProperty, value } of genericProperties) {
test(`can set ${label} and change its visibility`, async ({ page, user }) => {
await page.goto('/settings/user')
const saved = waitForSave(page)
await page.getByRole('textbox', { name: label }).fill(value)
await handlePasswordConfirmation(page, user.password)
await saved
await expect(page.getByRole('textbox', { name: label })).toHaveValue(value)
await expect(
page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*local`, 'i') }),
).toHaveCount(1)
// Cycle Private → Local and verify the final state persists
await changeVisibility(page, scopeProperty, Visibility.Federated, user.password)
await expect(
page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*federated`, 'i') }),
).toBeVisible()
await page.reload()
await expect(
page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*federated`, 'i') }),
).toBeVisible()
await changeVisibility(page, scopeProperty, Visibility.Private, user.password)
await expect(
page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*private`, 'i') }),
).toBeVisible()
// With Local visibility the value appears on the public profile
await page.goto(`/u/${user.userId}`)
await expect(page.getByText(value)).toBeVisible()
})
}
// ── Non-federated properties (Local and Private only) ─────────────────────
const nonfederatedProperties = [
{ label: 'Organisation', scopeProperty: 'organisation' },
{ label: 'Role', scopeProperty: 'role' },
{ label: 'Headline', scopeProperty: 'headline' },
{ label: 'About', scopeProperty: 'about' },
] as const
for (const { label, scopeProperty } of nonfederatedProperties) {
test(`can set ${label} and change its visibility`, async ({ page, user }) => {
// Use a value unique to this property to identify it on the profile page
const uniqueValue = `${label.toUpperCase()} ${label.toLowerCase()}`
await page.goto('/settings/user')
const input = page.getByRole('textbox', { name: label })
const saved = waitForSave(page)
await input.fill(uniqueValue)
await handlePasswordConfirmation(page, user.password)
await saved
await page.reload()
await expect(input).toHaveValue(uniqueValue)
// Toggle Private → Local (the two supported scopes for these properties)
await changeVisibility(page, scopeProperty, Visibility.Private, user.password)
await page.reload()
await expect(
page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*private`, 'i') }),
).toBeVisible()
await changeVisibility(page, scopeProperty, Visibility.Local, user.password)
// With Local visibility the value appears on the public profile
await page.goto(`/u/${user.userId}`)
await expect(page.getByText(uniqueValue)).toBeVisible()
})
}
})

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import { test } from '../../support/fixtures/admin-session.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
test.describe('Settings: Show and hide columns', () => {
test.beforeEach(async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
// Reset: open settings, uncheck all optional columns, re-enable last-login
await settingsPage.openSettingsDialog()
const dialog = settingsPage.settingsDialog()
// Uncheck both optional columns
for (const name of ['Show language', 'Show last login']) {
const checkbox = dialog.getByRole('checkbox', { name })
if (await checkbox.isChecked()) {
await checkbox.uncheck({ force: true })
}
}
// Re-enable last-login so each test starts from a known baseline
await dialog.getByRole('checkbox', { name: 'Show last login' }).check({ force: true })
await settingsPage.closeSettingsDialog()
})
test('can show the Language column', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
// Language column must not be visible before the toggle
await expect(page.getByRole('columnheader', { name: /Language/i })).toHaveCount(0)
await expect(page.locator('[data-cy-user-list-cell-language]').first()).toHaveCount(0)
await settingsPage.openSettingsDialog()
const dialog = settingsPage.settingsDialog()
const checkbox = dialog.getByRole('checkbox', { name: 'Show language' })
await expect(checkbox).not.toBeChecked()
await checkbox.check({ force: true })
await expect(checkbox).toBeChecked()
await settingsPage.closeSettingsDialog()
// Language column header must now be visible
await expect(page.getByRole('columnheader', { name: /Language/i })).toBeVisible()
// Every row must have a language cell
await expect(page.locator('[data-cy-user-list-cell-language]').first()).toBeVisible()
// Reload to verify the preference is persisted (stored in DB, not just localStorage)
await page.evaluate(() => localStorage.clear())
await page.reload()
await expect(page.getByRole('columnheader', { name: /Language/i })).toBeVisible()
})
test('can hide the Last login column', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
// Last login column must be visible (enabled in beforeEach)
await expect(page.getByRole('columnheader', { name: /Last login/i })).toBeVisible()
await expect(page.locator('[data-cy-user-list-cell-last-login]').first()).toBeVisible()
await settingsPage.openSettingsDialog()
const dialog = settingsPage.settingsDialog()
const checkbox = dialog.getByRole('checkbox', { name: 'Show last login' })
await expect(checkbox).toBeChecked()
await checkbox.uncheck({ force: true })
await expect(checkbox).not.toBeChecked()
await settingsPage.closeSettingsDialog()
// Column header must now be gone
await expect(page.getByRole('columnheader', { name: /Last login/i })).toHaveCount(0)
await expect(page.locator('[data-cy-user-list-cell-last-login]').first()).toHaveCount(0)
})
})

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, test as baseTest } from '@playwright/test'
import { type User } from '@nextcloud/e2e-test-server'
import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test as adminTest } from '../../support/fixtures/admin-session.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
const test = adminTest.extend<{ testUser: User }>({
testUser: async ({}, use) => {
const user = await createRandomUser()
await use(user)
await runOcc(['user:delete', user.userId])
},
})
test.describe('Settings: Disable and enable users', () => {
test('can disable a user', async ({ page, testUser }) => {
// Ensure user is enabled
await runOcc(['user:enable', testUser.userId])
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
await settingsPage.openActionsMenu(testUser.userId)
await page.getByRole('menuitem', { name: 'Disable account' }).click()
// User should no longer be in the main list
await expect(settingsPage.userRow(testUser.userId)).toHaveCount(0)
// Disabled accounts nav link should now appear
const disabledLink = settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })
await expect(disabledLink).toBeVisible()
// Navigate to disabled users
await disabledLink.click()
await expect(page).toHaveURL(/\/disabled/)
// The disabled user should be in the list
await settingsPage.userList().waitFor({ state: 'visible' })
await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
})
test('can enable a user', async ({ page, testUser }) => {
// Ensure user is disabled
await runOcc(['user:disable', testUser.userId])
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
// Navigate to disabled users
const disabledLink = settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })
await expect(disabledLink).toBeVisible()
await disabledLink.click()
await expect(page).toHaveURL(/\/disabled/)
await settingsPage.userList().waitFor({ state: 'visible' })
const waitForEnableRequest = page.waitForResponse((r) => r.request().url().match(/\/ocs\/v2\.php\/cloud\/users\/[^/]+\/enable/) !== null)
await settingsPage.openActionsMenu(testUser.userId)
await page.getByRole('menuitem', { name: 'Enable account' }).click()
await waitForEnableRequest
// Disabled accounts section should disappear (no more disabled users)
await expect(settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })).toHaveCount(0)
// After reload, still no disabled accounts section
await page.reload()
await expect(settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })).toHaveCount(0)
})
})

View file

@ -0,0 +1,65 @@
/*
* 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 { expect, test as baseTest } from '@playwright/test'
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
const test = baseTest.extend<{ subadmin: User; group: string }>({
group: async ({}, use) => {
const groupName = crypto.randomUUID()
await runOcc(['group:add', groupName])
await use(groupName)
await runOcc(['group:delete', groupName]).catch(() => {})
},
subadmin: async ({ group, request }, use) => {
const user = await createRandomUser()
await runOcc(['group:adduser', group, user.userId])
// Grant subadmin rights via OCS API authenticated as admin
await request.post(`/ocs/v2.php/cloud/users/${user.userId}/subadmins`, {
headers: {
'OCS-APIRequest': 'true',
Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'),
},
form: { groupid: group },
})
await use(user)
await runOcc(['user:delete', user.userId])
},
})
test.describe('Settings: Create accounts as a group admin', () => {
test('can create a user with the group pre-filled', async ({ page, context, subadmin, group }) => {
// Log in as the subadmin (not as admin)
await login(context.request, subadmin)
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openNewUserDialog()
const dialog = settingsPage.newUserDialog()
// The subadmin's single group must be pre-selected in the groups field.
// NcSelect renders selected values as .vs__selected (no accessible role).
await expect(dialog.locator('.vs__selected').filter({ hasText: group })).toBeVisible()
// Fill in the new user details and submit
const newUserId = crypto.randomUUID()
await dialog.getByLabel(/Account name/).fill(newUserId)
await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123')
await dialog.getByRole('button', { name: 'Add new account' }).click()
await handlePasswordConfirmation(page, subadmin.password)
await dialog.waitFor({ state: 'hidden' })
await expect(settingsPage.userRow(newUserId)).toContainText(newUserId)
await runOcc(['user:delete', newUserId])
})
})

View file

@ -0,0 +1,235 @@
/*
* 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 { expect } from '@playwright/test'
import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test } from '../../support/fixtures/admin-session.ts'
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
// ── Create group ──────────────────────────────────────────────────────────────
test('Account Management: Can create a group', async ({ page }) => {
const groupName = crypto.randomUUID()
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
try {
const createGroupsResponsePromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups($|\?)/)
await page.getByRole('button', { name: 'Create group' }).click()
await page.getByLabel('Group name').fill(groupName)
await page.getByLabel('Group name').press('Enter')
await handlePasswordConfirmation(page)
await createGroupsResponsePromise
await expect(settingsPage.customGroupsList()).toContainText(groupName)
} finally {
await runOcc(['group:delete', groupName]).catch(() => {})
}
})
// ── Assign user to group ──────────────────────────────────────────────────────
const userGroupTest = test.extend<{ testUser: User, testGroup: string }>({
async testUser({}, use) {
const testUser = await createRandomUser()
await use(testUser)
await runOcc(['user:delete', testUser.userId])
},
async testGroup({}, use) {
const testGroup = crypto.randomUUID()
await runOcc(['group:add', testGroup])
await use(testGroup)
await runOcc(['group:delete', testGroup])
},
})
userGroupTest('Account Management: Assign user to a group', async ({ page, testGroup, testUser }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
// group is in the list with no members
await expect(settingsPage.groupListItem(testGroup)).toBeVisible()
// Counter bubble is absent when member count is 0
await expect(settingsPage.groupListItem(testGroup).locator('.counter-bubble__counter')).toHaveCount(0)
// user is in the list
await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
// can assign the group via the edit dialog
await settingsPage.openEditDialog(testUser.userId)
const dialog = settingsPage.editUserDialog()
const groupsCombobox = dialog.getByRole('combobox', { name: /Member of the following groups/i })
const searchRequest = page.waitForResponse((r) => r.request().url().match(new RegExp('/ocs/v2\\.php/cloud/groups/details\\?(.+&|)search=' + testGroup.slice(0, 5))) !== null)
await groupsCombobox.fill(testGroup.slice(0, 5))
await searchRequest
await page.getByRole('option', { name: new RegExp(testGroup.slice(0, 8)) }).click()
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// user is now group now shows 1 member
await expect(settingsPage.groupListItem(testGroup).locator('.counter-bubble__counter')).toHaveText('1')
// backend confirms the user is in the group
const info = JSON.parse(await runOcc(['user:info', '--output=json', testUser.userId]))
expect(info?.groups).toContain(testGroup)
})
// ── Delete an empty group ─────────────────────────────────────────────────────
test.describe('Settings: Delete an empty group', () => {
const groupName = crypto.randomUUID()
test.beforeAll(async () => {
await runOcc(['group:add', groupName])
})
test.afterAll(async () => {
await runOcc(['group:delete', groupName]).catch(() => {})
})
test('can delete an empty group', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
const groupItem = settingsPage.groupListItem(groupName)
await expect(groupItem).toBeVisible()
// Open the group's actions menu
await groupItem.hover()
await expect(groupItem.getByRole('button', { name: /Actions/i })).toBeVisible()
await groupItem.getByRole('button', { name: /Actions/i }).click()
// and delete the group
await page.getByRole('button', { name: 'Delete group' }).click()
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click()
await handlePasswordConfirmation(page)
// Group must be gone from the UI
await expect(settingsPage.groupListItem(groupName)).toHaveCount(0)
// Verify backend
const groups: Record<string, unknown> = JSON.parse(await runOcc(['group:list', '--output=json']))
expect(Object.keys(groups)).not.toContain(groupName)
})
})
// ── Delete a non-empty group ──────────────────────────────────────────────────
test.describe('Settings: Delete a non-empty group', () => {
const groupName = crypto.randomUUID()
let testUser: User
test.beforeAll(async () => {
testUser = await createRandomUser()
await runOcc(['group:add', groupName])
await runOcc(['group:adduser', groupName, testUser.userId])
})
test.afterAll(async () => {
await runOcc(['user:delete', testUser.userId])
await runOcc(['group:delete', groupName]).catch(() => {})
})
test('can delete a non-empty group', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
const groupItem = settingsPage.groupListItem(groupName)
await expect(groupItem).toBeVisible()
// Open the group's actions menu
await groupItem.hover()
expect(groupItem.getByRole('button', { name: /Actions/i })).toBeVisible()
await groupItem.getByRole('button', { name: /Actions/i }).click()
// and delete the group
await page.getByRole('button', { name: 'Delete group' }).click()
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click()
await handlePasswordConfirmation(page)
await expect(settingsPage.groupListItem(groupName)).toHaveCount(0)
const groups: Record<string, unknown> = JSON.parse(await runOcc(['group:list', '--output=json']))
expect(Object.keys(groups)).not.toContain(groupName)
})
})
// ── Sort groups ───────────────────────────────────────────────────────────────
const sortGroupsTest = test.extend<{ testUser: User, testGroups: [string, string] }>({
async testGroups({ testUser }, use) {
const suffix = crypto.randomUUID().slice(0, 8)
const groupA = `A-${suffix}`
const groupB = `B-${suffix}`
await runOcc(['group:add', groupA])
await runOcc(['group:add', groupB])
await runOcc(['group:adduser', groupB, testUser.userId])
await use([groupA, groupB])
await runOcc(['group:delete', groupA]).catch(() => {})
await runOcc(['group:delete', groupB]).catch(() => {})
},
testUser: async ({}, use) => {
const testUser = await createRandomUser()
await use(testUser)
await runOcc(['user:delete', testUser.userId])
},
})
sortGroupsTest('Settings: Sort groups by member count and then by name', async ({ page, testGroups }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
// ── sort by member count ──
await settingsPage.openSettingsDialog()
await settingsPage.settingsDialog()
.getByRole('radio', { name: 'By member count' })
.check({ force: true })
await settingsPage.closeSettingsDialog()
// B (1 member) must come before A (0 members)
await checkGroupOrder([testGroups[1], testGroups[0]], settingsPage)
// Reload to confirm persistence
await page.reload()
await checkGroupOrder([testGroups[1], testGroups[0]], settingsPage)
// ── sort by name ──
await settingsPage.openSettingsDialog()
await settingsPage.settingsDialog().getByRole('radio', { name: 'By name' }).check({ force: true })
await settingsPage.closeSettingsDialog()
// A comes before B alphabetically
await checkGroupOrder([testGroups[0], testGroups[1]], settingsPage)
// Reload to confirm persistence
await page.reload()
await checkGroupOrder([testGroups[0], testGroups[1]], settingsPage)
})
/**
* Check that the groups are in the expected order in the UI.
*
* @param order - The expected group order
* @param settingsPage - The settings page
*/
async function checkGroupOrder(order: string[], settingsPage: SettingsUsersPage) {
// B (1 member) must come before A (0 members)
const listItems = settingsPage.customGroupsList().getByRole('listitem')
for (const group of order) {
await expect(listItems.filter({ hasText: group })).toHaveCount(1)
}
const contents = (await listItems.allTextContents())
.map((text) => text.trim().replaceAll(/\s+.*/g, '')) // trim and remove member count
.filter((text) => order.includes(text)) // filter out other groups that might be in the list
expect(contents).toEqual(order)
}

View file

@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import { type User } from '@nextcloud/e2e-test-server'
import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test as adminTest } from '../../support/fixtures/admin-session.ts'
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
const test = adminTest.extend<{ user: User; manager: User }>({
user: async ({}, use) => {
const u = await createRandomUser()
await use(u)
await runOcc(['user:delete', u.userId])
},
manager: async ({}, use) => {
const u = await createRandomUser()
await use(u)
await runOcc(['user:delete', u.userId])
},
})
test.describe('Settings: User Manager Management', () => {
test('can assign a manager through the edit dialog', async ({ page, user, manager }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
const managerCombobox = dialog.getByRole('combobox', { name: /Manager/i })
await managerCombobox.fill(manager.userId)
await page.getByRole('option', { name: manager.userId }).click()
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify via OCS API (page shares admin auth cookies)
const response = await page.request.get(
`/ocs/v2.php/cloud/users/${user.userId}`,
{ headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } },
)
const data = await response.json()
expect(data?.ocs?.data?.manager).toBe(manager.userId)
})
test('can remove a manager through the edit dialog', async ({ page, user, manager }) => {
// Set manager via OCC first
await runOcc([
'user:setting', user.userId, 'settings', 'manager',
`["${manager.userId}"]`,
])
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
// Clear the currently-set manager using the NcSelect's clear button
await dialog.getByRole('button', { name: /Clear Selected/i }).click()
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify backend: manager must be empty
const response = await page.request.get(
`/ocs/v2.php/cloud/users/${user.userId}`,
{ headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } },
)
const data = await response.json()
expect(data?.ocs?.data?.manager).toBeFalsy()
})
})

View file

@ -0,0 +1,165 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import { type User } from '@nextcloud/e2e-test-server'
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test as adminTest } from '../../support/fixtures/admin-session.ts'
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
const test = adminTest.extend<{ user: User }>({
user: async ({}, use) => {
const user = await createRandomUser()
await use(user)
await runOcc(['user:delete', user.userId])
},
})
test.describe('Settings: Change user properties', () => {
test('can change the display name', async ({ page, user }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
const displayNameInput = dialog.getByLabel('Display name')
await expect(displayNameInput).toHaveValue(user.userId)
await displayNameInput.fill('John Doe')
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify backend
const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
expect(info?.display_name).toBe('John Doe')
})
test('can change the password', async ({ page, user, context }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
const passwordInput = dialog.getByLabel(/New password/i).and(page.locator('input')) // hack because there is no accessible role for input fields with type=password
await expect(passwordInput).toHaveValue('')
await passwordInput.fill('newpassword123')
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify by logging in with the new password
await login(context.request, { ...user, password: 'newpassword123' })
await page.goto('/apps/dashboard')
await expect(page).toHaveURL(/\/apps\/dashboard/)
})
test('can change the email address', async ({ page, user }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
const emailInput = dialog.getByLabel(/Email/)
await expect(emailInput).toHaveValue('')
await emailInput.fill('mymail@example.com')
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify backend
const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
expect(info?.email).toBe('mymail@example.com')
})
test('can change the user quota to a predefined value', async ({ page, user }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
// Open the Quota NcSelect and choose 5 GB
const quotaCombobox = dialog.getByRole('combobox', { name: /Quota/i })
await quotaCombobox.click()
await page.getByRole('option', { name: '5 GB' }).click()
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify backend
const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
expect(info?.quota).toBe('5 GB')
})
test('can change the user quota to a custom value', async ({ page, user }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
// Type a custom value directly into the combobox
const quotaCombobox = dialog.getByRole('combobox', { name: /Quota/i })
await quotaCombobox.fill('4 MB')
await quotaCombobox.press('Enter')
await handlePasswordConfirmation(page)
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify backend (stored as bytes)
const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
expect(info?.quota).not.toBe('none')
})
test('can make user a subadmin of a group', async ({ page, user }) => {
const groupName = crypto.randomUUID().slice(0, 6)
const shortName = groupName.slice(0, 4)
await runOcc(['group:add', groupName])
try {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openEditDialog(user.userId)
const dialog = settingsPage.editUserDialog()
// Open the subadmin NcSelect and pick the group
const subadminCombobox = dialog.getByRole('combobox', { name: /Admin of the following groups/i })
await subadminCombobox.click()
const waitForSearch = page
.waitForResponse((r) => r.request().url().includes(`ocs/v2.php/cloud/groups/details?search=${shortName}`))
await subadminCombobox.fill(shortName)
await waitForSearch
await page.getByRole('option', { name: new RegExp(groupName) }).click()
await settingsPage.saveEditDialog()
await expect(page.getByText(/Account updated/i)).toBeVisible()
// Verify backend via OCS API (page shares admin auth state)
const response = await page.request.get(
`/ocs/v2.php/cloud/users/${user.userId}/subadmins`,
{ headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } },
)
const data = await response.json()
expect(data?.ocs?.data).toContain(groupName)
} finally {
await runOcc(['group:delete', groupName])
}
})
})

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import { type User } from '@nextcloud/e2e-test-server'
import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test as adminTest } from '../../support/fixtures/admin-session.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
adminTest.describe.configure({ mode: 'serial' })
adminTest.describe('Settings: Unified search for accounts and groups', () => {
// Stable, searchable prefix so we can match the group independently of the random suffix
const matchingGroup = `zzz-match-${crypto.randomUUID().slice(0, 5)}`
const otherGroup = `aaa-other-${crypto.randomUUID().slice(0, 5)}`
let alice: User
let bob: User
adminTest.beforeAll(async () => {
alice = await createRandomUser()
bob = await createRandomUser()
await runOcc(['group:add', matchingGroup])
await runOcc(['group:add', otherGroup])
})
adminTest.afterAll(async () => {
await runOcc(['user:delete', alice.userId])
await runOcc(['user:delete', bob.userId])
await runOcc(['group:delete', matchingGroup])
await runOcc(['group:delete', otherGroup])
})
adminTest('shows the search input in the navigation sidebar', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
await expect(searchbox).toBeVisible()
await expect(searchbox).toHaveValue('')
})
adminTest('dispatches the query to both the users and groups API', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
const usersRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/users\/details/)
const groupsRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
await searchbox.fill(alice.userId)
const usersResp = await usersRespPromise
const groupsResp = await groupsRespPromise
expect(new URL(usersResp.url()).searchParams.get('search')).toBe(alice.userId)
expect(new URL(groupsResp.url()).searchParams.get('search')).toBe(alice.userId)
// User list reflects the filtered result
await expect(settingsPage.userRow(alice.userId)).toBeVisible()
await expect(settingsPage.userList()).not.toContainText(bob.userId)
})
adminTest('filters the group list when the query matches a group name', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
const groupsRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
await searchbox.fill(matchingGroup)
const groupsResp = await groupsRespPromise
expect(new URL(groupsResp.url()).searchParams.get('search')).toBe(matchingGroup)
await expect(settingsPage.customGroupsList()).toContainText(matchingGroup)
await expect(settingsPage.customGroupsList()).not.toContainText(otherGroup)
})
adminTest('resets both lists when the clear button is clicked', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
// Prime the search box with a term first
const primeUsersPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/users\/details/)
const primeGroupsPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
await searchbox.fill(alice.userId)
await primeUsersPromise
await primeGroupsPromise
// Now clear
const usersRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/users\/details/)
const groupsRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
await settingsPage.navigation().getByRole('button', { name: /clear search/i }).click()
await usersRespPromise
await groupsRespPromise
await expect(searchbox).toHaveValue('')
// Both users and both groups must be visible again
await expect(settingsPage.userRow(alice.userId)).toBeVisible()
await expect(settingsPage.userRow(bob.userId)).toBeVisible()
await expect(settingsPage.customGroupsList()).toContainText(matchingGroup)
await expect(settingsPage.customGroupsList()).toContainText(otherGroup)
})
})

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test } from '../../support/fixtures/admin-session.ts'
import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
test.describe('Settings: Create and delete accounts', () => {
test('can create a user with username and password', async ({ page }) => {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openNewUserDialog()
const dialog = settingsPage.newUserDialog()
await dialog.getByLabel(/Account name/).fill('newuser-basic')
await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123')
await dialog.getByRole('button', { name: 'Add new account' }).click()
await handlePasswordConfirmation(page)
await dialog.waitFor({ state: 'hidden' })
await expect(settingsPage.userRow('newuser-basic')).toContainText('newuser-basic')
await runOcc(['user:delete', 'newuser-basic'])
})
test('can create a user with display name and email', async ({ page }) => {
const newUserId = crypto.randomUUID()
try {
const settingsPage = new SettingsUsersPage(page)
await settingsPage.open()
await settingsPage.openNewUserDialog()
const dialog = settingsPage.newUserDialog()
await dialog.getByLabel(/Account name/).fill(newUserId)
await dialog.getByLabel('Display name').fill('John Smith')
await dialog.getByLabel(/Email/).fill('john@example.org')
await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123')
await dialog.getByRole('button', { name: 'Add new account' }).click()
await handlePasswordConfirmation(page)
await dialog.waitFor({ state: 'hidden' })
await expect(settingsPage.userRow(newUserId)).toContainText(newUserId)
} finally {
await runOcc(['user:delete', newUserId])
}
})
test('can delete a user', async ({ page }) => {
const testUser = await createRandomUser()
const settingsPage = new SettingsUsersPage(page)
try {
await settingsPage.open()
await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
await settingsPage.openActionsMenu(testUser.userId)
await page.getByRole('menuitem', { name: 'Delete account' }).click()
await handlePasswordConfirmation(page)
// Confirm the deletion in the confirmation dialog
await page.getByRole('dialog').getByRole('button', { name: `Delete ${testUser.userId}` }).click()
await expect(settingsPage.userRow(testUser.userId)).toHaveCount(0)
} finally {
await runOcc(['user:delete', testUser.userId]).catch(() => {})
}
})
})

View file

@ -49,13 +49,19 @@ async function start() {
await configureNextcloud()
process.stdout.write('\nApply custom configuration for Playwright tests\n')
await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");'])
process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n')
await runOcc(['config:system:set', 'cache_app_config', '--value', 'false', '--type', 'boolean'])
process.stdout.write('├─ Disabled caching AppConfig\n') // otherwise test setup using OCC will need to wait 3s so that web cache TTL expires
await runOcc(['config:system:set', 'appstoreenabled', '--value', 'false', '--type', 'boolean'])
process.stdout.write('├─ Disabled app store\n')
// createRandomUser() generates short passwords that the policy would reject
await runOcc(['app:disable', 'password_policy'])
process.stdout.write('├─ Disabled password policy for random test users\n')
await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");'])
process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n')
process.stdout.write('├─ Initialize cron job...\n')
await runExec(['php', 'cron.php'])
process.stdout.write('│ └─ OK !\n')

View file

@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, type Locator, type Page } from '@playwright/test'
import { handlePasswordConfirmation } from '../utils/password-confirmation'
/**
* Page object for the Admin Users Management page (/settings/users).
*
* Selector strategy:
* - Prefer role / label / text selectors.
* - `data-cy-user-row` and `data-cy-user-list` are the only data-attribute
* selectors used the virtual-scroll list and individual rows have no
* semantic ARIA alternative.
* - `data-cy-users-settings-navigation-groups` is used for the custom groups
* list because the list has no distinct accessible name.
*/
export class SettingsUsersPage {
constructor(private readonly page: Page) {}
async open(): Promise<void> {
await this.page.goto('/settings/users')
await this.userList().waitFor({ state: 'visible' })
}
// ── Sidebar navigation ──────────────────────────────────────────────────
navigation(): Locator {
return this.page.getByRole('navigation', { name: 'Account management' })
}
/** Click a named link in the account management sidebar. */
async navigateTo(name: string | RegExp): Promise<void> {
await this.navigation().getByRole('link', { name }).click()
}
/** The custom groups section in the sidebar navigation. */
customGroupsList(): Locator {
return this.page.locator('[data-cy-users-settings-navigation-groups="custom"]')
}
groupListItem(groupName: string): Locator {
return this.customGroupsList().getByRole('listitem').filter({ hasText: groupName })
}
// ── User list ────────────────────────────────────────────────────────────
userList(): Locator {
return this.page.locator('[data-cy-user-list]')
}
userRow(userId: string): Locator {
return this.page.locator(`[data-cy-user-row="${userId}"]`)
}
// ── Dialogs ──────────────────────────────────────────────────────────────
/** Open the "New account" dialog and wait for it to appear. */
async openNewUserDialog(): Promise<void> {
await this.page.getByRole('navigation')
.getByRole('button', { name: 'New account' })
.click()
await this.newUserDialog().waitFor({ state: 'visible' })
}
newUserDialog(): Locator {
return this.page.getByRole('dialog', { name: 'New account' })
}
/** Open the edit dialog for `userId` by clicking its inline Edit button. */
async openEditDialog(userId: string): Promise<void> {
await this.userRow(userId).getByRole('button', { name: 'Edit' }).click()
await this.editUserDialog().waitFor({ state: 'visible' })
}
editUserDialog(): Locator {
return this.page.getByRole('dialog', { name: 'Edit account' })
}
/** Save and close the currently open edit dialog. */
async saveEditDialog(): Promise<void> {
const dialog = this.editUserDialog()
const button = dialog.getByRole('button', { name: 'Save' })
await button.focus()
await button.click({ force: true })
await handlePasswordConfirmation(this.page)
await dialog.waitFor({ state: 'hidden' })
}
/** Open the actions dropdown for `userId`. */
async openActionsMenu(userId: string): Promise<void> {
const button = this.userRow(userId).getByRole('button', { name: 'Toggle account actions menu' })
await button.click()
await expect(button).toHaveAttribute('aria-controls')
await expect(this.page.getByRole('menu').and(this.page.locator('#' + await button.getAttribute('aria-controls')))).toBeVisible()
}
/** Open the "Account management settings" dialog. */
async openSettingsDialog(): Promise<void> {
await this.page.getByRole('button', { name: 'Account management settings' }).click()
await this.settingsDialog().waitFor({ state: 'visible' })
}
settingsDialog(): Locator {
return this.page.getByRole('dialog', { name: 'Account management settings' })
}
/** Close the "Account management settings" dialog. */
async closeSettingsDialog(): Promise<void> {
await this.settingsDialog().getByRole('button', { name: 'Close' }).click()
await this.settingsDialog().waitFor({ state: 'hidden' })
}
}