diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 5dc9217..84f6055 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -50,6 +50,9 @@ async function aufraeumen(): Promise { await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } }) await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } }) await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } }) + // Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test + // vorher abbricht, hier trotzdem noch einmal aufräumen. + await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } }) await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.webauthnChallenge.deleteMany({ where: { kennung } }) @@ -146,6 +149,102 @@ async function main(): Promise { }), ) + // --- Kalenderkonto und Termine ------------------------------------------ + const kalenderkonto = await db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: `${kennung} Firma Outlook`, + extKontoId: `${kennung}-konto`, + }, + }) + pruefe( + 'Kalenderkonto zeigt im Zweifel auf den Standardkalender', + kalenderkonto.kalenderId === 'standard', + ) + pruefe( + 'Ein frisches Konto hat noch keinen Abgleichverweis', + kalenderkonto.deltaVerweis === null, + ) + + await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () => + db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: `${kennung} Doppelt`, + extKontoId: `${kennung}-konto`, + }, + }), + ) + + // Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung. + const zweiterKalender = await db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: `${kennung} Zweiter Kalender`, + extKontoId: `${kennung}-konto`, + kalenderId: 'AAMkAD-zweiter', + }, + }) + pruefe( + 'Dasselbe Konto mit anderem Kalender ist erlaubt', + zweiterKalender.id !== kalenderkonto.id, + ) + + const termin = await db.termin.create({ + data: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + betreff: 'Wochenbesprechung', + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T09:00:00Z'), + }, + }) + pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false) + + await scheitert('Derselbe Termin je Konto nur einmal', () => + db.termin.create({ + data: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T09:00:00Z'), + }, + }), + ) + + // So arbeitet der Abgleich: upsert statt anlegen. + const gewandelterTermin = await db.termin.upsert({ + where: { + kalenderkontoId_extId: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + }, + }, + update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' }, + create: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T09:00:00Z'), + }, + }) + pruefe( + 'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln', + gewandelterTermin.id === termin.id && + gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' && + (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1, + ) + + // --- Standard-Kalenderkonto eines Bereichs ------------------------------- + await db.bereich.update({ + where: { id: bereich.id }, + data: { standardKalenderkontoId: kalenderkonto.id }, + }) + await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } }) + // --- Audit überlebt den Benutzer ---------------------------------------- const audit = await db.auditEintrag.create({ data: { @@ -177,6 +276,19 @@ async function main(): Promise { 'Verknüpfte Konten verschwinden mit dem Benutzer', (await db.konto.count({ where: { providerAccountId: kennung } })) === 0, ) + pruefe( + 'Kalenderkonten verschwinden mit dem Benutzer', + (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, + ) + pruefe( + 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', + (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, + ) + pruefe( + 'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos', + (await db.bereich.findUnique({ where: { id: bereich.id } })) + ?.standardKalenderkontoId === null, + ) // --- Aufgaben: Standardwerte und Inbox ----------------------------------- const aufgabe = await db.aufgabe.create({ diff --git a/src/server/krypto.test.ts b/src/server/krypto.test.ts new file mode 100644 index 0000000..2327991 --- /dev/null +++ b/src/server/krypto.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Die Verschlüsselung liegt in src/server, weil sie AUTH_SECRET braucht. + * Für den Test wird die Umgebung ersetzt und "server-only" auf ein leeres + * Modul umgebogen — sonst bricht der Import außerhalb einer Serverumgebung ab. + */ +vi.mock('server-only', () => ({})) + +vi.mock('@/server/umgebung', () => ({ + umgebung: () => ({ AUTH_SECRET: 'test-geheimnis-fuer-die-ableitung-1234567890' }), +})) + +const { entschluesseln, schluesselVergessen, verschluesseln } = + await import('@/server/krypto') + +describe('verschluesseln und entschluesseln', () => { + beforeEach(() => { + schluesselVergessen() + }) + + it('gibt zurück, was hineinging', () => { + const klartext = 'ya29.beispiel-aktualisierungstoken' + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext) + }) + + it('kommt mit Umlauten und langen Texten zurecht', () => { + const klartext = 'Rückgabewert mit Umlauten: äöüß — ' + 'x'.repeat(4000) + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext) + }) + + it('erzeugt bei gleichem Text verschiedene Chiffren', () => { + // Sonst ließe sich an der Datenbank ablesen, welche Konten dasselbe + // Token tragen. + expect(verschluesseln('gleich')).not.toBe(verschluesseln('gleich')) + }) + + it('lässt den Klartext nicht durchscheinen', () => { + expect(verschluesseln('geheim')).not.toContain('geheim') + }) + + it('gibt für nichts auch nichts zurück', () => { + expect(entschluesseln(null)).toBeNull() + }) + + it('weist Veränderungen ab, statt Unsinn zu liefern', () => { + const chiffre = verschluesseln('unverändert') + const teile = chiffre.split(':') + // Ein Bit im Inhalt kippen. + const inhalt = Buffer.from(teile[3] ?? '', 'base64url') + inhalt[0] = (inhalt[0] ?? 0) ^ 0x01 + teile[3] = inhalt.toString('base64url') + + expect(entschluesseln(teile.join(':'))).toBeNull() + }) + + it('weist eine fremde Form ab', () => { + expect(entschluesseln('einfach nur Text')).toBeNull() + expect(entschluesseln('v2:a:b:c')).toBeNull() + expect(entschluesseln('v1:zu:wenige')).toBeNull() + }) +})