import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; // vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy // and can run before top-level `let` is initialized, since imports are // hoisted above variable declarations. const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') })); vi.mock('$app/state', () => ({ page: { get url() { return state.pageUrl; } } })); vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$lib/auth/store.svelte', () => ({ login: vi.fn(), user: { value: null } })); import LoginPage from './+page.svelte'; import { login } from '$lib/auth/store.svelte'; import { goto } from '$app/navigation'; afterEach(() => { vi.clearAllMocks(); state.pageUrl = new URL('http://localhost/login'); }); async function submit(username = 'alice', password = 'pw') { const u = screen.getByLabelText(/username/i) as HTMLInputElement; const p = screen.getByLabelText(/password/i) as HTMLInputElement; await fireEvent.input(u, { target: { value: username } }); await fireEvent.input(p, { target: { value: password } }); await fireEvent.click(screen.getByRole('button', { name: /sign in/i })); } describe('login page', () => { test('renders username, password, and sign-in button', () => { render(LoginPage); expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); test('invalid credentials show inline error and clear password', async () => { (login as ReturnType).mockRejectedValue({ code: 'invalid_credentials', message: 'bad creds', status: 401 }); render(LoginPage); await submit(); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i); }); expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe(''); expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); }); test('server error shows generic message and preserves both fields', async () => { (login as ReturnType).mockRejectedValue({ code: 'server_error', message: 'boom', status: 500 }); render(LoginPage); await submit('alice', 'pw'); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i); }); expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw'); expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); }); test('success navigates to returnTo when safe', async () => { state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc'); (login as ReturnType).mockResolvedValue(undefined); render(LoginPage); await submit(); await waitFor(() => { expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true }); }); }); test('success falls back to "/" when returnTo is missing', async () => { (login as ReturnType).mockResolvedValue(undefined); render(LoginPage); await submit(); await waitFor(() => { expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); }); }); test.each([ ['//evil.com', 'protocol-relative'], ['http://evil.com', 'absolute URL'], ['/login', 'self-redirect'], ['../admin', 'parent traversal'] ])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => { state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`); (login as ReturnType).mockResolvedValue(undefined); render(LoginPage); await submit(); await waitFor(() => { expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); }); }); test('submit button disables and marks aria-busy while pending', async () => { let release!: () => void; (login as ReturnType).mockReturnValue( new Promise((resolve) => { release = () => resolve(); }) ); render(LoginPage); await submit(); const btn = screen.getByRole('button', { name: /sign in/i }); expect(btn).toBeDisabled(); expect(btn).toHaveAttribute('aria-busy', 'true'); release(); await waitFor(() => expect(btn).not.toBeDisabled()); }); });