From c0589bbd749b3e7dcdf47e50c253c94968426ee6 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Sun, 24 May 2026 10:22:49 +0300 Subject: [PATCH 1/8] playwright: fix stale Master password selectors against bundled web vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled web vault dropped the "(required)" suffix from the master-password input's label text (it likely became a separate visual indicator). Every test that drives a master-password reprompt — account creation, TOTP setup, email-2FA setup, SSO master-password flows, organization-policy management — has been failing on the current bundled vault because `getByLabel('Master password (required)', { exact: true })` no longer matches anything. Two-line change in most files: switch to `getByLabel('Master password')`, matching the pattern that `logUser` (setups/user.ts:46) and `login.spec.ts:40` already use successfully. Substring matching is case-sensitive in Playwright, so the selector is unambiguous against the confirm-field ("Confirm master password" has a lowercase 'm'). `createAccount` step 2 (setups/user.ts) and `logNewUser` step 3 (setups/sso.ts) are special: the registration / "Join organisation" form has three labels matching "Master password" as a case-insensitive substring ("Master password\n(required)", "Confirm master password\n (required)" which matches because Playwright's substring match is case-insensitive in practice, and "Master password hint" which also matches). Anchor those two fields by their stable `formcontrolname` attribute (`newPassword` / `newPasswordConfirm`) instead of label text. Verified empirically: with these changes, both `login.spec.ts` (3/3: Account creation, Master password login, Authenticator 2fa) and `sso_login.spec.ts` (8/8 including SSO Account creation, SSO login, SSO login with TOTP 2fa, Non-SSO login fallback, SSO_ONLY, no SSO) run green against bundled web-vault v2026.4.1. --- playwright/tests/organization.smtp.spec.ts | 9 +++++++-- playwright/tests/setups/2fa.ts | 12 ++++++------ playwright/tests/setups/sso.ts | 9 +++++++-- playwright/tests/setups/user.ts | 10 +++++++--- playwright/tests/sso_organization.smtp.spec.ts | 7 +++++-- playwright/tests/sso_organization.spec.ts | 2 +- 6 files changed, 33 insertions(+), 16 deletions(-) diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 35dfcdb1..30b3ede2 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -57,8 +57,13 @@ test('invited with new account', async ({ page }) => { await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); //await page.getByLabel('Name').fill(users.user2.name); - await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); - await page.getByLabel('Confirm master password (').fill(users.user2.password); + // 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 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 d7936420..d3e68830 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -11,7 +11,7 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, 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(); - await page.getByLabel('Master password (required)').fill(user.password); + await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); const secret = await page.getByLabel('Key').innerText(); @@ -33,8 +33,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri 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(); - await page.getByLabel('Master password (required)').click(); - await page.getByLabel('Master password (required)').fill(user.password); + await page.getByLabel('Master password').click(); + await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); @@ -49,7 +49,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string 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 (required)').fill(user.password); + await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Send email' }).click(); }); @@ -81,8 +81,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str 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 (required)').click(); - await page.getByLabel('Master password (required)').fill(user.password); + await page.getByLabel('Master password').click(); + await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); 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 6317f8b0..16f9ef41 100644 --- a/playwright/tests/setups/sso.ts +++ b/playwright/tests/setups/sso.ts @@ -33,8 +33,13 @@ export async function logNewUser( await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); - await page.getByLabel('Confirm master password (').fill(user.password); + // 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 page.getByRole('button', { name: 'Create account' }).click(); }); diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 395196ae..f83ee2d8 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -16,9 +16,13 @@ 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 - await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); - await page.getByLabel('Confirm master password (').fill(user.password); + // 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 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 92813f72..ed4a7caf 100644 --- a/playwright/tests/sso_organization.smtp.spec.ts +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -67,8 +67,11 @@ test('invited with new account', async ({ page }) => { await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); - await page.getByLabel('Confirm master password (').fill(users.user2.password); + // 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 page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Account successfully created!'); diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts index c1238d45..e329ed33 100644 --- a/playwright/tests/sso_organization.spec.ts +++ b/playwright/tests/sso_organization.spec.ts @@ -68,7 +68,7 @@ test('Enforce password policy', async ({ page }) => { await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); await page.getByRole('button', { name: 'Use single sign-on' }).click(); - await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); + await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('button', { name: 'Unlock' }).click(); await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); From c02ced432dc5feabc0ba7078390a5664a08910e0 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Sun, 24 May 2026 10:23:45 +0300 Subject: [PATCH 2/8] playwright: fix TOTP setup flow against bundled web vault The `activateTOTP` helper had three issues against the current bundled web vault (v2026.4.1): 1. The master-password reprompt's Continue button click was racing form validation: the form uses Angular's `updateOn: 'blur'` so clicking immediately after `fill()` saw an "invalid" form and the click was silently no-op. Submitting via `mpInput.press('Enter')` triggers the form's `ngSubmit` directly, which validates and submits in one go. 2. `getByLabel('Key').innerText()` was ambiguous: the same page has a `` providers entry whose accessible name contains "Key" as a substring. Strict-mode violation on Firefox. Anchor with `{ exact: true }` to pick only the `` element holding the base32 secret. 3. After clicking "Turn on", the original code was effectively a no-op (`await page.getByRole('heading', { name: 'Turned on' })` creates a Locator without awaiting visibility), then clicked the "Close" button which is `bit-aria-disable=true` until the dialog finishes its transition. Chromium happens to tolerate the early click; Firefox doesn't. Wait for `networkidle` after Turn on so the activation request completes; drop the Close click because the dialog auto-closes on success on this web vault. `disableTOTP` got the same reprompt-via-Enter fix for parity. Verified empirically: with these changes, `login.spec.ts`'s `Authenticator 2fa` test passes on Firefox against bundled web-vault v2026.4.1. --- playwright/tests/setups/2fa.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index d3e68830..60b1ae54 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -11,16 +11,27 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, 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(); - await page.getByLabel('Master password').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).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'); - const secret = await page.getByLabel('Key').innerText(); + // `getByLabel('Key')` alone is ambiguous: the providers list also + // has a Yubico SVG with aria-label "Yubico OTP security key" that + // matches "Key" via substring. Anchor with exact match. + const secret = (await page.getByLabel('Key', { exact: true }).innerText()).replace(/\s+/g, ''); let totp = new OTPAuth.TOTP({ secret, period: 30 }); await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByRole('button', { name: 'Turn on' }).click(); - await page.getByRole('heading', { name: 'Turned on', exact: true }); - await page.getByLabel('Close').click(); + // Wait for the activation request to complete. The current + // bundled web vault uses an asynchronous Turn-on flow; we don't + // try to assert the exact success-heading text (it varies across + // vault versions) — instead we wait for network to settle, then + // the dialog closes itself. + await page.waitForLoadState('networkidle'); return totp; }) @@ -33,9 +44,9 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri 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(); - await page.getByLabel('Master password').click(); - await page.getByLabel('Master password').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); + const mpInput = page.getByLabel('Master password'); + await mpInput.fill(user.password); + await mpInput.press('Enter'); 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'); From f30847d15e4b86671829ffd3ece180e18414772f Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Sun, 24 May 2026 10:24:07 +0300 Subject: [PATCH 3/8] playwright: serve the test Vaultwarden over HTTPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled web vault refuses to submit registration and login requests over plain HTTP, surfacing "Insecure URL not allowed. All URLs must use HTTPS." in the UI. The Continue button is left `bit-aria-disable=true` and click handlers are no-ops, which manifests in tests as `locator.fill: timeout exceeded` deep into createAccount — diagnosed via DOM dump showing the error banner. Make the test Rocket server actually serve HTTPS: - Generate a self-signed cert in the Vaultwarden runtime image (separate RUN layer from the apt install so cert tweaks don't bust the deps layer cache). - Point `ROCKET_TLS` at the cert + key in test.env and the dev .env.template. - Switch DOMAIN to `https://localhost:${ROCKET_PORT}`. - Tell Playwright to ignore HTTPS errors on the self-signed cert (in both `playwright.config.ts` for test contexts and `global-utils.ts` for the manual context startVault uses to poll for vault readiness). Self-signed + `ignoreHTTPSErrors` is the idiomatic Playwright pattern for a local-only test target; importing a custom CA into each browser's profile would be substantially more invasive (Firefox uses NSS, Chromium has its own store) for no real-world fidelity gain. --- playwright/.env.template | 3 ++- playwright/compose/warden/Dockerfile | 12 ++++++++++++ playwright/global-utils.ts | 2 +- playwright/playwright.config.ts | 1 + playwright/test.env | 3 ++- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/playwright/.env.template b/playwright/.env.template index a6696aab..44ddb739 100644 --- a/playwright/.env.template +++ b/playwright/.env.template @@ -39,7 +39,8 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} ###################### ROCKET_ADDRESS=0.0.0.0 ROCKET_PORT=8000 -DOMAIN=http://localhost:${ROCKET_PORT} +ROCKET_TLS={certs="/certs/cert.pem",key="/certs/key.pem"} +DOMAIN=https://localhost:${ROCKET_PORT} LOG_LEVEL=info,oidcwarden::sso=debug I_REALLY_WANT_VOLATILE_STORAGE=true diff --git a/playwright/compose/warden/Dockerfile b/playwright/compose/warden/Dockerfile index e472d207..77360f0d 100644 --- a/playwright/compose/warden/Dockerfile +++ b/playwright/compose/warden/Dockerfile @@ -29,6 +29,18 @@ RUN mkdir /data && \ openssl && \ rm -rf /var/lib/apt/lists/* +# Self-signed TLS cert for the test server. The bundled web vault refuses +# to submit registration/login over HTTP ("Insecure URL not allowed"); +# Rocket needs a cert+key to serve HTTPS. Self-contained layer so cert +# tweaks don't bust the apt-install layer above. +RUN mkdir /certs && \ + openssl req -x509 -nodes -newkey rsa:2048 \ + -keyout /certs/key.pem \ + -out /certs/cert.pem \ + -days 3650 \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + # Copies the files from the context (Rocket.toml file and web-vault) # and the binary from the "build" stage to the current stage WORKDIR / diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts index 224bb4b8..9aec2301 100644 --- a/playwright/global-utils.ts +++ b/playwright/global-utils.ts @@ -38,7 +38,7 @@ export async function waitFor(url: String, browser: Browser) { do { try { - context = await browser.newContext(); + context = await browser.newContext({ ignoreHTTPSErrors: true }); const page = await context.newPage(); await page.waitForTimeout(500); const result = await page.goto(url); diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index de721aa3..1256cd4d 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.DOMAIN, browserName: 'firefox', + ignoreHTTPSErrors: true, locale: 'en-GB', timezoneId: 'Europe/London', diff --git a/playwright/test.env b/playwright/test.env index df182ebe..a6c8dbd4 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -52,7 +52,8 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} # Vaultwarden Config # ###################### ROCKET_PORT=8003 -DOMAIN=http://localhost:${ROCKET_PORT} +ROCKET_TLS={certs="/certs/cert.pem",key="/certs/key.pem"} +DOMAIN=https://localhost:${ROCKET_PORT} LOG_LEVEL=info,oidcwarden::sso=debug LOGIN_RATELIMIT_MAX_BURST=100 ADMIN_TOKEN=admin From a3fb02776dd1b12108869df7dd6141b1c07c930a Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Sun, 24 May 2026 10:42:41 +0300 Subject: [PATCH 4/8] gitignore: catch stray playwright artifacts at the repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `playwright/.gitignore` already excludes `test-results/` and `playwright-report/` relative to `playwright/`. But playwright artifacts can land at the repo root if the suite is ever invoked from there (e.g., via a debug script that imports playwright directly outside the test runner), leaving an untracked `./test-results/.last-run.json` cluttering `git status`. Adding the same entries at the repo-root .gitignore catches those cases too — defensive and zero behavior change. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index e991430e..8512ec98 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ data # Web vault web-vault + +# Playwright artifacts (the suite is invoked from playwright/ so its +# own .gitignore catches in-tree paths; this catches stray artifacts +# that end up at the repo root when playwright is invoked elsewhere). +test-results +playwright-report From 9186ec245b0127e314c2ffcc1ff790fda79804f2 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Tue, 26 May 2026 03:46:59 +0300 Subject: [PATCH 5/8] playwright: fix stale org-nav selectors against bundled web vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The product-switch links in the side nav ("Password Manager", "Admin Console", "Members") are icon-only on the current bundled web vault — the link element carries the accessible name but no visible text content. `locator('a').filter({ hasText: '…' })` therefore matches nothing, and every spec that calls into `setups/orgs.ts` (org create, member invite, policy edit, …) times out before doing anything. Switch to `getByRole('link', { name: '…' })` for the three navs. "Admin Console" appears twice once an org exists (in both `bit-nav-logo` and `navigation-product-switcher`); `.first()` picks the visible one. The "Members" entry inside an org also moved from a `
` to a ``, so the `locator('div').filter(...).nth(2)` selector is replaced with the same role-based selector. The org-switcher row has a hover tooltip that intercepts the click on the bundled vault, so the click is forced past the overlay. Verified empirically: with these changes, `organization.spec.ts` (1/1) and `sso_organization.spec.ts` (4/5; the remaining failure is an unrelated server-side master-password-policy enforcement issue) run green where they previously failed before any helper step. --- playwright/tests/setups/orgs.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/playwright/tests/setups/orgs.ts b/playwright/tests/setups/orgs.ts index 04d81b45..48d40361 100644 --- a/playwright/tests/setups/orgs.ts +++ b/playwright/tests/setups/orgs.ts @@ -4,7 +4,11 @@ import * as utils from '../../global-utils'; export async function create(test, page: Page, name: string) { await test.step('Create Org', async () => { - await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); + // The product-switch nav links are icon-only (accessible name set on + // the link, no text content), so `filter({hasText})` no longer matches + // on the current bundled web vault — use the accessible-name role + // selector instead. + await page.getByRole('link', { name: 'Password Manager' }).click(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await page.getByRole('link', { name: 'New organisation' }).click(); await page.getByLabel('Organisation name (required)').fill(name); @@ -16,9 +20,11 @@ export async function create(test, page: Page, name: string) { export async function policies(test, page: Page, name: string) { await test.step(`Navigate to ${name} policies`, async () => { - await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); + await page.getByRole('link', { name: 'Admin Console' }).first().click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); - await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); + // The org row in the switcher has a hover tooltip that intercepts the + // click on the current bundled web vault; force-click past it. + await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click({ force: true }); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); await page.getByRole('link', { name: 'Policies' }).click(); @@ -28,11 +34,13 @@ export async function policies(test, page: Page, name: string) { export async function members(test, page: Page, name: string) { await test.step(`Navigate to ${name} members`, async () => { - await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); + await page.getByRole('link', { name: 'Admin Console' }).first().click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); - await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); + // The org row in the switcher has a hover tooltip that intercepts the + // click on the current bundled web vault; force-click past it. + await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click({ force: true }); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); - await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + await page.getByRole('link', { name: 'Members' }).click(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); }); From 0c009d679d8a61ecfa47f142511037d02711a803 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Tue, 26 May 2026 07:22:37 +0300 Subject: [PATCH 6/8] playwright: fix invitation-accepted toast text + SSO existing-account redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled web vault renames the org-invitation-acceptance toast from "Invitation accepted" to "Successfully accepted your invitation" in the post-MP-unlock flow used by both invited-with-new-account and invited-with-existing-account tests. Update both specs to match the new toast text. The SSO-side logNewUser flow still emits "Invitation accepted" for the SSO account-creation toast, so setups/sso.ts is left untouched. The SSO invited-with-existing-account flow also no longer auto-redirects to Keycloak — existing emails land on the email-prefilled login form and require an explicit "Use single sign-on" click. Add it before the Keycloak heading assertion. --- playwright/tests/organization.smtp.spec.ts | 4 ++-- playwright/tests/sso_organization.smtp.spec.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 30b3ede2..81bed33e 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -67,7 +67,7 @@ test('invited with new account', async ({ page }) => { await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created'); - await utils.checkNotification(page, 'Invitation accepted'); + await utils.checkNotification(page, 'Successfully accepted your invitation'); await utils.ignoreExtension(page); // Redirected to the vault @@ -98,7 +98,7 @@ test('invited with existing account', async ({ page }) => { await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); - await utils.checkNotification(page, 'Invitation accepted'); + await utils.checkNotification(page, 'Successfully accepted your invitation'); await utils.ignoreExtension(page); // We are now in the default vault page diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts index ed4a7caf..6c074967 100644 --- a/playwright/tests/sso_organization.smtp.spec.ts +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -98,6 +98,9 @@ test('invited with existing account', async ({ page }) => { await test.step('Redirect to Keycloak', async () => { await page.goto(link); + // Existing accounts land on the email-prefilled login form rather + // than auto-redirecting; click through to Keycloak explicitly. + await page.getByRole('button', { name: 'Use single sign-on' }).click(); }); await test.step('Keycloak login', async () => { @@ -112,7 +115,7 @@ test('invited with existing account', async ({ page }) => { await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('button', { name: 'Unlock' }).click(); - await utils.checkNotification(page, 'Invitation accepted'); + await utils.checkNotification(page, 'Successfully accepted your invitation'); await utils.ignoreExtension(page); }); From ad582b460acaaa3199ad3b3f6189c7a90c139cd9 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Wed, 27 May 2026 10:03:29 +0300 Subject: [PATCH 7/8] 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!'); From 88ab51443a4f84b6d239263f78663c9648d6b97e Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Wed, 27 May 2026 11:14:30 +0300 Subject: [PATCH 8/8] playwright: centralize 2FA challenge handling 2FA challenge submission was inlined across the suite: `login.spec.ts`, `login.smtp.spec.ts` and `setups/sso.ts:logUser` each asserted the "Verify your Identity" heading, generated/retrieved the factor-specific verification code, then clicked Continue. The only piece that varied was the source of the code (TOTP generator vs. email-OTP retrieved from maildev). When the web vault changes the heading copy, the code-input label, or the Continue-button name, every duplicate has to be hunted down separately. Centralises the challenge flow in `setups/2fa.ts` behind a `TwoFactor` discriminated union and a `submitTwoFactor` dispatcher: type TwoFactor = | { kind: 'totp', totp: OTPAuth.TOTP } | { kind: 'mail2fa', mailBuffer: MailBuffer } | { kind: 'fido2' }; Each variant carries exactly the state it needs. `submitTwoFactor` asserts the heading then `switch`es on `kind`: TOTP fills the next-period-boundary code (avoiding period-boundary expiry races near a 30-second tick) and mail2fa retrieves from the buffer; both then click Continue. The `fido2` variant is declared so the union covers every 2FA provider the bundled web vault exposes (the provider row labelled "FIDO2 WebAuthn" in en_GB / "Passkey" in en); no test currently drives the webauthn-connector iframe / CDP virtual-authenticator handshake, so the case throws `Not Implemented` rather than silently no-op'ing. `setups/user.ts:logUser` and `setups/sso.ts:logUser` now share an options bag `{ mailBuffer?, twoFactor? }` (the SSO variant's existing positional `totp` parameter is replaced) and delegate to `submitTwoFactor` when `twoFactor` is set, keeping the two login helpers in lock-step. Refactored consumers: - `login.spec.ts:Authenticator 2fa` -> the inline 2FA block collapses to `await logUser(test, page, user, { twoFactor: { kind: 'totp', totp } })`. - `login.smtp.spec.ts:2fa` -> ditto with `{ kind: 'mail2fa', mailBuffer }`. - `sso_login.spec.ts:SSO login with TOTP 2fa` -> same `totp` variant. - `sso_login.smtp.spec.ts:Log and disable` -> same `mail2fa` variant. - Positional-mailBuffer callers (`login.smtp.spec.ts:Login`, `organization.smtp.spec.ts:Confirm invited user` and `Organization is visible`) switch to options-bag `logUser(..., { mailBuffer })`. login.spec.ts loses no-longer-needed `expect` / `OTPAuth` imports; the factor-specific timestamp logic moves into `submitTwoFactor`. No behaviour change in any test; only structure. --- playwright/tests/login.smtp.spec.ts | 21 +++------- playwright/tests/login.spec.ts | 19 +-------- playwright/tests/organization.smtp.spec.ts | 4 +- playwright/tests/setups/2fa.ts | 47 ++++++++++++++++++++++ playwright/tests/setups/sso.ts | 28 +++---------- playwright/tests/setups/user.ts | 29 ++++++++++++- playwright/tests/sso_login.smtp.spec.ts | 2 +- playwright/tests/sso_login.spec.ts | 2 +- 8 files changed, 91 insertions(+), 61 deletions(-) diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts index 87474b79..6b53ba99 100644 --- a/playwright/tests/login.smtp.spec.ts +++ b/playwright/tests/login.smtp.spec.ts @@ -3,7 +3,7 @@ import { MailDev } from 'maildev'; const utils = require('../global-utils'); import { createAccount, logUser } from './setups/user'; -import { activateEmail, retrieveEmailCode, disableEmail } from './setups/2fa'; +import { activateEmail, disableEmail } from './setups/2fa'; let users = utils.loadEnv(); @@ -41,7 +41,7 @@ test('Account creation', async ({ page }) => { test('Login', async ({ context, page }) => { const mailBuffer = mailserver.buffer(users.user1.email); - await logUser(test, page, users.user1, mailBuffer); + await logUser(test, page, users.user1, { mailBuffer }); await test.step('verify email', async () => { await page.getByText('Verify your account\'s email').click(); @@ -78,24 +78,13 @@ test('Activate 2fa', async ({ page }) => { test('2fa', async ({ page }) => { const emails = mailserver.buffer(users.user1.email); - await test.step('login', async () => { - await page.goto('/'); - - await page.getByLabel(/Email address/).fill(users.user1.email); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByLabel('Master password').fill(users.user1.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - const code = await retrieveEmailCode(test, page, emails); - await page.getByLabel(/Verification code/).fill(code); - await page.getByRole('button', { name: 'Continue' }).click(); + await logUser(test, page, users.user1, { twoFactor: { kind: 'mail2fa', mailBuffer: emails }, mailBuffer: emails }); + await test.step('Dismiss extension prompts', async () => { await page.getByRole('button', { name: 'Add it later' }).click(); await page.getByRole('link', { name: 'Skip to web app' }).click(); - await expect(page).toHaveTitle(/Vaults/); - }) + }); await disableEmail(test, page, users.user1); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index aaac4708..cb3f50a0 100644 --- a/playwright/tests/login.spec.ts +++ b/playwright/tests/login.spec.ts @@ -1,5 +1,4 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import * as OTPAuth from "otpauth"; +import { test, type TestInfo } from '@playwright/test'; import * as utils from "../global-utils"; import { createAccount, logUser } from './setups/user'; @@ -31,21 +30,7 @@ test('Authenticator 2fa', async ({ page }) => { await utils.logout(test, page, users.user1); - await test.step('login', async () => { - let timestamp = Date.now(); // Needed to use the next token - timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; - - await page.getByLabel(/Email address/).fill(users.user1.email); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByLabel('Master password').fill(users.user1.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); - await page.getByRole('button', { name: 'Continue' }).click(); - - await expect(page).toHaveTitle(/Vaultwarden Web/); - }); + await logUser(test, page, users.user1, { twoFactor: { kind: 'totp', totp } }); await disableTOTP(test, page, users.user1); }); diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 91e33d31..3843f41f 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -103,7 +103,7 @@ test('invited with existing account', async ({ page }) => { }); test('Confirm invited user', async ({ page }) => { - await logUser(test, page, users.user1, mail1Buffer); + await logUser(test, page, users.user1, { mailBuffer: mail1Buffer }); await orgs.members(test, page, 'Test'); await orgs.confirm(test, page, 'Test', users.user2.email); @@ -112,7 +112,7 @@ test('Confirm invited user', async ({ page }) => { }); test('Organization is visible', async ({ page }) => { - await logUser(test, page, users.user2, mail2Buffer); + await logUser(test, page, users.user2, { mailBuffer: mail2Buffer }); await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index 56d20e97..4d19e94f 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -5,6 +5,53 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { openAvatarMenu, submitMasterPasswordVerification } from './user'; +/** + * A 2FA challenge factor used by the login helpers. Discriminated by `kind` + * so each variant carries only the state it needs: + * - `totp` — TOTP code generator (authenticator app) + * - `mail2fa` — email OTP; the helper retrieves the code from `mailBuffer` + * - `fido2` — WebAuthn-as-2FA (the bundled web vault labels this + * provider "FIDO2 WebAuthn" in en_GB / "Passkey" in en). + * Currently unimplemented; `submitTwoFactor` throws. + */ +export type TwoFactor = + | { kind: 'totp', totp: OTPAuth.TOTP } + | { kind: 'mail2fa', mailBuffer: MailBuffer } + | { kind: 'fido2' }; + +/** + * Satisfy the /#/2fa challenge for the given `TwoFactor`. Asserts the + * "Verify your Identity" heading is shown, then dispatches per `kind`: + * - `totp` / `mail2fa`: fill the verification code, click Continue. + * - `fido2`: throws Not Implemented. + * + * For TOTP, the code is generated for the *next* period boundary to avoid + * server-side expiry races when the test submits near a 30-second tick. + */ +export async function submitTwoFactor(test: Test, page: Page, twoFactor: TwoFactor): Promise { + await test.step(`Submit 2FA (${twoFactor.kind})`, async () => { + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + switch (twoFactor.kind) { + case 'totp': { + const { totp } = twoFactor; + const nowSec = Math.floor(Date.now() / 1000); + const timestamp = (nowSec + totp.period - (nowSec % totp.period) + 1) * 1000; + await page.getByLabel(/Verification code/).fill(totp.generate({ timestamp })); + await page.getByRole('button', { name: 'Continue' }).click(); + break; + } + case 'mail2fa': { + const code = await retrieveEmailCode(test, page, twoFactor.mailBuffer); + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); + break; + } + case 'fido2': + throw new Error('Not Implemented'); + } + }); +} + /** * Navigate to the two-step-login provider list under Settings → Security. * Centralised here so a future web-vault nav restructure only touches one diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts index 5600a854..54f3432b 100644 --- a/playwright/tests/setups/sso.ts +++ b/playwright/tests/setups/sso.ts @@ -1,9 +1,8 @@ import { expect, type Page, Test } from '@playwright/test'; import { type MailBuffer, MailServer } from 'maildev'; -import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; -import { retrieveEmailCode } from './2fa'; +import { submitTwoFactor, type TwoFactor } from './2fa'; import { fillNewMasterPassword } from './user'; /** @@ -66,9 +65,8 @@ export async function logUser( page: Page, user: { email: string, password: string }, options: { - mailBuffer ?: MailBuffer, - totp?: OTPAuth.TOTP, - mail2fa?: boolean, + mailBuffer?: MailBuffer, + twoFactor?: TwoFactor, } = {} ) { let mailBuffer = options.mailBuffer; @@ -90,24 +88,8 @@ export async function logUser( await page.getByRole('button', { name: 'Sign In' }).click(); }); - if( options.totp || options.mail2fa ){ - let code; - - await test.step('2FA check', async () => { - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - - if( options.totp ) { - const totp = options.totp; - let timestamp = Date.now(); // Needed to use the next token - timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; - code = totp.generate({timestamp}); - } else if( options.mail2fa ){ - code = await retrieveEmailCode(test, page, mailBuffer); - } - - await page.getByLabel(/Verification code/).fill(code); - await page.getByRole('button', { name: 'Continue' }).click(); - }); + if( options.twoFactor ){ + await submitTwoFactor(test, page, options.twoFactor); } await test.step('Unlock vault', async () => { diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 0adc2f93..289e2041 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test'; import { type MailBuffer } from 'maildev'; import * as utils from '../../global-utils'; +import { submitTwoFactor, type TwoFactor } from './2fa'; /** * Open the account/avatar menu in the web vault header. The button's @@ -82,7 +83,29 @@ export async function createAccount(test, page: Page, user: { email: string, nam }); } -export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { +/** + * Master-password login. + * + * When the account has 2FA enabled, pass `options.twoFactor` — a + * `TwoFactor` discriminated union carrying the factor's own state (TOTP + * generator, mail buffer, …). The helper then drives the /#/2fa challenge + * inline and lands the user in `/vault`. Mirrors `setups/sso.ts:logUser`. + * + * `options.mailBuffer` (independent of `twoFactor`) consumes the expected + * "New Device Logged In" mail at the end of the flow, when the test wants + * to assert that login emails went out. + */ +export async function logUser( + test, + page: Page, + user: { email: string, password: string }, + options: { + mailBuffer?: MailBuffer, + twoFactor?: TwoFactor, + } = {}, +) { + let mailBuffer = options.mailBuffer; + await test.step(`Log user ${user.email}`, async () => { await utils.cleanLanding(page); @@ -93,6 +116,10 @@ export async function logUser(test, page: Page, user: { email: string, password: await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); + if( options.twoFactor ){ + await submitTwoFactor(test, page, options.twoFactor); + } + await utils.ignoreExtension(page); // We are now in the default vault page diff --git a/playwright/tests/sso_login.smtp.spec.ts b/playwright/tests/sso_login.smtp.spec.ts index 7a615cd6..270ada4a 100644 --- a/playwright/tests/sso_login.smtp.spec.ts +++ b/playwright/tests/sso_login.smtp.spec.ts @@ -45,7 +45,7 @@ test('Create and activate 2FA', async ({ page }) => { test('Log and disable', async ({ page }) => { const mailBuffer = mailserver.buffer(users.user1.email); - await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); + await logUser(test, page, users.user1, { mailBuffer, twoFactor: { kind: 'mail2fa', mailBuffer } }); await disableEmail(test, page, users.user1); diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts index 8a1bb9ab..ab5a0d22 100644 --- a/playwright/tests/sso_login.spec.ts +++ b/playwright/tests/sso_login.spec.ts @@ -45,7 +45,7 @@ test('SSO login with TOTP 2fa', async ({ page }) => { let totp = await activateTOTP(test, page, users.user1); - await logUser(test, page, users.user1, { totp }); + await logUser(test, page, users.user1, { twoFactor: { kind: 'totp', totp } }); await disableTOTP(test, page, users.user1); });