diff --git a/e2e/anmeldung.spec.ts b/e2e/anmeldung.spec.ts new file mode 100644 index 0000000..7003e8f --- /dev/null +++ b/e2e/anmeldung.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test' + +/** + * Rauchtest der Anmeldung. + * + * Prüft den Weg, der in M1 wirklich trägt: Wer nicht angemeldet ist, kommt + * nirgends hin; mit richtigem Passwort schon; und die Verwaltungsseite bleibt + * verschlossen, wenn die Rolle nicht reicht. + * + * Voraussetzung: "npm run db:seed" ist gelaufen und ADMIN_EMAIL sowie + * ADMIN_PASSWORT sind gesetzt. + */ + +const EMAIL = process.env.ADMIN_EMAIL ?? '' +const PASSWORT = process.env.ADMIN_PASSWORT ?? '' + +test.describe('Anmeldung', () => { + test.skip( + !EMAIL || !PASSWORT, + 'ADMIN_EMAIL und ADMIN_PASSWORT sind nicht gesetzt – ohne sie gibt es kein Testkonto.', + ) + + test('leitet nicht Angemeldete zur Anmeldeseite', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveURL(/\/anmelden/) + await expect(page.getByRole('heading', { name: 'Planer' })).toBeVisible() + }) + + test('merkt sich das Ziel und kehrt nach der Anmeldung dorthin zurück', async ({ + page, + }) => { + await page.goto('/bereiche') + await expect(page).toHaveURL(/\/anmelden\?weiter=%2Fbereiche/) + }) + + test('weist falsche Zugangsdaten ab, ohne zu verraten warum', async ({ page }) => { + await page.goto('/anmelden') + await page.getByLabel('E-Mail-Adresse').fill(EMAIL) + await page.getByLabel('Passwort').fill('ganz-sicher-falsch-123') + await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() + + await expect(page.getByText('Anmeldung nicht möglich')).toBeVisible() + await expect(page).toHaveURL(/\/anmelden/) + }) + + test('lässt mit richtigem Passwort hinein und wieder hinaus', async ({ page }) => { + await page.goto('/anmelden') + await page.getByLabel('E-Mail-Adresse').fill(EMAIL) + await page.getByLabel('Passwort').fill(PASSWORT) + await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() + + await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() + await expect(page.getByRole('link', { name: 'Bereiche' })).toBeVisible() + + await page.getByTitle('Abmelden').click() + await expect(page).toHaveURL(/\/anmelden/) + }) + + test('zeigt der Verwaltung das Protokoll', async ({ page }) => { + await page.goto('/anmelden') + await page.getByLabel('E-Mail-Adresse').fill(EMAIL) + await page.getByLabel('Passwort').fill(PASSWORT) + await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() + await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() + + await page.getByRole('link', { name: 'Protokoll' }).click() + await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible() + }) +}) + +test('der Health-Endpunkt antwortet ohne Anmeldung', async ({ request }) => { + const antwort = await request.get('/api/health') + expect(antwort.ok()).toBe(true) + + const inhalt = (await antwort.json()) as Record + expect(inhalt.status).toBe('ok') + expect(inhalt.datenbank).toBe('ok') + expect(typeof inhalt.version).toBe('string') +}) diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e7de6a3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,46 @@ +import { defineConfig, devices } from '@playwright/test' +import 'dotenv/config' + +const PORT = Number(process.env.E2E_PORT ?? 3100) +const BASIS = `http://127.0.0.1:${PORT}` + +/** + * E2E-Tests gegen eine echte, gebaute Anwendung. + * + * Der Testserver läuft auf einem eigenen Port, damit er einer laufenden + * Entwicklungsinstanz nicht in die Quere kommt. Die Datenbank ist dieselbe wie + * im Entwicklungsbetrieb – die Tests legen nur Daten mit eigenem Präfix an und + * räumen hinter sich auf. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? 'github' : 'list', + timeout: 30_000, + + use: { + baseURL: BASIS, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + + webServer: { + command: 'npm run start', + url: `${BASIS}/api/health`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + PORT: String(PORT), + APP_URL: BASIS, + WEBAUTHN_ORIGIN: BASIS, + WEBAUTHN_RP_ID: '127.0.0.1', + }, + }, +}) diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts new file mode 100644 index 0000000..41f217c --- /dev/null +++ b/scripts/integrationstest.ts @@ -0,0 +1,208 @@ +/** + * Integrationstest gegen eine echte Datenbank. + * + * Prüft, was Vitest nicht abdeckt: Eindeutigkeit, Fremdschlüssel, Kaskaden + * und dass das Audit-Protokoll den Benutzer überlebt. + * + * Bewusst ohne umschließende Transaktion: Der Test prüft absichtlich Fälle, + * die scheitern müssen, und ein gescheiterter Befehl bricht in PostgreSQL die + * ganze Transaktion ab. Stattdessen tragen alle Testdaten dieselbe Kennung + * und werden am Ende wieder entfernt — auch wenn der Test unterwegs abbricht. + * + * Aufruf: npm run test:integration + */ +import 'dotenv/config' +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from '../src/generated/prisma/client.js' + +const verbindung = process.env.DATABASE_URL +if (!verbindung) { + console.error('DATABASE_URL ist nicht gesetzt.') + process.exit(1) +} + +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) + +type Pruefung = { name: string; ok: boolean; hinweis?: string } +const ergebnisse: Pruefung[] = [] + +function pruefe(name: string, bedingung: boolean, hinweis?: string): void { + ergebnisse.push(hinweis ? { name, ok: bedingung, hinweis } : { name, ok: bedingung }) +} + +/** Erwartet, dass die Aktion scheitert. */ +async function scheitert(name: string, tun: () => Promise): Promise { + try { + await tun() + pruefe(name, false, 'Die Aktion war erlaubt, obwohl sie scheitern sollte.') + } catch { + pruefe(name, true) + } +} + +const kennung = `zzz-test-${Date.now()}` +const testEmail = `${kennung}@example.invalid` + +async function aufraeumen(): Promise { + // Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag + // hängt bewusst an keinem Benutzer mehr. + await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } }) + await db.benutzer.deleteMany({ where: { email: testEmail } }) + await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) + await db.webauthnChallenge.deleteMany({ where: { kennung } }) +} + +async function main(): Promise { + // --- Benutzer und Eindeutigkeit der Adresse ------------------------------ + const benutzer = await db.benutzer.create({ + data: { email: testEmail, name: 'Testkonto' }, + }) + pruefe('Benutzer anlegen', benutzer.id.length > 0) + pruefe('Rolle ist im Zweifel BENUTZER', benutzer.rolle === 'BENUTZER') + pruefe('Konto ist im Zweifel aktiv', benutzer.aktiv === true) + + await scheitert('E-Mail-Adresse ist eindeutig', () => + db.benutzer.create({ data: { email: testEmail } }), + ) + + // --- Bereich und Eindeutigkeit des Namens -------------------------------- + const bereich = await db.bereich.create({ + data: { name: `${kennung}-bereich`, farbe: '#2563eb', kapazitaetMo: 120 }, + }) + pruefe('Bereich anlegen', bereich.name.endsWith('-bereich')) + pruefe( + 'Arbeitsfenster hat brauchbare Standardwerte', + bereich.arbeitsfensterVon === 480 && bereich.arbeitsfensterBis === 1020, + ) + + await scheitert('Bereichsname ist eindeutig', () => + db.bereich.create({ data: { name: bereich.name, farbe: '#000000' } }), + ) + + // --- Passkey, Eindeutigkeit und Fremdschlüssel --------------------------- + const passkey = await db.passkey.create({ + data: { + benutzerId: benutzer.id, + credentialId: `${kennung}-cred`, + publicKey: Buffer.from([1, 2, 3]), + bezeichnung: 'Testgerät', + }, + }) + pruefe('Passkey anlegen', passkey.counter === 0n) + pruefe( + 'Zähler ist ein BigInt und beginnt bei null', + typeof passkey.counter === 'bigint', + ) + + await scheitert('Credential-ID ist eindeutig', () => + db.passkey.create({ + data: { + benutzerId: benutzer.id, + credentialId: passkey.credentialId, + publicKey: Buffer.from([4]), + bezeichnung: 'Zweitgerät', + }, + }), + ) + + await scheitert('Passkey ohne gültigen Benutzer ist nicht möglich', () => + db.passkey.create({ + data: { + benutzerId: 'gibt-es-nicht', + credentialId: `${kennung}-waise`, + publicKey: Buffer.from([9]), + bezeichnung: 'Waise', + }, + }), + ) + + // --- Öffentlicher Schlüssel kommt unverändert zurück --------------------- + const gelesen = await db.passkey.findUnique({ where: { id: passkey.id } }) + pruefe( + 'Der öffentliche Schlüssel kommt Byte für Byte zurück', + Buffer.from(gelesen?.publicKey ?? []).equals(Buffer.from([1, 2, 3])), + ) + + // --- Konto und Eindeutigkeit je Anbieter --------------------------------- + await db.konto.create({ + data: { + benutzerId: benutzer.id, + type: 'oidc', + provider: 'oidc', + providerAccountId: kennung, + }, + }) + await scheitert('Ein Anbieterkonto ist nur einmal verknüpfbar', () => + db.konto.create({ + data: { + benutzerId: benutzer.id, + type: 'oidc', + provider: 'oidc', + providerAccountId: kennung, + }, + }), + ) + + // --- Audit überlebt den Benutzer ---------------------------------------- + const audit = await db.auditEintrag.create({ + data: { + benutzerId: benutzer.id, + benutzerEmail: benutzer.email, + aktion: 'test.durchgefuehrt', + objektTyp: 'Bereich', + objektId: bereich.id, + details: { name: bereich.name }, + instanz: 'daheim', + }, + }) + pruefe('Audit-Eintrag anlegen', audit.id.length > 0) + + await db.benutzer.delete({ where: { id: benutzer.id } }) + + const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) + pruefe('Audit-Eintrag überlebt das Löschen des Benutzers', nachher !== null) + pruefe( + 'Die Kennung wird gelöst, die Adresse bleibt als Nachweis stehen', + nachher?.benutzerId === null && nachher?.benutzerEmail === testEmail, + ) + + pruefe( + 'Passkeys verschwinden mit dem Benutzer', + (await db.passkey.count({ where: { credentialId: passkey.credentialId } })) === 0, + ) + pruefe( + 'Verknüpfte Konten verschwinden mit dem Benutzer', + (await db.konto.count({ where: { providerAccountId: kennung } })) === 0, + ) +} + +let abbruch: unknown = null + +main() + .catch((fehler: unknown) => { + abbruch = fehler + }) + .finally(async () => { + await aufraeumen() + + const uebrig = + (await db.benutzer.count({ where: { email: testEmail } })) + + (await db.bereich.count({ where: { name: { startsWith: kennung } } })) + pruefe('Aufräumen hat nichts übrig gelassen', uebrig === 0) + + await db.$disconnect() + + if (abbruch) { + console.error('Der Integrationstest ist abgebrochen:', abbruch) + process.exit(1) + } + + const gefallen = ergebnisse.filter((e) => !e.ok) + for (const e of ergebnisse) { + console.log(`${e.ok ? '✓' : '✗'} ${e.name}${e.hinweis ? ` – ${e.hinweis}` : ''}`) + } + console.log( + `\n${ergebnisse.length - gefallen.length} von ${ergebnisse.length} Prüfungen bestanden.`, + ) + process.exit(gefallen.length === 0 ? 0 : 1) + }) diff --git a/scripts/migrationstest.ts b/scripts/migrationstest.ts new file mode 100644 index 0000000..52fbe4f --- /dev/null +++ b/scripts/migrationstest.ts @@ -0,0 +1,121 @@ +/** + * Migrationstest. + * + * Legt eine Wegwerf-Datenbank an, spielt alle Migrationen der Reihe nach ein + * und prüft anschließend, ob das Ergebnis genau dem Schema entspricht. Damit + * fällt auf, wenn jemand das Schema ändert, ohne eine Migration zu erzeugen — + * beim Aufspielen im Betrieb wäre das sonst erst spät zu merken. + * + * Aufruf: npm run test:migration + */ +import 'dotenv/config' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' +import { Client } from 'pg' + +const WURZEL = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1') + +const verbindung = process.env.DATABASE_URL +if (!verbindung) { + console.error('DATABASE_URL ist nicht gesetzt.') + process.exit(1) +} + +const testDatenbank = `planer_migrationstest_${Date.now()}` + +/** Dieselbe Verbindung, aber auf eine andere Datenbank gerichtet. */ +function mitDatenbank(url: string, name: string): string { + const zerlegt = new URL(url) + zerlegt.pathname = `/${name}` + return zerlegt.toString() +} + +function prisma(argumente: string[], zusatz: Record): number { + const ergebnis = spawnSync( + process.execPath, + [join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js'), ...argumente], + { + cwd: WURZEL, + stdio: 'inherit', + env: { ...process.env, ...zusatz }, + }, + ) + return ergebnis.status ?? 1 +} + +async function aufVerwaltungsdatenbank( + tun: (klient: Client) => Promise, +): Promise { + // "postgres" ist auf jedem Server vorhanden und wird hier nur benutzt, um + // die Wegwerf-Datenbank anzulegen und wieder zu entfernen. + const klient = new Client({ connectionString: mitDatenbank(verbindung!, 'postgres') }) + await klient.connect() + try { + return await tun(klient) + } finally { + await klient.end() + } +} + +async function main(): Promise { + console.log(`▸ Wegwerf-Datenbank ${testDatenbank} anlegen`) + await aufVerwaltungsdatenbank((k) => k.query(`CREATE DATABASE "${testDatenbank}"`)) + + const testUrl = mitDatenbank(verbindung!, testDatenbank) + + console.log('▸ Alle Migrationen einspielen') + if (prisma(['migrate', 'deploy'], { DATABASE_URL: testUrl }) !== 0) { + console.error('✗ Die Migrationen ließen sich nicht einspielen.') + return 1 + } + + console.log('▸ Ergebnis mit dem Schema vergleichen') + // --exit-code liefert 2, wenn ein Unterschied bleibt. + const abweichung = prisma( + [ + 'migrate', + 'diff', + // Die Quelle ist die eben eingespielte Wegwerf-Datenbank, das Ziel das + // Schema. So braucht der Vergleich keine Schattendatenbank. + '--from-config-datasource', + '--to-schema', + 'prisma/schema.prisma', + '--exit-code', + ], + { DATABASE_URL: testUrl }, + ) + + if (abweichung === 2) { + console.error( + '\n✗ Nach dem Einspielen weicht die Datenbank vom Schema ab.\n' + + ' Vermutlich fehlt eine Migration: "npx prisma migrate dev --name ".', + ) + return 1 + } + if (abweichung !== 0) { + console.error('✗ Der Vergleich ließ sich nicht durchführen.') + return 1 + } + + console.log('\n✓ Die Migrationen ergeben genau das Schema.') + return 0 +} + +let code = 1 +try { + code = await main() +} catch (fehler) { + console.error('Der Migrationstest ist abgebrochen:', fehler) +} finally { + await aufVerwaltungsdatenbank(async (k) => { + // Offene Verbindungen trennen, sonst lässt sich die Datenbank nicht löschen. + await k.query( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', + [testDatenbank], + ) + await k.query(`DROP DATABASE IF EXISTS "${testDatenbank}"`) + }) + console.log(`▸ Wegwerf-Datenbank ${testDatenbank} entfernt`) +} + +process.exit(code) diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 4f4161f..0ad2e81 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,7 +1,7 @@ import { Kopfleiste } from '@/components/kopfleiste' import { INSTANZ_NAME } from '@/lib/instanz' import { versionAnzeige } from '@/lib/version' -import { verlangeAnmeldung } from '@/server/berechtigung' +import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung' import { aktuelleInstanz } from '@/server/instanz' /** @@ -14,7 +14,9 @@ export default async function AngemeldetesLayout({ }: { children: React.ReactNode }) { - const benutzer = await verlangeAnmeldung() + // Der Pfad kommt aus der Kopfzeile der Middleware, damit es nach der + // Anmeldung dort weitergeht, wo man hinwollte. + const benutzer = await verlangeAnmeldung(await angefragterPfad()) const instanz = aktuelleInstanz() return ( diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..5d982bc --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,25 @@ +import { NextResponse, type NextRequest } from 'next/server' + +export const PFAD_KOPFZEILE = 'x-planer-pfad' + +/** + * Reicht den angefragten Pfad als Kopfzeile weiter. + * + * Ein Layout kennt seinen eigenen Pfad nicht, braucht ihn aber, um nach der + * Anmeldung wieder dorthin zurückzuführen. Hier steht bewusst keine + * Anmeldelogik: die Middleware läuft im Edge-Umfeld, in dem weder Prisma noch + * der Adapter verfügbar sind. Geprüft wird weiterhin im Layout, in der Seite + * und in jeder Server Action. + */ +export function middleware(anfrage: NextRequest) { + const kopfzeilen = new Headers(anfrage.headers) + kopfzeilen.set(PFAD_KOPFZEILE, `${anfrage.nextUrl.pathname}${anfrage.nextUrl.search}`) + return NextResponse.next({ request: { headers: kopfzeilen } }) +} + +export const config = { + matcher: [ + // Alles außer den Auth-Routen, statischen Dateien und Bildern. + '/((?!api/auth|_next/static|_next/image|favicon.ico).*)', + ], +} diff --git a/src/server/berechtigung.ts b/src/server/berechtigung.ts index 5a2ef6d..a44e1a6 100644 --- a/src/server/berechtigung.ts +++ b/src/server/berechtigung.ts @@ -1,9 +1,11 @@ import 'server-only' +import { headers } from 'next/headers' import { redirect } from 'next/navigation' import type { Session } from 'next-auth' import type { Rolle } from '@/generated/prisma/enums' import { rolleReichtAus } from '@/lib/rollen' import { auth } from '@/server/auth' +import { PFAD_KOPFZEILE } from '@/middleware' /** * Rollenpruefung. @@ -72,3 +74,13 @@ export async function pruefeRolle(verlangt: Rolle): Promise { export async function verlangeAnmeldungFuerApi(): Promise { return angemeldet() } + +/** + * Der angefragte Pfad, den die Middleware durchgereicht hat. Wird gebraucht, + * um nach der Anmeldung wieder dorthin zurueckzufuehren. + */ +export async function angefragterPfad(): Promise { + const kopfzeilen = await headers() + const pfad = kopfzeilen.get(PFAD_KOPFZEILE) + return pfad && pfad.startsWith('/') ? pfad : undefined +}