Fold duplicated campanha access, loaders, list/filter/form shells, and scripts into shared modules; reorganize utilities by domain; stabilize e2e; and document prevention-first audit workflow so the same smells cannot creep back in.
114 lines
4.8 KiB
TypeScript
114 lines
4.8 KiB
TypeScript
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'
|
|
|
|
/**
|
|
* Biometric login (B40) end to end, against a **virtual authenticator** driven
|
|
* over CDP: `WebAuthn.addVirtualAuthenticator` with `transport: 'internal'` +
|
|
* `hasUserVerification` is what makes
|
|
* `isUserVerifyingPlatformAuthenticatorAvailable()` answer `true` and lets the
|
|
* ceremonies complete headlessly. Without it neither island even mounts, so
|
|
* this is the only way to cover the flow at all.
|
|
*/
|
|
const addVirtualAuthenticator = async (context: BrowserContext, page: Page): Promise<void> => {
|
|
const cdp = await context.newCDPSession(page)
|
|
await cdp.send('WebAuthn.enable')
|
|
await cdp.send('WebAuthn.addVirtualAuthenticator', {
|
|
options: {
|
|
protocol: 'ctap2',
|
|
transport: 'internal',
|
|
hasResidentKey: true,
|
|
hasUserVerification: true,
|
|
// Pre-verified: the virtual authenticator cannot show a fingerprint
|
|
// sheet, and every ceremony here requires user verification.
|
|
isUserVerified: true,
|
|
automaticPresenceSimulation: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The first ceremony of the run pays for compiling two route handlers in the dev
|
|
* server, which under load is well past the 5 s default. Generous only where the
|
|
* wait is a compile, never as a blanket setting.
|
|
*/
|
|
const CEREMONY_TIMEOUT = 60_000
|
|
|
|
/**
|
|
* The enrollment toast would cover the profile card it is advertising. The key
|
|
* travels as an argument rather than as a literal inside the init script, so a
|
|
* rename cannot silently stop silencing it.
|
|
*/
|
|
const silenceEnrollmentToast = (context: BrowserContext) =>
|
|
context.addInitScript((key: string) => {
|
|
window.localStorage.setItem(key, '1')
|
|
}, CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY)
|
|
|
|
// Step 3 signs in with a revoked passkey, and the refusal is a handled 400 that
|
|
// the browser still logs as a failed request — declared here so the shared
|
|
// failure guard does not read it as a bug.
|
|
test.use({ expectedRequestFailurePaths: ['/campanha/webauthn/login'] })
|
|
|
|
test('enrolls a passkey, signs in with it, then revokes it', async ({
|
|
campaign,
|
|
context,
|
|
page,
|
|
}) => {
|
|
test.slow()
|
|
const { fixtures } = campaign
|
|
const biometricUser = await fixtures.createCampaignUser('advisor', {
|
|
name: fixtures.value('Assessora com biometria'),
|
|
})
|
|
const email = biometricUser.email!
|
|
const password = biometricUser.password
|
|
|
|
await silenceEnrollmentToast(context)
|
|
await addVirtualAuthenticator(context, page)
|
|
|
|
// 1. Enrollment is authenticated: the passkey card only exists behind a
|
|
// password login.
|
|
await campaign.login(page, email, password)
|
|
await page.goto('/campanha/perfil')
|
|
|
|
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
|
|
|
|
const deviceLabel = page.getByLabel('Nome deste aparelho')
|
|
await expect(deviceLabel).toBeVisible()
|
|
await deviceLabel.fill('Celular da assessora')
|
|
await page.getByRole('button', { name: 'Ativar neste aparelho' }).click()
|
|
|
|
await expect(page.getByText('Celular da assessora')).toBeVisible({ timeout: CEREMONY_TIMEOUT })
|
|
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeHidden()
|
|
|
|
// 2. Password-less sign-in issues the 14-day cookie — enrolling the device IS
|
|
// the "remember me" opt-in, so there is no checkbox in this path.
|
|
await context.clearCookies()
|
|
await page.goto('/campanha/login')
|
|
const biometricButton = page.getByRole('button', { name: 'Entrar com digital ou Face ID' })
|
|
await expect(biometricButton).toBeVisible()
|
|
|
|
const signedInAt = Math.floor(Date.now() / 1000)
|
|
await biometricButton.click()
|
|
await page.waitForURL(/\/campanha$/)
|
|
|
|
const tokenCookie = (await context.cookies()).find((cookie) => cookie.name === 'campaign-token')
|
|
expect(tokenCookie?.expires).toBeGreaterThan(signedInAt + CAMPAIGN_SESSION_TTL_LONG - 120)
|
|
|
|
// 3. Revocation is the answer to "my phone is gone", so it has to work from
|
|
// the profile and it has to actually stop the credential.
|
|
await page.goto('/campanha/perfil')
|
|
await page.getByRole('button', { name: 'Remover Celular da assessora' }).click()
|
|
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
|
|
|
|
await context.clearCookies()
|
|
await page.goto('/campanha/login')
|
|
await page.getByRole('button', { name: 'Entrar com digital ou Face ID' }).click()
|
|
// The device still holds its private key, so the ceremony itself succeeds and
|
|
// the refusal must come from the server not knowing the credential anymore.
|
|
await expect(
|
|
page.getByRole('alert').filter({ hasText: /não está mais autorizado/i }),
|
|
).toBeVisible({ timeout: CEREMONY_TIMEOUT })
|
|
await expect(page).toHaveURL(/\/campanha\/login/)
|
|
})
|