@@ -5,6 +5,7 @@ import { reverificationController, reverificationFactorKey } from './reverificat
55import type {
66 ReverificationAttempt ,
77 ReverificationAttemptResult ,
8+ ReverificationBackupCodeFactor ,
89 ReverificationChallenge ,
910 ReverificationCompleteResult ,
1011 ReverificationEmailCodeFactor ,
@@ -39,6 +40,11 @@ const totpFactor: ReverificationTOTPFactor = {
3940 strategy : 'totp' ,
4041} ;
4142
43+ const backupCodeFactor : ReverificationBackupCodeFactor = {
44+ stage : 'second' ,
45+ strategy : 'backup_code' ,
46+ } ;
47+
4248const secondPhoneFactor : ReverificationSecondFactorPhoneCodeFactor = {
4349 stage : 'second' ,
4450 strategy : 'phone_code' ,
@@ -339,7 +345,8 @@ describe('reverificationController', () => {
339345 expect ( prepare ) . toHaveBeenNthCalledWith ( 3 , emailFactor ) ;
340346 } ) ;
341347
342- it ( 'stays on the current factor when preparation fails, and retries through resend' , async ( ) => {
348+ it ( 'holds the send cooldown when preparation fails, and retries through resend' , async ( ) => {
349+ vi . useFakeTimers ( ) ;
343350 const prepare = vi
344351 . fn < ( factor : ReverificationPreparationFactor ) => Promise < void > > ( )
345352 . mockRejectedValueOnce ( new Error ( 'Could not send the code.' ) )
@@ -351,26 +358,95 @@ describe('reverificationController', () => {
351358
352359 expect ( actor . getSnapshot ( ) ) . toMatchObject ( {
353360 value : 'preparing' ,
354- context : { currentFactor : emailFactor , resendSecondsRemaining : 0 } ,
361+ context : { currentFactor : emailFactor , resendSecondsRemaining : 30 } ,
355362 } ) ;
356363 expect ( actor . can ( { type : 'RESEND' } ) ) . toBe ( false ) ;
357364 expect ( actor . can ( { type : 'SHOW_ALTERNATIVES' } ) ) . toBe ( true ) ;
358365
359- await vi . waitFor ( ( ) => expect ( actor . getSnapshot ( ) . value ) . toBe ( 'preparationFailed' ) ) ;
360- expect ( actor . getSnapshot ( ) . context ) . toMatchObject ( {
361- currentFactor : emailFactor ,
362- error : { scope : 'flow' , message : 'Could not send the code.' } ,
363- resendSecondsRemaining : 0 ,
366+ await vi . runAllTicks ( ) ;
367+ expect ( actor . getSnapshot ( ) ) . toMatchObject ( {
368+ value : 'verifyingCooldown' ,
369+ context : {
370+ currentFactor : emailFactor ,
371+ error : { scope : 'flow' , message : 'Could not send the code.' } ,
372+ resendSecondsRemaining : 30 ,
373+ } ,
364374 } ) ;
365- expect ( actor . can ( { type : 'RESEND' } ) ) . toBe ( true ) ;
366- expect ( actor . can ( { type : 'SHOW_ALTERNATIVES' } ) ) . toBe ( true ) ;
375+ expect ( actor . can ( { type : 'RESEND' } ) ) . toBe ( false ) ;
367376
377+ await vi . advanceTimersByTimeAsync ( 30_000 ) ;
378+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ;
368379 actor . send ( { type : 'RESEND' } ) ;
369- await vi . waitFor ( ( ) => expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifyingCooldown' ) ) ;
380+ await vi . runAllTicks ( ) ;
381+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifyingCooldown' ) ;
370382 expect ( prepare ) . toHaveBeenCalledTimes ( 2 ) ;
371383 } ) ;
372384
373- it ( 'returns verification failures to the field and clears them on input' , async ( ) => {
385+ it ( 'does not resend inside the cooldown when alternatives are opened after a failed send' , async ( ) => {
386+ vi . useFakeTimers ( ) ;
387+ const prepare = vi
388+ . fn < ( factor : ReverificationPreparationFactor ) => Promise < void > > ( )
389+ . mockRejectedValueOnce ( new Error ( 'Could not send the code.' ) )
390+ . mockResolvedValue ( undefined ) ;
391+ const { actor } = start ( {
392+ challenge : firstFactorChallenge ( { initialFactor : emailFactor } ) ,
393+ prepare,
394+ } ) ;
395+ await vi . runAllTicks ( ) ;
396+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
397+
398+ actor . send ( { type : 'SHOW_ALTERNATIVES' } ) ;
399+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'selectingFactor' ) ;
400+ actor . send ( { type : 'BACK' } ) ;
401+ await vi . runAllTicks ( ) ;
402+
403+ // The factor is still unprepared, but the cooldown from the failed send outranks that.
404+ expect ( actor . getSnapshot ( ) ) . toMatchObject ( {
405+ value : 'verifyingCooldown' ,
406+ context : { preparedFactorKey : null , resendSecondsRemaining : 20 } ,
407+ } ) ;
408+ expect ( prepare ) . toHaveBeenCalledOnce ( ) ;
409+
410+ await vi . advanceTimersByTimeAsync ( 20_000 ) ;
411+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ;
412+ expect ( prepare ) . toHaveBeenCalledOnce ( ) ;
413+ } ) ;
414+
415+ it ( 'throttles from when the send was issued, not from when it landed' , async ( ) => {
416+ vi . useFakeTimers ( ) ;
417+ const prepare = vi
418+ . fn < ( factor : ReverificationPreparationFactor ) => Promise < void > > ( )
419+ . mockImplementation ( ( ) => new Promise < void > ( resolve => setTimeout ( resolve , 5_000 ) ) ) ;
420+ const { actor } = start ( {
421+ challenge : firstFactorChallenge ( { initialFactor : emailFactor } ) ,
422+ prepare,
423+ } ) ;
424+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'preparing' ) ;
425+
426+ await vi . advanceTimersByTimeAsync ( 5_000 ) ;
427+ expect ( actor . getSnapshot ( ) ) . toMatchObject ( {
428+ value : 'verifyingCooldown' ,
429+ context : { resendSecondsRemaining : 25 } ,
430+ } ) ;
431+
432+ await vi . advanceTimersByTimeAsync ( 25_000 ) ;
433+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ;
434+ } ) ;
435+
436+ it ( 'clears a half-entered code when a new one is sent' , async ( ) => {
437+ vi . useFakeTimers ( ) ;
438+ const { actor } = start ( { challenge : firstFactorChallenge ( { initialFactor : emailFactor } ) } ) ;
439+ await vi . runAllTicks ( ) ;
440+ await vi . advanceTimersByTimeAsync ( 30_000 ) ;
441+
442+ actor . send ( { type : 'CHANGE_VALUE' , value : '123' } ) ;
443+ expect ( actor . getSnapshot ( ) . context . value ) . toBe ( '123' ) ;
444+
445+ actor . send ( { type : 'RESEND' } ) ;
446+ expect ( actor . getSnapshot ( ) . context . value ) . toBe ( '' ) ;
447+ } ) ;
448+
449+ it ( 'keeps a rejected password in the field and clears the error on input' , async ( ) => {
374450 const attempt = vi
375451 . fn < ( attempt : ReverificationAttempt ) => Promise < ReverificationAttemptResult > > ( )
376452 . mockRejectedValue ( { scope : 'answer' , message : 'Incorrect password.' } ) ;
@@ -381,13 +457,56 @@ describe('reverificationController', () => {
381457 await vi . waitFor ( ( ) => expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ) ;
382458
383459 expect ( actor . getSnapshot ( ) . context ) . toMatchObject ( {
384- value : '' ,
460+ value : 'wrong ' ,
385461 error : { scope : 'answer' , message : 'Incorrect password.' } ,
386462 } ) ;
387463 actor . send ( { type : 'CHANGE_VALUE' , value : 'new value' } ) ;
388464 expect ( actor . getSnapshot ( ) . context . error ) . toBeNull ( ) ;
389465 } ) ;
390466
467+ it ( 'keeps a rejected backup code in the field' , async ( ) => {
468+ const attempt = vi
469+ . fn < ( attempt : ReverificationAttempt ) => Promise < ReverificationAttemptResult > > ( )
470+ . mockRejectedValue ( { scope : 'answer' , message : 'Incorrect backup code.' } ) ;
471+ const { actor } = start ( {
472+ challenge : {
473+ status : 'needs_second_factor' ,
474+ factors : [ backupCodeFactor ] ,
475+ initialFactor : backupCodeFactor ,
476+ } ,
477+ attempt,
478+ } ) ;
479+
480+ actor . send ( { type : 'CHANGE_VALUE' , value : 'abcd-efgh' } ) ;
481+ actor . send ( { type : 'SUBMIT' } ) ;
482+ await vi . waitFor ( ( ) => expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ) ;
483+
484+ expect ( actor . getSnapshot ( ) . context ) . toMatchObject ( {
485+ value : 'abcd-efgh' ,
486+ error : { scope : 'answer' , message : 'Incorrect backup code.' } ,
487+ } ) ;
488+ } ) ;
489+
490+ it ( 'clears a rejected one-time code so the next one can be typed' , async ( ) => {
491+ vi . useFakeTimers ( ) ;
492+ const attempt = vi
493+ . fn < ( attempt : ReverificationAttempt ) => Promise < ReverificationAttemptResult > > ( )
494+ . mockRejectedValue ( { scope : 'answer' , message : 'Incorrect code.' } ) ;
495+ const { actor } = start ( {
496+ challenge : firstFactorChallenge ( { initialFactor : emailFactor } ) ,
497+ attempt,
498+ } ) ;
499+ await vi . runAllTicks ( ) ;
500+
501+ actor . send ( { type : 'CHANGE_VALUE' , value : '123456' } ) ;
502+ await vi . runAllTicks ( ) ;
503+
504+ expect ( actor . getSnapshot ( ) . context ) . toMatchObject ( {
505+ value : '' ,
506+ error : { scope : 'answer' , message : 'Incorrect code.' } ,
507+ } ) ;
508+ } ) ;
509+
391510 it ( 'owns resend cooldown and only retries after it expires' , async ( ) => {
392511 vi . useFakeTimers ( ) ;
393512 const prepare = vi . fn < ( factor : ReverificationPreparationFactor ) => Promise < void > > ( ) . mockResolvedValue ( undefined ) ;
@@ -407,7 +526,7 @@ describe('reverificationController', () => {
407526 await vi . advanceTimersByTimeAsync ( 30_000 ) ;
408527 expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ;
409528 actor . send ( { type : 'RESEND' } ) ;
410- expect ( actor . getSnapshot ( ) . value ) . toBe ( 'resending ' ) ;
529+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'preparing ' ) ;
411530 await vi . runAllTicks ( ) ;
412531 expect ( actor . getSnapshot ( ) ) . toMatchObject ( {
413532 value : 'verifyingCooldown' ,
@@ -416,7 +535,7 @@ describe('reverificationController', () => {
416535 expect ( prepare ) . toHaveBeenCalledTimes ( 2 ) ;
417536 } ) ;
418537
419- it ( 'allows immediate resend retry after a resend failure ' , async ( ) => {
538+ it ( 'holds the cooldown after a failed resend ' , async ( ) => {
420539 vi . useFakeTimers ( ) ;
421540 const prepare = vi
422541 . fn < ( factor : ReverificationPreparationFactor ) => Promise < void > > ( )
@@ -433,15 +552,19 @@ describe('reverificationController', () => {
433552 actor . send ( { type : 'RESEND' } ) ;
434553 await vi . runAllTicks ( ) ;
435554 expect ( actor . getSnapshot ( ) ) . toMatchObject ( {
436- value : 'verifying ' ,
555+ value : 'verifyingCooldown ' ,
437556 context : {
438- resendSecondsRemaining : 0 ,
557+ resendSecondsRemaining : 30 ,
439558 error : { scope : 'flow' , message : 'Rate limited.' } ,
440559 } ,
441560 } ) ;
442561
562+ expect ( actor . can ( { type : 'RESEND' } ) ) . toBe ( false ) ;
563+ expect ( prepare ) . toHaveBeenCalledTimes ( 2 ) ;
564+
565+ await vi . advanceTimersByTimeAsync ( 30_000 ) ;
566+ expect ( actor . getSnapshot ( ) . value ) . toBe ( 'verifying' ) ;
443567 actor . send ( { type : 'RESEND' } ) ;
444- expect ( actor . getSnapshot ( ) . value ) . toBe ( 'resending' ) ;
445568 expect ( prepare ) . toHaveBeenCalledTimes ( 3 ) ;
446569 } ) ;
447570
0 commit comments