feat(auth): unify sign-in options
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
@@ -10,6 +12,15 @@ 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'),
|
||||
@@ -62,22 +73,116 @@ describe('LoginPage', () => {
|
||||
});
|
||||
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;
|
||||
(window as any).google = { accounts: { id: {
|
||||
initialize: jest.fn((options: any) => { callback = options.callback; }),
|
||||
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(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('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
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' }));
|
||||
@@ -113,7 +218,7 @@ describe('LoginPage', () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage("login", "/\\evil.example");
|
||||
await userEvent.type(await screen.findByLabelText('Email'), 'person@example.com');
|
||||
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' }));
|
||||
|
||||
@@ -122,9 +227,9 @@ describe('LoginPage', () => {
|
||||
|
||||
it('opens the separate forgot-password page with the typed email prefilled', async () => {
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
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');
|
||||
@@ -144,14 +249,14 @@ describe('LoginPage', () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
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('Email')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Username or email')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||
await click(screen.getByRole('button', { name: 'Verify' }));
|
||||
@@ -173,8 +278,8 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
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' }));
|
||||
|
||||
@@ -200,9 +305,9 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'unverified@example.com');
|
||||
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' }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user