Files
homelable/frontend/src/components/__tests__/LoginPage.test.tsx
T

147 lines
6.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginPage } from '../LoginPage'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/api/client', () => ({
authApi: {
login: vi.fn(),
},
}))
import { authApi } from '@/api/client'
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
vi.mocked(authApi.login).mockReset()
})
// ── Rendering ────────────────────────────────────────────────────────────
it('renders username and password fields', () => {
render(<LoginPage />)
expect(screen.getByLabelText('Username')).toBeDefined()
expect(screen.getByLabelText('Password')).toBeDefined()
})
it('renders a Sign in button', () => {
render(<LoginPage />)
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.type).toBe('password')
})
it('username field has autocomplete="username"', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.getAttribute('autocomplete')).toBe('username')
})
it('password field has autocomplete="current-password" (supports password managers)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.getAttribute('autocomplete')).toBe('current-password')
})
it('shows a generic error message — no credential enumeration', async () => {
vi.mocked(authApi.login).mockRejectedValue(new Error('401'))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText('Invalid username or password')).toBeDefined()
})
// Must show exactly ONE error — not separate per-field messages (no enumeration)
const errors = document.querySelectorAll('p.text-\\[\\#f85149\\]')
expect(errors.length).toBe(1)
expect(errors[0].textContent).toBe('Invalid username or password')
})
it('clears previous error before each new attempt', async () => {
vi.mocked(authApi.login)
.mockRejectedValueOnce(new Error('401'))
.mockRejectedValueOnce(new Error('401'))
render(<LoginPage />)
const form = screen.getByRole('button', { name: /sign in/i }).closest('form')!
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'bad' } })
fireEvent.submit(form)
await waitFor(() => screen.getByText('Invalid username or password'))
fireEvent.submit(form)
// Error clears while loading (setError('') before try)
await waitFor(() => screen.getByText('Invalid username or password'))
expect(screen.getAllByText('Invalid username or password')).toHaveLength(1)
})
it('disables submit button while loading — prevents double-submit', async () => {
let resolve!: (v: unknown) => void
vi.mocked(authApi.login).mockReturnValue(new Promise((r) => { resolve = r }))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
})
resolve({ data: { access_token: 'tok' } })
})
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'tok' } } as never)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith('admin', 'secret')
})
})
it('stores token in authStore on successful login', async () => {
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'mytoken123' } } as never)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().token).toBe('mytoken123')
})
})
it('token persisted via sessionStorage — not localStorage', () => {
// The authStore uses createJSONStorage(() => sessionStorage)
// Verify the storage key exists in sessionStorage after login
render(<LoginPage />)
// Even before login, the store is backed by sessionStorage
expect(typeof sessionStorage).toBe('object')
// localStorage should NOT contain the auth token
expect(localStorage.getItem('homelable-auth')).toBeNull()
})
it('does not show error on initial render', () => {
render(<LoginPage />)
expect(screen.queryByText('Invalid username or password')).toBeNull()
})
it('requires username (HTML required attribute)', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.required).toBe(true)
})
it('requires password (HTML required attribute)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.required).toBe(true)
})
})