M4-T08: add two-step login and TOTP settings panel (qrcode.react)

This commit is contained in:
2026-06-21 22:42:41 +02:00
parent 51da8c5716
commit ee1264b66b
10 changed files with 1405 additions and 51 deletions
+174 -5
View File
@@ -1,16 +1,21 @@
/**
* Tests for LoginPage (M2-T07).
* 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.
* 2. Successful login → invalidates session query + navigates.
* 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'
@@ -86,6 +91,10 @@ describe('LoginPage', () => {
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()
@@ -99,8 +108,11 @@ describe('LoginPage', () => {
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Successful login
// -------------------------------------------------------------------------
it('navigates to "/" on successful login', async () => {
// Simulate a successful POST /api/auth/login response
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
@@ -130,10 +142,14 @@ describe('LoginPage', () => {
})
})
// -------------------------------------------------------------------------
// 3. 401 bad credentials
// -------------------------------------------------------------------------
it('shows inline error on 401 and does NOT navigate', async () => {
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid username or password' },
response: { status: 401, ok: false },
})
@@ -154,6 +170,7 @@ describe('LoginPage', () => {
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 },
})
@@ -167,6 +184,10 @@ describe('LoginPage', () => {
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'))
@@ -180,6 +201,10 @@ describe('LoginPage', () => {
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
})
// -------------------------------------------------------------------------
// 4. Already authenticated
// -------------------------------------------------------------------------
it('redirects already-authenticated users to "/"', async () => {
mockUseSession.mockReturnValue({
status: 'authenticated',
@@ -192,4 +217,148 @@ describe('LoginPage', () => {
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)
})
})