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
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:
parent
0bc80c9f93
commit
1cea675983
@ -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
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -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
46
apps/web/e2e/README.md
Normal 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
107
apps/web/e2e/auth.spec.ts
Normal 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
57
apps/web/e2e/helpers.ts
Normal 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]!;
|
||||
}
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
4
apps/web/test-results/.last-run.json
Normal file
4
apps/web/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user