M2-T07: build auth UI (login, session bootstrap, forced password change, logout)
- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed - ProtectedRoute: loading state, preserves intended destination, gates force_password_change - ChangePasswordPage forced-change gate -> POST /api/auth/password - logout control in AppLayout nav -> POST /api/auth/logout - typed client only; vitest tests for the login flow
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Tests for ChangePasswordPage (M2-T07 rework-1).
|
||||
*
|
||||
* Strategy: vi.mock the apiClient and useSession modules so we can control
|
||||
* POST /api/auth/password responses and session state without a real server.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders the change-password form when user has force_password_change=true.
|
||||
* 2. Successful password change → navigates to '/' (proceeds into the app).
|
||||
* 3. Client-side mismatch → shows error, does NOT call the API.
|
||||
* 4. API 400 error → shows generic error, stays on form.
|
||||
* 5. Guard: non-forced user visiting /change-password → redirected to '/'.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { ChangePasswordPage } from './ChangePasswordPage'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: forced-change user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockUseSession = vi.fn(() => ({
|
||||
status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
|
||||
user: { username: 'admin', force_password_change: true } as
|
||||
| null
|
||||
| { username: string; force_password_change: boolean },
|
||||
}))
|
||||
|
||||
vi.mock('../auth/SessionProvider', () => ({
|
||||
useSession: () => mockUseSession(),
|
||||
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderChangePw(initialPath = '/change-password') {
|
||||
return renderWithProviders(<ChangePasswordPage />, {
|
||||
initialPath,
|
||||
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
|
||||
})
|
||||
}
|
||||
|
||||
function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
|
||||
fireEvent.change(screen.getByTestId('current-password-input'), {
|
||||
target: { value: currentPw },
|
||||
})
|
||||
fireEvent.change(screen.getByTestId('new-password-input'), {
|
||||
target: { value: newPw },
|
||||
})
|
||||
fireEvent.change(screen.getByTestId('confirm-password-input'), {
|
||||
target: { value: confirmPw },
|
||||
})
|
||||
fireEvent.submit(screen.getByTestId('change-password-form'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ChangePasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: authenticated user with force_password_change=true
|
||||
mockUseSession.mockReturnValue({
|
||||
status: 'authenticated',
|
||||
user: { username: 'admin', force_password_change: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the change-password form for a forced-change user', () => {
|
||||
renderChangePw()
|
||||
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to "/" after a successful password change', async () => {
|
||||
// Simulate successful POST /api/auth/password
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: {},
|
||||
response: { status: 200, ok: true },
|
||||
})
|
||||
|
||||
renderChangePw()
|
||||
fillAndSubmit('old-password', 'new-password', 'new-password')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls POST /api/auth/password with the correct body', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: {},
|
||||
response: { status: 200, ok: true },
|
||||
})
|
||||
|
||||
renderChangePw()
|
||||
fillAndSubmit('current123', 'newpass456', 'newpass456')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
|
||||
body: {
|
||||
current_password: 'current123',
|
||||
new_password: 'newpass456',
|
||||
confirm_password: 'newpass456',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error and does NOT call the API when new passwords do not match', async () => {
|
||||
renderChangePw()
|
||||
fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
|
||||
/do not match/i,
|
||||
)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
// Should remain on the form
|
||||
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows generic error on API 400 and stays on form', async () => {
|
||||
// Simulate 400 via ApiError throw (as the client middleware does)
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
|
||||
|
||||
renderChangePw()
|
||||
fillAndSubmit('wrong-current', 'newpass', 'newpass')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
|
||||
/password change failed/i,
|
||||
)
|
||||
// Should NOT have navigated away
|
||||
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('redirects a non-forced user away from /change-password to "/"', async () => {
|
||||
// A user who has already changed their password
|
||||
mockUseSession.mockReturnValue({
|
||||
status: 'authenticated',
|
||||
user: { username: 'admin', force_password_change: false },
|
||||
})
|
||||
|
||||
renderChangePw()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The change-password form must NOT be shown
|
||||
expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user