From df50627a20e618add1406a30d1e647c8407ba281 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:12:05 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Verschl=C3=BCsselung=20und=20Kalendermo?= =?UTF-8?q?delle=20absichern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sieben Tests für die Tokenverschlüsselung: Rückweg, Umlaute, verschiedene Chiffren bei gleichem Klartext, und dass eine Veränderung am Chiffrat null ergibt statt Unsinn. Der Integrationstest deckt jetzt auch ab, dass dasselbe Konto mit verschiedenen Kalendern erlaubt ist, ein Upsert den Termin aktualisiert statt zu doppeln, und ein Bereich das Löschen seines Standard-Kalenderkontos überlebt. Co-Authored-By: Claude Opus 5 --- scripts/integrationstest.ts | 112 ++++++++++++++++++++++++++++++++++++ src/server/krypto.test.ts | 62 ++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/server/krypto.test.ts 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() + }) +})