Files
TREK/client/src/pages/LoginPage.test.tsx
T
jubnl 47d9cce936 fix(tests): update tests for granular auth toggles
- Add new fields to AppConfig type and buildAppConfig factory
- Update FE-PAGE-ADMIN-018: heading changed to "Authentication Methods"
- Update FE-PAGE-ADMIN-053: oidc_only toggle removed from OIDC panel
- Update FE-PAGE-LOGIN-007/017: mocks now include password_login/oidc_login
- Update ADMIN-SVC-049: updateOidcSettings no longer writes oidc_only
2026-04-11 20:33:51 +02:00

596 lines
21 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores } from '../../tests/helpers/store';
import LoginPage from './LoginPage';
// LoginPage uses inline styles for labels (no htmlFor/id pairing).
// We find inputs by placeholder text.
const EMAIL_PLACEHOLDER = 'your@email.com';
const PASSWORD_PLACEHOLDER = '••••••••';
beforeEach(() => {
resetAllStores();
});
describe('LoginPage', () => {
describe('FE-PAGE-LOGIN-001: Renders login form', () => {
it('shows email and password inputs', async () => {
render(<LoginPage />);
// Wait for appConfig to load (useEffect fetches it)
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER)).toBeInTheDocument();
});
});
describe('FE-PAGE-LOGIN-002: Submitting valid credentials triggers login', () => {
it('shows takeoff animation on successful login', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// On success, takeoff overlay appears
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-003: Invalid credentials shows error', () => {
it('displays error message on login failure', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'bad@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'wrongpass');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
// authStore.login throws, LoginPage catches and sets error text from API response
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-004: Loading state while login in progress', () => {
it('disables submit button and shows spinner during login', async () => {
server.use(
http.post('/api/auth/login', async () => {
await new Promise(resolve => setTimeout(resolve, 150));
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// While loading, button becomes disabled with spinner text
await waitFor(() => {
const submitBtn = screen.getByRole('button', { name: /signing in/i });
expect(submitBtn).toBeDisabled();
});
});
});
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
it('shows a Register button to switch to registration mode', async () => {
// Default appConfig has allow_registration: true, has_users: true
render(<LoginPage />);
await waitFor(() => {
// The register toggle link text appears
expect(screen.getByRole('button', { name: /^register$/i })).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-006: Register creates account', () => {
it('switches to register mode and submits registration form', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /^register$/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /^register$/i }));
// Username field appears in register mode
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('admin'), 'newuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'new@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /create account/i }));
// On success, takeoff animation
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-007: OIDC button shown when configured', () => {
it('renders SSO sign-in link when oidc_configured is true', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: true,
allow_registration: true,
demo_mode: false,
oidc_configured: true,
oidc_display_name: 'Okta',
oidc_only_mode: false,
oidc_login: true,
password_login: true,
password_registration: true,
setup_complete: true,
});
}),
);
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByText(/sign in with okta/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-008: Demo login available in demo mode', () => {
it('shows demo button when demo_mode is true', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: true,
allow_registration: false,
demo_mode: true,
oidc_configured: false,
oidc_only_mode: false,
setup_complete: true,
});
}),
);
render(<LoginPage />);
await waitFor(() => {
// Demo hint button appears
expect(screen.getByText(/try the demo/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-009: MFA prompt after initial login', () => {
it('shows MFA code input when login returns mfa_required', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
mfa_required: true,
mfa_token: 'test-mfa-token-abc',
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// MFA step: the title changes to "Two-factor authentication"
await waitFor(() => {
expect(screen.getByText(/two-factor authentication/i)).toBeInTheDocument();
});
// MFA code input with correct placeholder
expect(screen.getByPlaceholderText('000000 or XXXX-XXXX')).toBeInTheDocument();
});
});
describe('FE-PAGE-LOGIN-010: Successful login triggers navigation', () => {
it('shows takeoff overlay (navigation signal) after successful auth', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'pass1234');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Takeoff animation signals navigation away from login
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-011: Password change step appears when must_change_password', () => {
it('transitions to change password form when login returns must_change_password=true', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
});
});
describe('FE-PAGE-LOGIN-012: Password change form validates length', () => {
it('shows error when new password is shorter than 8 characters', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('New password'), 'short');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'short');
await user.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(screen.getByText(/at least 8/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-013: Password change form validates mismatch', () => {
it('shows error when new passwords do not match', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('New password'), 'newpassword123');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'differentpassword123');
await user.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(screen.getByText(/do not match/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-014: Password change success navigates', () => {
it('shows takeoff overlay after successful password change', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
http.put('/api/auth/me/password', () => {
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('New password'), 'newpassword123');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'newpassword123');
await user.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-015: First-setup mode switches to register when has_users=false', () => {
it('shows register form automatically when has_users is false', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: false,
allow_registration: true,
demo_mode: false,
oidc_configured: false,
oidc_only_mode: false,
setup_complete: true,
});
}),
);
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-016: Registration disabled hides register option', () => {
it('does not show register button when allow_registration is false', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: true,
allow_registration: false,
demo_mode: false,
oidc_configured: false,
oidc_only_mode: false,
setup_complete: true,
});
}),
);
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /^register$/i })).toBeNull();
});
});
describe('FE-PAGE-LOGIN-017: OIDC-only mode hides standard login form', () => {
it('does not render email/password inputs in oidc_only_mode', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: true,
allow_registration: false,
demo_mode: false,
oidc_configured: true,
oidc_only_mode: true,
password_login: false,
oidc_login: true,
setup_complete: true,
});
}),
);
// Pass noRedirect via location.state to prevent window.location.href redirect
render(<LoginPage />, {
initialEntries: [{ pathname: '/login', state: { noRedirect: true } }],
});
await waitFor(() => {
expect(screen.queryByPlaceholderText(EMAIL_PLACEHOLDER)).toBeNull();
expect(screen.queryByPlaceholderText(PASSWORD_PLACEHOLDER)).toBeNull();
});
});
});
describe('FE-PAGE-LOGIN-018: MFA code submission completes login', () => {
it('shows takeoff overlay after successful MFA verification', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
mfa_required: true,
mfa_token: 'test-mfa-token-abc',
});
}),
http.post('/api/auth/mfa/verify-login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('000000 or XXXX-XXXX')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('000000 or XXXX-XXXX'), '123456');
await user.click(screen.getByRole('button', { name: /verify/i }));
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-019: Empty MFA code shows error', () => {
it('shows error when MFA code is empty and does not show takeoff overlay', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
mfa_required: true,
mfa_token: 'test-mfa-token-abc',
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('000000 or XXXX-XXXX')).toBeInTheDocument();
});
// Submit the form directly (bypasses browser constraint validation on required field)
const form = document.querySelector('form')!;
fireEvent.submit(form);
await waitFor(() => {
expect(screen.getByText(/enter the code from your authenticator/i)).toBeInTheDocument();
});
expect(document.querySelector('.takeoff-overlay')).toBeNull();
});
});
describe('FE-PAGE-LOGIN-020: Register form validates password length', () => {
it('shows error when registration password is shorter than 8 characters', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /^register$/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('admin'), 'newuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'new@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'short');
await user.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByText(/at least 8/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-021: Invite token pre-fills register mode', () => {
it('renders register form when invite query param is present', async () => {
server.use(
http.get('/api/auth/invite/:token', () => {
return HttpResponse.json({ valid: true });
}),
);
// Simulate ?invite=abc123 by replacing window.location.search
const originalSearch = window.location.search;
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, search: '?invite=abc123' },
});
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, search: originalSearch },
});
});
});
});