365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
/**
|
|
* 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(<LoginPage />, {
|
|
initialPath,
|
|
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
|
|
})
|
|
}
|
|
|
|
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)
|
|
})
|
|
})
|