M4-T08: add two-step login and TOTP settings panel (qrcode.react)
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Tests for TotpSettings (M4-T08).
|
||||
*
|
||||
* Strategy: vi.mock the auth/totp module so we can control API responses
|
||||
* without a real server. We also mock the api/client module for ApiError.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Shows loading spinner while fetching TOTP status.
|
||||
* 2. Shows error when TOTP status fetch fails.
|
||||
* 3. Shows "Disabled" badge and enable button when TOTP is disabled.
|
||||
* 4. Enable flow: clicking setup → shows QR code + recovery codes + confirm form.
|
||||
* 5. Confirm with correct code → TOTP enabled (status refetched).
|
||||
* 6. Confirm with wrong code → shows error, stays on confirm form.
|
||||
* 7. Shows "Enabled" badge and disable form when TOTP is enabled.
|
||||
* 8. Disable with password → TOTP disabled.
|
||||
* 9. Disable with TOTP code → TOTP disabled.
|
||||
* 10. Disable without providing password or code → shows validation error.
|
||||
* 11. Setup error shows error message.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { TotpSettings } from './TotpSettings'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock auth/totp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGetTotpStatus = vi.fn()
|
||||
const mockSetupTotp = vi.fn()
|
||||
const mockEnableTotp = vi.fn()
|
||||
const mockDisableTotp = vi.fn()
|
||||
|
||||
vi.mock('../auth/totp', () => ({
|
||||
getTotpStatus: (...args: unknown[]) => mockGetTotpStatus(...args),
|
||||
setupTotp: (...args: unknown[]) => mockSetupTotp(...args),
|
||||
enableTotp: (...args: unknown[]) => mockEnableTotp(...args),
|
||||
disableTotp: (...args: unknown[]) => mockDisableTotp(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock api/client (for ApiError)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
PUT: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_SETUP_DATA = {
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
otpauth_uri: 'otpauth://totp/TestApp:admin?secret=JBSWY3DPEHPK3PXP&issuer=TestApp',
|
||||
recovery_codes: ['abcd-1234', 'efgh-5678', 'ijkl-9012'],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderTotp() {
|
||||
return renderWithProviders(<TotpSettings />, { initialPath: '/config' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TotpSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Loading state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows loading spinner while fetching TOTP status', async () => {
|
||||
// Never resolves during this test
|
||||
mockGetTotpStatus.mockReturnValue(new Promise(() => {}))
|
||||
renderTotp()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-loading')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Error state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error when TOTP status fetch fails', async () => {
|
||||
mockGetTotpStatus.mockRejectedValue(new Error('Network error'))
|
||||
renderTotp()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Disabled state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows Disabled badge and enable button when TOTP is disabled', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Enable flow: setup shows QR + recovery codes + confirm form
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows QR code, secret, and recovery codes after clicking setup', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-qr-code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP')
|
||||
expect(screen.getByTestId('totp-recovery-codes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('totp-recovery-codes')).toHaveTextContent('abcd-1234')
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('totp-confirm-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Confirm with correct code → enabled
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('enables TOTP after confirming with correct code', async () => {
|
||||
mockGetTotpStatus
|
||||
.mockResolvedValueOnce({ enabled: false })
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
|
||||
mockEnableTotp.mockResolvedValue(undefined)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '123456' } })
|
||||
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableTotp).toHaveBeenCalledWith('123456')
|
||||
})
|
||||
|
||||
// After enable the status refetches → shows enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Confirm with wrong code → error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error on wrong TOTP code during enable', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
|
||||
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockEnableTotp.mockRejectedValue(new ApiError(400, { detail: 'invalid code' }))
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '000000' } })
|
||||
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-confirm-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should still show confirm form (not enabled)
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Enabled state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows Enabled badge and disable form when TOTP is enabled', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: true })
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('totp-disable-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Disable with password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('disables TOTP when correct password is provided', async () => {
|
||||
mockGetTotpStatus
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
.mockResolvedValueOnce({ enabled: false })
|
||||
mockDisableTotp.mockResolvedValue(undefined)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-disable-password'), {
|
||||
target: { value: 'my-password' },
|
||||
})
|
||||
fireEvent.submit(screen.getByTestId('totp-disable-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableTotp).toHaveBeenCalledWith({ password: 'my-password', code: null })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. Disable with TOTP code
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('disables TOTP when correct TOTP code is provided', async () => {
|
||||
mockGetTotpStatus
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
.mockResolvedValueOnce({ enabled: false })
|
||||
mockDisableTotp.mockResolvedValue(undefined)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-disable-code'), { target: { value: '654321' } })
|
||||
fireEvent.submit(screen.getByTestId('totp-disable-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableTotp).toHaveBeenCalledWith({ password: null, code: '654321' })
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 10. Disable without credentials → validation error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows validation error when disable submitted without password or code', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: true })
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Submit without filling in anything
|
||||
fireEvent.submit(screen.getByTestId('totp-disable-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockDisableTotp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 11. Setup error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error when TOTP setup request fails', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
mockSetupTotp.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-enable-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user