Files
supabase/apps/studio/components/interfaces/Account/TOTPFactors/GenerateRecoveryCodesModal.test.tsx
Gildas Garcia ce683aa0e3 Recovery Codes: improve code format (#50646)
## Problem

1. Recovery are displayed as returned by the backend
<img width="516" height="347" alt="image"
src="https://github.com/user-attachments/assets/c2599858-a65d-42d4-af3f-6bc738a810e1"
/>

2. Recovery codes are not displayed even if present when more than 1 MFA
is set up

## Solution

1. Format them as uppercased groups of 4 characters
<img width="541" height="394" alt="image"
src="https://github.com/user-attachments/assets/74c6b4eb-03c1-4de2-a772-b30ec4d7bb52"
/>

3. Fix the condition check to display recovery codes

## How to test

- Generate or regenerate your recovery codes: check the format is
correct
- If you haven't already, add a 2nd MFA: check recovery codes are still
displayed

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Improvements**
- Recovery codes are now displayed in uppercase with clear
hyphen-separated groups.
- Copied recovery codes use the same formatted presentation for easier
sharing and entry.
- The recovery codes section is available whenever recovery codes are
enabled, regardless of the number of authenticator apps configured.
  - Codes that do not match the expected format remain unchanged.

- **Tests**
- Added coverage to verify consistent recovery code formatting and
clipboard behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-21 15:50:19 +02:00

104 lines
3.8 KiB
TypeScript

import { AuthError } from '@supabase/auth-js'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, test, vi } from 'vitest'
import { GenerateRecoveryCodesModal } from './GenerateRecoveryCodesModal'
import { formatRecoveryCode } from './RecoveryCodesModal.utils'
import { auth } from '@/lib/gotrue'
import { customRender } from '@/tests/lib/custom-render'
const { mockCopyToClipboard } = vi.hoisted(() => ({
mockCopyToClipboard: vi.fn((_value: string, callback: () => void) => {
return callback()
}),
}))
vi.mock('ui', async (importOriginal) => ({
...(await importOriginal<typeof import('ui')>()),
copyToClipboard: mockCopyToClipboard,
}))
const codes = [
'wto24t5xbeulvjmi',
'ade6in2sufndbbwd',
'oge3npsrhrr66k25',
'pxbkfvxlbi6ggvnf',
'kc3oaioqdjn4htc3',
'363gseoknplambiy',
'3urgae2p2jegem4m',
'tbxog2guayp6uvud',
'rpcvcf4owbclxrfp',
'kyyfsp3eqjydj53t',
]
describe('GenerateRecoveryCodesModal', () => {
test('generate the recovery codes and allow users to copy them', async () => {
vi.spyOn(auth.mfa.recoveryCodes, 'generate').mockResolvedValue({
data: {
id: 'some_id',
total: 10,
codes,
type: 'recovery_code',
},
error: null,
})
customRender(<GenerateRecoveryCodesModal />)
fireEvent.click(await screen.findByRole('button', { name: 'Generate recovery codes' }))
// Codes are generated
await screen.findByText('Save your recovery codes')
await screen.findByText('WTO2-4T5X-BEUL-VJMI')
await screen.findByText('KYYF-SP3E-QJYD-J53T')
// Users have to copy the codes to close the modal, next click should fail if they managed to close it
expect(await screen.findAllByRole('button', { name: 'Close' })).toHaveLength(1)
fireEvent.click(await screen.findByRole('button', { name: 'Close' }))
fireEvent.click(await screen.findByRole('button', { name: 'Copy to clipboard' }))
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'I have copied the codes' })).toBeChecked()
)
expect(mockCopyToClipboard).toHaveBeenCalledWith(
codes.map((code) => formatRecoveryCode(code)).join('\n'),
expect.any(Function)
)
// We should have 2 close buttons (header icon and a standard button)
expect(await screen.findAllByRole('button', { name: 'Close' })).toHaveLength(2)
fireEvent.click((await screen.findAllByRole('button', { name: 'Close' })).at(1)!)
await waitFor(() => expect(screen.queryByText('Save your recovery codes')).toBeNull())
})
test('allow users to retry in case of error', async () => {
vi.spyOn(auth.mfa.recoveryCodes, 'generate')
.mockRejectedValueOnce({
data: null,
error: new AuthError('boom'),
})
.mockResolvedValue({
data: {
id: 'some_id',
total: 10,
codes,
type: 'recovery_code',
},
error: null,
})
customRender(<GenerateRecoveryCodesModal />)
fireEvent.click(await screen.findByRole('button', { name: 'Generate recovery codes' }))
await screen.findByText(
"We couldn't generate your recovery code. Please try again later or contact support if the problem persists."
)
// We should have 2 close buttons (header icon and a standard button)
expect(await screen.findAllByRole('button', { name: 'Close' })).toHaveLength(2)
fireEvent.click((await screen.findAllByRole('button', { name: 'Close' })).at(1)!)
await waitFor(() => expect(screen.queryByText('Save your recovery codes')).toBeNull())
// Retry
fireEvent.click(await screen.findByRole('button', { name: 'Generate recovery codes' }))
// Codes are generated
await screen.findByText('Save your recovery codes')
await screen.findByText('WTO2-4T5X-BEUL-VJMI')
await screen.findByText('KYYF-SP3E-QJYD-J53T')
})
})