diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 780ab32..04fb242 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -91,7 +91,7 @@ jobs: echo "Test stage did not become ready" >&2; exit 1 - name: Run smoke suite - run: E2E_BASE_URL=https://test.dorfteich.cloud pnpm --filter @dorfteich/web exec playwright test + run: E2E_BASE_URL=https://test.dorfteich.cloud pnpm --filter @dorfteich/web exec playwright test e2e/smoke.spec.ts promote-int: name: Promote to Int diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7400695..8fbbe41 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -59,6 +59,63 @@ jobs: - name: Translation key parity (de/en) run: pnpm i18n:check + auth-e2e: + name: Auth e2e pack + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.5-alpine + env: + POSTGRES_USER: e2e + POSTGRES_PASSWORD: e2e + POSTGRES_DB: e2e + mailpit: + image: axllent/mailpit:latest + env: + DATABASE_URL: postgresql://e2e:e2e@postgres:5432/e2e + APP_BASE_URL: http://localhost:5173 + SMTP_HOST: mailpit + SMTP_PORT: '1025' + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Seed fixtures + run: pnpm --filter @dorfteich/api db:seed + + - name: Start api and web dev server + run: | + (cd apps/api && PORT=3001 node dist/main.js &) + (pnpm --filter @dorfteich/web dev -- --port 5173 --strictPort &) + for i in $(seq 1 30); do + curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break + sleep 2 + done + curl -sf http://localhost:5173/ >/dev/null + + - name: Install Playwright browser + run: pnpm --filter @dorfteich/web exec playwright install --with-deps chromium + + - name: Run auth pack + run: | + E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \ + pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts + images: name: Build container images # PR-only: on main the CD workflow builds and pushes the same images — diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 36284c1..20d96fb 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,19 +1,84 @@ /** * Development/Test fixture seeding. Idempotent: running it twice must not - * duplicate anything. Real fixtures (users, ponds, pages) arrive with their - * feature stories (#20, #32); until then this only proves the wiring. + * duplicate anything. Never run against production data. + * + * Fixture matrix (documented in apps/web/e2e/README.md): + * fixture-admin active, Site Admin + * fixture-user active, regular account + * fixture-pending registered but e-mail not verified + * + * All fixture accounts share the password below — they exist only on + * dev machines and disposable CI/Test databases. */ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, UserStatus } from '@prisma/client'; + +import { hashPassword } from '../src/users/password'; + +export const FIXTURE_PASSWORD = 'fixture passwort 123'; const prisma = new PrismaClient(); +interface FixtureUser { + username: string; + displayName: string; + status: UserStatus; + isSiteAdmin: boolean; +} + +const FIXTURES: FixtureUser[] = [ + { username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true }, + { username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false }, + { + username: 'fixture-pending', + displayName: 'Fixture Pending', + status: 'PENDING_VERIFICATION', + isSiteAdmin: false, + }, +]; + +async function upsertFixtureUser(fixture: FixtureUser): Promise { + const email = `${fixture.username}@dorfteich.test`; + const user = await prisma.user.upsert({ + where: { username: fixture.username }, + create: { + username: fixture.username, + email, + displayName: fixture.displayName, + locale: 'de', + status: fixture.status, + isSiteAdmin: fixture.isSiteAdmin, + emailVerifiedAt: fixture.status === 'ACTIVE' ? new Date() : null, + }, + update: { + status: fixture.status, + isSiteAdmin: fixture.isSiteAdmin, + }, + }); + await prisma.userIdentity.upsert({ + where: { provider_subject: { provider: 'password', subject: user.id } }, + create: { + userId: user.id, + provider: 'password', + subject: user.id, + credential: await hashPassword(FIXTURE_PASSWORD), + }, + update: {}, + }); +} + async function main(): Promise { + // Fresh rate-limit budget for e2e runs — seed targets are always + // disposable dev/CI databases, never production. + await prisma.rateLimit.deleteMany({}); + for (const fixture of FIXTURES) { + await upsertFixtureUser(fixture); + } await prisma.instanceSetting.upsert({ where: { key: 'seed.marker' }, create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } }, update: { value: { seededAt: new Date().toISOString() } }, }); - console.log('seed: done (no fixtures defined yet)'); + console.log(`seed: done (${FIXTURES.length} fixture users)`); } main() diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md new file mode 100644 index 0000000..1f4d92d --- /dev/null +++ b/apps/web/e2e/README.md @@ -0,0 +1,46 @@ +# End-to-end tests + +Two Playwright suites with different targets: + +| Suite | Target | Where it runs | +| --------------- | --------------------------------- | --------------------------------------------------------------------- | +| `smoke.spec.ts` | any deployed stage | CD pipeline against `https://test.dorfteich.cloud` after every deploy | +| `auth.spec.ts` | full local stack **with Mailpit** | CI job `auth-e2e` on every PR/push; locally against the dev stack | + +## Running locally + +```sh +# 1. Stack: database + Mailpit, api (3001), web dev server (5173) +docker compose -f deploy/compose/docker-compose.yml -f deploy/compose/compose.dev.yml up -d db mailpit +DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich pnpm --filter @dorfteich/api db:seed +DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich PORT=3001 pnpm --filter @dorfteich/api start:dev & +pnpm --filter @dorfteich/web dev & + +# 2. Tests +E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://localhost:8025 pnpm --filter @dorfteich/web e2e +``` + +`auth.spec.ts` skips itself when `E2E_MAILPIT_URL` is unset, so the CD +smoke run never trips over it. + +## Fixture matrix + +Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running +never duplicates). Shared password: `fixture passwort 123`. Fixtures exist +only on dev machines and disposable CI/Test databases. + +| Username | State | Purpose | +| ----------------- | ------------------- | ------------------------------------ | +| `fixture-admin` | active, Site Admin | admin UI/permissions cases | +| `fixture-user` | active | regular journeys, settings, sessions | +| `fixture-pending` | e-mail not verified | unverified-login cases | + +## Conventions + +- New feature packs get their own `.spec.ts` next to these and + extend the fixture matrix here (permission matrix arrives with M5, + issue #60). +- Use `contextForUser()` from `helpers.ts` for signed-in tests — it logs + in through the api and hands you a browser context with the session + cookie, no UI login repetition. +- Flaky tests are defects (ADR 0014): fix or quarantine immediately. diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts new file mode 100644 index 0000000..f95dfb7 --- /dev/null +++ b/apps/web/e2e/auth.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser, latestMailFor, tokenFromMail } from './helpers'; + +/** + * M1 auth regression pack. Runs against a full local stack with Mailpit + * (CI job `auth-e2e`, or `deploy/compose` dev stack locally); skipped + * where no mail catcher is available (e.g. the CD smoke run against a + * deployed stage — that pipeline runs e2e/smoke.spec.ts only anyway). + */ +const MAILPIT_URL = process.env.E2E_MAILPIT_URL; +test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)'); + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +test('full signup journey: register, mail, verify, first login', async ({ page }) => { + const stamp = Date.now().toString(36); + const username = `e2e-${stamp}`; + const email = `${username}@dorfteich.test`; + const password = 'ein sehr langes e2e passwort'; + + await page.goto('/signup'); + await page.getByLabel(/username|benutzername/i).fill(username); + await page.getByLabel(/e-mail/i).fill(email); + await page.getByLabel(/display name|anzeigename/i).fill(username); + await page.getByLabel(/^password|^passwort/i).fill(password); + await page.getByRole('button', { name: /register|registrieren/i }).click(); + + await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible(); + + const mail = await latestMailFor(MAILPIT_URL!, email); + const token = tokenFromMail(mail.text); + + await page.goto(`/verify-email?token=${token}`); + await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible(); + + await page.getByRole('link', { name: /sign-in|anmeldung/i }).click(); + await page.getByLabel(/username or e-mail|benutzername oder e-mail/i).fill(username); + await page.getByLabel(/^password|^passwort/i).fill(password); + await page.getByRole('button', { name: /sign in|anmelden/i }).click(); + + // Signed in: the user menu shows the display name. + await expect(page.getByRole('button', { name: username })).toBeVisible(); +}); + +test('login rejects a wrong password with a visible error', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/username or e-mail|benutzername oder e-mail/i).fill('fixture-user'); + await page.getByLabel(/^password|^passwort/i).fill('definitiv falsch'); + await page.getByRole('button', { name: /sign in|anmelden/i }).click(); + await expect(page.getByRole('alert')).toBeVisible(); +}); + +test('anonymous visitors are redirected to login and return after', async ({ page }) => { + await page.goto('/settings'); + await expect(page).toHaveURL(/\/login\?next=%2Fsettings/); + await page.getByLabel(/username or e-mail|benutzername oder e-mail/i).fill('fixture-user'); + await page.getByLabel(/^password|^passwort/i).fill('fixture passwort 123'); + await page.getByRole('button', { name: /sign in|anmelden/i }).click(); + await expect(page).toHaveURL(/\/settings/); +}); + +test('fixture user signs out via the menu', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const page = await context.newPage(); + await page.goto('/'); + await page.getByRole('button', { name: 'Fixture User' }).click(); + await page.getByRole('menuitem', { name: /sign out|abmelden/i }).click(); + await expect(page).toHaveURL(/\/login/); + await context.close(); +}); + +test('admin menu and page are reserved for site admins', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const adminPage = await admin.newPage(); + await adminPage.goto('/admin'); + await expect(adminPage.getByRole('heading', { name: /administration/i })).toBeVisible(); + await admin.close(); + + const member = await contextForUser(browser, BASE_URL, 'fixture-user'); + const memberPage = await member.newPage(); + await memberPage.goto('/admin'); + // Non-admins are bounced to the start page. + await expect(memberPage).toHaveURL(/\/$/); + await member.close(); +}); + +test('profile display-name change shows up in the top bar', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const page = await context.newPage(); + await page.goto('/settings'); + const nameField = page.getByLabel(/display name|anzeigename/i); + await nameField.fill('Fixture Umbenannt'); + await page + .getByRole('button', { name: /^save$|^speichern$/i }) + .first() + .click(); + await expect(page.getByRole('button', { name: 'Fixture Umbenannt' })).toBeVisible(); + // Restore for the next run (idempotent pack). + await nameField.fill('Fixture User'); + await page + .getByRole('button', { name: /^save$|^speichern$/i }) + .first() + .click(); + await expect(page.getByRole('button', { name: 'Fixture User' })).toBeVisible(); + await context.close(); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts new file mode 100644 index 0000000..02f2793 --- /dev/null +++ b/apps/web/e2e/helpers.ts @@ -0,0 +1,57 @@ +import { type Browser, type BrowserContext, request } from '@playwright/test'; + +export const FIXTURE_PASSWORD = 'fixture passwort 123'; + +/** + * Signs a fixture user in through the api and returns a browser context + * that already carries the session cookie — UI tests skip the login form + * unless the form itself is under test. + */ +export async function contextForUser( + browser: Browser, + baseURL: string, + username: string, + password: string = FIXTURE_PASSWORD, +): Promise { + const api = await request.newContext({ baseURL }); + const response = await api.post('/api/v1/auth/login', { + data: { usernameOrEmail: username, password }, + }); + if (!response.ok()) { + throw new Error(`fixture login for ${username} failed: ${response.status()}`); + } + const storageState = await api.storageState(); + await api.dispose(); + return browser.newContext({ baseURL, storageState }); +} + +/** Latest mail for an address from the Mailpit REST api. */ +export async function latestMailFor( + mailpitUrl: string, + address: string, +): Promise<{ subject: string; text: string }> { + const api = await request.newContext({ baseURL: mailpitUrl }); + for (let attempt = 0; attempt < 30; attempt += 1) { + const list = await api.get('/api/v1/search', { + params: { query: `to:${address}`, limit: 1 }, + }); + const body = (await list.json()) as { messages?: { ID: string; Subject: string }[] }; + const found = body.messages?.[0]; + if (found) { + const message = await api.get(`/api/v1/message/${found.ID}`); + const details = (await message.json()) as { Text: string }; + await api.dispose(); + return { subject: found.Subject, text: details.Text }; + } + // The outbox worker delivers every 15s — poll until it does. + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + await api.dispose(); + throw new Error(`no mail arrived for ${address}`); +} + +export function tokenFromMail(text: string): string { + const match = text.match(/token=([A-Za-z0-9_-]+)/); + if (!match) throw new Error('mail contains no token link'); + return match[1]!; +} diff --git a/apps/web/src/auth/guards.tsx b/apps/web/src/auth/guards.tsx index f508550..232a77e 100644 --- a/apps/web/src/auth/guards.tsx +++ b/apps/web/src/auth/guards.tsx @@ -17,8 +17,15 @@ export function RequireAuth(): React.JSX.Element { /** Wraps auth pages: signed-in users have no business on /login etc. */ export function RequireAnonymous(): React.JSX.Element { const { user, isLoading } = useAuth(); + const location = useLocation(); if (isLoading) return <>; - if (user) return ; + if (user) { + // Honor ?next= here: after a successful login the user state updates + // and this redirect fires before the page's own navigate() — + // both paths must agree on the target. + const next = new URLSearchParams(location.search).get('next'); + return ; + } return ; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 061db9a..1cf032d 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -35,8 +35,9 @@ async function requestJson(method: string, path: string, body?: unknown): Pro parsed ?? { code: `http_${response.status}`, message: response.statusText }, ); } - if (response.status === 204) return undefined as T; - return (await response.json()) as T; + // 201/204 responses may carry no body at all. + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; } export const apiGet = (path: string): Promise => requestJson('GET', path); diff --git a/apps/web/test-results/.last-run.json b/apps/web/test-results/.last-run.json new file mode 100644 index 0000000..f740f7c --- /dev/null +++ b/apps/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +}