/** * Tests for LoginPage (M2-T07, extended M4-T08). * * Strategy: vi.mock the apiClient module so we can control POST /api/auth/login * responses without a real server. We also mock useSession so tests can control * the authentication state. * * Coverage: * 1. Renders the login form (step 1). * 2. Successful login (step 1) → invalidates session query + navigates. * 3. 401 bad credentials → shows inline error, does not navigate. * 4. Already-authenticated users visiting /login → redirected to '/'. * 5. Unexpected error → shows generic error message. * 6. (M4-T08) 401 with totp_required → switches to step 2 TOTP form. * 7. (M4-T08) Step 2 submit with correct code → success + navigate. * 8. (M4-T08) Step 2 submit with wrong code → shows error, stays on step 2. * 9. (M4-T08) Step 2 "Back to login" button → returns to step 1. * 10. (M4-T08) 429 response → shows "too many attempts" message. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { screen, waitFor, fireEvent } from '@testing-library/react' import { renderWithProviders } from '../test-utils' import { LoginPage } from './LoginPage' // --------------------------------------------------------------------------- // Mock apiClient // --------------------------------------------------------------------------- // We mock the entire api/client module. Each test can override POST as needed. const mockPost = vi.fn() vi.mock('../api/client', () => ({ default: { POST: (...args: unknown[]) => mockPost(...args), GET: 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(), })) // --------------------------------------------------------------------------- // Mock useSession — default: unauthenticated // --------------------------------------------------------------------------- // Typed as returning the wider union so mockReturnValue accepts all status variants. const mockUseSession = vi.fn(() => ({ status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated', user: null as null | { username: string; force_password_change: boolean }, })) vi.mock('../auth/SessionProvider', () => ({ useSession: () => mockUseSession(), SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function renderLogin(initialPath = '/login') { return renderWithProviders(, { initialPath, routes: [{ path: '/', element:
Home
}], }) } function fillAndSubmit(username: string, password: string) { fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } }) fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } }) fireEvent.submit(screen.getByTestId('login-form')) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('LoginPage', () => { beforeEach(() => { vi.clearAllMocks() // Reset to unauthenticated by default mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null }) }) // ------------------------------------------------------------------------- // 1. Renders the form // ------------------------------------------------------------------------- it('renders the login form with username and password fields', () => { renderLogin() expect(screen.getByTestId('login-form')).toBeInTheDocument() expect(screen.getByTestId('username-input')).toBeInTheDocument() expect(screen.getByTestId('password-input')).toBeInTheDocument() expect(screen.getByTestId('login-submit')).toBeInTheDocument() }) it('shows Sign In heading', () => { renderLogin() expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument() }) // ------------------------------------------------------------------------- // 2. Successful login // ------------------------------------------------------------------------- it('navigates to "/" on successful login', async () => { mockPost.mockResolvedValueOnce({ data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' }, response: { status: 200, ok: true }, }) renderLogin() fillAndSubmit('admin', 'correct-password') await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument() }) }) it('calls POST /api/auth/login with the correct body', async () => { mockPost.mockResolvedValueOnce({ data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' }, response: { status: 200, ok: true }, }) renderLogin() fillAndSubmit('myuser', 'mypassword') await waitFor(() => { expect(mockPost).toHaveBeenCalledWith('/api/auth/login', { body: { username: 'myuser', password: 'mypassword' }, }) }) }) // ------------------------------------------------------------------------- // 3. 401 bad credentials // ------------------------------------------------------------------------- it('shows inline error on 401 and does NOT navigate', async () => { mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: 'invalid username or password' }, response: { status: 401, ok: false }, }) renderLogin() fillAndSubmit('admin', 'wrong-password') await waitFor(() => { expect(screen.getByTestId('login-error')).toBeInTheDocument() }) expect(screen.getByTestId('login-error')).toHaveTextContent( /incorrect username or password/i, ) // Should still be on the login form, not navigated away expect(screen.getByTestId('login-form')).toBeInTheDocument() }) it('does not include the password in the error message', async () => { mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: 'invalid username or password' }, response: { status: 401, ok: false }, }) renderLogin() fillAndSubmit('admin', 'super-secret-password') await waitFor(() => { expect(screen.getByTestId('login-error')).toBeInTheDocument() }) expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password') }) // ------------------------------------------------------------------------- // 5. Unexpected error // ------------------------------------------------------------------------- it('shows generic error on unexpected network failure', async () => { mockPost.mockRejectedValueOnce(new Error('Network error')) renderLogin() fillAndSubmit('admin', 'password') await waitFor(() => { expect(screen.getByTestId('login-error')).toBeInTheDocument() }) expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i) }) // ------------------------------------------------------------------------- // 4. Already authenticated // ------------------------------------------------------------------------- it('redirects already-authenticated users to "/"', async () => { mockUseSession.mockReturnValue({ status: 'authenticated', user: { username: 'admin', force_password_change: false }, }) renderLogin() await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument() }) }) // ------------------------------------------------------------------------- // 6. (M4-T08) 401 with totp_required → step 2 // ------------------------------------------------------------------------- it('switches to TOTP step on 401 with totp_required=true', async () => { mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: { totp_required: true } }, response: { status: 401, ok: false }, }) renderLogin() fillAndSubmit('admin', 'correct-password') await waitFor(() => { expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() }) // Step 1 form should be gone expect(screen.queryByTestId('login-form')).not.toBeInTheDocument() // TOTP code input should be visible expect(screen.getByTestId('totp-code-input')).toBeInTheDocument() }) // ------------------------------------------------------------------------- // 7. (M4-T08) Step 2 correct code → success // ------------------------------------------------------------------------- it('completes login on correct TOTP code (step 2)', async () => { // Step 1: trigger totp_required mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: { totp_required: true } }, response: { status: 401, ok: false }, }) // Step 2: success mockPost.mockResolvedValueOnce({ data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok456' }, response: { status: 200, ok: true }, }) renderLogin() fillAndSubmit('admin', 'correct-password') await waitFor(() => { expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() }) // Enter the TOTP code and submit step 2 fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '123456' } }) fireEvent.submit(screen.getByTestId('login-totp-form')) await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument() }) // Check step 2 request included totp_code expect(mockPost).toHaveBeenCalledWith('/api/auth/login', { body: { username: 'admin', password: 'correct-password', totp_code: '123456' }, }) }) // ------------------------------------------------------------------------- // 8. (M4-T08) Step 2 wrong code → error, stays on step 2 // ------------------------------------------------------------------------- it('shows error on wrong TOTP code and stays on step 2', async () => { // Step 1: totp_required mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: { totp_required: true } }, response: { status: 401, ok: false }, }) // Step 2: wrong code → 401 mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: 'invalid totp code' }, response: { status: 401, ok: false }, }) renderLogin() fillAndSubmit('admin', 'correct-password') await waitFor(() => { expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() }) fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '000000' } }) fireEvent.submit(screen.getByTestId('login-totp-form')) await waitFor(() => { expect(screen.getByTestId('login-error')).toBeInTheDocument() }) // Should still be on TOTP form expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() expect(screen.getByTestId('login-error')).toHaveTextContent(/incorrect verification code/i) }) // ------------------------------------------------------------------------- // 9. (M4-T08) Step 2 "Back to login" → returns to step 1 // ------------------------------------------------------------------------- it('returns to step 1 when "Back to login" is clicked', async () => { mockPost.mockResolvedValueOnce({ data: undefined, error: { detail: { totp_required: true } }, response: { status: 401, ok: false }, }) renderLogin() fillAndSubmit('admin', 'correct-password') await waitFor(() => { expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('totp-back')) await waitFor(() => { expect(screen.getByTestId('login-form')).toBeInTheDocument() }) expect(screen.queryByTestId('login-totp-form')).not.toBeInTheDocument() }) // ------------------------------------------------------------------------- // 10. (M4-T08) 429 → "too many attempts" message // ------------------------------------------------------------------------- it('shows "too many attempts" message on 429', async () => { const { ApiError } = await import('../api/client') mockPost.mockRejectedValueOnce(new ApiError(429, { detail: 'Too Many Requests' })) renderLogin() fillAndSubmit('admin', 'password') await waitFor(() => { expect(screen.getByTestId('login-error')).toBeInTheDocument() }) expect(screen.getByTestId('login-error')).toHaveTextContent(/too many login attempts/i) }) })