Files
jobtrackingapp/job-tracker-ui/src/public-auth-presentation.test.tsx
T

242 lines
14 KiB
TypeScript

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<typeof api>;
function mount(view: React.ReactNode, path: string) {
window.history.replaceState({}, '', path);
return render(<MemoryRouter initialEntries={[path]}><I18nProvider><ToastProvider>{view}</ToastProvider></I18nProvider></MemoryRouter>);
}
function submit() {
fireEvent.submit(document.querySelector('form')!);
}
function deferred() {
let resolve!: (value: any) => void;
const promise = new Promise<any>(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(<LoginPage initialMode={mode} />, 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: <ForgotPasswordPage />, path: '/forgot-password', title: 'Request a password reset' },
{ name: 'password reset', view: <ResetPasswordPage />, path: '/reset-password', title: 'Reset password' },
{ name: 'email verification', view: <VerifyEmailPage />, path: '/verify-email', title: 'Verify your email' },
{ name: 'email change', view: <VerifyEmailPage emailChange />, path: '/confirm-email-change', title: 'Confirm your new email' },
{ name: 'Microsoft recovery', view: <MicrosoftLegacyRelinkPage />, 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(<VerifyEmailPage emailChange={emailChange} />, 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(<ForgotPasswordPage />, '/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(<ResetPasswordPage />, '/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(<LoginPage />, '/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(<ForgotPasswordPage />, '/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(<LoginPage />, '/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(<ResetPasswordPage />, '/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(<LoginPage />, '/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(<ResetPasswordPage />, '/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(<ForgotPasswordPage />, '/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(<ForgotPasswordPage />, '/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(<LoginPage initialMode={initialMode} />, 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(<LoginPage />, '/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();
});
});