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 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 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 35dfcdb1..3843f41f 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,12 +57,11 @@ 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); + await fillNewMasterPassword(page, users.user2.password); 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 @@ -93,7 +92,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 @@ -104,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); @@ -113,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 d7936420..4d19e94f 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -3,39 +3,106 @@ 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 { +/** + * 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 + * 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(); - await page.getByLabel('Master password (required)').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); - - const secret = await page.getByLabel('Key').innerText(); + 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 + // 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; }) } -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(); - await page.getByLabel('Master password (required)').click(); - await page.getByLabel('Master password (required)').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); + 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'); @@ -44,13 +111,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 (required)').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(); }); @@ -63,7 +126,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(); @@ -74,16 +137,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 (required)').click(); - await page.getByLabel('Master password (required)').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/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(); }); diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts index 6317f8b0..54f3432b 100644 --- a/playwright/tests/setups/sso.ts +++ b/playwright/tests/setups/sso.ts @@ -1,9 +1,9 @@ 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'; /** * If a MailBuffer is passed it will be used and consume the expected emails @@ -33,8 +33,7 @@ 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); + await fillNewMasterPassword(page, user.password); await page.getByRole('button', { name: 'Create account' }).click(); }); @@ -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 395196ae..289e2041 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -3,6 +3,56 @@ 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 + * 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 () => { @@ -16,9 +66,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 - await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); - await page.getByLabel('Confirm master password (').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') @@ -35,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); @@ -46,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); }); diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts index 92813f72..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,8 +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(); - await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); - await page.getByLabel('Confirm master password (').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!'); @@ -95,6 +95,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 () => { @@ -109,7 +112,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); }); 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();