test: Integrations-, Migrations- und E2E-Tests ergänzen

Der Integrationstest prüft gegen eine echte Datenbank, was Vitest nicht
sieht: Eindeutigkeit, Fremdschlüssel, Kaskaden und dass das Protokoll den
gelöschten Benutzer überlebt. Er läuft bewusst ohne umschließende
Transaktion — er 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.

Der Migrationstest legt eine Wegwerf-Datenbank an, spielt alle
Migrationen ein und vergleicht das Ergebnis mit dem Schema. Dass er
Abweichungen wirklich bemerkt, wurde mit einem probeweise ergänzten Feld
nachgewiesen.

Der E2E-Rauchtest deckt den Weg ab, der in M1 trägt: Umleitung ohne
Anmeldung samt gemerktem Ziel, Abweisung falscher Zugangsdaten,
erfolgreiche Anmeldung und die Verwaltungsseite.

Dafür neu: eine Middleware, die nur den angefragten Pfad als Kopfzeile
durchreicht. Ein Layout kennt seinen Pfad nicht, braucht ihn aber für die
Rückkehr nach der Anmeldung. Anmeldelogik steht dort bewusst nicht — die
Middleware läuft im Edge-Umfeld ohne Prisma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 13:10:54 +02:00
co-authored by Claude Opus 5
parent 24a799d6ba
commit 126ad93301
7 changed files with 495 additions and 2 deletions
+208
View File
@@ -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<unknown>): Promise<void> {
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<void> {
// 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<void> {
// --- 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)
})
+121
View File
@@ -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<string, string>): 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<T>(
tun: (klient: Client) => Promise<T>,
): Promise<T> {
// "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<number> {
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 <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)