import React from 'react'; import '@testing-library/jest-dom'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { api } from './api'; import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import ForgotPasswordPage from './views/ForgotPasswordPage'; import LoginPage from './views/LoginPage'; import MicrosoftLegacyRelinkPage from './views/MicrosoftLegacyRelinkPage'; import ResetPasswordPage from './views/ResetPasswordPage'; import VerifyEmailPage from './views/VerifyEmailPage'; const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, })); const mockedApi = api as jest.Mocked; function mount(view: React.ReactNode, path: string) { window.history.replaceState({}, '', path); return render({view}); } function submit() { fireEvent.submit(document.querySelector('form')!); } function deferred() { let resolve!: (value: any) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } beforeEach(() => { localStorage.clear(); sessionStorage.clear(); delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID; mockedApi.post.mockReset(); mockNavigate.mockReset(); mockedApi.get.mockReset().mockImplementation((url: string) => Promise.resolve({ data: url === '/auth/config' ? { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } : { email: 'person@example.test', roles: [] } } as any)); }); describe('public auth presentation', () => { it.each([ { mode: 'login' as const, label: 'Current password', autocomplete: 'current-password', absent: 'New password' }, { mode: 'register' as const, label: 'New password', autocomplete: 'new-password', absent: 'Current password' }, ])('names the password field for $mode intent and retains its autocomplete contract', async ({ mode, label, autocomplete, absent }) => { mount(, mode === 'register' ? '/register' : '/login'); await screen.findByText('Authentication is required to use this app.'); expect(screen.getByLabelText(label, { exact: true })).toHaveAttribute('autocomplete', autocomplete); expect(screen.getByLabelText(label, { exact: true })).toHaveAttribute('type', 'password'); expect(screen.queryByLabelText(absent, { exact: true })).not.toBeInTheDocument(); }); it.each([ { name: 'password request', view: , path: '/forgot-password', title: 'Request a password reset' }, { name: 'password reset', view: , path: '/reset-password', title: 'Reset password' }, { name: 'email verification', view: , path: '/verify-email', title: 'Verify your email' }, { name: 'email change', view: , path: '/confirm-email-change', title: 'Confirm your new email' }, { name: 'Microsoft recovery', view: , path: '/microsoft-legacy-relink', title: 'Recover Microsoft sign-in' }, ])('provides one named main landmark and H1 for $name', async ({ view, path, title }) => { mount(view, path); const heading = await screen.findByRole('heading', { level: 1, name: title }); expect(screen.getAllByRole('main')).toHaveLength(1); expect(screen.getByRole('main', { name: title })).toContainElement(heading); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); }); it.each([ { emailChange: false, path: '/verify-email?userId=user-1&token=pending', text: 'Verifying your email...' }, { emailChange: true, path: '/confirm-email-change?userId=user-1&email=new%40example.test&token=pending', text: 'Confirming your new email...' }, ])('announces the pending confirmation without an unnamed progressbar: $emailChange', async ({ emailChange, path, text }) => { const request = deferred(); mockedApi.post.mockReturnValueOnce(request.promise); mount(, path); expect(screen.getByRole('status')).toHaveTextContent(text); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); await act(async () => request.resolve({ data: {} })); expect(await screen.findByRole('status')).toHaveTextContent(emailChange ? 'Your email has changed.' : 'Your email has been verified.'); }); it('retains the entered reset email and a durable error in the page when the command fails', async () => { mockedApi.post.mockRejectedValueOnce({ response: { status: 503, data: 'Reset delivery unavailable.' } }); mount(, '/forgot-password?email=prefilled%40example.test'); const email = screen.getByLabelText('Email'); fireEvent.change(email, { target: { value: 'person@example.test' } }); await act(async () => fireEvent.submit(email.closest('form')!)); const error = await within(screen.getByRole('main')).findByRole('alert'); expect(error).toHaveTextContent('Reset delivery unavailable.'); expect(email).toHaveValue('person@example.test'); expect(mockedApi.post).toHaveBeenCalledWith('/auth/request-password-reset', { email: 'person@example.test' }); expect(mockNavigate).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: 'Send reset link' })).toBeEnabled(); }); it('retains both new-password fields and the decoded recovery token after a rejected reset', async () => { mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Reset token expired.' } }); mount(, '/reset-password?email=person%40example.test&token=encoded%2Btoken%2Fproof%3D'); fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'NewPassword123!' } }); fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'NewPassword123!' } }); await act(async () => fireEvent.submit(screen.getByLabelText('New password').closest('form')!)); expect(await within(screen.getByRole('main')).findByRole('alert')).toHaveTextContent('Reset token expired.'); expect(screen.getByLabelText('New password')).toHaveValue('NewPassword123!'); expect(screen.getByLabelText('Confirm password')).toHaveValue('NewPassword123!'); expect(mockedApi.post).toHaveBeenCalledWith('/auth/reset-password', { email: 'person@example.test', token: 'encoded+token/proof=', newPassword: 'NewPassword123!', }); expect(mockNavigate).not.toHaveBeenCalled(); }); it('keeps a rejected local sign-in beside the retained fields without navigating', async () => { mockedApi.post.mockRejectedValueOnce({ response: { status: 401, data: 'Invalid credentials.' } }); mount(, '/login'); fireEvent.change(await screen.findByLabelText('Username or email'), { target: { value: 'person' } }); fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'wrong-password' } }); await act(async () => submit()); expect(await within(screen.getByRole('main')).findByRole('alert')).toHaveTextContent('Invalid credentials.'); expect(screen.getByLabelText('Username or email')).toHaveValue('person'); expect(screen.getByLabelText('Current password')).toHaveValue('wrong-password'); expect(mockNavigate).not.toHaveBeenCalled(); }); it('keeps the reset-request action name while announcing an in-flight command', async () => { const request = deferred(); mockedApi.post.mockReturnValueOnce(request.promise); mount(, '/forgot-password?email=person%40example.test'); fireEvent.submit(screen.getByLabelText('Email').closest('form')!); expect(screen.getByRole('button', { name: 'Send reset link' })).toBeDisabled(); expect(screen.getByRole('status')).toHaveTextContent('Sending reset link...'); await act(async () => request.resolve({ data: {} })); expect(await within(screen.getByRole('main')).findByRole('status')).toHaveTextContent('If that account exists, a reset link has been sent.'); }); it('announces an in-flight local sign-in while preserving its action name', async () => { const request = deferred(); mockedApi.post.mockReturnValueOnce(request.promise); mount(, '/login'); fireEvent.change(await screen.findByLabelText('Username or email'), { target: { value: 'person' } }); fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Password123!' } }); submit(); expect(screen.getByRole('button', { name: 'Sign in' })).toBeDisabled(); expect(screen.getByRole('status')).toHaveTextContent('Loading...'); await act(async () => request.resolve({ data: {} })); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); }); it('announces a pending password save without changing its payload or successful destination', async () => { const request = deferred(); mockedApi.post.mockReturnValueOnce(request.promise); mount(, '/reset-password?email=person%40example.test&token=good-token'); fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'NewPassword123!' } }); fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'NewPassword123!' } }); submit(); expect(screen.getByRole('button', { name: 'Update password' })).toBeDisabled(); expect(screen.getByRole('status')).toHaveTextContent('Saving…'); await act(async () => request.resolve({ data: {} })); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true })); expect(mockedApi.post).toHaveBeenCalledWith('/auth/reset-password', { email: 'person@example.test', token: 'good-token', newPassword: 'NewPassword123!' }); }); it('focuses the first invalid local-login field and associates its explanation', async () => { mount(, '/login'); const email = await screen.findByLabelText('Username or email'); act(() => screen.getByLabelText('Current password').focus()); submit(); expect(email).toHaveFocus(); expect(email).toHaveAccessibleDescription('Username or email is required'); expect(mockedApi.post).not.toHaveBeenCalled(); }); it('focuses the invalid new password without losing confirmation input', () => { mount(, '/reset-password?email=person%40example.test&token=good-token'); const password = screen.getByLabelText('New password'); fireEvent.change(password, { target: { value: 'short' } }); fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'retained' } }); act(() => screen.getByLabelText('Confirm password').focus()); submit(); expect(password).toHaveFocus(); expect(password).toHaveAttribute('aria-invalid', 'true'); expect(password).toHaveAccessibleDescription('Password must be at least 8 characters, with a number and a lowercase letter.'); expect(screen.getByLabelText('Confirm password')).toHaveValue('retained'); expect(mockedApi.post).not.toHaveBeenCalled(); }); it('focuses and explains the invalid reset-request email without sending a command', () => { mount(, '/forgot-password'); const email = screen.getByLabelText('Email'); act(() => email.blur()); fireEvent.submit(email.closest('form')!); expect(email).toHaveFocus(); expect(email).toHaveAccessibleDescription('Enter your email address.'); expect(mockedApi.post).not.toHaveBeenCalled(); }); it('replaces previous reset-request success when a later request fails', async () => { mockedApi.post.mockResolvedValueOnce({ data: {} } as any) .mockRejectedValueOnce({ response: { status: 503, data: 'Reset delivery unavailable.' } }); mount(, '/forgot-password?email=person%40example.test'); await act(async () => submit()); expect(within(screen.getByRole('main')).getByRole('status')).toHaveTextContent('If that account exists, a reset link has been sent.'); await act(async () => submit()); expect(within(screen.getByRole('main')).getByRole('alert')).toHaveTextContent('Reset delivery unavailable.'); expect(within(screen.getByRole('main')).queryByRole('status')).not.toBeInTheDocument(); expect(screen.getByLabelText('Email')).toHaveValue('person@example.test'); }); it.each(['login', 'register'] as const)('does not assert optional authentication while %s configuration is unresolved', async (initialMode) => { const configuration = deferred(); mockedApi.get.mockReturnValueOnce(configuration.promise); mount(, initialMode === 'register' ? '/register' : '/login'); expect(screen.queryByText('Authentication is optional in this environment.')).not.toBeInTheDocument(); expect(screen.getByRole('status')).toHaveTextContent('Loading...'); await act(async () => configuration.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: false, } })); expect(screen.getByText('Authentication is optional in this environment.')).toBeInTheDocument(); }); it('offers a read-only configuration retry while retaining entered sign-in fields', async () => { mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }); mount(, '/login'); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/config')); fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'retained-user' } }); fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'retained-password' } }); expect(screen.queryByText('Authentication is optional in this environment.')).not.toBeInTheDocument(); expect(within(screen.getByRole('main')).getByRole('alert')).toHaveTextContent('An unexpected error occurred while loading this page.'); await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Retry' }))); expect(screen.getByText('Authentication is required to use this app.')).toBeInTheDocument(); expect(screen.getByLabelText('Username or email')).toHaveValue('retained-user'); expect(screen.getByLabelText('Current password')).toHaveValue('retained-password'); expect(mockedApi.get.mock.calls.filter(([path]) => path === '/auth/config')).toHaveLength(2); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); }); });