Browse Source

playwright: centralize web-vault selectors into shared setup helpers

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 `<input id="masterPassword">`
  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<OTPAuth.TOTP>`); `retrieveEmailCode`
  similarly declared `: string` instead of `: Promise<string>`. 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
pull/7248/head
Zaid Marji 3 weeks ago
parent
commit
ad582b460a
  1. 10
      playwright/tests/organization.smtp.spec.ts
  2. 76
      playwright/tests/setups/2fa.ts
  3. 9
      playwright/tests/setups/sso.ts
  4. 57
      playwright/tests/setups/user.ts
  5. 7
      playwright/tests/sso_organization.smtp.spec.ts

10
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');

76
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<OTPAuth.TOTP> {
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<string> {
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();

9
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();
});

57
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')

7
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!');

Loading…
Cancel
Save