You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
463 lines
20 KiB
463 lines
20 KiB
/**
|
|
* Shared helpers for the account-lifecycle specs (MP-mode and SSO-mode
|
|
* lifecycles parameterised across `account-lifecycle` and `account-lifecycle-sso`
|
|
* Playwright projects). Mode-agnostic: nothing here knows whether the
|
|
* test signs the user in via master password or SSO. Login choreography
|
|
* is owned by the spec; these helpers cover the bits that don't vary:
|
|
*
|
|
* • CDP virtual-authenticator wrangling (with `autoPresence` toggle).
|
|
* • Login-passkey enrolment + removal (Settings → Security → Master
|
|
* password).
|
|
* • WebAuthn-as-2FA enrolment + disable (Settings → Security →
|
|
* Two-step login).
|
|
* • Lock vault + unlock helpers (passkey, MP).
|
|
* • MP change + KDF iterations bump.
|
|
* • Lock-screen-affordance baseline assertion.
|
|
* • Fresh-context spawn + "Log in with device" + approve flow.
|
|
*
|
|
* Call `resetVirtualAuthenticators()` from a `test.beforeEach` so the
|
|
* module-scoped CDP session cache is dropped between tests in the same
|
|
* file (Playwright recycles the page each test, and a stale session
|
|
* would crash the next `send()`).
|
|
*/
|
|
|
|
import { test, expect, type CDPSession, type Page } from '@playwright/test';
|
|
|
|
import * as utils from '../../global-utils';
|
|
|
|
// `Test` is exported as a value in the playwright runtime but not in the
|
|
// .d.ts namespace; mirror the alias the other setups files use.
|
|
export type Test = typeof test;
|
|
|
|
export const AUTHENTICATOR_OPTIONS = {
|
|
protocol: 'ctap2' as const,
|
|
ctap2Version: 'ctap2_1' as const,
|
|
transport: 'internal' as const,
|
|
hasResidentKey: true,
|
|
hasUserVerification: true,
|
|
hasPrf: true,
|
|
automaticPresenceSimulation: true,
|
|
isUserVerified: true,
|
|
defaultBackupEligibility: false,
|
|
defaultBackupState: false,
|
|
};
|
|
|
|
/**
|
|
* Attach a CDP virtual authenticator. The first call also enables the
|
|
* WebAuthn domain on the session. Subsequent calls add another
|
|
* authenticator on the same session, simulating a user with multiple
|
|
* devices — required for multi-credential enrolment, because the server
|
|
* passes `excludeCredentials` and any authenticator already holding a
|
|
* listed credential refuses to create another for the same user.
|
|
*
|
|
* Chrome enforces "at most one `internal` (platform) authenticator per
|
|
* environment", so the first authenticator is internal (Touch ID /
|
|
* Windows Hello-like) and additional ones use USB transport.
|
|
*/
|
|
let sharedCdpSession: CDPSession | null = null;
|
|
const virtualAuthenticatorIds: string[] = [];
|
|
|
|
export async function addVirtualAuthenticator(page: Page) {
|
|
if (!sharedCdpSession) {
|
|
sharedCdpSession = await page.context().newCDPSession(page);
|
|
await sharedCdpSession.send('WebAuthn.enable');
|
|
}
|
|
const isFirst = virtualAuthenticatorIds.length === 0;
|
|
const options = isFirst
|
|
? AUTHENTICATOR_OPTIONS
|
|
: { ...AUTHENTICATOR_OPTIONS, transport: 'usb' as const };
|
|
const { authenticatorId } = await sharedCdpSession.send('WebAuthn.addVirtualAuthenticator', { options });
|
|
virtualAuthenticatorIds.push(authenticatorId);
|
|
}
|
|
|
|
/**
|
|
* Detach a previously-added virtual authenticator by add-order index (0 =
|
|
* first added). Used when a test removes a credential server-side and must
|
|
* stop that credential's now-orphaned resident key from answering a later
|
|
* discoverable `credentials.get()`: with multiple authenticators holding
|
|
* resident credentials and an empty allow-list, which one responds is
|
|
* otherwise non-deterministic.
|
|
*/
|
|
export async function removeVirtualAuthenticator(index: number) {
|
|
if (!sharedCdpSession) {
|
|
throw new Error('removeVirtualAuthenticator called before addVirtualAuthenticator');
|
|
}
|
|
const authenticatorId = virtualAuthenticatorIds[index];
|
|
if (authenticatorId === undefined) {
|
|
throw new Error(`removeVirtualAuthenticator: no authenticator at index ${index}`);
|
|
}
|
|
await sharedCdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
|
virtualAuthenticatorIds.splice(index, 1);
|
|
}
|
|
|
|
/**
|
|
* Drop the cached CDP session + authenticator IDs. Call from
|
|
* `test.beforeEach`: CDP sessions are bound to a specific Page;
|
|
* Playwright recycles the page between tests in the same file, so a
|
|
* stale session would crash the next `send()` with "Target page,
|
|
* context or browser has been closed".
|
|
*/
|
|
export function resetVirtualAuthenticators() {
|
|
sharedCdpSession = null;
|
|
virtualAuthenticatorIds.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Toggle automatic user-presence simulation across every attached
|
|
* virtual authenticator. See `withAuthenticatorDisabled` for the safer
|
|
* wrapper.
|
|
*/
|
|
export async function setAuthenticatorAutoPresence(enabled: boolean) {
|
|
if (!sharedCdpSession) {
|
|
// A silent no-op here would let `withAuthenticatorDisabled` run its
|
|
// body with auto-presence still live, reintroducing the iframe
|
|
// auto-fire race the wrapper exists to prevent. Fail loudly instead
|
|
// so a future call ordered before `addVirtualAuthenticator` surfaces.
|
|
throw new Error('setAuthenticatorAutoPresence called before addVirtualAuthenticator');
|
|
}
|
|
for (const authenticatorId of virtualAuthenticatorIds) {
|
|
await sharedCdpSession.send('WebAuthn.setAutomaticPresenceSimulation', {
|
|
authenticatorId,
|
|
enabled,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run `body` with the virtual authenticators' auto-presence simulation
|
|
* disabled, restoring it (even on failure) when `body` returns. Needed
|
|
* when the test wants to click "Select another method" on /#/2fa — the
|
|
* connector iframe otherwise auto-fires WebAuthn the instant it mounts
|
|
* and the page races to /vault before the picker is reachable.
|
|
*/
|
|
export async function withAuthenticatorDisabled<T>(body: () => Promise<T>): Promise<T> {
|
|
await setAuthenticatorAutoPresence(false);
|
|
try {
|
|
return await body();
|
|
} finally {
|
|
await setAuthenticatorAutoPresence(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enrol a login passkey via Settings → Security → Master password. Two
|
|
* entry points open the same dialog: "Turn on" for the first credential,
|
|
* "New passkey" once login-with-passkey is already on.
|
|
*/
|
|
export async function enrollLoginPasskey(
|
|
page: Page,
|
|
mp: string,
|
|
credentialName: string,
|
|
{ useForEncryption }: { useForEncryption: boolean },
|
|
) {
|
|
await page.goto('/#/settings/security/password');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const enrolButton = page
|
|
.getByRole('button', { name: /Turn on|New passkey/i })
|
|
.first();
|
|
await enrolButton.waitFor({ state: 'visible' });
|
|
await enrolButton.click();
|
|
|
|
const mpInput = page.locator('input#masterPassword');
|
|
await mpInput.waitFor({ state: 'visible' });
|
|
await mpInput.fill(mp);
|
|
// Two `Continue` buttons coexist on this page; pressing Enter inside
|
|
// the password input submits the dialog form unambiguously.
|
|
await mpInput.press('Enter');
|
|
|
|
const nameInput = page.locator('input[formcontrolname="name"]');
|
|
await nameInput.waitFor({ state: 'visible' });
|
|
await nameInput.fill(credentialName);
|
|
|
|
// `useForEncryption` is default-checked in the bundled web vault, so
|
|
// the disabled case has to set it explicitly.
|
|
const prfToggle = page.locator('input[formcontrolname="useForEncryption"]');
|
|
if (useForEncryption) {
|
|
await prfToggle.check();
|
|
} else {
|
|
await prfToggle.uncheck();
|
|
}
|
|
|
|
// Dialog submit — "Turn on" on first enrolment, "Save" on subsequent;
|
|
// both type=submit inside the bit-dialog.
|
|
await page.locator('bit-dialog button[type="submit"]').click();
|
|
await expect(page.locator('bit-dialog')).toHaveCount(0);
|
|
}
|
|
|
|
/**
|
|
* Remove a registered login passkey. The credentials list is a table;
|
|
* each row has a "Remove <credentialName>" action. Clicking it opens an
|
|
* MP user-verification gate; submitting MP both verifies and applies the
|
|
* removal — no separate "Yes" confirm.
|
|
*/
|
|
export async function removeLoginPasskey(page: Page, mp: string, credentialName: string) {
|
|
await page.goto('/#/settings/security/password');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: `Remove ${credentialName}`, exact: true }).click();
|
|
|
|
const mpInput = page.locator('input#masterPassword');
|
|
await mpInput.waitFor({ state: 'visible' });
|
|
await mpInput.fill(mp);
|
|
await mpInput.press('Enter');
|
|
|
|
// Anchor on the master-password dialog closing before the absence check,
|
|
// so the latter can't pass against the pre-removal DOM (or a still-open
|
|
// dialog after a rejected master password).
|
|
await expect(mpInput).toHaveCount(0);
|
|
await expect(page.getByText(credentialName, { exact: true })).toHaveCount(0);
|
|
}
|
|
|
|
/**
|
|
* Enrol the WebAuthn-as-2FA provider (Settings → Security → Two-step
|
|
* login → Passkey row). Separate code path from "Log in with passkey":
|
|
* the credential is stored in `two_factor` (TwoFactor::Webauthn) rather
|
|
* than `web_authn_credentials`, used as a second factor during login.
|
|
*/
|
|
export async function enrollWebauthn2FA(page: Page, mp: string, credentialName: string) {
|
|
await page.goto('/#/settings/security/two-factor');
|
|
await page.waitForLoadState('networkidle');
|
|
await page.locator('bit-item').filter({ hasText: 'Passkey' }).first().getByRole('button').first().click();
|
|
|
|
const mpInput = page.locator('input#masterPassword');
|
|
await mpInput.waitFor({ state: 'visible' });
|
|
await mpInput.fill(mp);
|
|
await mpInput.press('Enter');
|
|
|
|
const nameInput = page.locator('input[formcontrolname="name"]');
|
|
await nameInput.waitFor({ state: 'visible' });
|
|
await nameInput.fill(credentialName);
|
|
await page.getByRole('button', { name: 'Read key' }).click();
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
|
|
await expect(page.locator('bit-dialog')).toHaveCount(0);
|
|
}
|
|
|
|
/**
|
|
* Disable the WebAuthn-as-2FA provider (Settings → Security → Two-step
|
|
* login → Passkey row → Manage → Deactivate all keys → Yes). The
|
|
* bundled web vault uses "Deactivate all keys" rather than "Turn off"
|
|
* for the WebAuthn provider.
|
|
*/
|
|
export async function disableWebauthn2FA(page: Page, mp: string) {
|
|
await page.goto('/#/settings/security/two-factor');
|
|
await page.waitForLoadState('networkidle');
|
|
await page.locator('bit-item').filter({ hasText: 'Passkey' }).first().getByRole('button').first().click();
|
|
|
|
const mpInput = page.locator('input#masterPassword');
|
|
await mpInput.waitFor({ state: 'visible' });
|
|
await mpInput.fill(mp);
|
|
await mpInput.press('Enter');
|
|
|
|
await page.getByRole('button', { name: 'Deactivate all keys' }).click();
|
|
await page.getByRole('button', { name: 'Yes' }).click();
|
|
await utils.checkNotification(page, 'Two-step login provider turned off');
|
|
}
|
|
|
|
/**
|
|
* Click the avatar menu's "Lock now". Vault transitions to /lock.
|
|
* Cipher rows also expose `aria-haspopup="menu"` ellipsis buttons, so
|
|
* we anchor on the avatar's accessible name (the user's display name).
|
|
*/
|
|
export async function lockVault(page: Page, userName: string) {
|
|
await page.getByRole('button', { name: userName, exact: true }).click();
|
|
await page.getByRole('menuitem', { name: /^Lock/i }).first().click();
|
|
await expect(page).toHaveURL(/\/lock/, { timeout: 10_000 });
|
|
}
|
|
|
|
/**
|
|
* Click "Unlock with passkey" on the lock screen. The web vault performs
|
|
* WebAuthn.get() in the main frame (no iframe ceremony), so the virtual
|
|
* authenticator satisfies it. PRF output decrypts the user key locally
|
|
* from the wrapped-key blobs in /sync's webAuthnPrfOptions.
|
|
*/
|
|
export async function unlockWithPasskey(page: Page) {
|
|
await page.getByRole('button', { name: /Unlock with passkey/i }).click();
|
|
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
|
|
}
|
|
|
|
/** Unlock the (locked) vault by typing the master password. */
|
|
export async function unlockWithMP(page: Page, password: string) {
|
|
await page.getByLabel('Master password').fill(password);
|
|
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
|
|
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
|
|
}
|
|
|
|
/**
|
|
* Click "Log in with passkey" on the unauthenticated login page. The
|
|
* web vault opens a same-origin /webauthn-connector.html iframe which
|
|
* immediately calls navigator.credentials.get() — the CDP virtual
|
|
* authenticator attached to the page satisfies it across the iframe
|
|
* boundary in current Chromium.
|
|
*
|
|
* No URL assertion here on purpose: the caller knows whether 2FA is
|
|
* enrolled and asserts /vault vs /#/2fa accordingly.
|
|
*/
|
|
export async function clickLoginWithPasskey(page: Page) {
|
|
await utils.cleanLanding(page);
|
|
await page.getByRole('button', { name: /Log in with passkey/i }).click();
|
|
}
|
|
|
|
/**
|
|
* Drive the /#/login email-entry step to the master-password unlock
|
|
* page (where "Log in with master password" + the conditional "Log in
|
|
* with device" affordance live).
|
|
*
|
|
* Vaultwarden's CSS overrides hide a different email field per SSO
|
|
* mode, so the path to the same MP page differs:
|
|
* • MP mode (`SSO_ENABLED=false`): `.vw-email-sso` and "Other" are
|
|
* hidden; `.vw-email-continue` + "Continue" are visible. Fill +
|
|
* Continue gets to MP page.
|
|
* • SSO mode (`SSO_ENABLED=true`): `.vw-email-continue` + "Continue"
|
|
* are hidden; `.vw-email-sso` is the only visible email input and
|
|
* "Other" replaces "Continue" to switch into the MP branch. Fill
|
|
* the SSO input, click "Other" — lands directly on MP page.
|
|
*/
|
|
export async function enterEmailOnLoginPage(page: Page, email: string, opts: { sso?: boolean } = {}) {
|
|
if (opts.sso) {
|
|
await page.locator('input[type=email].vw-email-sso').fill(email);
|
|
await page.getByRole('button', { name: 'Other' }).click();
|
|
} else {
|
|
await page.getByLabel(/Email address/).fill(email);
|
|
await page.getByRole('button', { name: 'Continue' }).click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lock-screen affordance baseline assertion: master-password unlock +
|
|
* log-out are always present; passkey-unlock conditional on
|
|
* `expectPasskeyUnlock`. Mode-agnostic — the lock screen looks the same
|
|
* whether the user logged in via MP or SSO.
|
|
*/
|
|
export async function expectLockScreenButtons(page: Page, expectPasskeyUnlock: boolean) {
|
|
await expect(page.getByRole('button', { name: 'Unlock', exact: true })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Log out' })).toBeVisible();
|
|
|
|
const unlock = page.getByRole('button', { name: /Unlock with passkey/i });
|
|
if (expectPasskeyUnlock) {
|
|
await expect(unlock).toBeVisible();
|
|
} else {
|
|
await expect(unlock).toHaveCount(0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drive Settings → Security → Master password → "Change master password".
|
|
* Optionally also ticks "Also rotate my account's encryption key", which
|
|
* opens a confirmation dialog (must be acknowledged before submit).
|
|
*
|
|
* Bitwarden v2026 rotation is async: clicking submit kicks off a
|
|
* multi-second client-side rewrap (re-wraps user key + all PRF/passkey
|
|
* credentials) BEFORE the API call fires. Endpoints:
|
|
* • non-rotation password change → POST /api/accounts/password
|
|
* • rotation → POST /api/accounts/key-management/rotate-user-account-keys
|
|
* The bundled web vault auto-navigates to /#/login on success; returning
|
|
* before the POST goes out tears down the in-flight rewrap. Waits on the
|
|
* response so callers can assume rotation is durable.
|
|
*/
|
|
export async function changeMasterPassword(
|
|
page: Page,
|
|
currentMp: string,
|
|
newMp: string,
|
|
rotateEncryptionKey = false,
|
|
) {
|
|
await page.goto('/#/settings/security/password');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// "Current master password" is unique. "New master password" overlaps
|
|
// with "Confirm new master password" under substring matching, so
|
|
// anchor those by formcontrolname.
|
|
await page.getByLabel('Current master password').first().fill(currentMp);
|
|
await page.locator('input[formcontrolname="newPassword"]').fill(newMp);
|
|
await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(newMp);
|
|
|
|
if (rotateEncryptionKey) {
|
|
await page.getByLabel(/Also rotate my account's encryption key/i).check();
|
|
await page
|
|
.getByRole('dialog', { name: /Rotate encryption key/i })
|
|
.getByRole('button', { name: 'Yes' })
|
|
.click();
|
|
}
|
|
|
|
const submitResp = page.waitForResponse(r => {
|
|
const u = r.url();
|
|
return u.includes('/api/accounts/password')
|
|
|| u.includes('/api/accounts/key-management/rotate-user-account-keys');
|
|
}, { timeout: 60_000 });
|
|
await page.getByRole('button', { name: 'Change master password' }).click();
|
|
await submitResp;
|
|
}
|
|
|
|
/**
|
|
* Bump the user's KDF iteration count via Settings → Security → Keys.
|
|
* Submitting rotates the security stamp (auto-logout) so callers pick
|
|
* up from /#/login. The form requires MP verification before the actual
|
|
* `/api/accounts/kdf` POST fires.
|
|
*/
|
|
export async function changeKdfIterations(page: Page, mp: string, iterations: number) {
|
|
await page.goto('/#/settings/security/security-keys');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const iterationsInput = page.getByLabel('KDF iterations');
|
|
await iterationsInput.waitFor({ state: 'visible' });
|
|
await iterationsInput.fill(String(iterations));
|
|
await iterationsInput.press('Tab');
|
|
|
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
|
|
|
// Confirmation dialog with an MP gate. The actual POST only fires
|
|
// after MP is supplied and "Update settings" inside the dialog is
|
|
// clicked, so register the waitForResponse here, not before the
|
|
// first click.
|
|
const dialog = page.getByRole('dialog', { name: 'Update your encryption settings' });
|
|
await dialog.getByLabel('Master password').fill(mp);
|
|
const kdfPosted = page.waitForResponse(
|
|
r => /\/api\/accounts\/kdf\b/.test(r.url()) && r.request().method() === 'POST',
|
|
{ timeout: 60_000 },
|
|
);
|
|
await dialog.getByRole('button', { name: 'Update settings' }).click();
|
|
await kdfPosted;
|
|
}
|
|
|
|
/**
|
|
* Spawn a fresh browser context (a "new device" from the server's
|
|
* perspective) and return its page, parked on /#/login. Caller is
|
|
* responsible for `page.context().close()` to dispose of it.
|
|
*/
|
|
export async function createNewDevice(existing: Page): Promise<Page> {
|
|
const ctx = await existing.context().browser()!.newContext({ ignoreHTTPSErrors: true });
|
|
const page = await ctx.newPage();
|
|
await page.goto(`${process.env.DOMAIN}/#/login`);
|
|
return page;
|
|
}
|
|
|
|
/**
|
|
* Drive the "Log in with device" passwordless flow against a context
|
|
* whose device is already known. Clicks "Log in with device" — POSTs
|
|
* `/api/auth-requests` and parks the second device on /login-with-device
|
|
* polling for approval. The `approver` page (still authenticated)
|
|
* surfaces the "Review login request" banner via its periodic poll;
|
|
* clicking through and confirming the request lands the second device
|
|
* in /vault.
|
|
*/
|
|
export async function loginWithDeviceAndApprove(secondDevice: Page, approver: Page) {
|
|
const authRequestPosted = secondDevice.waitForResponse(
|
|
r => /\/api\/auth-requests\b/.test(r.url()) && r.request().method() === 'POST' && r.status() === 200,
|
|
{ timeout: 30_000 },
|
|
);
|
|
await secondDevice.getByRole('button', { name: /Log in with device/i }).click();
|
|
await authRequestPosted;
|
|
|
|
const reviewLink = approver.getByRole('link', { name: /Review login request/i });
|
|
await reviewLink.waitFor({ state: 'visible', timeout: 60_000 });
|
|
await reviewLink.click();
|
|
// Lands on Settings → Security → Devices. The pending request is a
|
|
// row with a "Request pending" badge; clicking the device link opens
|
|
// the approval dialog whose primary action is "Confirm access".
|
|
await approver.getByRole('row').filter({ hasText: /Request pending/i })
|
|
.getByRole('link').first().click();
|
|
await approver.getByRole('button', { name: 'Confirm access' }).click();
|
|
|
|
await expect(secondDevice).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
|
|
}
|
|
|