Merge pull request #61225 from nextcloud/test/pw-login

test(login): migrate end-to-end tests to PlayWright
This commit is contained in:
Ferdinand Thiessen 2026-06-12 16:29:42 +02:00 committed by GitHub
commit a0a8ee5144
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 306 additions and 381 deletions

View file

@ -1,60 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
* Test that when a session expires / the user logged out in another tab,
* the user gets redirected to the login on the next request.
*/
describe('Logout redirect ', { testIsolation: true }, () => {
let user
before(() => {
cy.createRandomUser()
.then(($user) => {
user = $user
})
})
it('Redirects to login if session timed out', () => {
// Login and see settings
cy.login(user)
cy.visit('/settings/user#profile')
cy.findByRole('checkbox', { name: /Enable profile/i })
.should('exist')
// clear session
cy.clearAllCookies()
// trigger an request
cy.findByRole('checkbox', { name: /Enable profile/i })
.click({ force: true })
// See that we are redirected
cy.url()
.should('match', /\/login/i)
.and('include', `?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`)
cy.get('form[name="login"]').should('be.visible')
})
it('Redirect from login works', () => {
cy.logout()
// visit the login
cy.visit(`/login?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`)
// see login
cy.get('form[name="login"]').should('be.visible')
cy.get('form[name="login"]').within(() => {
cy.get('input[name="user"]').type(user.userId)
cy.get('input[name="password"]').type(user.password)
cy.contains('button[data-login-form-submit]', 'Log in').click()
})
// see that we are correctly redirected
cy.url().should('include', '/index.php/settings/user#profile')
cy.findByRole('checkbox', { name: /Enable profile/i })
.should('exist')
})
})

View file

@ -1,153 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts'
describe('Login', () => {
let user: User
let disabledUser: User
after(() => cy.deleteUser(user))
before(() => {
// disable brute force protection
cy.runOccCommand('config:system:set auth.bruteforce.protection.enabled --value false --type bool')
cy.createRandomUser().then(($user) => {
user = $user
})
cy.createRandomUser().then(($user) => {
disabledUser = $user
cy.runOccCommand(`user:disable '${disabledUser.userId}'`)
})
})
beforeEach(() => {
cy.logout()
})
it('log in with valid account and password', () => {
// Given I visit the Home page
cy.visit('/')
// I see the login page
cy.get('form[name="login"]').should('be.visible')
// I log in with a valid account
cy.get('form[name="login"]').within(() => {
cy.get('input[name="user"]').type(user.userId)
cy.get('input[name="password"]').type(user.password)
cy.contains('button[data-login-form-submit]', 'Log in').click()
})
// see that the login is done
cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
// Then I see that the current page is the Files app
cy.url().should('match', /apps\/dashboard(\/|$)/)
})
it('try to log in with valid account and invalid password', () => {
// Given I visit the Home page
cy.visit('/')
// I see the login page
cy.get('form[name="login"]').should('be.visible')
// I log in with a valid account but invalid password
cy.get('form[name="login"]').within(() => {
cy.get('input[name="user"]').type(user.userId)
cy.get('input[name="password"]').type(`${user.password}--wrong`)
cy.contains('button', 'Log in').click()
})
// see that the login is done
cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
// Then I see that the current page is the Login page
cy.url().should('match', /\/login/)
// And I see that a wrong password message is shown
cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i))
cy.get('input[name="password"]:invalid').should('exist')
})
it('try to log in with valid account and invalid password', () => {
// Given I visit the Home page
cy.visit('/')
// I see the login page
cy.get('form[name="login"]').should('be.visible')
// I log in with a valid account but invalid password
cy.get('form[name="login"]').within(() => {
cy.get('input[name="user"]').type(user.userId)
cy.get('input[name="password"]').type(`${user.password}--wrong`)
cy.contains('button', 'Log in').click()
})
// see that the login is done
cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
// Then I see that the current page is the Login page
cy.url().should('match', /\/login/)
// And I see that a wrong password message is shown
cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/))
cy.get('input[name="password"]:invalid').should('exist')
})
it('try to log in with invalid account', () => {
// Given I visit the Home page
cy.visit('/')
// I see the login page
cy.get('form[name="login"]').should('be.visible')
// I log in with an invalid user but valid password
cy.get('form[name="login"]').within(() => {
cy.get('input[name="user"]').type(`${user.userId}--wrong`)
cy.get('input[name="password"]').type(user.password)
cy.contains('button', 'Log in').click()
})
// see that the login is done
cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
// Then I see that the current page is the Login page
cy.url().should('match', /\/login/)
// And I see that a wrong password message is shown
cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/))
cy.get('input[name="password"]:invalid').should('exist')
})
it('try to log in as disabled account', () => {
// Given I visit the Home page
cy.visit('/')
// I see the login page
cy.get('form[name="login"]').should('be.visible')
// When I log in with user disabledUser and password
cy.get('form[name="login"]').within(() => {
cy.get('input[name="user"]').type(disabledUser.userId)
cy.get('input[name="password"]').type(disabledUser.password)
cy.contains('button', 'Log in').click()
})
// see that the login is done
cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in')
// Then I see that the current page is the Login page
cy.url().should('match', /\/login/)
// And I see that the disabled account message is shown
cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Account.+disabled/i))
cy.get('input[name="password"]:invalid').should('exist')
})
it('try to logout', () => {
cy.login(user)
// Given I visit the Home page
cy.visit('/')
// I see the dashboard
cy.url().should('match', /apps\/dashboard(\/|$)/)
// When click logout
getNextcloudUserMenuToggle().should('exist').click()
getNextcloudUserMenu().contains('a', 'Log out').click()
// Then I see that the current page is the Login page
cy.url().should('match', /\/login/)
})
})

