/** * Tests for the LoginPage component. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { LoginPage } from '../../pages/LoginPage'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; describe('LoginPage', () => { beforeEach(() => { server.use( http.get('/api/v1/auth/status', () => { return HttpResponse.json({ auth_enabled: true, requires_setup: false }); }) ); }); describe('rendering', () => { it('renders the login form', async () => { render(); await waitFor(() => { expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument(); }); expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); expect(screen.getByLabelText(/Password/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument(); }); it('renders the sign in description', async () => { render(); await waitFor(() => { expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument(); }); }); }); describe('form validation', () => { it('shows error when submitting empty form', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Sign in/i })); // The form has required fields, so HTML5 validation should prevent submission // or the component shows a toast }); it('allows entering username and password', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/Username/i), 'testuser'); await user.type(screen.getByLabelText(/Password/i), 'testpassword'); expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser'); expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword'); }); }); describe('login flow', () => { it('submits login request with credentials', async () => { const user = userEvent.setup(); let loginCalled = false; server.use( http.post('/api/v1/auth/login', async ({ request }) => { loginCalled = true; const body = await request.json() as { username: string; password: string }; if (body.username === 'validuser' && body.password === 'validpass') { return HttpResponse.json({ access_token: 'test-token', token_type: 'bearer', user: { id: 1, username: 'validuser', role: 'admin', is_active: true, created_at: new Date().toISOString(), }, }); } return HttpResponse.json( { detail: 'Incorrect username or password' }, { status: 401 } ); }) ); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/Username/i), 'validuser'); await user.type(screen.getByLabelText(/Password/i), 'validpass'); await user.click(screen.getByRole('button', { name: /Sign in/i })); // Verify the login endpoint was called await waitFor(() => { expect(loginCalled).toBe(true); }); }); it('shows loading state during login', async () => { const user = userEvent.setup(); let resolveLogin: () => void; const loginPromise = new Promise(resolve => { resolveLogin = resolve; }); // Slow login endpoint that we control server.use( http.post('/api/v1/auth/login', async () => { await loginPromise; return HttpResponse.json({ access_token: 'test-token', token_type: 'bearer', user: { id: 1, username: 'testuser', role: 'admin', is_active: true, created_at: new Date().toISOString(), }, }); }) ); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/Username/i), 'testuser'); await user.type(screen.getByLabelText(/Password/i), 'testpass'); await user.click(screen.getByRole('button', { name: /Sign in/i })); // Check for loading state - button text should change to "Logging in..." await waitFor(() => { expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument(); }); // Release the login request resolveLogin!(); }); }); describe('2FA flow', () => { // Helper: login as a 2FA user and get to the 2FA step async function loginWith2FA(twoFAMethods = ['totp', 'backup']) { const user = userEvent.setup(); server.use( http.post('/api/v1/auth/login', () => HttpResponse.json({ requires_2fa: true, pre_auth_token: 'test-pre-auth-token', two_fa_methods: twoFAMethods, }) ) ); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/Username/i), 'mfa-user'); await user.type(screen.getByLabelText(/Password/i), 'mfa-password'); await user.click(screen.getByRole('button', { name: /Sign in/i })); return user; } it('shows 2FA step when login returns requires_2fa', async () => { await loginWith2FA(); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); }); it('shows code input on the 2FA step', async () => { await loginWith2FA(); await waitFor(() => { // The code input field is rendered expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument(); }); }); it('submits 2FA verify request with code and pre_auth_token', async () => { let verifyCalled = false; let verifyBody: unknown; server.use( http.post('/api/v1/auth/2fa/verify', async ({ request }) => { verifyCalled = true; verifyBody = await request.json(); return HttpResponse.json({ access_token: 'final-jwt', token_type: 'bearer', user: { id: 1, username: 'mfa-user', role: 'admin', is_active: true, created_at: new Date().toISOString(), }, }); }) ); const user = await loginWith2FA(); await waitFor(() => { expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument(); }); await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456'); await user.click(screen.getByRole('button', { name: /Verify/i })); await waitFor(() => { expect(verifyCalled).toBe(true); }); expect(verifyBody).toMatchObject({ pre_auth_token: 'test-pre-auth-token', code: '123456', method: 'totp', }); }); it('returns to credentials step when back button is clicked', async () => { await loginWith2FA(); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); const user = userEvent.setup(); const backButton = screen.getByRole('button', { name: /Back to login/i }); await user.click(backButton); await waitFor(() => { expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument(); }); }); it('shows method selector when multiple 2FA methods are available', async () => { await loginWith2FA(['totp', 'email', 'backup']); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); // Multiple method buttons should be visible expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument(); }); it('does not show method selector with only one 2FA method', async () => { await loginWith2FA(['totp']); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); // Single-method: no method selector buttons expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument(); }); it('shows send code button when email method is selected', async () => { const _user = await loginWith2FA(['email']); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); // For email method the "Send code" button should be shown await waitFor(() => { expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument(); }); }); }); describe('Remember Me', () => { const mockUser = { id: 1, username: 'testuser', role: 'admin' as const, is_active: true, created_at: new Date().toISOString(), }; beforeEach(() => { vi.mocked(localStorage.setItem).mockClear(); sessionStorage.clear(); server.use( http.post('/api/v1/auth/login', () => HttpResponse.json({ access_token: 'test-token', token_type: 'bearer', user: mockUser, }) ), // Prevent checkAuthStatus from clearing the token when getCurrentUser is called http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser)) ); }); it('renders Remember Me checkbox on credentials step', async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/Remember Me/i)).toBeInTheDocument(); }); expect(screen.getByRole('checkbox', { name: /Remember Me/i })).not.toBeChecked(); }); it('does not persist token to localStorage when unchecked (default)', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/Username/i), 'testuser'); await user.type(screen.getByLabelText(/Password/i), 'testpassword'); await user.click(screen.getByRole('button', { name: /Sign in/i })); // Token must be in sessionStorage (tab-only) but not in localStorage await waitFor(() => { expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String)); expect(sessionStorage.getItem('auth_token')).toBe('test-token'); }); }); it('persists token to localStorage when Remember Me is checked', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.click(screen.getByRole('checkbox', { name: /Remember Me/i })); await user.type(screen.getByLabelText(/Username/i), 'testuser'); await user.type(screen.getByLabelText(/Password/i), 'testpassword'); await user.click(screen.getByRole('button', { name: /Sign in/i })); await waitFor(() => { expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'test-token'); }); }); it('carries Remember Me through 2FA verification', async () => { server.use( http.post('/api/v1/auth/login', () => HttpResponse.json({ requires_2fa: true, pre_auth_token: 'pre-token', two_fa_methods: ['totp'], }) ), http.post('/api/v1/auth/2fa/verify', () => HttpResponse.json({ access_token: 'final-token', token_type: 'bearer', user: mockUser, }) ) ); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); // Check Remember Me before submitting credentials await user.click(screen.getByRole('checkbox', { name: /Remember Me/i })); await user.type(screen.getByLabelText(/Username/i), 'testuser'); await user.type(screen.getByLabelText(/Password/i), 'testpassword'); await user.click(screen.getByRole('button', { name: /Sign in/i })); // Now on 2FA step — enter code and verify await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456'); await user.click(screen.getByRole('button', { name: /Verify/i })); // Token must be persisted to localStorage because Remember Me was checked await waitFor(() => { expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'final-token'); }); }); it('checkbox is not shown on 2FA step', async () => { server.use( http.post('/api/v1/auth/login', () => HttpResponse.json({ requires_2fa: true, pre_auth_token: 'pre-token', two_fa_methods: ['totp'], }) ) ); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/Username/i), 'testuser'); await user.type(screen.getByLabelText(/Password/i), 'testpassword'); await user.click(screen.getByRole('button', { name: /Sign in/i })); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); expect(screen.queryByLabelText(/Remember Me/i)).not.toBeInTheDocument(); }); }); describe('OIDC with Remember Me', () => { const mockUser = { id: 1, username: 'oidcuser', role: 'admin' as const, is_active: true, created_at: new Date().toISOString(), }; beforeEach(() => { vi.mocked(localStorage.setItem).mockClear(); sessionStorage.clear(); }); afterEach(() => { window.location.hash = ''; window.history.pushState({}, '', '/login'); sessionStorage.clear(); }); it('persists token to localStorage after OIDC redirect when Remember Me was set', async () => { sessionStorage.setItem('auth_remember_me', '1'); server.use( http.post('/api/v1/auth/oidc/exchange', () => HttpResponse.json({ access_token: 'oidc-token', token_type: 'bearer', user: mockUser, }) ) ); window.location.hash = '#oidc_token=test-exchange-token'; render(); await waitFor(() => { expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-token'); }); expect(sessionStorage.getItem('auth_remember_me')).toBeNull(); }); it('carries Remember Me through OIDC + 2FA flow', async () => { sessionStorage.setItem('auth_remember_me', '1'); server.use( http.post('/api/v1/auth/oidc/exchange', () => HttpResponse.json({ requires_2fa: true, pre_auth_token: 'oidc-pre-token', two_fa_methods: ['totp'], }) ), http.post('/api/v1/auth/2fa/verify', () => HttpResponse.json({ access_token: 'oidc-2fa-token', token_type: 'bearer', user: mockUser, }) ) ); window.location.hash = '#oidc_token=test-exchange-token'; const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument(); }); // Flag consumed on mount — no stale value for future flows expect(sessionStorage.getItem('auth_remember_me')).toBeNull(); await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456'); await user.click(screen.getByRole('button', { name: /Verify/i })); await waitFor(() => { expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-2fa-token'); }); }); it('cleans up auth_remember_me flag when OIDC returns an error', async () => { sessionStorage.setItem('auth_remember_me', '1'); window.history.pushState({}, '', '/login?oidc_error=invalid_state'); render(); await waitFor(() => { expect(sessionStorage.getItem('auth_remember_me')).toBeNull(); }); }); it('does not persist token to localStorage after OIDC redirect when Remember Me was not set', async () => { // No auth_remember_me flag set — token must stay session-only server.use( http.post('/api/v1/auth/oidc/exchange', () => HttpResponse.json({ access_token: 'oidc-session-token', token_type: 'bearer', user: mockUser, }) ) ); window.location.hash = '#oidc_token=test-exchange-token'; render(); await waitFor(() => { expect(sessionStorage.getItem('auth_token')).toBe('oidc-session-token'); }); expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String)); }); it('shows error toast when OIDC exchange returns unexpected response shape', async () => { sessionStorage.setItem('auth_remember_me', '1'); server.use( // Response is missing both access_token and requires_2fa — hits the else branch http.post('/api/v1/auth/oidc/exchange', () => HttpResponse.json({ token_type: 'bearer' }) ) ); window.location.hash = '#oidc_token=test-exchange-token'; render(); await waitFor(() => { expect(screen.getByText(/Login.*failed|failed.*login/i)).toBeInTheDocument(); }); // Flag must still be cleaned up even on malformed response expect(sessionStorage.getItem('auth_remember_me')).toBeNull(); }); it('writes auth_remember_me flag to sessionStorage before OIDC provider redirect', async () => { server.use( http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([ { id: 42, name: 'FlagIdP', issuer_url: 'https://flag.test', client_id: 'c', is_enabled: true, icon_url: null, has_icon: false, email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, }, ]) ), http.get('/api/v1/auth/oidc/authorize/42', () => HttpResponse.json({ auth_url: 'https://flag.test/authorize?state=abc' }) ) ); const user = userEvent.setup(); render(); // Tick "Remember Me" await waitFor(() => { expect(screen.getByRole('checkbox', { name: /Remember Me/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('checkbox', { name: /Remember Me/i })); // Wait for OIDC provider button to appear await waitFor(() => { expect(screen.getByRole('button', { name: /FlagIdP/i })).toBeInTheDocument(); }); // Stub window.location so the OIDC redirect doesn't actually navigate. // Keep href valid so relative fetch URLs resolve correctly. Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, href: 'http://localhost:3000/' }, }); await user.click(screen.getByRole('button', { name: /FlagIdP/i })); await waitFor(() => { expect(sessionStorage.getItem('auth_remember_me')).toBe('1'); }); }); }); // #1333: icon proxy — login page renders from /icon endpoint // rather than the upstream icon_url, so the strict img-src CSP holds. describe('OIDC icon proxy (#1333)', () => { beforeEach(() => { server.use( http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, setup_required: false }) ), ); }); it('renders provider icon via the proxy URL when has_icon is true', async () => { server.use( http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([ { id: 7, name: 'IconProv', issuer_url: 'https://idp.test', client_id: 'c', is_enabled: true, icon_url: 'https://idp.test/icon.png', email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, has_icon: true, }, ]) ), ); render(); const button = await screen.findByRole('button', { name: /IconProv/i }); const img = button.querySelector('img'); expect(img).not.toBeNull(); // Same-origin path — never the upstream icon_url. This is the entire // point of the proxy: keep img-src strictly 'self' data: blob:. expect(img!.getAttribute('src')).toBe('/api/v1/auth/oidc/providers/7/icon'); }); it('renders shield fallback when has_icon is false', async () => { server.use( http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([ { id: 8, name: 'NoIconProv', issuer_url: 'https://idp.test', client_id: 'c', is_enabled: true, icon_url: null, email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, has_icon: false, }, ]) ), ); render(); const button = await screen.findByRole('button', { name: /NoIconProv/i }); expect(button.querySelector('img')).toBeNull(); }); it('renders mixed has_icon providers without crash', async () => { // N12 — multiple providers on the login page with a mix of // has_icon=true / false. No React-keys-collision warning, both // branches render correctly side by side. server.use( http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([ { id: 10, name: 'WithIcon', issuer_url: 'https://idp.test', client_id: 'c1', is_enabled: true, icon_url: 'https://idp.test/icon.png', has_icon: true, email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, }, { id: 11, name: 'NoIcon', issuer_url: 'https://idp.test', client_id: 'c2', is_enabled: true, icon_url: null, has_icon: false, email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, }, ]) ), ); render(); const withIconBtn = await screen.findByRole('button', { name: /WithIcon/i }); const noIconBtn = await screen.findByRole('button', { name: /NoIcon/i }); expect(withIconBtn.querySelector('img')).not.toBeNull(); expect(noIconBtn.querySelector('img')).toBeNull(); }); it('swaps in shield fallback when the icon fails to load', async () => { // I3 (#1333 review): the LoginPage must not show the browser // broken-image glyph to anonymous users. onError must fall back to // the Shield icon. server.use( http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([ { id: 9, name: 'FlakyIcon', issuer_url: 'https://idp.test', client_id: 'c', is_enabled: true, icon_url: 'https://idp.test/icon.png', email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, has_icon: true, }, ]) ), ); render(); const img = (await screen.findByRole('button', { name: /FlakyIcon/i })).querySelector('img'); expect(img).not.toBeNull(); // Fire the image's onError — jsdom doesn't fetch network resources // so we simulate the failure directly. fireEvent.error(img!); // After error, no more in the button; Shield fallback rendered. await waitFor(() => { const button = screen.getByRole('button', { name: /FlakyIcon/i }); expect(button.querySelector('img')).toBeNull(); }); }); it('keeps each provider button\'s iconFailed state independent', async () => { // The OIDCProviderButton sub-component exists specifically so each // provider owns its own iconFailed state. If a future refactor hoists // useState into the parent loop, an error on provider A would also // hide provider B's icon — exactly the regression this test catches. server.use( http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([ { id: 21, name: 'AlphaIdP', issuer_url: 'https://a.test', client_id: 'a', is_enabled: true, icon_url: 'https://a.test/icon.png', email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, has_icon: true, }, { id: 22, name: 'BetaIdP', issuer_url: 'https://b.test', client_id: 'b', is_enabled: true, icon_url: 'https://b.test/icon.png', email_claim: 'email', require_email_verified: true, auto_create_users: false, auto_link_existing_accounts: false, has_icon: true, }, ]) ), ); render(); const alphaImg = (await screen.findByRole('button', { name: /AlphaIdP/i })).querySelector('img'); const betaImg = (await screen.findByRole('button', { name: /BetaIdP/i })).querySelector('img'); expect(alphaImg).not.toBeNull(); expect(betaImg).not.toBeNull(); fireEvent.error(alphaImg!); // Alpha's icon swaps to the Shield fallback… await waitFor(() => { expect(screen.getByRole('button', { name: /AlphaIdP/i }).querySelector('img')).toBeNull(); }); // …but Beta's icon stays put. If state leaks to the parent, this fails. expect(screen.getByRole('button', { name: /BetaIdP/i }).querySelector('img')).not.toBeNull(); }); }); });