From ad582b460acaaa3199ad3b3f6189c7a90c139cd9 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Wed, 27 May 2026 10:03:29 +0300 Subject: [PATCH] playwright: centralize web-vault selectors into shared setup helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite had several locator patterns scattered across helpers and specs; changes to the bundled web vault would require touching N call sites for each. This commit funnels them into shared helpers so the next web-vault update only touches one place per pattern. `setups/user.ts` additions: - `openAvatarMenu(page, userName)` — header avatar menu open, anchored on the user's display name (`{ exact: true }` to avoid cipher-name substring matches). - `fillNewMasterPassword(page, password)` — registration / MP-change form's `newPassword` + `newPasswordConfirm` `formcontrolname` inputs (the three labels containing "Master password" make label-based locators ambiguous). - `submitMasterPasswordVerification(page, mp)` — the in-dialog `app-user-verification` master-password gate (the `` inside any sensitive-operation dialog: 2FA enrol/disable, passkey enrol/remove, key rotation, KDF change). Presses Enter on the input to avoid the multi-`Continue`-button ambiguity that the current bundled vault renders. - `createAccount` switched to `fillNewMasterPassword`. `setups/2fa.ts` additions + refactor: - `gotoTwoStepLogin(page, userName)` — Settings → Security → Two-step login navigation, used by every 2FA enrol/disable function. - `clickTwoFactorProviderManage(page, providerLabel)` — `bit-item` provider row → Manage button. Accepts string or RegExp for the row's hasText. - `activateTOTP` / `disableTOTP` / `activateEmail` / `disableEmail` all rewritten to use the new helpers, removing the inline duplication. `setups/sso.ts:logNewUser` — uses `fillNewMasterPassword`. `organization.smtp.spec.ts` and `sso_organization.smtp.spec.ts` invited-with- new-account flows — use `fillNewMasterPassword`. Incidental fixes spotted while refactoring: - `disableTOTP` / `disableEmail` previously had `getByRole('button', { name: 'Test' })` hardcoded for the avatar menu — broke for any user not named "Test". Now `openAvatarMenu(page, user.name)`, parameterised. - `activateTOTP` declared its return type as `: OTPAuth.TOTP` (an async function actually returns `Promise`); `retrieveEmailCode` similarly declared `: string` instead of `: Promise`. Both fixed. Post-refactor scatter check (rg-confirmed): - `formcontrolname="newPassword"` outside setups/user.ts: 0 - `input#masterPassword` outside setups/user.ts: 0 - `bit-item` provider-row pattern outside setups/2fa.ts: 0 - Avatar-menu via `name: user.name` outside setups/user.ts: 0 --- playwright/tests/organization.smtp.spec.ts | 10 +-- playwright/tests/setups/2fa.ts | 76 +++++++++---------- playwright/tests/setups/sso.ts | 9 +-- playwright/tests/setups/user.ts | 57 ++++++++++++-- .../tests/sso_organization.smtp.spec.ts | 7 +- 5 files changed, 94 insertions(+), 65 deletions(-) diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 81bed33e..91e33d31 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -3,7 +3,7 @@ import { MailDev } from 'maildev'; import * as utils from '../global-utils'; import * as orgs from './setups/orgs'; -import { createAccount, logUser } from './setups/user'; +import { createAccount, fillNewMasterPassword, logUser } from './setups/user'; let users = utils.loadEnv(); @@ -57,13 +57,7 @@ test('invited with new account', async ({ page }) => { await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); //await page.getByLabel('Name').fill(users.user2.name); - // Three labels match "Master password" via Playwright's case-insensitive - // substring matching ("Master password (required)", "Confirm master - // password (required)", "Master password hint"), so anchor by - // formcontrolname — same pattern as setups/user.ts:createAccount and - // setups/sso.ts:logNewUser. - await page.locator('input[formcontrolname="newPassword"]').fill(users.user2.password); - await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(users.user2.password); + await fillNewMasterPassword(page, users.user2.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created'); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index 60b1ae54..56d20e97 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -3,20 +3,34 @@ import { type MailBuffer } from 'maildev'; import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; +import { openAvatarMenu, submitMasterPasswordVerification } from './user'; -export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { +/** + * Navigate to the two-step-login provider list under Settings → Security. + * Centralised here so a future web-vault nav restructure only touches one + * call chain. + */ +export async function gotoTwoStepLogin(page: Page, userName: string) { + await openAvatarMenu(page, userName); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); +} + +/** + * Click the "Manage" button on a 2FA provider row identified by a substring + * of its label (e.g. /Authenticator app/, /Passkey/, 'Email'). The Manage + * dialog typically opens with the user-verification gate as its first step. + */ +export async function clickTwoFactorProviderManage(page: Page, providerLabel: string | RegExp) { + await page.locator('bit-item').filter({ hasText: providerLabel }).first().getByRole('button').first().click(); +} + +export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): Promise { return await test.step('Activate TOTP 2FA', async () => { - await page.getByRole('button', { name: user.name }).click(); - await page.getByRole('menuitem', { name: 'Account settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Two-step login' }).click(); - await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); - const mpInput = page.getByLabel('Master password'); - await mpInput.fill(user.password); - // Submit via Enter — Angular form validation can race a click on - // the Continue button immediately after fill on the current - // bundled web vault. - await mpInput.press('Enter'); + await gotoTwoStepLogin(page, user.name); + await clickTwoFactorProviderManage(page, /Authenticator app/); + await submitMasterPasswordVerification(page, user.password); // `getByLabel('Key')` alone is ambiguous: the providers list also // has a Yubico SVG with aria-label "Yubico OTP security key" that @@ -37,16 +51,11 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, }) } -export async function disableTOTP(test: Test, page: Page, user: { password: string }) { +export async function disableTOTP(test: Test, page: Page, user: { name: string, password: string }) { await test.step('Disable TOTP 2FA', async () => { - await page.getByRole('button', { name: 'Test' }).click(); - await page.getByRole('menuitem', { name: 'Account settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Two-step login' }).click(); - await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); - const mpInput = page.getByLabel('Master password'); - await mpInput.fill(user.password); - await mpInput.press('Enter'); + await gotoTwoStepLogin(page, user.name); + await clickTwoFactorProviderManage(page, /Authenticator app/); + await submitMasterPasswordVerification(page, user.password); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); await utils.checkNotification(page, 'Two-step login provider turned off'); @@ -55,13 +64,9 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) { await test.step('Activate Email 2FA', async () => { - await page.getByRole('button', { name: user.name }).click(); - await page.getByRole('menuitem', { name: 'Account settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Two-step login' }).click(); - await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click(); - await page.getByLabel('Master password').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); + await gotoTwoStepLogin(page, user.name); + await clickTwoFactorProviderManage(page, 'Enter a code sent to your email'); + await submitMasterPasswordVerification(page, user.password); await page.getByRole('button', { name: 'Send email' }).click(); }); @@ -74,7 +79,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string }); } -export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { +export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): Promise { return await test.step('retrieve code', async () => { const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); const page2 = await page.context().newPage(); @@ -85,16 +90,11 @@ export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: Mail }); } -export async function disableEmail(test: Test, page: Page, user: { password: string }) { +export async function disableEmail(test: Test, page: Page, user: { name: string, password: string }) { await test.step('Disable Email 2FA', async () => { - await page.getByRole('button', { name: 'Test' }).click(); - await page.getByRole('menuitem', { name: 'Account settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Two-step login' }).click(); - await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); - await page.getByLabel('Master password').click(); - await page.getByLabel('Master password').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); + await gotoTwoStepLogin(page, user.name); + await clickTwoFactorProviderManage(page, 'Email'); + await submitMasterPasswordVerification(page, user.password); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts index 16f9ef41..5600a854 100644 --- a/playwright/tests/setups/sso.ts +++ b/playwright/tests/setups/sso.ts @@ -4,6 +4,7 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { retrieveEmailCode } from './2fa'; +import { fillNewMasterPassword } from './user'; /** * If a MailBuffer is passed it will be used and consume the expected emails @@ -33,13 +34,7 @@ export async function logNewUser( await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - // Three labels on this form match "Master password" via Playwright's - // case-insensitive substring matching ("Master password (required)", - // "Confirm master password (required)", "Master password hint"), so - // anchor by formcontrolname (the pattern setups/user.ts:createAccount - // also uses for the same reason). - await page.locator('input[formcontrolname="newPassword"]').fill(user.password); - await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(user.password); + await fillNewMasterPassword(page, user.password); await page.getByRole('button', { name: 'Create account' }).click(); }); diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index f83ee2d8..0adc2f93 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -4,6 +4,55 @@ import { type MailBuffer } from 'maildev'; import * as utils from '../../global-utils'; +/** + * Open the account/avatar menu in the web vault header. The button's + * accessible name is the user's display name; centralising the locator here + * insulates callers from web-vault changes to that element's structure. + * + * Note: cipher rows also expose `aria-haspopup="menu"` ellipsis buttons, so + * naïve `aria-haspopup` selectors mis-target on the vault page. Anchor on the + * accessible-name (`{ exact: true }` to avoid substring matches against any + * cipher whose name happens to start with the user's display name). + */ +export async function openAvatarMenu(page: Page, userName: string) { + await page.getByRole('button', { name: userName, exact: true }).click(); +} + +/** + * Fill the registration / change-master-password form's "new" + "confirm new" + * master-password fields. Anchored by `formcontrolname` rather than label — + * the current bundled web-vault renders three labels matching "Master + * password" via Playwright's case-insensitive substring matching ("Master + * password (required)", "Confirm master password (required)", and "Master + * password hint"), so a label-based locator is ambiguous. + */ +export async function fillNewMasterPassword(page: Page, password: string) { + await page.locator('input[formcontrolname="newPassword"]').fill(password); + await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(password); +} + +/** + * Submit the in-dialog user-verification (`app-user-verification`) master- + * password gate that the bundled web vault renders before any sensitive + * operation (2FA enrol/disable, passkey enrol/remove, key rotation, KDF + * change). + * + * Pressing Enter inside the password input submits the form unambiguously — + * the surrounding page often has multiple `Continue` buttons (dialog action, + * stale settings header), so a button-text click is brittle. + * + * Note: the user-verification component falls back to email-OTP verification + * when the master password isn't "fresh" in the current session (e.g. after a + * passkey login). Callers reaching this helper from a post-passkey-login + * state must arrange a recent MP entry first. + */ +export async function submitMasterPasswordVerification(page: Page, masterPassword: string) { + const mpInput = page.locator('input#masterPassword'); + await mpInput.waitFor({ state: 'visible' }); + await mpInput.fill(masterPassword); + await mpInput.press('Enter'); +} + export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { await test.step(`Create user ${user.name}`, async () => { await utils.cleanLanding(page); @@ -16,13 +65,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam await page.getByLabel('Name').fill(user.name); await page.getByRole('button', { name: 'Continue' }).click(); - // Vault finish Creation. The current bundled web-vault renders the - // required field's label as "Master password\n(required)", so a bare - // substring match for "Master password" is ambiguous with the - // "Master password hint" label on the same page. Anchor to the - // password input by its formcontrolname instead. - await page.locator('input[formcontrolname="newPassword"]').fill(user.password); - await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(user.password); + await fillNewMasterPassword(page, user.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created') diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts index 6c074967..68709c5c 100644 --- a/playwright/tests/sso_organization.smtp.spec.ts +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -4,6 +4,7 @@ import { MailDev } from 'maildev'; import * as utils from "../global-utils"; import * as orgs from './setups/orgs'; import { logNewUser, logUser } from './setups/sso'; +import { fillNewMasterPassword } from './setups/user'; let users = utils.loadEnv(); @@ -67,11 +68,7 @@ test('invited with new account', async ({ page }) => { await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - // Three labels match "Master password" via Playwright's case-insensitive - // substring matching, so anchor by formcontrolname — same pattern as - // setups/sso.ts:logNewUser. - await page.locator('input[formcontrolname="newPassword"]').fill(users.user2.password); - await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(users.user2.password); + await fillNewMasterPassword(page, users.user2.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Account successfully created!');