View file

@ -1,152 +0,0 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
interface IChromeVirtualAuthenticator {
authenticatorId: string
}
/**
* Create a virtual authenticator using chrome debug protocol
*/
async function createAuthenticator(): Promise<IChromeVirtualAuthenticator> {
await Cypress.automation('remote:debugger:protocol', {
command: 'WebAuthn.enable',
})
const authenticator = await Cypress.automation('remote:debugger:protocol', {
command: 'WebAuthn.addVirtualAuthenticator',
params: {
options: {
protocol: 'ctap2',
ctap2Version: 'ctap2_1',
hasUserVerification: true,
transport: 'usb',
automaticPresenceSimulation: true,
isUserVerified: true,
},
},
})
return authenticator
}
/**
* Delete a virtual authenticator using chrome devbug protocol
*
* @param authenticator the authenticator object
*/
async function deleteAuthenticator(authenticator: IChromeVirtualAuthenticator) {
await Cypress.automation('remote:debugger:protocol', {
command: 'WebAuthn.removeVirtualAuthenticator',
params: {
...authenticator,
},
})
}
describe('Login using WebAuthn', () => {
let authenticator: IChromeVirtualAuthenticator
let user: User
afterEach(() => {
cy.deleteUser(user)
.then(() => deleteAuthenticator(authenticator))
})
beforeEach(() => {
cy.createRandomUser()
.then(($user) => {
user = $user
cy.login(user)
})
.then(() => createAuthenticator())
.then(($authenticator) => {
authenticator = $authenticator
cy.log('Created virtual authenticator')
})
})
it('add and delete WebAuthn', () => {
cy.intercept('**/settings/api/personal/webauthn/registration').as('webauthn')
cy.visit('/settings/user/security')
cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
cy.findByRole('button', { name: /Add WebAuthn device/i })
.should('be.visible')
.click()
cy.wait('@webauthn')
cy.findByRole('textbox', { name: /Device name/i })
.should('be.visible')
.type('test device{enter}')
cy.wait('@webauthn')
cy.contains('[role="note"]', /No devices configured/i).should('not.exist')
cy.findByRole('list', { name: /following devices are configured for your account/i })
.should('be.visible')
.contains('li', 'test device')
.should('be.visible')
.findByRole('button', { name: /Actions/i })
.click()
cy.findByRole('menuitem', { name: /Delete/i })
.should('be.visible')
.click()
cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
cy.findByRole('list', { name: /following devices are configured for your account/i })
.should('not.exist')
cy.reload()
cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
})
it('add WebAuthn and login', () => {
cy.intercept('GET', '**/settings/api/personal/webauthn/registration').as('webauthnSetupInit')
cy.intercept('POST', '**/settings/api/personal/webauthn/registration').as('webauthnSetupDone')
cy.intercept('POST', '**/login/webauthn/start').as('webauthnLogin')
cy.visit('/settings/user/security')
cy.findByRole('button', { name: /Add WebAuthn device/i })
.should('be.visible')
.click()
cy.wait('@webauthnSetupInit')
cy.findByRole('textbox', { name: /Device name/i })
.should('be.visible')
.type('test device{enter}')
cy.wait('@webauthnSetupDone')
cy.findByRole('list', { name: /following devices are configured for your account/i })
.should('be.visible')
.findByText('test device')
.should('be.visible')
cy.logout()
cy.visit('/login')
cy.findByRole('button', { name: /Log in with a device/i })
.should('be.visible')
.click()
cy.findByRole('form', { name: /Log in with a device/i })
.should('be.visible')
.findByRole('textbox', { name: /Login or email/i })
.should('be.visible')
.type(`{selectAll}${user.userId}`)
cy.findByRole('button', { name: /Log in/i })
.click()
cy.wait('@webauthnLogin')
// Then I see that the current page is the Files app
cy.url().should('match', /apps\/dashboard(\/|$)/)
})
})

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, test } from '@playwright/test'
import { User } from '@nextcloud/e2e-test-server'
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { LoginPage } from '../../support/sections/LoginPage.ts'
test.describe('Login: Redirect', () => {
let user: User
test.beforeAll(async () => {
user = await createRandomUser()
})
test.afterAll(async () => {
await runOcc(['user:delete', user.userId])
})
test('redirects to login with redirect_url when session expires', async ({ page, context }) => {
await login(context.request, user)
await page.goto('/settings/user#profile')
// Wait for the profile settings checkbox to confirm the page has loaded
await expect(page.getByRole('checkbox', { name: /Enable profile/i })).toBeVisible()
// Simulate session expiry by clearing all cookies
await context.clearCookies()
// Clicking the checkbox triggers an authenticated request that returns 302 to login
await page.getByRole('checkbox', { name: /Enable profile/i }).click({ force: true })
await expect(page).toHaveURL(/\/login/i)
await expect(page).toHaveURL(/redirect_url=/)
})
test('redirect_url parameter redirects to the original page after login', async ({ page }) => {
const redirectTarget = 'settings/user#profile'
await page.goto(redirectTarget)
await expect(page).toHaveURL(new RegExp(`/login\\?redirect_url=(\/index.php\/)?${redirectTarget}`))
const loginPage = new LoginPage(page)
await expect(loginPage.usernameInput()).toBeVisible()
await loginPage.login(user.userId, user.password)
await expect(page).toHaveURL(/\/settings\/user/)
await expect(page.getByRole('checkbox', { name: /Enable profile/i })).toBeVisible()
})
})

