88961596b1
+page.test.ts triggered svelte-kit sync's route walker (which treats any +-prefixed file as a route). Renaming to login.test.ts lets the sync pass without disabling it in scripts, preserving the upstream guarantee that types in .svelte-kit/ are fresh before check/test. Plan bug introduced in Task 10 (filename chosen in the plan); fixing here and updating the plan so re-runs use the correct name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
|
|
new Promise<void>((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());
|
|
});
|
|
});
|