Browse Source
Add a 23-step single-session lifecycle test covering every code path a real PRF-passkey user exercises: register → enrol passkey #1 → enrol passkey #2 on a second virtual authenticator → log in with passkey → lock + unlock with passkey → register a second-device context + "Log in with device" approval flow → enrol WebAuthn-2FA + TOTP-2FA → log in with passkey (server skips 2FA on webauthn grant) → log in with MP + WebAuthn-2FA → lock + unlock → remove passkey #1 → bump KDF iterations (auto-logout) → re-login with WebAuthn-2FA → rotate account encryption keys (auto-logout) → re-login with MP + TOTP-2FA → lock + unlock with passkey #2 → remove passkey #2 → log in with WebAuthn-2FA (post-login sync refreshes the client cache so the lock-screen assertion sees the credential-free state) → unlock with MP → disable both 2FA providers → log in with MP alone. A sibling `account-lifecycle-sso` project runs the same 23-step lifecycle under `SSO_ENABLED=true`. Login flows that previously typed MP at the prompt go through Keycloak + the bundled "Unlock vault" MP-decrypt step instead, exercising the SSO + WebAuthn-2FA + PRF passkey composition the original MP-only project couldn't cover. The body of the lifecycle is shared between both projects via mode- dispatch (`modeOps(sso)`); CDP virtual-authenticator wrangling, passkey enrol/remove, lock/unlock, KDF + MP rotation, and the second-device auth-request flow are extracted into `tests/setups/account_lifecycle_helpers.ts`. Notable wire-shape coverage: * `userDecryption.webAuthnPrfOptions` is populated only after PRF enrolment and emptied after passkey removal. * Rotation re-wraps each PRF credential's stored encryptedUserKey/encryptedPrivateKey; passkey #2 still unlocks the rotated user key. * KDF change auto-logs out via security-stamp rotation. * "Log in with device" is gated on `isKnownDevice` in the bundled web vault — the test asserts the affordance is absent on a fresh second-device context and surfaces after that context's first MP login. Reuses `logUser`/`submitTwoFactor` from `setups/`; the only spec-local helpers are CDP-specific (virtual-authenticator creation, `withAuthenticatorDisabled` callback wrapper) or test-local expectations (`expectLockScreenButtons`, `expectPostEmailPageNoPasskey`, etc.). Supporting changes for SSO mode: - `enterEmailOnLoginPage`: SSO branch fills `.vw-email-sso` and clicks "Other" to reveal the MP-continue flow — the standard email-label selector matches the SCSS-hidden `.vw-email-continue` input under `SSO_ENABLED=true`. - `sso.ts#logUser`: accepts a separate `kcPassword` for cases where vault MP and the IdP credential diverge (post-MP-rotation); accepts either /#/lock or /#/vault after 2FA so PRF auto-unlock via the lock screen's `promptBiometric=true` redirect is tolerated; uses `name: 'Unlock', exact: true` to disambiguate from the "Unlock with passkey" affordance when PRF is enrolled. The "Join organi[sz]ation" heading match is locale-tolerant (en vs en_GB). - `2fa.ts#submitTwoFactor`: post-2FA URL waiter accepts /#/lock too; TOTP submission tracks its own `last_used` time-step and waits for the next period boundary when a repeat would land on a consumed step, so consecutive TOTP submissions in the same period don't trip vw's `last_used > current` rejection. - `2fa.ts#ensure2FAProvider`: 5s probe (was 1s) for the default provider's input before falling through to the picker — under SSO mode the extra Keycloak round-trip can delay the connector iframe mount enough to race the switcher. - `global-utils.ts#cleanLanding`: swallow "navigation interrupted" / `net::ERR_ABORTED` from `page.goto('/')` — the bundled web vault's `/` → `/#/login` hash-route redirect occasionally fires while the initial nav is still resolving under docker's slower I/O. - `global-utils.ts#startVault`/`dbConfig`: register `account-lifecycle-sso` as a sqlite-backed project. - `user.ts#logUser`: accepts `kcPassword` for option-shape parity with `sso.ts#logUser`. Ignored in MP-only mode. - `global-setup.ts`: short-circuits the docker-compose build when `PW_USE_EXTERNAL_VAULT=1` — host-iteration runs against a cargo-run vw don't need the multi-minute release rebuild. In SSO mode, login-with-passkey is left as MP-mode-only coverage (the SSO project skips the two wire-shape probes for the same reason); the two smaller tests check the same server endpoint shape and are mode- invariant. The SSO lifecycle uses TOTP-2FA with `withAuthenticatorDisabled` at the MP-fresh-required logins so the lock screen waits for manual MP entry instead of auto-firing PRF unlock. Plumbing changes that come with this spec: * `playwright.config.ts` threads `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` into the `account-lifecycle` project's `launchOptions.executablePath` so the spec can run locally against a host-running Vaultwarden on systems where `npx playwright install chromium` is unsupported. * `compose/playwright/Dockerfile` installs Chromium alongside Firefox so the docker harness can run the `account-lifecycle` project too. * `account_lifecycle.spec.ts` honors `PW_USE_EXTERNAL_VAULT=1` to skip the docker startVault/stopVault hooks (host-mode iteration only; CI leaves it unset). Run requires `LOGIN_RATELIMIT_MAX_BURST` raised above the 10/60s default — multiple `connect/token` POSTs during the auth-request approval + back-to-back 2FA cycles exhaust the limit otherwise. Runtime: ~1.5min MP-mode, ~2min SSO-mode in docker; both well under the 180s test budget.pull/7297/head
8 changed files with 1026 additions and 86 deletions
@ -0,0 +1,463 @@ |
|||
/** |
|||
* 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 }); |
|||
} |
|||
Loading…
Reference in new issue