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.