@@ -12,6 +12,7 @@ import { http, HttpResponse } from 'msw';
1212import { useRef , useState } from 'react' ;
1313import { describe , expect , it , vi } from 'vitest' ;
1414
15+ import { deferred } from '../../../__tests__/async' ;
1516import { type FakeFapiSeed , fapiUrl , holdRequests , serveFapi , worker } from '../../../__tests__/feature/fake-fapi' ;
1617import type { FakeVerificationSeed } from '../../../__tests__/feature/fake-fapi/verification' ;
1718import { fapiClient , fapiEmailAddress , fapiEnvironment , fapiSession , fapiUser } from '../../../__tests__/feature/fapi' ;
@@ -29,6 +30,14 @@ const alice = fapiUser({
2930} ) ;
3031
3132const aliceSession = fapiSession ( { id : 'sess_1' , user : alice } ) ;
33+ const bobSession = fapiSession ( {
34+ id : 'sess_2' ,
35+ user : fapiUser ( {
36+ id : 'user_2' ,
37+ first_name : 'Bob' ,
38+ email_addresses : [ fapiEmailAddress ( { id : 'idn_bob' , email_address : 'bob@example.com' } ) ] ,
39+ } ) ,
40+ } ) ;
3241
3342const VERIFY = '/v1/client/sessions/sess_1/verify' ;
3443const PREPARE_FIRST = `${ VERIFY } /prepare_first_factor` ;
@@ -109,11 +118,15 @@ function errorCode(error: unknown) {
109118 return 'unknown' ;
110119}
111120
112- function Host ( { action } : { action : ( ) => Promise < unknown > } ) {
121+ function Host ( { action, onOutcome } : { action : ( ) => Promise < unknown > ; onOutcome ?: ( outcome : string ) => void } ) {
113122 const [ run , reverification ] = useReverificationFlow ( action ) ;
114123 const [ outcome , setOutcome ] = useState ( '' ) ;
115124 const [ running , setRunning ] = useState ( false ) ;
116125 const inFlight = useRef ( false ) ;
126+ const report = ( value : string ) => {
127+ setOutcome ( value ) ;
128+ onOutcome ?.( value ) ;
129+ } ;
117130
118131 return (
119132 < >
@@ -127,8 +140,8 @@ function Host({ action }: { action: () => Promise<unknown> }) {
127140 }
128141 void run ( )
129142 . then (
130- ( ) => setOutcome ( 'resolved' ) ,
131- ( error : unknown ) => setOutcome ( `rejected: ${ errorCode ( error ) } ` ) ,
143+ ( ) => report ( 'resolved' ) ,
144+ ( error : unknown ) => report ( `rejected: ${ errorCode ( error ) } ` ) ,
132145 )
133146 . finally ( ( ) => {
134147 if ( owner ) {
@@ -196,11 +209,13 @@ function renderReverification(
196209 action = guardedAction ( ) ,
197210 seed : FakeFapiSeed = signedIn ( ) ,
198211 localization ?: MosaicLocalization ,
212+ options ?: Parameters < typeof renderWithClerk > [ 1 ] ,
199213) {
200214 const fapi = serveFapi ( seed ) ;
201215 const host = < Host action = { action } /> ;
202216 return renderWithClerk (
203217 localization ? < MosaicProvider localization = { localization } > { host } </ MosaicProvider > : host ,
218+ options ,
204219 ) . then ( view => ( { ...view , fapi, action } ) ) ;
205220}
206221
@@ -1060,7 +1075,32 @@ describe('Reverification', () => {
10601075 // Email support opens a mail to support@ the Frontend API host
10611076 await user . click ( await screen . findByRole ( 'button' , { name : 'Email support' } ) ) ;
10621077 await waitFor ( ( ) => expect ( navigations . urls ) . toHaveLength ( 1 ) ) ;
1063- expect ( navigations . urls [ 0 ] ) . toMatch ( / ^ m a i l t o : s u p p o r t @ / ) ;
1078+ expect ( navigations . urls [ 0 ] ) . toMatch ( / ^ m a i l t o : s u p p o r t @ r u n \d + \. 1 2 3 4 5 \. p r o d \. l c l c l e r k \. c o m $ / ) ;
1079+ } finally {
1080+ navigations . stop ( ) ;
1081+ }
1082+ } ) ;
1083+
1084+ it ( 'emails the support address from the supportEmail option over the environment' , async ( ) => {
1085+ await renderReverification (
1086+ guardedAction ( ) ,
1087+ verificationSeed (
1088+ { firstFactors : [ passwordFactor , emailFactor ] } ,
1089+ { environment : fapiEnvironment ( { display_config : { support_email : 'help@acme.test' } } ) } ,
1090+ ) ,
1091+ undefined ,
1092+ { supportEmail : 'option@acme.test' } ,
1093+ ) ;
1094+ const navigations = captureNavigations ( ) ;
1095+
1096+ try {
1097+ const user = await startVerification ( ) ;
1098+ expect ( await screen . findByText ( STEP . password ) ) . toBeVisible ( ) ;
1099+ await useAnotherMethod ( user ) ;
1100+ await user . click ( await screen . findByRole ( 'button' , { name : 'Get help' } ) ) ;
1101+
1102+ await user . click ( await screen . findByRole ( 'button' , { name : 'Email support' } ) ) ;
1103+ await waitFor ( ( ) => expect ( navigations . urls ) . toEqual ( [ 'mailto:option@acme.test' ] ) ) ;
10641104 } finally {
10651105 navigations . stop ( ) ;
10661106 }
@@ -1127,6 +1167,7 @@ describe('Reverification', () => {
11271167 // A second call rejects with request_already_in_progress
11281168 await user . click ( screen . getByRole ( 'button' , { name : 'Run action' } ) ) ;
11291169 await waitFor ( ( ) => expect ( outcome ( ) ) . toBe ( 'rejected: request_already_in_progress' ) ) ;
1170+ expect ( action ) . toHaveBeenCalledTimes ( 2 ) ;
11301171 expect ( passwordField ( ) ) . toHaveAttribute ( 'aria-disabled' , 'true' ) ;
11311172 expect ( passwordField ( ) ) . toHaveAttribute ( 'readonly' ) ;
11321173
@@ -1369,8 +1410,26 @@ describe('Reverification', () => {
13691410 } ) ;
13701411
13711412 describe ( 'action lifecycle' , ( ) => {
1413+ it ( 'rejects a second call while the first request is in flight' , async ( ) => {
1414+ const response = deferred < { done : true } > ( ) ;
1415+ const action = vi . fn ( ( ) => response . promise ) ;
1416+ await renderReverification ( action ) ;
1417+ const user = await startVerification ( ) ;
1418+
1419+ await user . click ( screen . getByRole ( 'button' , { name : 'Run action' } ) ) ;
1420+
1421+ await waitFor ( ( ) => expect ( outcome ( ) ) . toBe ( 'rejected: request_already_in_progress' ) ) ;
1422+ expect ( action ) . toHaveBeenCalledOnce ( ) ;
1423+
1424+ response . resolve ( { done : true } ) ;
1425+ await untilResolved ( ) ;
1426+ } ) ;
1427+
13721428 it ( 'rejects a second call while a challenge is open' , async ( ) => {
1373- await renderReverification ( guardedAction ( ) , verificationSeed ( { firstFactors : [ passwordFactor ] } ) ) ;
1429+ const { action } = await renderReverification (
1430+ guardedAction ( ) ,
1431+ verificationSeed ( { firstFactors : [ passwordFactor ] } ) ,
1432+ ) ;
13741433 const user = await startVerification ( ) ;
13751434 await user . type ( await screen . findByLabelText ( 'Password' ) , 'typed' ) ;
13761435
@@ -1380,6 +1439,7 @@ describe('Reverification', () => {
13801439 await waitFor ( ( ) => expect ( outcome ( ) ) . toBe ( 'rejected: request_already_in_progress' ) ) ;
13811440
13821441 // The open challenge is unaffected
1442+ expect ( action ) . toHaveBeenCalledOnce ( ) ;
13831443 expect ( screen . getByText ( STEP . password ) ) . toBeVisible ( ) ;
13841444 expect ( passwordField ( ) ) . toHaveValue ( 'typed' ) ;
13851445 } ) ;
@@ -1401,6 +1461,134 @@ describe('Reverification', () => {
14011461 await waitFor ( ( ) => expect ( outcome ( ) ) . toBe ( 'rejected: reverification_cancelled' ) ) ;
14021462 } ) ;
14031463
1464+ it ( 'cancels the challenge when the active session changes' , async ( ) => {
1465+ const { clerk } = await renderReverification (
1466+ guardedAction ( ) ,
1467+ verificationSeed ( { firstFactors : [ passwordFactor ] } , { client : fapiClient ( [ aliceSession , bobSession ] ) } ) ,
1468+ ) ;
1469+ await startVerification ( ) ;
1470+ expect ( await screen . findByText ( STEP . password ) ) . toBeVisible ( ) ;
1471+
1472+ await act ( ( ) => clerk . setActive ( { session : 'sess_2' } ) ) ;
1473+
1474+ await untilClosed ( ) ;
1475+ await waitFor ( ( ) => expect ( outcome ( ) ) . toBe ( 'rejected: reverification_cancelled' ) ) ;
1476+ } ) ;
1477+
1478+ it ( 'keeps the challenge open while the session is briefly unset during a navigation' , async ( ) => {
1479+ const { clerk, navigate } = await renderReverification (
1480+ guardedAction ( ) ,
1481+ verificationSeed ( { firstFactors : [ passwordFactor ] } ) ,
1482+ ) ;
1483+ const navigation = deferred < undefined > ( ) ;
1484+ navigate . mockImplementationOnce ( ( ) => navigation . promise ) ;
1485+ const user = await startVerification ( ) ;
1486+ expect ( await screen . findByText ( STEP . password ) ) . toBeVisible ( ) ;
1487+
1488+ let switching : Promise < void > = Promise . resolve ( ) ;
1489+ act ( ( ) => {
1490+ switching = clerk . setActive ( { session : 'sess_1' , redirectUrl : '/elsewhere' } ) ;
1491+ } ) ;
1492+ await waitFor ( ( ) => expect ( navigate ) . toHaveBeenCalledWith ( '/elsewhere' ) ) ;
1493+ expect ( clerk . session ) . toBeUndefined ( ) ;
1494+ navigation . resolve ( undefined ) ;
1495+ await act ( ( ) => switching ) ;
1496+
1497+ expect ( clerk . session ?. id ) . toBe ( 'sess_1' ) ;
1498+ expect ( screen . getByText ( STEP . password ) ) . toBeVisible ( ) ;
1499+ expect ( outcome ( ) ) . toBe ( '' ) ;
1500+
1501+ await dismiss ( user ) ;
1502+ await waitFor ( ( ) => expect ( outcome ( ) ) . toBe ( 'rejected: reverification_cancelled' ) ) ;
1503+ } ) ;
1504+
1505+ it ( 'lets a retry finish when the session changes or signs out' , async ( ) => {
1506+ const retry = deferred < { done : true } > ( ) ;
1507+ const action = vi
1508+ . fn ( )
1509+ . mockResolvedValueOnce ( reverificationError ( ) )
1510+ . mockImplementationOnce ( ( ) => retry . promise ) ;
1511+ const { clerk } = await renderReverification (
1512+ action ,
1513+ verificationSeed ( { firstFactors : [ passwordFactor ] } , { client : fapiClient ( [ aliceSession , bobSession ] ) } ) ,
1514+ ) ;
1515+ const user = await startVerification ( ) ;
1516+ await user . type ( await screen . findByLabelText ( 'Password' ) , `${ PASSWORD } {Enter}` ) ;
1517+ await waitFor ( ( ) => expect ( action ) . toHaveBeenCalledTimes ( 2 ) ) ;
1518+
1519+ await act ( ( ) => clerk . setActive ( { session : 'sess_2' } ) ) ;
1520+ await act ( ( ) => clerk . signOut ( ) ) ;
1521+ await settle ( ) ;
1522+ expect ( outcome ( ) ) . toBe ( '' ) ;
1523+
1524+ retry . resolve ( { done : true } ) ;
1525+ await untilResolved ( ) ;
1526+ } ) ;
1527+
1528+ it ( 'cancels the challenge when its owner unmounts' , async ( ) => {
1529+ serveFapi ( verificationSeed ( { firstFactors : [ passwordFactor ] } ) ) ;
1530+ const onOutcome = vi . fn ( ) ;
1531+ const { rerender } = await renderWithClerk (
1532+ < Host
1533+ action = { guardedAction ( ) }
1534+ onOutcome = { onOutcome }
1535+ /> ,
1536+ ) ;
1537+ await startVerification ( ) ;
1538+ expect ( await screen . findByText ( STEP . password ) ) . toBeVisible ( ) ;
1539+
1540+ rerender ( < p > Gone</ p > ) ;
1541+
1542+ await waitFor ( ( ) => expect ( onOutcome ) . toHaveBeenCalledWith ( 'rejected: reverification_cancelled' ) ) ;
1543+ } ) ;
1544+
1545+ it ( 'cancels the challenge when its owner unmounts before the action asks for reverification' , async ( ) => {
1546+ serveFapi ( signedIn ( ) ) ;
1547+ const response = deferred < unknown > ( ) ;
1548+ const action = vi . fn ( ( ) => response . promise ) ;
1549+ const onOutcome = vi . fn ( ) ;
1550+ const { rerender } = await renderWithClerk (
1551+ < Host
1552+ action = { action }
1553+ onOutcome = { onOutcome }
1554+ /> ,
1555+ ) ;
1556+ await startVerification ( ) ;
1557+ await waitFor ( ( ) => expect ( action ) . toHaveBeenCalledOnce ( ) ) ;
1558+
1559+ rerender ( < p > Gone</ p > ) ;
1560+ response . resolve ( reverificationError ( ) ) ;
1561+
1562+ await waitFor ( ( ) => expect ( onOutcome ) . toHaveBeenCalledWith ( 'rejected: reverification_cancelled' ) ) ;
1563+ expect ( action ) . toHaveBeenCalledOnce ( ) ;
1564+ } ) ;
1565+
1566+ it ( 'lets a retry finish when its owner unmounts' , async ( ) => {
1567+ serveFapi ( verificationSeed ( { firstFactors : [ passwordFactor ] } ) ) ;
1568+ const retry = deferred < { done : true } > ( ) ;
1569+ const action = vi
1570+ . fn ( )
1571+ . mockResolvedValueOnce ( reverificationError ( ) )
1572+ . mockImplementationOnce ( ( ) => retry . promise ) ;
1573+ const onOutcome = vi . fn ( ) ;
1574+ const { rerender } = await renderWithClerk (
1575+ < Host
1576+ action = { action }
1577+ onOutcome = { onOutcome }
1578+ /> ,
1579+ ) ;
1580+ const user = await startVerification ( ) ;
1581+ await user . type ( await screen . findByLabelText ( 'Password' ) , `${ PASSWORD } {Enter}` ) ;
1582+ await waitFor ( ( ) => expect ( action ) . toHaveBeenCalledTimes ( 2 ) ) ;
1583+
1584+ rerender ( < p > Gone</ p > ) ;
1585+ await settle ( ) ;
1586+ expect ( onOutcome ) . not . toHaveBeenCalled ( ) ;
1587+
1588+ retry . resolve ( { done : true } ) ;
1589+ await waitFor ( ( ) => expect ( onOutcome ) . toHaveBeenCalledWith ( 'resolved' ) ) ;
1590+ } ) ;
1591+
14041592 it ( 'passes through an error that is unrelated to reverification' , async ( ) => {
14051593 const action = vi . fn ( ) . mockRejectedValue ( new Error ( 'unrelated failure' ) ) ;
14061594 await renderReverification ( action ) ;
0 commit comments