View file

@ -0,0 +1,87 @@
/*
* 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 { User } from '@nextcloud/e2e-test-server'
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts'
import { LoginPage } from '../../support/sections/LoginPage.ts'
const test = baseTest.extend<{
user: User
disabledUser: User
}>({
user: async ({}, use) => {
const user = await createRandomUser()
await use(user)
await runOcc(['user:delete', user.userId])
},
disabledUser: async ({}, use) => {
const user = await createRandomUser()
await runOcc(['user:disable', user.userId])
await use(user)
await runOcc(['user:delete', user.userId])
},
})
test.describe('Login', () => {
test.beforeAll(async () => {
await runOcc(['config:system:set', 'auth.bruteforce.protection.enabled', '--value', 'false', '--type', 'bool'])
})
test.afterAll(async () => {
await runOcc(['config:system:delete', 'auth.bruteforce.protection.enabled'])
})
test('successful login lands on the dashboard', async ({ page, user }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login(user.userId, user.password)
await expect(page).toHaveURL(/apps\/dashboard(\/|$)/)
})
test('wrong password shows error and marks password field invalid', async ({ page, user }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login(user.userId, `${user.password}--wrong`)
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText(/Wrong login or password/i)).toBeVisible()
await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1)
})
test('wrong account name shows error and marks password field invalid', async ({ page, user }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login(`${user.userId}--wrong`, user.password)
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText(/Wrong login or password/i)).toBeVisible()
await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1)
})
test('disabled account shows disabled error', async ({ page, disabledUser }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login(disabledUser.userId, disabledUser.password)
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText(/Account.*disabled/i)).toBeVisible()
await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1)
})
test('logout redirects to the login page', async ({ page, context, user }) => {
await login(context.request, user)
await page.goto('/')
const accountMenu = new AccountMenuPage(page)
await accountMenu.open()
await accountMenu.entry('Log out').getByRole('link').click()
await expect(page).toHaveURL(/\/login($|\?)/)
})
})

View file

@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, test, type BrowserContext } from '@playwright/test'
import { 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'
test.describe('Login: WebAuthn', () => {
test.skip(({ browserName }) => browserName !== 'chromium', 'WebAuthn emulator only is supported in Chromium-based browsers')
let user: User
let cdpSession: Awaited<ReturnType<BrowserContext['newCDPSession']>>
let authenticatorId: string
test.beforeEach(async ({ page, context }) => {
user = await createRandomUser()
await login(context.request, user)
cdpSession = await page.context().newCDPSession(page)
await cdpSession.send('WebAuthn.enable', { enableUI: false })
const result = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
ctap2Version: 'ctap2_1',
hasUserVerification: true,
transport: 'usb',
automaticPresenceSimulation: true,
isUserVerified: true,
},
})
authenticatorId = result.authenticatorId
})
test.afterEach(async () => {
await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId })
await runOcc(['user:delete', user.userId])
})
test('add and delete a WebAuthn device', async ({ page }) => {
const registrationChallenge = page.waitForResponse(
(r) => r.url().includes('/settings/api/personal/webauthn/registration'),
)
await page.goto('/settings/user/security')
const securitySection = page.locator('#security-webauthn')
await expect(
securitySection.getByRole('note').filter({ hasText: /No devices configured/i }),
).toBeVisible()
await page.getByRole('button', { name: /Add WebAuthn device/i }).click()
await handlePasswordConfirmation(page, user.password)
await registrationChallenge
const deviceNameInput = page.getByLabel('Device name')
await expect(deviceNameInput).toBeVisible()
const registrationComplete = page.waitForResponse(
(r) => r.url().includes('/settings/api/personal/webauthn/registration'),
)
await deviceNameInput.fill('test device')
await deviceNameInput.press('Enter')
await registrationComplete
const deviceList = page.getByRole('list', { name: /following devices/i })
await expect(deviceList).toBeVisible()
const deviceItem = deviceList.getByRole('listitem').filter({ hasText: 'test device' })
await expect(deviceItem).toBeVisible()
await deviceItem.getByRole('button', { name: 'Actions' }).click()
await handlePasswordConfirmation(page, user.password)
await page.getByRole('menuitem', { name: 'Delete' }).click()
await handlePasswordConfirmation(page, user.password)
await expect(
securitySection.getByRole('note').filter({ hasText: /No devices configured/i }),
).toBeVisible()
await expect(deviceList).toHaveCount(0)
await page.reload()
await expect(
securitySection.getByRole('note').filter({ hasText: /No devices configured/i }),
).toBeVisible()
})
test('add a WebAuthn device and use it to log in', async ({ page, context }) => {
const registrationChallenge = page.waitForResponse(
(r) => r.url().includes('/settings/api/personal/webauthn/registration') && r.request().method() === 'GET',
)
await page.goto('/settings/user/security')
await page.getByRole('button', { name: /Add WebAuthn device/i }).click()
await handlePasswordConfirmation(page, user.password)
await registrationChallenge
const registrationComplete = page.waitForResponse(
(r) => r.url().includes('/settings/api/personal/webauthn/registration') && r.request().method() === 'POST',
)
const deviceNameInput = page.getByLabel('Device name')
await deviceNameInput.fill('test device')
await deviceNameInput.press('Enter')
await registrationComplete
const deviceList = page.getByRole('list', { name: /following devices/i })
await expect(deviceList.getByRole('listitem').filter({ hasText: 'test device' })).toBeVisible()
// Log out and return to the login page
await context.clearCookies()
await page.goto('/login')
// Switch to passwordless login form
await page.getByRole('button', { name: /Log in with a device/i }).click()
const passwordlessForm = page.getByRole('form', { name: /Log in with a device/i })
await expect(passwordlessForm).toBeVisible()
await passwordlessForm.getByLabel('Login or email').fill(user.userId)
const webauthnLogin = page.waitForResponse(
(r) => r.url().includes('/login/webauthn/start') && r.request().method() === 'POST',
)
await page.getByRole('button', { name: 'Log in' }).click()
await webauthnLogin
await expect(page).toHaveURL(/apps\/dashboard(\/|$)/)
})
})

View file

@ -17,39 +17,27 @@ export class AccountMenuPage {
constructor(private readonly page: Page) {}
private trigger(): Locator {
// NcHeaderMenu trigger button inside the #user-menu nav.
// The button's accessible name includes the dynamic avatar description
// ("Avatar of {displayName} — {status}"), so we scope by the stable
// container ID and the BEM class instead — the same approach used in
// NavigationHeaderPage for the waffle button.
return this.page.locator('header#header').locator('#user-menu .header-menu__trigger')
return this.page.getByRole('button', { name: 'Settings menu' })
}
private panel(): Locator {
// NcHeaderMenu generates a content div with id="header-menu-{id}".
return this.page.locator('#header-menu-user-menu')
}
/** Open the settings menu and wait until the panel is visible. */
async open(): Promise<void> {
const isOpen = await this.trigger().getAttribute('aria-expanded') === 'true'
if (!isOpen) await this.trigger().click()
if (!isOpen) {
await this.trigger().click()
}
await this.panel().waitFor({ state: 'visible' })
}
/**
* All <li> entries currently shown in the panel.
* Use with toHaveCount() to assert the total number of menu items.
*/
entries(): Locator {
return this.panel().getByRole('listitem')
}
/**
* A single entry matched by visible text.
* Uses filter({ hasText }) so it works for both the primary name and
* the subname slot (e.g. the profile entry whose link label is the
* user's display name, while "View profile" appears as a subname).
*/
entry(name: string): Locator {
return this.panel().getByRole('listitem').filter({ hasText: name })

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Locator, Page } from '@playwright/test'
export class LoginPage {
constructor(private readonly page: Page) {}
usernameInput(): Locator {
// Label is "Account name" or "Account name or email" depending on config
return this.page.getByLabel(/Account name/)
}
passwordInput(): Locator {
return this.page.getByLabel('Password').and(this.page.locator('input'))
}
submitButton(): Locator {
return this.page.getByRole('button', { name: 'Log in', exact: true })
}
async goto(): Promise<void> {
await this.page.goto('/login')
}
async login(userId: string, password: string): Promise<void> {
await this.usernameInput().fill(userId)
await this.passwordInput().fill(password)
await this.submitButton().click()
}
}