Files
Mangalord/frontend/e2e/auth-flow.spec.ts
MechaCat02 ded77fe4ba test(e2e): kill cold-start flakiness with an /api/v1 fallback + one retry
A full parallel run intermittently failed the first few tests: unmocked
/api/v1 calls fell through to the dev proxy, whose backend isn't running
under E2E, so each incurred a slow ECONNREFUSED round-trip + Vite error
logging that piled up across 8 workers at cold start.

- Add e2e/fixtures.ts: a shared `test` whose auto-use fixture installs an
  `**/api/v1/**` fallback (fast 503, logged) for any endpoint a spec
  didn't mock. Registered before the test body, so per-test routes still
  win; only genuine gaps land here. Turns silent mock-gap hangs into
  instant, attributable failures — and cuts the full run ~96s → ~25s.
- Point all 22 specs at ./fixtures instead of @playwright/test.
- retries: 1 in the config as a safety net for the residual Vite
  cold-compile timing (re-runs on the now-warm server).

Two consecutive full runs: 110 passed, 0 flaky, no retries consumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:06:20 +02:00

156 lines
6.0 KiB
TypeScript

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();
});