import { expect, test, type APIRequestContext } from '@playwright/test'; /** * M2 — Auth flow. * * Each test starts from a clean DB by hitting an internal helper that * truncates users/refresh_tokens/invitations and force-mints a fresh install * token. The helper is the `/api/v1/diag/reset` endpoint exposed *only* when * `APP_ENV=test` — see backend/app/api/diag.py. * * The flow exercised: setup → login → me → invite → register → 2nd login. */ const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`; const ADMIN_PASSWORD = 'AdminPass1234!'; const ALICE_EMAIL = `alice-${Math.floor(Math.random() * 1e6)}@metamorph.local`; const ALICE_PASSWORD = 'AlicePass1234!'; interface ResetPayload { install_token: string; } async function resetAndMintToken(request: APIRequestContext): Promise { const r = await request.post('/api/v1/diag/reset'); expect(r.status(), `reset endpoint must respond 200 — got ${r.status()}`).toBe(200); const body = (await r.json()) as ResetPayload; expect(body.install_token).toMatch(/^[A-Za-z0-9_-]{30,}$/); return body.install_token; } test.describe.configure({ mode: 'serial' }); test.describe('M2 — auth flow', () => { let installToken: string; let invitationToken: string; test.beforeAll(async ({ request }) => { installToken = await resetAndMintToken(request); }); test('setup status is uncompleted before bootstrap', async ({ request }) => { const r = await request.get('/api/v1/setup'); expect(r.status()).toBe(200); expect((await r.json()).completed).toBe(false); }); test('SPA setup form creates the first admin', async ({ page }) => { await page.goto('/setup'); await page.getByLabel(/install token/i).fill(installToken); await page.getByLabel(/admin email/i).fill(ADMIN_EMAIL); await page.getByLabel('Password', { exact: true }).fill(ADMIN_PASSWORD); await page.getByLabel(/confirm password/i).fill(ADMIN_PASSWORD); await page.getByRole('button', { name: /create admin/i }).click(); await expect(page.getByText(/admin created/i)).toBeVisible(); // Auto-redirect lands us on /login. await expect(page).toHaveURL(/\/login$/, { timeout: 5000 }); }); test('SPA login works and reveals the profile page', async ({ page }) => { await page.goto('/login'); await page.getByLabel(/email/i).fill(ADMIN_EMAIL); await page.getByLabel(/password/i).fill(ADMIN_PASSWORD); await page.getByRole('button', { name: /sign in/i }).click(); // Lands on home with header showing the admin email. await expect(page).toHaveURL(/\/$/); await expect(page.getByTestId('me-email')).toHaveText(ADMIN_EMAIL); // Visit profile to check identity card. The email appears both in the nav // bar (testid me-email) and in the Identity card () — that's two // locator matches, so we look at the card-side explicitly. await page.getByRole('link', { name: /profile/i }).click(); await expect(page.getByRole('code').filter({ hasText: ADMIN_EMAIL })).toBeVisible(); await expect(page.getByText(/admin\s+account/i)).toBeVisible(); }); test('admin issues an invitation via the API and the front renders the registration form', async ({ page, request, }) => { // Reuse the page session: read access token from /auth/me cookie chain. The // SPA keeps it in memory, so we exercise the API via a fresh API request // logged in with the same credentials. const login = await request.post('/api/v1/auth/login', { data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, }); expect(login.status()).toBe(200); const access = (await login.json()).access_token as string; const created = await request.post('/api/v1/invitations', { headers: { Authorization: `Bearer ${access}` }, data: { email_hint: ALICE_EMAIL }, }); expect(created.status()).toBe(201); invitationToken = (await created.json()).token as string; expect(invitationToken).toMatch(/^[A-Za-z0-9_-]{30,}$/); // Open the registration page and confirm the preview loaded. await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`); await expect(page.getByText(/account.*registration/i)).toBeVisible(); // Email pre-filled from the hint. const emailInput = page.getByLabel(/email/i); await expect(emailInput).toHaveValue(ALICE_EMAIL); }); test('invitee submits the registration form and can log in', async ({ page }) => { await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`); await page.getByLabel(/email/i).fill(ALICE_EMAIL); await page.getByLabel('Password', { exact: true }).fill(ALICE_PASSWORD); await page.getByLabel(/confirm password/i).fill(ALICE_PASSWORD); await page.getByRole('button', { name: /create account/i }).click(); await expect(page.getByText(/account created/i)).toBeVisible(); await expect(page).toHaveURL(/\/login$/, { timeout: 5000 }); await page.getByLabel(/email/i).fill(ALICE_EMAIL); await page.getByLabel(/password/i).fill(ALICE_PASSWORD); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL); }); test('non-admin gets 403 on the admin invitations endpoint', async ({ request }) => { const login = await request.post('/api/v1/auth/login', { data: { email: ALICE_EMAIL, password: ALICE_PASSWORD }, }); const access = (await login.json()).access_token as string; const r = await request.post('/api/v1/invitations', { headers: { Authorization: `Bearer ${access}` }, data: {}, }); expect(r.status()).toBe(403); }); test('refresh token rotation works through the SPA', async ({ page }) => { // Login fresh. await page.goto('/login'); await page.getByLabel(/email/i).fill(ALICE_EMAIL); await page.getByLabel(/password/i).fill(ALICE_PASSWORD); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL); // Force a refresh via the API client interceptor: clear the in-memory access // token and trigger a request that needs auth. await page.evaluate(async () => { const r = await fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include', }); return r.ok; }); // After refresh the page is still authenticated. await page.reload(); await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL); }); test('logout clears the session and redirects to login', async ({ page }) => { await page.goto('/login'); await page.getByLabel(/email/i).fill(ALICE_EMAIL); await page.getByLabel(/password/i).fill(ALICE_PASSWORD); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL); await page.getByRole('button', { name: /logout/i }).click(); await expect(page).toHaveURL(/\/login$/); // Going to /profile while logged out must redirect. await page.goto('/profile'); await expect(page).toHaveURL(/\/login/); }); });