From 88ab51443a4f84b6d239263f78663c9648d6b97e Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Wed, 27 May 2026 11:14:30 +0300 Subject: [PATCH] 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); });