Files
home-automation/frontend/src/pages/TotpSettings.test.tsx
T

345 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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()
})
})
})