Browse Source

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.
pull/7248/head
Zaid Marji 3 weeks ago
parent
commit
88ab51443a
  1. 21
      playwright/tests/login.smtp.spec.ts
  2. 19
      playwright/tests/login.spec.ts
  3. 4
      playwright/tests/organization.smtp.spec.ts
  4. 47
      playwright/tests/setups/2fa.ts
  5. 28
      playwright/tests/setups/sso.ts
  6. 29
      playwright/tests/setups/user.ts
  7. 2
      playwright/tests/sso_login.smtp.spec.ts
  8. 2
      playwright/tests/sso_login.spec.ts

21
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);

19
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);
});

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

47
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<void> {
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

28
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 () => {

29
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

2
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);

2
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);
});

Loading…
Cancel
Save