mirror of
https://github.com/nextcloud/server.git
synced 2026-06-13 18:50:47 -04:00
Merge pull request #61225 from nextcloud/test/pw-login
test(login): migrate end-to-end tests to PlayWright
This commit is contained in:
commit
a0a8ee5144
8 changed files with 306 additions and 381 deletions
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(\/|$)/)
|
||||
})
|
||||
})
|
||||
52
tests/playwright/e2e/login/login-redirect.spec.ts
Normal file
52
tests/playwright/e2e/login/login-redirect.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
87
tests/playwright/e2e/login/login.spec.ts
Normal file
87
tests/playwright/e2e/login/login.spec.ts
Normal 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($|\?)/)
|
||||
})
|
||||
})
|
||||
130
tests/playwright/e2e/login/webauth.spec.ts
Normal file
130
tests/playwright/e2e/login/webauth.spec.ts
Normal 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(\/|$)/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
33
tests/playwright/support/sections/LoginPage.ts
Normal file
33
tests/playwright/support/sections/LoginPage.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue