import { reverificationError } from '@clerk/shared/authorization-errors'; import { ClerkWebAuthnError } from '@clerk/shared/error'; import type { PublicKeyCredentialWithAuthenticatorAssertionResponse, ReverificationConfig, SignInFirstFactorJSON, SignInSecondFactorJSON, } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { useRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { deferred } from '../../../__tests__/async'; import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; import type { FakeVerificationSeed } from '../../../__tests__/feature/fake-fapi/verification'; import { fapiClient, fapiEmailAddress, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { Card } from '../../../components/card'; import type { MosaicLocalization } from '../../../localization'; import { MosaicProvider } from '../../../mosaic-provider'; import { Reverification, useReverificationFlow } from '../reverification'; const alice = fapiUser({ id: 'user_1', first_name: 'Alice', last_name: 'Smith', email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], }); const aliceSession = fapiSession({ id: 'sess_1', user: alice }); const bobSession = fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2', first_name: 'Bob', email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })], }), }); const VERIFY = '/v1/client/sessions/sess_1/verify'; const PREPARE_FIRST = `${VERIFY}/prepare_first_factor`; const ATTEMPT_FIRST = `${VERIFY}/attempt_first_factor`; const ATTEMPT_SECOND = `${VERIFY}/attempt_second_factor`; const TOUCH = '/v1/client/sessions/sess_1/touch'; const PASSWORD = 'hunter2'; const CODE = '123456'; const WRONG_CODE = '000000'; const BACKUP_CODE = 'backup-1'; const SECRETS = { password: PASSWORD, email_code: CODE, phone_code: CODE, totp: CODE, backup_code: BACKUP_CODE }; const passwordFactor: SignInFirstFactorJSON = { strategy: 'password' }; const emailFactor: SignInFirstFactorJSON = { strategy: 'email_code', email_address_id: 'idn_alice', safe_identifier: 'alice@example.com', }; const workEmailFactor: SignInFirstFactorJSON = { strategy: 'email_code', email_address_id: 'idn_work', safe_identifier: 'alice@work.example', }; const phoneFactor: SignInFirstFactorJSON = { strategy: 'phone_code', phone_number_id: 'idn_phone', safe_identifier: '+1 555 0100', }; const passkeyFactor: SignInFirstFactorJSON = { strategy: 'passkey' }; const enterpriseSsoFactor = { strategy: 'enterprise_sso', enterprise_connection_id: 'ec_1', enterprise_connection_name: 'Acme SSO', } as unknown as SignInFirstFactorJSON; const totpFactor: SignInSecondFactorJSON = { strategy: 'totp' }; const backupCodeFactor: SignInSecondFactorJSON = { strategy: 'backup_code' }; const secondPhoneFactor: SignInSecondFactorJSON = { strategy: 'phone_code', phone_number_id: 'idn_phone', safe_identifier: '+1 555 0100', }; const TITLE = 'Verification required'; const NO_FACTORS_TITLE = 'Cannot verify your account'; const GENERIC_ERROR_TITLE = 'Something went wrong'; const STEP = { password: 'Enter your current password to continue', emailCode: 'Enter the code sent to your email to continue', phoneCode: 'Enter the code sent to your phone to continue', totp: 'Enter the code generated by your authenticator app to continue', backupCode: 'Enter the backup code you received when setting up two-step authentication', passkey: /Using your passkey confirms your identity/, }; function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { return { client: fapiClient([aliceSession]), ...overrides }; } function verificationSeed(verification: FakeVerificationSeed, overrides: FakeFapiSeed = {}): FakeFapiSeed { return signedIn({ verification: { secrets: SECRETS, ...verification }, ...overrides }); } function preferring(strategy: 'password' | 'otp') { return fapiEnvironment({ display_config: { preferred_sign_in_strategy: strategy } }); } function guardedAction(config?: ReverificationConfig) { return vi.fn().mockResolvedValueOnce(reverificationError(config)).mockResolvedValue({ done: true }); } function errorCode(error: unknown) { if (error instanceof Error) { return 'code' in error && typeof error.code === 'string' ? error.code : error.message; } return 'unknown'; } function Host({ action, onOutcome }: { action: () => Promise; onOutcome?: (outcome: string) => void }) { const [run, reverification] = useReverificationFlow(action); const [outcome, setOutcome] = useState(''); const [running, setRunning] = useState(false); const inFlight = useRef(false); const report = (value: string) => { setOutcome(value); onOutcome?.(value); }; return ( <> {outcome} {running ? ( {reverification.onCancel ? ( ) : null} ) : null} ); } function HoldingHost({ action }: { action: () => Promise }) { const [run, reverification] = useReverificationFlow(action); const [outcome, setOutcome] = useState(''); return ( <> {outcome} {String(reverification.visible)} {reverification.onCancel ? ( ) : null} ); } function renderReverification( action = guardedAction(), seed: FakeFapiSeed = signedIn(), localization?: MosaicLocalization, options?: Parameters[1], ) { const fapi = serveFapi(seed); const host = ; return renderWithClerk( localization ? {host} : host, options, ).then(view => ({ ...view, fapi, action })); } type User = ReturnType; async function startVerification() { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Run action' })); return user; } const outcome = () => screen.getByLabelText('Outcome').textContent; const dismiss = (user: User) => user.click(screen.getByRole('button', { name: 'Dismiss' })); const passwordField = () => screen.getByLabelText('Password'); const slots = () => within(screen.getByRole('group', { name: 'Verification code' })).getAllByRole('textbox'); const slotValues = () => slots().map(slot => slot.value); const verifying = () => screen.getByRole('progressbar', { name: 'Verifying' }); const useAnotherMethod = (user: User) => user.click(screen.getByRole('button', { name: 'Use another method' })); const method = (name: RegExp | string) => screen.getByRole('button', { name }); function pretendToBeARealBrowser() { Object.defineProperty(navigator, 'webdriver', { value: false, configurable: true }); return () => { Reflect.deleteProperty(navigator, 'webdriver'); }; } function pretendWebAuthnIsUnsupported() { const restoreBrowser = pretendToBeARealBrowser(); const original = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); Object.defineProperty(window, 'PublicKeyCredential', { value: undefined, configurable: true, writable: true }); return () => { restoreBrowser(); if (original) { Object.defineProperty(window, 'PublicKeyCredential', original); } }; } function fakeAssertion() { const buffer = () => new Uint8Array([1, 2, 3]).buffer; return { type: 'public-key', id: 'cred_1', rawId: buffer(), authenticatorAttachment: 'platform', response: { clientDataJSON: buffer(), authenticatorData: buffer(), signature: buffer(), userHandle: null }, } as unknown as PublicKeyCredentialWithAuthenticatorAssertionResponse; } function captureNavigations() { const navigation = (window as unknown as { navigation: EventTarget }).navigation; const urls: string[] = []; const onNavigate = (event: Event) => { urls.push((event as Event & { destination: { url: string } }).destination.url); event.preventDefault(); }; navigation.addEventListener('navigate', onNavigate); return { urls, stop: () => navigation.removeEventListener('navigate', onNavigate) }; } const settle = () => new Promise(resolve => setTimeout(resolve, 100)); async function pasteCode(user: User, code: string) { await user.click(slots()[0]); await user.paste(code); } async function untilResolved() { await waitFor(() => expect(outcome()).toBe('resolved')); } async function untilClosed() { await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull()); } describe('Reverification', () => { describe('starting', () => { it('passes through when no reverification is needed', async () => { const action = vi.fn().mockResolvedValue({ done: true }); await renderReverification(action); expect(screen.queryByText(TITLE)).toBeNull(); await startVerification(); await untilResolved(); expect(action).toHaveBeenCalledTimes(1); expect(screen.queryByText(TITLE)).toBeNull(); }); it('opens on the first factor when the hint asks for the first factor level', async () => { const { action } = await renderReverification( guardedAction({ level: 'first_factor', afterMinutes: 10 }), verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), ); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await user.type(passwordField(), `${PASSWORD}{Enter}`); await untilResolved(); expect(action).toHaveBeenCalledTimes(2); }); it('opens on the second factor when the hint asks for the second factor level', async () => { await renderReverification( guardedAction({ level: 'second_factor', afterMinutes: 10 }), verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), ); await startVerification(); expect(await screen.findByText(STEP.totp)).toBeVisible(); expect(screen.queryByLabelText('Password')).toBeNull(); }); it('starts verification at the second factor level when the hint carries no level', async () => { const levelessHint = { clerk_error: { type: 'forbidden', reason: 'reverification-error' } }; const action = vi.fn().mockResolvedValueOnce(levelessHint).mockResolvedValue({ done: true }); await renderReverification( action, verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), ); await startVerification(); expect(await screen.findByText(STEP.totp)).toBeVisible(); }); it('lets the user dismiss the pending card while verification is starting', async () => { await renderReverification(guardedAction(), verificationSeed({})); const start = holdRequests('post', VERIFY); const user = await startVerification(); await waitFor(() => expect(start.requests).toHaveLength(1)); expect(screen.getByText(TITLE)).toBeVisible(); expect(screen.queryByLabelText('Password')).toBeNull(); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); start.release(); }); // This can't use renderWithClerk since that awaits Clerk.load() it.todo('shows the pending card while Clerk is still loading', () => { // A pending card is shown while the session or environment is missing // The first step opens once Clerk is ready }); it('keeps showing a generic error card until it is dismissed when verification cannot be started', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use( http.post(fapiUrl(VERIFY), () => HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Nope' }] }, { status: 400 }), ), ); vi.useFakeTimers({ shouldAdvanceTime: true }); try { const user = await startVerification(); // The error card stays on screen without the server's message expect(await screen.findByText(GENERIC_ERROR_TITLE)).toBeVisible(); expect(screen.getByText('Please try again.')).toBeVisible(); expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); expect(screen.queryByText('Nope')).toBeNull(); await act(() => vi.advanceTimersByTimeAsync(60_000)); expect(screen.getByText(GENERIC_ERROR_TITLE)).toBeVisible(); expect(outcome()).toBe(''); // Closing it rejects the action as cancelled await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); } finally { vi.useRealTimers(); } }); it('keeps showing the no factors card until it is dismissed when none of the factors are supported', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [enterpriseSsoFactor] })); vi.useFakeTimers({ shouldAdvanceTime: true }); try { const user = await startVerification(); expect(await screen.findByText(NO_FACTORS_TITLE)).toBeVisible(); expect(screen.getByText(/No suitable authentication factor is configured/)).toBeVisible(); expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); await act(() => vi.advanceTimersByTimeAsync(60_000)); expect(screen.getByText(NO_FACTORS_TITLE)).toBeVisible(); expect(outcome()).toBe(''); await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); } finally { vi.useRealTimers(); } }); }); describe('choosing the starting method', () => { it('starts with a passkey when the browser supports WebAuthn and the user has one', async () => { await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }, { environment: preferring('password') }), ); const restore = pretendToBeARealBrowser(); try { await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); expect(screen.queryByLabelText('Password')).toBeNull(); } finally { restore(); } }); it('starts with the password when the instance prefers password sign in', async () => { await renderReverification( guardedAction(), verificationSeed({ firstFactors: [emailFactor, passwordFactor] }, { environment: preferring('password') }), ); await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); }); it('starts with a code method when the instance prefers code sign in', async () => { await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] }, { environment: preferring('otp') }), ); await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); }); it('starts the second factor on the authenticator app when the user has one', async () => { await renderReverification( guardedAction({ level: 'second_factor', afterMinutes: 10 }), verificationSeed({ secondFactors: [backupCodeFactor, secondPhoneFactor, totpFactor] }), ); await startVerification(); expect(await screen.findByText(STEP.totp)).toBeVisible(); }); it('starts the second factor on a phone code when there is no authenticator app', async () => { await renderReverification( guardedAction({ level: 'second_factor', afterMinutes: 10 }), verificationSeed({ secondFactors: [backupCodeFactor, secondPhoneFactor] }), ); await startVerification(); expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); }); it('starts the second factor on the first remaining method when there is neither', async () => { await renderReverification( guardedAction({ level: 'second_factor', afterMinutes: 10 }), verificationSeed({ secondFactors: [backupCodeFactor] }), ); await startVerification(); expect(await screen.findByText(STEP.backupCode)).toBeVisible(); }); // This is a future behavior we will need to account for, here's the PR that builds it for the existing reverification: https://github.com/clerk/javascript/pull/9127 it.todo('offer a passkey listed as a second factor', () => { // The passkey is not offered on the second factor step }); }); describe('password', () => { it('recovers from a wrong password and completes the challenge', async () => { const { action } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), ); expect(screen.queryByText(TITLE)).toBeNull(); // The password step opens when the action needs reverification const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); // A wrong password shows an error and stays on the step // Pressing Enter submits the password await user.type(passwordField(), 'wrong{Enter}'); expect(await screen.findByText(/Password is incorrect/)).toBeInTheDocument(); // The typed value is kept after the error expect(passwordField()).toHaveValue('wrong'); // Typing again clears the error await user.clear(passwordField()); await user.type(passwordField(), PASSWORD); expect(screen.queryByText(/Password is incorrect/)).toBeNull(); // The form is disabled and shows the verifying label while the attempt is in flight const attempt = holdRequests('post', ATTEMPT_FIRST); const touch = holdRequests('post', TOUCH); await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(attempt.requests).toHaveLength(1)); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); expect(passwordField()).toHaveAttribute('readonly'); expect(verifying()).toBeInTheDocument(); attempt.release(); // The last step stays visible and pending while the session is being activated await waitFor(() => expect(touch.requests).toHaveLength(1)); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); expect(passwordField()).toHaveAttribute('readonly'); expect(verifying()).toBeInTheDocument(); expect(action).toHaveBeenCalledTimes(1); touch.release(); // The action is retried once the session is activated and resolves with its result await untilResolved(); expect(action).toHaveBeenCalledTimes(2); // The card closes once the retried action has settled await untilClosed(); }); it('reveals and hides the password on request', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(passwordField()).toHaveAttribute('type', 'password'); await user.click(screen.getByRole('button', { name: 'Show password' })); expect(passwordField()).toHaveAttribute('type', 'text'); await user.click(screen.getByRole('button', { name: 'Hide password' })); expect(passwordField()).toHaveAttribute('type', 'password'); }); it('keeps Continue disabled until a password is typed', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); const attempt = holdRequests('post', ATTEMPT_FIRST); expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); await user.type(passwordField(), '{Enter}'); await user.type(passwordField(), PASSWORD); expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); await user.keyboard('{Enter}'); await waitFor(() => expect(attempt.requests).toHaveLength(1)); expect(new URLSearchParams(await attempt.requests[0]?.text()).get('password')).toBe(PASSWORD); attempt.release(); }); }); describe('email code', () => { it('sends a code and completes the challenge with the correct code', async () => { const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const prepare = holdRequests('post', PREPARE_FIRST); // A code is sent once when the step opens const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); await waitFor(() => expect(prepare.requests).toHaveLength(1)); // The field is usable while the code is being sent await user.click(slots()[0]); await user.keyboard(WRONG_CODE.slice(0, 3)); expect(slotValues()).toEqual(['0', '0', '0', '', '', '']); prepare.release(); await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); expect(prepare.requests).toHaveLength(1); // A wrong code submits automatically on the last digit and shows the server error // The field is read-only and shows the verifying label while the attempt is in flight const attempt = holdRequests('post', ATTEMPT_FIRST); await user.keyboard(WRONG_CODE.slice(3)); await waitFor(() => expect(attempt.requests).toHaveLength(1)); expect(slots().every(slot => slot.readOnly && slot.getAttribute('aria-disabled') === 'true')).toBe(true); expect(verifying()).toBeInTheDocument(); attempt.release(); expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); await waitFor(() => expect(slots().every(slot => !slot.disabled)).toBe(true)); // The correct code completes the challenge await user.click(slots()[5]); await user.keyboard('{Backspace>6/}'); expect(slotValues()).toEqual(['', '', '', '', '', '']); await pasteCode(user, CODE); await untilResolved(); }); it('resubmits the typed code with the Continue button', async () => { const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); await pasteCode(user, WRONG_CODE); expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); await waitFor(() => expect(slots().every(slot => !slot.disabled)).toBe(true)); const attempt = holdRequests('post', ATTEMPT_FIRST); await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(attempt.requests).toHaveLength(1)); expect(verifying()).toBeInTheDocument(); attempt.release(); expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); }); it('keeps Continue disabled until the code is complete', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); const attempt = holdRequests('post', ATTEMPT_FIRST); await pasteCode(user, CODE.slice(0, 3)); expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); await user.keyboard('{Enter}'); await user.keyboard(CODE.slice(3)); await waitFor(() => expect(attempt.requests).toHaveLength(1)); expect(new URLSearchParams(await attempt.requests[0]?.text()).get('code')).toBe(CODE); attempt.release(); }); it('holds a submit made while a code is being sent', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const prepare = holdRequests('post', PREPARE_FIRST); const attempt = holdRequests('post', ATTEMPT_FIRST); const user = await startVerification(); await waitFor(() => expect(prepare.requests).toHaveLength(1)); // A submit made while the first code is being sent is held await pasteCode(user, CODE); expect(attempt.requests).toHaveLength(0); // It is sent once the code has been sent prepare.release(); await waitFor(() => expect(attempt.requests).toHaveLength(1)); attempt.release(); await untilResolved(); }); it('discards a held submit when sending the code fails', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const prepare = holdRequests('post', PREPARE_FIRST); const attempt = holdRequests('post', ATTEMPT_FIRST); const user = await startVerification(); await waitFor(() => expect(prepare.requests).toHaveLength(1)); await pasteCode(user, CODE); prepare.fail(); // The error is shown on the step expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); expect(screen.getByText(STEP.emailCode)).toBeVisible(); // The held submit is not sent expect(attempt.requests).toHaveLength(0); attempt.release(); // The user can resend immediately expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); }); it('resends a code once the cooldown has passed', async () => { const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); try { const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); await vi.waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); // Resend is disabled and shows the countdown while the cooldown runs const resend = await screen.findByRole('button', { name: /Resend \(\d+\)/ }); expect(resend).toBeDisabled(); // Resend is enabled again after the cooldown vi.setSystemTime(Date.now() + 30_000); await act(() => vi.advanceTimersByTimeAsync(1000)); expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); expect(screen.getByRole('button', { name: /Resend/ }).textContent).not.toMatch(/\(\d+\)/); // Resending sends a new code and clears the typed value await user.click(slots()[0]); await user.keyboard('123'); expect(slotValues()).toEqual(['1', '2', '3', '', '', '']); const prepare = holdRequests('post', PREPARE_FIRST); await user.click(screen.getByRole('button', { name: /Resend/ })); await vi.waitFor(() => expect(prepare.requests).toHaveLength(1)); expect(slotValues()).toEqual(['', '', '', '', '', '']); expect(screen.getByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled(); // A submit made while a resend is in flight is held and sent once the resend has been sent const attempt = holdRequests('post', ATTEMPT_FIRST); await pasteCode(user, CODE); await settle(); expect(attempt.requests).toHaveLength(0); prepare.release(); await vi.waitFor(() => expect(attempt.requests).toHaveLength(1)); attempt.release(); await untilResolved(); } finally { vi.useRealTimers(); } }); }); describe('phone code', () => { it('sends an SMS code for the first factor and completes the challenge', async () => { const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [phoneFactor] })); const user = await startVerification(); // The SMS code is sent through the first factor endpoints when the step opens expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('phone_code')); // The correct code completes the challenge await pasteCode(user, CODE); await untilResolved(); }); }); describe('moving from the first to the second factor', () => { it('completes a multi factor challenge', async () => { const { fapi } = await renderReverification( guardedAction({ level: 'multi_factor', afterMinutes: 10 }), verificationSeed({ firstFactors: [passwordFactor], secondFactors: [secondPhoneFactor, backupCodeFactor] }), ); // The first factor step opens first const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await user.type(passwordField(), `${PASSWORD}{Enter}`); // The second factor step opens after the first factor is verified expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull()); // The second factor starts with an empty field and no error expect(slotValues()).toEqual(['', '', '', '', '', '']); expect(screen.queryByText('Incorrect code')).toBeNull(); // The SMS code is sent through the second factor endpoints when the step opens await waitFor(() => expect(fapi.verification.secondFactorVerification?.strategy).toBe('phone_code')); // The picker lists second factors only await useAnotherMethod(user); expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Continue with your password' })).toBeNull(); await user.click(screen.getByRole('button', { name: 'Back' })); // The correct code completes the challenge expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); await pasteCode(user, CODE); await untilResolved(); }); }); describe('second factor alternatives', () => { it('switches from the authenticator app to a backup code', async () => { await renderReverification( guardedAction({ level: 'second_factor', afterMinutes: 10 }), verificationSeed({ secondFactors: [totpFactor, backupCodeFactor] }), ); // The authenticator app step opens first const user = await startVerification(); expect(await screen.findByText(STEP.totp)).toBeVisible(); // The picker offers the backup code await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Use a backup code' })); expect(await screen.findByText(STEP.backupCode)).toBeVisible(); // An invalid backup code shows the server error await user.type(screen.getByLabelText('Backup code'), 'invalid{Enter}'); expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); // A valid backup code completes the challenge await user.clear(screen.getByLabelText('Backup code')); await user.type(screen.getByLabelText('Backup code'), `${BACKUP_CODE}{Enter}`); await untilResolved(); }); it('keeps Continue disabled until a backup code is typed', async () => { await renderReverification( guardedAction({ level: 'second_factor', afterMinutes: 10 }), verificationSeed({ secondFactors: [backupCodeFactor] }), ); const user = await startVerification(); expect(await screen.findByText(STEP.backupCode)).toBeVisible(); const attempt = holdRequests('post', ATTEMPT_SECOND); expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); await user.type(screen.getByLabelText('Backup code'), '{Enter}'); await user.type(screen.getByLabelText('Backup code'), BACKUP_CODE); expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); await user.keyboard('{Enter}'); await waitFor(() => expect(attempt.requests).toHaveLength(1)); expect(new URLSearchParams(await attempt.requests[0]?.text()).get('code')).toBe(BACKUP_CODE); attempt.release(); }); }); describe('passkey', () => { it('completes the challenge with the passkey', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), ); let acceptCredential: () => void = () => {}; const getCredential = vi .fn() .mockResolvedValueOnce({ publicKeyCredential: null, error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_cancelled' }), }) .mockImplementationOnce( () => new Promise(resolve => { acceptCredential = () => resolve({ publicKeyCredential: fakeAssertion(), error: null }); }), ); clerk.__internal_getPublicCredentials = getCredential; const restore = pretendToBeARealBrowser(); try { // The passkey step opens const user = await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); // A refused credential shows a localized error banner and stays on the step await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Passkey verification was cancelled or timed out.'), ); expect(screen.getByRole('alert')).not.toHaveTextContent('The operation was refused'); expect(screen.getByText(STEP.passkey)).toBeVisible(); // The button is disabled and shows the verifying label while the credential is being verified await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(verifying()).toBeInTheDocument()); expect(screen.getByRole('button', { name: 'Use another method' })).toBeDisabled(); // A second attempt with an accepted credential completes the challenge await waitFor(() => expect(getCredential).toHaveBeenCalledTimes(2)); acceptCredential(); await untilResolved(); } finally { restore(); } }); it('shows no error when the passkey prompt is aborted', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), ); const getCredential = vi.fn().mockResolvedValue({ publicKeyCredential: null, error: new ClerkWebAuthnError('The operation was aborted', { code: 'passkey_operation_aborted' }), }); clerk.__internal_getPublicCredentials = getCredential; const restore = pretendToBeARealBrowser(); try { const user = await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(getCredential).toHaveBeenCalledTimes(1)); await settle(); // The step stays open and ready for another attempt, without an error expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); expect(screen.getByText(STEP.passkey)).toBeVisible(); expect(screen.getByRole('alert').textContent).toBe(''); } finally { restore(); } }); it('clears an earlier passkey error when the next prompt is aborted', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), ); const getCredential = vi .fn() .mockResolvedValueOnce({ publicKeyCredential: null, error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_cancelled' }), }) .mockResolvedValueOnce({ publicKeyCredential: null, error: new ClerkWebAuthnError('The operation was aborted', { code: 'passkey_operation_aborted' }), }); clerk.__internal_getPublicCredentials = getCredential; const restore = pretendToBeARealBrowser(); try { const user = await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Passkey verification was cancelled or timed out.'), ); await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(getCredential).toHaveBeenCalledTimes(2)); await settle(); expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); expect(screen.getByRole('alert').textContent).toBe(''); } finally { restore(); } }); it('offers the passkey from the picker when another method is showing', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] })); const restore = pretendToBeARealBrowser(); try { const user = await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); expect(await screen.findByText(STEP.password)).toBeVisible(); // The passkey is listed once the user is on another method await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Use your passkey' })); expect(await screen.findByText(STEP.passkey)).toBeVisible(); } finally { restore(); } }); it('does not offer a passkey when the browser does not support WebAuthn', async () => { await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor, emailFactor] }), ); const restore = pretendWebAuthnIsUnsupported(); try { // The passkey is not the starting method const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); // The passkey is not listed in the picker await useAnotherMethod(user); expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); } finally { restore(); } }); }); describe('switching methods', () => { // Unsure about behavior: Back keeps the typed value while picking a method clears it. It is not clear whether keeping it on Back is intended. it('walks through the picker and help steps', async () => { await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor, workEmailFactor, phoneFactor] }), ); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); // The picker lists every method except the one currently shown // Each email address and phone number is its own method await useAnotherMethod(user); expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); expect(method('Email code to alice@work.example')).toBeVisible(); expect(method('Send SMS code to +1 555 0100')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Continue with your password' })).toBeNull(); // Help opens from the picker and offers to email support await user.click(screen.getByRole('button', { name: 'Get help' })); expect(await screen.findByText(/If you have trouble verifying your account/)).toBeVisible(); expect(screen.getByRole('button', { name: 'Email support' })).toBeVisible(); // Back from help returns to the picker await user.click(screen.getByRole('button', { name: 'Back' })); expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); // Back from the picker returns to the method the user came from await user.click(screen.getByRole('button', { name: 'Back' })); expect(await screen.findByText(STEP.password)).toBeVisible(); // Focus moves to the first field of the step the user switches to await waitFor(() => expect(passwordField()).toHaveFocus()); }); it('does not offer another method when the user has only one', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(screen.queryByRole('button', { name: 'Use another method' })).toBeNull(); }); it('does not list factors Mosaic cannot verify', async () => { await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor, enterpriseSsoFactor, emailFactor] }), ); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await useAnotherMethod(user); expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); expect(screen.queryByText(/SSO/i)).toBeNull(); expect(screen.queryByText(/enterprise/i)).toBeNull(); }); it('completes the challenge with a method picked from the picker', async () => { const { fapi } = await renderReverification( guardedAction(), verificationSeed( { firstFactors: [emailFactor, passwordFactor, phoneFactor] }, { environment: preferring('otp') }, ), ); const firstPrepare = holdRequests('post', PREPARE_FIRST); const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); await waitFor(() => expect(firstPrepare.requests).toHaveLength(1)); firstPrepare.release(); // Picking the password shows the password step without sending anything await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(firstPrepare.requests).toHaveLength(1); // Picking an email code shows a pending row and disables the other actions while the code is sent const secondPrepare = holdRequests('post', PREPARE_FIRST); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Email code to alice@example.com' })); await waitFor(() => expect(secondPrepare.requests).toHaveLength(1)); expect(method('Send SMS code to +1 555 0100')).toBeDisabled(); expect(method('Back')).toBeDisabled(); secondPrepare.release(); // The code step opens with an empty field and no error once the code is sent await screen.findByRole('group', { name: 'Verification code' }); expect(screen.getByText(STEP.emailCode)).toBeVisible(); await waitFor(() => expect(slotValues()).toEqual(['', '', '', '', '', ''])); expect(screen.queryByText('Incorrect code')).toBeNull(); // Picking a phone code sends the SMS code through the first factor endpoints await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Send SMS code to +1 555 0100' })); expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); expect(fapi.verification.firstFactorVerification?.strategy).toBe('phone_code'); // The correct code completes the challenge await pasteCode(user, CODE); await untilResolved(); }); it('lands on the picked method with an error when the code cannot be sent', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); const prepare = holdRequests('post', PREPARE_FIRST); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Email code to alice@example.com' })); await waitFor(() => expect(prepare.requests).toHaveLength(1)); prepare.fail(); // The code step opens for the picked method expect(await screen.findByText(STEP.emailCode)).toBeVisible(); // The error is shown on the step expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); }); it('emails the support address from the environment', async () => { await renderReverification( guardedAction(), verificationSeed( { firstFactors: [passwordFactor, emailFactor] }, { environment: fapiEnvironment({ display_config: { support_email: 'help@acme.test' } }) }, ), ); const navigations = captureNavigations(); try { const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Get help' })); // Email support opens a mail to the configured address await user.click(await screen.findByRole('button', { name: 'Email support' })); await waitFor(() => expect(navigations.urls).toEqual(['mailto:help@acme.test'])); } finally { navigations.stop(); } }); it('emails the default support address when the environment has none', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); const navigations = captureNavigations(); try { const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Get help' })); // Email support opens a mail to support@ the Frontend API host await user.click(await screen.findByRole('button', { name: 'Email support' })); await waitFor(() => expect(navigations.urls).toHaveLength(1)); expect(navigations.urls[0]).toMatch(/^mailto:support@run\d+\.12345\.prod\.lclclerk\.com$/); } finally { navigations.stop(); } }); it('emails the support address from the supportEmail option over the environment', async () => { await renderReverification( guardedAction(), verificationSeed( { firstFactors: [passwordFactor, emailFactor] }, { environment: fapiEnvironment({ display_config: { support_email: 'help@acme.test' } }) }, ), undefined, { supportEmail: 'option@acme.test' }, ); const navigations = captureNavigations(); try { const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Get help' })); await user.click(await screen.findByRole('button', { name: 'Email support' })); await waitFor(() => expect(navigations.urls).toEqual(['mailto:option@acme.test'])); } finally { navigations.stop(); } }); }); describe('finishing', () => { it('shows a generic error card when the session cannot be activated', async () => { const { action } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), ); const touch = holdRequests('post', TOUCH); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); // The last step stays visible and pending while the session is being activated await waitFor(() => expect(touch.requests).toHaveLength(1)); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); expect(passwordField()).toHaveAttribute('readonly'); expect(verifying()).toBeInTheDocument(); touch.fail(); // The step is replaced by a generic error card without the server's message expect(await screen.findByText(GENERIC_ERROR_TITLE)).toBeVisible(); expect(screen.getByText('Please try again.')).toBeVisible(); expect(screen.queryByText('form_param_invalid')).toBeNull(); expect(screen.queryByText(STEP.password)).toBeNull(); // The action is not retried expect(action).toHaveBeenCalledTimes(1); expect(outcome()).toBe(''); // Dismissing it rejects the action as cancelled await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); }); it('cannot be interrupted while the action is being retried', async () => { let rejectRetry: (error: Error) => void = () => {}; const retry = new Promise((_, reject) => { rejectRetry = reject; }); const action = vi .fn() .mockResolvedValueOnce(reverificationError()) .mockImplementationOnce(() => retry) .mockResolvedValueOnce(reverificationError()) .mockResolvedValue({ done: true }); await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); // The last step stays visible and pending while the action is being retried await waitFor(() => expect(action).toHaveBeenCalledTimes(2)); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); expect(passwordField()).toHaveAttribute('readonly'); expect(verifying()).toBeInTheDocument(); // Dismissing is not available expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); // A second call rejects with request_already_in_progress await user.click(screen.getByRole('button', { name: 'Run action' })); await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); expect(action).toHaveBeenCalledTimes(2); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); expect(passwordField()).toHaveAttribute('readonly'); // An error thrown by the retried action rejects the action and closes the card rejectRetry(new Error('retry failed')); await waitFor(() => expect(outcome()).toBe('rejected: retry failed')); await untilClosed(); // A later call opens a new challenge from the first step with empty fields await user.click(screen.getByRole('button', { name: 'Run action' })); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(passwordField()).toHaveValue(''); expect(passwordField()).toBeEnabled(); }); }); describe('after the action has ended', () => { const renderHolding = (action: () => Promise) => { serveFapi(verificationSeed({ firstFactors: [passwordFactor] })); return renderWithClerk(); }; it('keeps the last card on screen, inert, after a dismissal, until the next challenge replaces it', async () => { await renderHolding(vi.fn().mockResolvedValue(reverificationError())); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), 'half-typed'); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); expect(screen.getByText(STEP.password)).toBeVisible(); expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); expect(passwordField()).toHaveAttribute('readonly'); await user.click(screen.getByRole('button', { name: 'Run action' })); await waitFor(() => expect(passwordField()).toBeEnabled()); expect(passwordField()).toHaveValue(''); expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); }); it('keeps the last card on screen after the action completes, until the next challenge replaces it', async () => { await renderHolding( vi .fn() .mockResolvedValueOnce(reverificationError()) .mockResolvedValueOnce({ done: true }) .mockResolvedValueOnce(reverificationError()) .mockResolvedValue({ done: true }), ); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await untilResolved(); expect(screen.getByText(STEP.password)).toBeVisible(); expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); expect(passwordField()).toHaveAttribute('aria-disabled', 'true'); await user.click(screen.getByRole('button', { name: 'Run action' })); await waitFor(() => expect(passwordField()).toBeEnabled()); expect(passwordField()).toHaveValue(''); expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); }); it('is visible once there is a card of its own to show, until the run ends', async () => { await renderHolding(guardedAction()); const start = holdRequests('post', VERIFY); const visible = () => screen.getByLabelText('Visible').textContent; expect(visible()).toBe('false'); const user = await startVerification(); await waitFor(() => expect(start.requests).toHaveLength(1)); expect(screen.getByText(TITLE)).toBeVisible(); expect(visible()).toBe('false'); start.release(); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(visible()).toBe('true'); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); expect(visible()).toBe('false'); expect(screen.getByText(STEP.password)).toBeVisible(); }); it('does not keep anything on screen when no reverification was needed', async () => { await renderHolding(vi.fn().mockResolvedValue({ done: true })); await startVerification(); await untilResolved(); expect(screen.queryByText(STEP.password)).toBeNull(); }); }); describe('dismissing', () => { it('dismisses the challenge and starts fresh the next time', async () => { const action = vi .fn() .mockResolvedValueOnce(reverificationError()) .mockResolvedValueOnce(reverificationError()) .mockResolvedValue({ done: true }); await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), 'half-typed'); // Dismissing rejects the action with reverification_cancelled await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); // The card closes await untilClosed(); // Running the action again opens from the first step with empty fields await user.click(screen.getByRole('button', { name: 'Run action' })); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(passwordField()).toHaveValue(''); }); it('can be dismissed from the method picker', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await useAnotherMethod(user); expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); }); it('can be dismissed from the help step', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await useAnotherMethod(user); await user.click(await screen.findByRole('button', { name: 'Get help' })); expect(await screen.findByRole('button', { name: 'Email support' })).toBeVisible(); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); }); it('can be dismissed while a code is being sent', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const prepare = holdRequests('post', PREPARE_FIRST); const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); await waitFor(() => expect(prepare.requests).toHaveLength(1)); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); prepare.release(); }); it('ignores a late failure from an attempt that was in flight when dismissed', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); const attempt = holdRequests('post', ATTEMPT_FIRST); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(attempt.requests).toHaveLength(1)); // The card closes and the action is rejected as cancelled await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); // The late failure does not reopen the card or show an error attempt.fail(); await settle(); expect(screen.queryByText(TITLE)).toBeNull(); expect(screen.queryByText('form_param_invalid')).toBeNull(); expect(outcome()).toBe('rejected: reverification_cancelled'); }); it('ignores a late success from an attempt that was in flight when dismissed', async () => { const { fapi, action } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), ); const attempt = holdRequests('post', ATTEMPT_FIRST); const touch = holdRequests('post', TOUCH); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(attempt.requests).toHaveLength(1)); // The card closes and the action is rejected as cancelled await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); // The session is not activated attempt.release(); await waitFor(() => expect(fapi.verification.status).toBe('complete')); await settle(); expect(touch.requests).toHaveLength(0); touch.release(); // The action is not retried expect(action).toHaveBeenCalledTimes(1); expect(screen.queryByText(TITLE)).toBeNull(); }); it('starts a fresh challenge when the action is run again while a dismissed attempt is in flight', async () => { const action = vi .fn() .mockResolvedValueOnce(reverificationError()) .mockResolvedValueOnce(reverificationError()) .mockResolvedValue({ done: true }); await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); const attempt = holdRequests('post', ATTEMPT_FIRST); const touch = holdRequests('post', TOUCH); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(attempt.requests).toHaveLength(1)); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); // The new challenge opens from the first step with empty fields await user.click(screen.getByRole('button', { name: 'Run action' })); expect(await screen.findByText(STEP.password)).toBeVisible(); expect(passwordField()).toHaveValue(''); expect(passwordField()).toBeEnabled(); // The late response from the dismissed attempt has no effect attempt.release(); await settle(); expect(passwordField()).toHaveValue(''); expect(passwordField()).toBeEnabled(); expect(screen.getByText(STEP.password)).toBeVisible(); expect(touch.requests).toHaveLength(0); touch.release(); expect(action).toHaveBeenCalledTimes(2); }); }); describe('action lifecycle', () => { it('rejects a second call while the first request is in flight', async () => { const response = deferred<{ done: true }>(); const action = vi.fn(() => response.promise); await renderReverification(action); const user = await startVerification(); await user.click(screen.getByRole('button', { name: 'Run action' })); await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); expect(action).toHaveBeenCalledOnce(); response.resolve({ done: true }); await untilResolved(); }); it('rejects a second call while a challenge is open', async () => { const { action } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), ); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), 'typed'); await user.click(screen.getByRole('button', { name: 'Run action' })); // The second call rejects with request_already_in_progress await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); // The open challenge is unaffected expect(action).toHaveBeenCalledOnce(); expect(screen.getByText(STEP.password)).toBeVisible(); expect(passwordField()).toHaveValue('typed'); }); it('cancels the challenge when the user signs out', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), ); await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await act(() => clerk.signOut()); // The card closes await untilClosed(); // The action is rejected as cancelled await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); }); it('cancels the challenge when the active session changes', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }, { client: fapiClient([aliceSession, bobSession]) }), ); await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); await act(() => clerk.setActive({ session: 'sess_2' })); await untilClosed(); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); }); it('keeps the challenge open while the session is briefly unset during a navigation', async () => { const { clerk, navigate } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), ); const navigation = deferred(); navigate.mockImplementationOnce(() => navigation.promise); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); let switching: Promise = Promise.resolve(); act(() => { switching = clerk.setActive({ session: 'sess_1', redirectUrl: '/elsewhere' }); }); await waitFor(() => expect(navigate).toHaveBeenCalledWith('/elsewhere')); expect(clerk.session).toBeUndefined(); navigation.resolve(undefined); await act(() => switching); expect(clerk.session?.id).toBe('sess_1'); expect(screen.getByText(STEP.password)).toBeVisible(); expect(outcome()).toBe(''); await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); }); it('lets a retry finish when the session changes or signs out', async () => { const retry = deferred<{ done: true }>(); const action = vi .fn() .mockResolvedValueOnce(reverificationError()) .mockImplementationOnce(() => retry.promise); const { clerk } = await renderReverification( action, verificationSeed({ firstFactors: [passwordFactor] }, { client: fapiClient([aliceSession, bobSession]) }), ); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(action).toHaveBeenCalledTimes(2)); await act(() => clerk.setActive({ session: 'sess_2' })); await act(() => clerk.signOut()); await settle(); expect(outcome()).toBe(''); retry.resolve({ done: true }); await untilResolved(); }); it('cancels the challenge when its owner unmounts', async () => { serveFapi(verificationSeed({ firstFactors: [passwordFactor] })); const onOutcome = vi.fn(); const { rerender } = await renderWithClerk( , ); await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); rerender(

Gone

); await waitFor(() => expect(onOutcome).toHaveBeenCalledWith('rejected: reverification_cancelled')); }); it('cancels the challenge when its owner unmounts before the action asks for reverification', async () => { serveFapi(signedIn()); const response = deferred(); const action = vi.fn(() => response.promise); const onOutcome = vi.fn(); const { rerender } = await renderWithClerk( , ); await startVerification(); await waitFor(() => expect(action).toHaveBeenCalledOnce()); rerender(

Gone

); response.resolve(reverificationError()); await waitFor(() => expect(onOutcome).toHaveBeenCalledWith('rejected: reverification_cancelled')); expect(action).toHaveBeenCalledOnce(); }); it('lets a retry finish when its owner unmounts', async () => { serveFapi(verificationSeed({ firstFactors: [passwordFactor] })); const retry = deferred<{ done: true }>(); const action = vi .fn() .mockResolvedValueOnce(reverificationError()) .mockImplementationOnce(() => retry.promise); const onOutcome = vi.fn(); const { rerender } = await renderWithClerk( , ); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(action).toHaveBeenCalledTimes(2)); rerender(

Gone

); await settle(); expect(onOutcome).not.toHaveBeenCalled(); retry.resolve({ done: true }); await waitFor(() => expect(onOutcome).toHaveBeenCalledWith('resolved')); }); it('passes through an error that is unrelated to reverification', async () => { const action = vi.fn().mockRejectedValue(new Error('unrelated failure')); await renderReverification(action); await startVerification(); // The action rejects with the original error await waitFor(() => expect(outcome()).toBe('rejected: unrelated failure')); // No challenge card opens expect(screen.queryByText(TITLE)).toBeNull(); }); }); describe('errors', () => { it('shows a localized message for a wrong password', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), { messages: { 'errors.form_password_incorrect': 'Mot de passe incorrect.' }, }); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), 'wrong{Enter}'); expect(await screen.findByText('Mot de passe incorrect.')).toBeInTheDocument(); expect(screen.queryByText(/Password is incorrect/)).toBeNull(); }); it('shows a localized message for a wrong code', async () => { const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] }), { messages: { 'errors.form_code_incorrect': 'Code incorrect.' }, }); const user = await startVerification(); expect(await screen.findByText(STEP.emailCode)).toBeVisible(); await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); await pasteCode(user, WRONG_CODE); expect(await screen.findByText('Code incorrect.')).toBeInTheDocument(); expect(screen.queryByText('Incorrect code')).toBeNull(); }); it('shows the long message from the server for a Clerk error code without a localized message', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use( http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.json( { errors: [{ code: 'some_new_error', message: 'Short message', long_message: 'Long message' }] }, { status: 422 }, ), ), ); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); expect(await screen.findByText('Long message')).toBeInTheDocument(); expect(screen.queryByText('Short message')).toBeNull(); }); it('shows the short message from the server when there is no long message', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use( http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Short message' }] }, { status: 422 }), ), ); const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); expect(await screen.findByText('Short message')).toBeInTheDocument(); }); it('shows a localized generic message when the failure is not a Clerk error', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), { messages: { 'reverification.unstable__errors__generic': 'Something custom went wrong' }, }, ); const user = await startVerification(); expect(await screen.findByText(STEP.password)).toBeVisible(); vi.spyOn(clerk.session!, 'attemptFirstFactorVerification').mockRejectedValue('not an error'); await user.type(passwordField(), `${PASSWORD}{Enter}`); expect(await screen.findByText('Something custom went wrong')).toBeInTheDocument(); }); it('shows the generic message instead of the request URL when there is a network failure', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); const restore = pretendToBeARealBrowser(); try { const user = await startVerification(); await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(passwordField()).toBeInvalid()); expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); expect(document.body).not.toHaveTextContent(/https?:\/\//); } finally { restore(); } }); it('keeps the step open and lets the user try again after a network failure', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); const restore = pretendToBeARealBrowser(); try { const user = await startVerification(); // The step stays open with an error await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(passwordField()).toBeInvalid()); expect(screen.getByText(STEP.password)).toBeVisible(); expect(passwordField()).toBeEnabled(); expect(passwordField()).toHaveValue(PASSWORD); // The user can try again await user.type(passwordField(), '{Enter}'); await untilResolved(); } finally { restore(); } }); }); }); /* Since this file already lists a lot of the behaviors worth knowing about for reverification, here are a few others that might be worth knowing about, but don't deserve tests here: Owned by the server (tested in clerk_go/tests/fapi/session_reverification_test.go): - A second_factor or multi_factor request falls back to first factor when the user has no second factor (TestSessionReverification_FallbackLevelFirstFactor) - The password factor is omitted when password reverification is disabled, and a second factor request then falls back to first factor (PasswordDisabled, PasswordDisabled_SecondFactorFallback) - Second factor only strategies are rejected as first factors (SecondFactorsRejectedAsFirstFactors) - Phone numbers reserved for second factor are rejected as first factors, and a phone number must be reserved for second factor to be used as one (FirstFactor_RejectsReservedForSecondFactorPhone, SecondFactor_PhoneCode) - A passkey can satisfy the second factor, which completes a multi factor request without a second step, unless the instance opts out (MultiFactor_PasskeySatisfiesSecondFactor and _Disabled) - A TOTP code that was already consumed is rejected (TOTP_RejectsConsumedCode) - A pwned password is still accepted for reverification (FirstFactor_PwnedPassword) - Wrong passwords and codes are 422 (form_password_incorrect, form_code_incorrect). Acting in the wrong state is 400 (invalid_action_for_session_reverification), as is attempting a code that was never sent (verification_not_sent) and acting before starting (session_reverification_missing) */