168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
|
|
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<string> {
|
||
|
|
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 (<code>) — that's two
|
||
|
|
// locator matches, so we look at the card-side <code> 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/);
|
||
|
|
});
|
||
|
|
});
|