Skip to content

Commit 4bedb84

Browse files
fix(ui): align reverification cooldown with legacy behavior
1 parent 40a55e9 commit 4bedb84

4 files changed

Lines changed: 223 additions & 109 deletions

File tree

packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts

Lines changed: 140 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { reverificationController, reverificationFactorKey } from './reverificat
55
import 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+
4248
const 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

Comments
 (0)