/** * 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) })