test: Verschlüsselung und Kalendermodelle absichern

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 18:12:05 +02:00
co-authored by Claude Opus 5
parent d8d3227c82
commit df50627a20
2 changed files with 174 additions and 0 deletions
+112
View File
@@ -50,6 +50,9 @@ async function aufraeumen(): Promise<void> {
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<void> {
}),
)
// --- 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<void> {
'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({
+62
View File
@@ -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()
})
})