345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
/**
|
|||
|
|
* 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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|