Files
jobtrackingapp/job-tracker-ui/src/login-page.test.tsx
T

376 lines
20 KiB
TypeScript

import React from 'react';
import '@testing-library/jest-dom';
jest.unmock('./components/GoogleAuthCard');
jest.unmock('./components/MicrosoftAuthCard');
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LoginPage from './views/LoginPage';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
const mockNavigate = jest.fn();
const mockMsalInitialize = jest.fn();
const mockMsalLoginPopup = jest.fn();
jest.mock('@azure/msal-browser', () => ({
PublicClientApplication: jest.fn().mockImplementation(() => ({
initialize: mockMsalInitialize,
loginPopup: mockMsalLoginPopup,
})),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const mockedApi = api as jest.Mocked<typeof api>;
let consoleErrorSpy: jest.SpyInstance;
function renderLoginPage(initialMode: "login" | "register" = "login", from?: string, fromState?: unknown, search = "") {
return render(
<MemoryRouter initialEntries={[{ pathname: "/login", search, state: from ? { from, fromState } : undefined }]}>
<I18nProvider>
<ToastProvider>
<LoginPage initialMode={initialMode} />
</ToastProvider>
</I18nProvider>
</MemoryRouter>,
);
}
async function click(element: Element) {
await act(async () => { userEvent.click(element); });
}
describe('LoginPage', () => {
it('renders a dedicated registration screen and explains when registration is disabled', async () => {
mockedApi.get.mockResolvedValueOnce({
data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true },
} as any);
renderLoginPage("register");
expect(await screen.findByText('Registration is currently unavailable.')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Create account' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create account' })).toBeDisabled();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
beforeEach(() => {
const originalConsoleError = console.error.bind(console);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
const [first] = args;
const message = typeof first === 'string' ? first : '';
if (message.includes('ForwardRef(TouchRipple) inside a test was not wrapped in act')) {
return;
}
originalConsoleError(...args);
});
window.localStorage.clear();
window.sessionStorage.clear();
mockedApi.get.mockReset();
mockedApi.get.mockImplementation((url: string) => {
if (url === '/auth/config') return Promise.resolve({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
if (url === '/auth/me') return Promise.resolve({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
return Promise.resolve({ data: {} } as any);
});
mockedApi.post.mockReset();
mockNavigate.mockReset();
mockMsalInitialize.mockReset().mockResolvedValue(undefined);
mockMsalLoginPopup.mockReset();
delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID;
delete (window as any).google;
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('renders one conventional sign-in card with enabled provider alternatives', async () => {
mockedApi.get.mockImplementation((url: string) => url === '/auth/config'
? Promise.resolve({ data: { requireAuth: true, googleEnabled: true, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any)
: Promise.reject({ response: { status: 401 } }));
renderLoginPage();
expect(await screen.findByLabelText('Username or email')).toBeInTheDocument();
expect(screen.getByLabelText('Current password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
expect(await screen.findByText('or')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Continue with Microsoft' })).toBeInTheDocument();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
expect(screen.queryByText('Google account')).not.toBeInTheDocument();
expect(screen.queryByText('Available to link')).not.toBeInTheDocument();
});
it('accepts a username and reports invalid credentials without changing routes', async () => {
mockedApi.get.mockImplementation((url: string) => url === '/auth/config'
? Promise.resolve({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any)
: Promise.reject({ response: { status: 401 } }));
mockedApi.post.mockRejectedValueOnce({ response: { status: 401, data: 'Invalid credentials.' } });
renderLoginPage();
await userEvent.type(await screen.findByLabelText('Username or email'), 'demo-user');
await userEvent.type(screen.getByLabelText('Current password'), 'wrong-password');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'demo-user', password: 'wrong-password', rememberMe: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials.');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('handles Microsoft cancellation without exchanging or navigating', async () => {
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = 'microsoft-client';
mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
mockMsalLoginPopup.mockRejectedValueOnce({ errorCode: 'user_cancelled', message: 'Microsoft sign-in was cancelled.' });
renderLoginPage();
await click(await screen.findByRole('button', { name: 'Continue with Microsoft' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Microsoft sign-in was cancelled.');
expect(mockedApi.post).not.toHaveBeenCalledWith('/auth/microsoft/exchange', expect.anything());
expect(mockNavigate).not.toHaveBeenCalled();
});
it('completes the Microsoft provider return through the hardened exchange endpoint', async () => {
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = 'microsoft-client';
mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
mockMsalLoginPopup.mockResolvedValueOnce({ idToken: 'synthetic-microsoft-token' });
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'microsoft' } } as any);
renderLoginPage('login', '/jobs?status=Applied#rows');
await click(await screen.findByRole('button', { name: 'Continue with Microsoft' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/microsoft/exchange', { token: 'synthetic-microsoft-token', rememberMe: true }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs?status=Applied#rows', { replace: true }));
});
it('completes a Google credential return without account-linking copy', async () => {
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = 'google-client';
let callback: ((response: { credential: string }) => void) | undefined;
const initialize = jest.fn((options: any) => { callback = options.callback; });
(window as any).google = { accounts: { id: {
initialize,
renderButton: jest.fn((host: HTMLElement) => {
const button = document.createElement('button');
button.textContent = 'Continue with Google';
button.onclick = () => callback?.({ credential: 'synthetic-google-token' });
host.appendChild(button);
}),
} } };
mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: true, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'google' } } as any);
renderLoginPage();
await click(await screen.findByRole('button', { name: 'Continue with Google' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/google/exchange', { token: 'synthetic-google-token', rememberMe: true }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
expect(initialize).toHaveBeenCalledTimes(1);
expect(screen.queryByText(/create your account automatically/i)).not.toBeInTheDocument();
});
it('posts remember-me preference without storing an auth token in browser storage', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage();
await screen.findByLabelText('Username or email');
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByLabelText('Remember me'));
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2', rememberMe: false }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
expect(window.sessionStorage.getItem('authToken')).toBeNull();
expect(window.localStorage.getItem('authToken')).toBeNull();
expect(window.localStorage.getItem('authTokenPersistence')).toBe('session');
});
it('does not complete login when registration requires email verification', async () => {
mockedApi.get.mockClear();
mockedApi.get.mockResolvedValueOnce({
data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true },
} as any);
mockedApi.post.mockResolvedValueOnce({ data: { verificationRequired: true } } as any);
renderLoginPage('register');
await userEvent.type(await screen.findByLabelText('Email'), 'pending@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm password'), 'password1');
await click(screen.getByRole('button', { name: 'Create account' }));
expect(await screen.findByText('Please verify your email address before signing in.')).toBeInTheDocument();
expect(mockedApi.get).not.toHaveBeenCalledWith('/auth/me');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('rejects an unsafe post-login redirect path', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage("login", "/\\evil.example");
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
});
it.each([
'https://evil.example/analytics', '//evil.example/analytics', '/\t/evil.example',
'/%2f%2fevil.example', '/%252f%252fevil.example', '/%5cevil.example',
'/analytics?months=%ZZ', '/jobs/../login', '/%6cogin', '/register',
'/analytics%0a', '/analytics?months=6\n', '/LOGIN', '/%4cOGIN',
])('uses the safe fallback for the untrusted return target %j', async (from) => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
renderLoginPage('login', from);
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
});
it('preserves encoded internal query and fragment without decoding the navigation target', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
const from = '/analytics?months=%36&context=%C3%B8#trend';
renderLoginPage('login', from);
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(from, { replace: true }));
});
it('restores valid owned-workspace navigation context separately from the sign-in destination', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
const fromState = { from: '/jobs?q=Engineer', focusJobId: 42 };
renderLoginPage('login', '/jobs/42?section=documents#files', fromState);
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs/42?section=documents#files', { replace: true, state: fromState }));
});
it('does not replay an unsafe nested return context after authentication', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
renderLoginPage('login', '/jobs/42', { from: '/\t/evil.example', focusJobId: 42 });
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs/42', { replace: true }));
});
it('abandons prior return context on the established account/session disposal entry', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
renderLoginPage('login', '/jobs/42', { from: '/jobs?q=Private', focusJobId: 42 }, '?sessionChanged=1');
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
});
it('opens the separate forgot-password page with the typed email prefilled', async () => {
renderLoginPage();
await screen.findByLabelText('Username or email');
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
await click(screen.getByRole('button', { name: 'Forgot password?' }));
expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com');
});
it('shows the 2FA code step when login requires two-factor, then proceeds like a normal login on success', async () => {
mockedApi.post.mockImplementation((url: string, payload?: any) => {
if (url === '/auth/login') {
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
}
if (url === '/auth/2fa/challenge') {
expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456', trustDevice: false });
return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage('login', '/analytics?months=24#activity');
await screen.findByLabelText('Username or email');
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByText('Two-factor verification');
expect(screen.queryByLabelText('Username or email')).not.toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Code'), '123456');
await click(screen.getByRole('button', { name: 'Verify' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/analytics?months=24#activity', { replace: true }));
});
it('shows a clear message when the 2FA challenge is rate-limited', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/login') {
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
}
if (url === '/auth/2fa/challenge') {
return Promise.reject({ response: { status: 429 } });
}
return Promise.resolve({ data: {} } as any);
});
renderLoginPage();
await screen.findByLabelText('Username or email');
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByText('Two-factor verification');
await userEvent.type(screen.getByLabelText('Code'), '123456');
await click(screen.getByRole('button', { name: 'Verify' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.');
});
it('offers a resend-verification action when login reports the account is not verified', async () => {
mockedApi.get.mockResolvedValueOnce({
data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true },
} as any);
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/login') {
return Promise.reject({ response: { status: 403, data: { error: 'email_not_verified' } } });
}
if (url === '/auth/resend-verification-email') {
return Promise.resolve({ data: {} } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderLoginPage();
await screen.findByLabelText('Username or email');
await userEvent.type(screen.getByLabelText('Username or email'), 'unverified@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await click(screen.getByRole('button', { name: 'Sign in' }));
expect(await screen.findByText('Please verify your email address before signing in.')).toBeInTheDocument();
await click(screen.getByRole('button', { name: 'Resend verification email' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/resend-verification-email', { email: 'unverified@example.com' }));
await screen.findByText('Verification email sent. Check your inbox.');
});
});