import { test, expect, type Page } from './fixtures'; // Mocks the auth endpoints at the network level so the journey is // deterministic and doesn't require a live backend. const userFixture = { id: 'user-1', username: 'alice', created_at: '2026-01-01T00:00:00Z' }; const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; async function stubAnonymousThenAuthenticated(page: Page) { let loggedIn = false; await page.route('**/api/v1/auth/me', async (route) => { if (loggedIn) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: userFixture }) }); } else { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }); } }); await page.route('**/api/v1/auth/login', async (route) => { loggedIn = true; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: userFixture }) }); }); await page.route('**/api/v1/auth/logout', async (route) => { loggedIn = false; await route.fulfill({ status: 204 }); }); await page.route('**/api/v1/mangas*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptyPage) }); }); } test('login then logout flips the layout between authenticated and anonymous', async ({ page }) => { await stubAnonymousThenAuthenticated(page); await page.goto('/'); // Initially anonymous → Login / Register links visible. await expect(page.getByTestId('nav-login')).toBeVisible(); // Log in. await page.goto('/login'); // Wait for hydration to finish before interacting — the nav-login link // is only rendered once /me resolves, so it doubles as a hydration // signal. Clicking before hydration would submit the form via the // browser default (action="javascript:void(0)") and our handler would // never run. await expect(page.getByTestId('nav-login')).toBeVisible(); await page.getByTestId('login-username').fill('alice'); await page.getByTestId('login-password').fill('hunter2hunter2'); await page.getByTestId('login-submit').click(); // Authenticated → username + Logout button. await expect(page.getByTestId('session-user')).toContainText('alice'); await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); // Log out. await page.getByRole('button', { name: 'Logout' }).click(); await expect(page).toHaveURL(/\/login$/); await expect(page.getByTestId('nav-login')).toBeVisible(); }); test('login redirects to a safe ?next= target after authenticating', async ({ page }) => { await stubAnonymousThenAuthenticated(page); // Arrive at /login carrying a same-origin ?next= (the shape the layout // produces when bouncing an unauthenticated user off a gated page). await page.goto('/login?next=%2Fsearch'); await expect(page.getByTestId('nav-login')).toBeVisible(); await page.getByTestId('login-username').fill('alice'); await page.getByTestId('login-password').fill('hunter2hunter2'); await page.getByTestId('login-submit').click(); // Lands on the requested page, not the default root. await expect(page).toHaveURL(/\/search$/); }); test('login ignores an unsafe ?next= and falls back to /', async ({ page }) => { await stubAnonymousThenAuthenticated(page); // A protocol-relative open-redirect attempt must be discarded. await page.goto('/login?next=%2F%2Fevil.com'); await expect(page.getByTestId('nav-login')).toBeVisible(); await page.getByTestId('login-username').fill('alice'); await page.getByTestId('login-password').fill('hunter2hunter2'); await page.getByTestId('login-submit').click(); // Authenticated and on the site root — never navigated off-origin. await expect(page).toHaveURL(/\/$/); await expect(page.getByTestId('session-user')).toContainText('alice'); }); test('login rejects the %09 control-char open-redirect bypass', async ({ page }) => { await stubAnonymousThenAuthenticated(page); // "/\t/evil.com" passes a naive startsWith('/') && !startsWith('//') // guard but collapses to "//evil.com" once the browser strips the tab. await page.goto('/login?next=%2F%09%2Fevil.com'); await expect(page.getByTestId('nav-login')).toBeVisible(); await page.getByTestId('login-username').fill('alice'); await page.getByTestId('login-password').fill('hunter2hunter2'); await page.getByTestId('login-submit').click(); // Stayed on-origin at the root, authenticated. await expect(page).toHaveURL(/\/$/); await expect(page.getByTestId('session-user')).toContainText('alice'); }); test('login surfaces the API error message on bad credentials', async ({ page }) => { await page.route('**/api/v1/auth/me', async (route) => { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }); }); await page.route('**/api/v1/auth/login', async (route) => { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }); }); await page.goto('/login'); await expect(page.getByTestId('nav-login')).toBeVisible(); await page.getByTestId('login-username').fill('alice'); await page.getByTestId('login-password').fill('wrongpassword'); await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-error')).toBeVisible(); });