Add auth e2e regression pack with fixtures and CI stack
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 1m41s
CI / Lint, typecheck, test (push) Failing after 56s
CI / Auth e2e pack (push) Failing after 43s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Has been cancelled

The seed script now provisions the documented fixture matrix
(fixture-admin / fixture-user / fixture-pending, idempotent upserts,
rate-limit reset for disposable databases). A six-test Playwright pack
drives the real UI against a full local stack with Mailpit: complete
signup→mail→verify→first-login journey, wrong-password error, guarded
route redirect honoring ?next (race between the login page and the
anonymous guard fixed by teaching the guard about ?next), menu logout,
site-admin gating, and a profile rename reflected in the top bar. The
pack self-skips without E2E_MAILPIT_URL, so the CD smoke stage (now
pinned to smoke.spec.ts) stays untouched; a new CI job boots api +
web dev server against postgres/mailpit service containers and runs
the pack on every PR and push. Also fixed: the web api client choked
on empty 201 bodies. e2e/README.md documents targets and fixtures.

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-05 05:43:05 +02:00
parent 0bc80c9f93
commit 1cea675983
9 changed files with 352 additions and 8 deletions

View File

@ -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

View File

@ -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 —

View File

@ -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<void> {
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<void> {
// 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()

46
apps/web/e2e/README.md Normal file
View File

@ -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 `<feature>.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.

107
apps/web/e2e/auth.spec.ts Normal file
View File

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

57
apps/web/e2e/helpers.ts Normal file
View File

@ -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<BrowserContext> {
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]!;
}

View File

@ -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 <Navigate to="/" replace />;
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 <Navigate to={next && next.startsWith('/') ? next : '/'} replace />;
}
return <Outlet />;
}

View File

@ -35,8 +35,9 @@ async function requestJson<T>(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 = <T>(path: string): Promise<T> => requestJson<T>('GET', path);

View File

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}