diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index fd57e3a056125..cf40b1c390335 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -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: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index dfb673edadb3f..84bd2ba12cfbc 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -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 }} diff --git a/cypress/e2e/settings/access-levels.cy.ts b/cypress/e2e/settings/access-levels.cy.ts deleted file mode 100644 index 2a3699e985431..0000000000000 --- a/cypress/e2e/settings/access-levels.cy.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts deleted file mode 100644 index 068af94aec9cb..0000000000000 --- a/cypress/e2e/settings/personal-info.cy.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/cypress/e2e/settings/users-group-admin.cy.ts b/cypress/e2e/settings/users-group-admin.cy.ts deleted file mode 100644 index 68dddac2e053f..0000000000000 --- a/cypress/e2e/settings/users-group-admin.cy.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/cypress/e2e/settings/users.cy.ts b/cypress/e2e/settings/users.cy.ts deleted file mode 100644 index 615455bc30282..0000000000000 --- a/cypress/e2e/settings/users.cy.ts +++ /dev/null @@ -1,131 +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 { 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') - }) - }) -}) diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts deleted file mode 100644 index ad7db65c4f8c1..0000000000000 --- a/cypress/e2e/settings/users_columns.cy.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts deleted file mode 100644 index 23b0397aa9908..0000000000000 --- a/cypress/e2e/settings/users_disable.cy.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts deleted file mode 100644 index b8738115a0e07..0000000000000 --- a/cypress/e2e/settings/users_groups.cy.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/cypress/e2e/settings/users_manager.cy.ts b/cypress/e2e/settings/users_manager.cy.ts deleted file mode 100644 index 68b453b1c9b9d..0000000000000 --- a/cypress/e2e/settings/users_manager.cy.ts +++ /dev/null @@ -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.userId}`) - }) - }) - - 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.userId}`) - expect($result.body).to.contain('') - }) - }) -}) diff --git a/cypress/e2e/settings/users_modify.cy.ts b/cypress/e2e/settings/users_modify.cy.ts deleted file mode 100644 index 7a3147124d5b0..0000000000000 --- a/cypress/e2e/settings/users_modify.cy.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { 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) - }) - }) -}) diff --git a/cypress/e2e/settings/users_search.cy.ts b/cypress/e2e/settings/users_search.cy.ts deleted file mode 100644 index 3c2bbc5f57a92..0000000000000 --- a/cypress/e2e/settings/users_search.cy.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 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 { 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) - }) -}) diff --git a/tests/playwright/e2e/core/header-contacts-menu.spec.ts b/tests/playwright/e2e/core/header-contacts-menu.spec.ts index 04eedcb9492eb..f11f3bc2d259e 100644 --- a/tests/playwright/e2e/core/header-contacts-menu.spec.ts +++ b/tests/playwright/e2e/core/header-contacts-menu.spec.ts @@ -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() diff --git a/tests/playwright/e2e/settings/access-levels.spec.ts b/tests/playwright/e2e/settings/access-levels.spec.ts new file mode 100644 index 0000000000000..528f675e3a8ba --- /dev/null +++ b/tests/playwright/e2e/settings/access-levels.spec.ts @@ -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() + }) +}) diff --git a/tests/playwright/e2e/settings/personal-info.spec.ts b/tests/playwright/e2e/settings/personal-info.spec.ts new file mode 100644 index 0000000000000..71aac5d3856bc --- /dev/null +++ b/tests/playwright/e2e/settings/personal-info.spec.ts @@ -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 { + 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 { + 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 ) + 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() + }) + } +}) diff --git a/tests/playwright/e2e/users/users-columns.spec.ts b/tests/playwright/e2e/users/users-columns.spec.ts new file mode 100644 index 0000000000000..8df06216d2a95 --- /dev/null +++ b/tests/playwright/e2e/users/users-columns.spec.ts @@ -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) + }) +}) diff --git a/tests/playwright/e2e/users/users-disable.spec.ts b/tests/playwright/e2e/users/users-disable.spec.ts new file mode 100644 index 0000000000000..aa50a701b4372 --- /dev/null +++ b/tests/playwright/e2e/users/users-disable.spec.ts @@ -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) + }) +}) diff --git a/tests/playwright/e2e/users/users-group-admin.spec.ts b/tests/playwright/e2e/users/users-group-admin.spec.ts new file mode 100644 index 0000000000000..fd4e38e703d1a --- /dev/null +++ b/tests/playwright/e2e/users/users-group-admin.spec.ts @@ -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]) + }) +}) diff --git a/tests/playwright/e2e/users/users-groups.spec.ts b/tests/playwright/e2e/users/users-groups.spec.ts new file mode 100644 index 0000000000000..6340022a98def --- /dev/null +++ b/tests/playwright/e2e/users/users-groups.spec.ts @@ -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 = 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 = 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) +} diff --git a/tests/playwright/e2e/users/users-manager.spec.ts b/tests/playwright/e2e/users/users-manager.spec.ts new file mode 100644 index 0000000000000..87835f640a307 --- /dev/null +++ b/tests/playwright/e2e/users/users-manager.spec.ts @@ -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() + }) +}) diff --git a/tests/playwright/e2e/users/users-modify.spec.ts b/tests/playwright/e2e/users/users-modify.spec.ts new file mode 100644 index 0000000000000..f82ede2d31bba --- /dev/null +++ b/tests/playwright/e2e/users/users-modify.spec.ts @@ -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]) + } + }) +}) diff --git a/tests/playwright/e2e/users/users-search.spec.ts b/tests/playwright/e2e/users/users-search.spec.ts new file mode 100644 index 0000000000000..a2640ba006f8c --- /dev/null +++ b/tests/playwright/e2e/users/users-search.spec.ts @@ -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) + }) +}) diff --git a/tests/playwright/e2e/users/users.spec.ts b/tests/playwright/e2e/users/users.spec.ts new file mode 100644 index 0000000000000..73f8a8d0c8804 --- /dev/null +++ b/tests/playwright/e2e/users/users.spec.ts @@ -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(() => {}) + } + }) +}) diff --git a/tests/playwright/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js index ac81788f692b8..bf4c9d54ea3e8 100644 --- a/tests/playwright/start-nextcloud-server.js +++ b/tests/playwright/start-nextcloud-server.js @@ -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') diff --git a/tests/playwright/support/sections/SettingsUsersPage.ts b/tests/playwright/support/sections/SettingsUsersPage.ts new file mode 100644 index 0000000000000..ab2a67f9015a4 --- /dev/null +++ b/tests/playwright/support/sections/SettingsUsersPage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.settingsDialog().getByRole('button', { name: 'Close' }).click() + await this.settingsDialog().waitFor({ state: 'hidden' }) + } +}