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; let consoleErrorSpy: jest.SpyInstance; function renderLoginPage(initialMode: "login" | "register" = "login", from?: string) { return render( , ); } 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'); 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', { 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('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(); 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('/dashboard', { 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.'); }); });