test(miss-53): gate WebAuthn ceremonies on authenticator readiness

The app's biometrics probe is one-shot per island mount: when
isUserVerifyingPlatformAuthenticatorAvailable() resolves before the CDP
virtual authenticator answers, the enrollment form / biometric button
never mount and no wait flips them (prod-build CI flake). New
expectCampaignBiometricsReady(page) polls the platform answer BEFORE the
surface whose island probes it; adopted at both ceremony entries in
campaignWebAuthn (verified green locally). Conventions pin:
e2eNavigationConventions fails the build when a virtual-authenticator
spec skips the readiness gate.
This commit is contained in:
Cursor Agent 2026-07-31 06:18:27 +00:00 committed by Francisco Ignácio Fontoura Solla
parent d28ecb831a
commit 5c59793e4c
2 changed files with 24 additions and 2 deletions

View file

@ -2,7 +2,7 @@ import type { BrowserContext, Page } from '@playwright/test'
import { CAMPAIGN_SESSION_TTL_LONG } from '../../src/lib/campaignSessionTtl.js'
import { CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY } from '../../src/utilities/campaignBiometricsPrompt.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import { expect, expectCampaignBiometricsReady, test } from './fixtures/campaignE2EFixtures.js'
/**
* Biometric login (B40) end to end, against a **virtual authenticator** driven
@ -68,8 +68,10 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
await addVirtualAuthenticator(context, page)
// 1. Enrollment is authenticated: the passkey card only exists behind a
// password login.
// password login. The platform answer is gated BEFORE the profile island
// runs its one-shot probe (miss #53).
await campaign.login(page, email, password)
await expectCampaignBiometricsReady(page)
await page.goto('/campanha/perfil')
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
@ -86,6 +88,7 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
// the "remember me" opt-in, so there is no checkbox in this path.
await context.clearCookies()
await page.goto('/campanha/login')
await expectCampaignBiometricsReady(page)
const biometricButton = page.getByRole('button', { name: 'Entrar com digital ou Face ID' })
await expect(biometricButton).toBeVisible()

View file

@ -474,6 +474,25 @@ export const checkRadixWhenHydrated = async (page: Page, label: string) => {
}).toPass({ timeout: 15_000 })
}
/**
* Miss #53: the app's biometrics probe is ONE-SHOT per island mount
* (`useCampaignBiometricsAvailable`) — if
* `isUserVerifyingPlatformAuthenticatorAvailable()` resolves before the CDP
* virtual authenticator is ready, the enrollment form / biometric button
* never mount and no amount of waiting flips them. Gate the ceremony on the
* platform answer BEFORE navigating into the surface whose island probes it,
* so the race is decided before the app even asks. Requires a loaded page
* (secure context) — call after `campaign.login` / `page.goto`.
*/
export const expectCampaignBiometricsReady = async (page: Page): Promise<void> => {
await expect(async () => {
const available = await page.evaluate(
() => window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable?.() ?? false,
)
expect(available).toBe(true)
}).toPass({ timeout: 15_000 })
}
type CampaignE2EFixture = {
baseURL: string
fixtures: CampaignE2EOwnership