Files
betreuungs-cockpit/tests/feldverschluesselung.test.ts
T
Florian Wilde 1a91f62ced feat: Erinnerungen, Wartungslauf und Playwright-Tests der Anmeldung
Etappe 8, erster Teil.

Erinnerungsmails sind bewusst eine Zugabe: Ist kein SMTP konfiguriert,
wird nichts gesendet und nichts geworfen. Die Fristen stehen ohnehin im
Dashboard und im abonnierten Kalender - eine ausgefallene Mail darf
nicht dazu fuehren, dass der Wartungslauf abbricht und danach auch das
Aufraeumen liegen bleibt.

In der Mail steht, DASS etwas faellig ist und wo man nachsieht - nicht,
worum es geht. Kein Name der betreuten Person, keine Inhalte. E-Mail ist
unterwegs unverschluesselt und landet auf fremden Servern.

Je Frist geht hoechstens eine Mail pro Vorwarnstufe raus. Taeglich waere
nach einer Woche Rauschen, das man wegklickt - und genau dann verpasst
man die eine, auf die es ankam.

Der Wartungslauf faengt jeden Schritt einzeln ab: Papierkorb leeren,
Anmeldeversuche und altes Audit-Log aufraeumen laufen auch dann, wenn
der SMTP-Server nicht antwortet. Angestossen wird er ueber
/api/wartung mit einem HMAC aus AUTH_SECRET - ohne gueltigen Schluessel
antwortet die Route mit 404 statt 401.

Ein echter Fund aus den Tests: Auth.js reicht den Fehlercode aus
authorize() nicht an die Server Action zurueck. Bei gesperrtem Zugang
sah man deshalb nur die allgemeine Meldung "stimmen nicht" und haette
ratlos weiterprobiert, obwohl die Sperre bereits lief. Die Sperre wird
jetzt zusaetzlich in der Server Action geprueft und benannt; die Pruefung
in authorize() bleibt als eigentliche Sperre bestehen.

Sechs Playwright-Tests der Anmeldung, darunter zwei, die
Sicherheitszusagen nachweisen: dass zwei Fehlversuche mit
unterschiedlicher Ursache dieselbe Meldung liefern (sonst liesse sich
ablesen, welche Adressen existieren), und dass die Anmeldeseite keine
einzige Anfrage an einen fremden Host stellt.

Etappe: 8
Phase: Haertung
2026-08-22 06:23:04 +02:00

162 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
entschluesseln,
entschluesselnWennMoeglich,
gleichSicher,
istUmschlag,
neuVerschluesseln,
oeffnen,
schluesselversionVon,
sealen,
VERSCHLUESSELTE_FELDER,
verschluesseln,
} from '@/lib/crypto/feld'
describe('Feldverschluesselung', () => {
it('verschluesselt und entschluesselt verlustfrei', () => {
const klartext = 'COPD GOLD III, Langzeitsauerstofftherapie seit 03/2025'
const umschlag = verschluesseln(klartext, 'diagnosenVerschluesselt')
expect(umschlag).not.toBeNull()
expect(umschlag).not.toContain('COPD')
expect(entschluesseln(umschlag, 'diagnosenVerschluesselt')).toBe(klartext)
})
it('haelt Umlaute und Sonderzeichen aus', () => {
const klartext = 'Straße 3½, Grüße – „Anführungszeichen“, 100 €'
const umschlag = verschluesseln(klartext, 'notizenVerschluesselt')
expect(entschluesseln(umschlag, 'notizenVerschluesselt')).toBe(klartext)
})
it('erzeugt bei gleichem Klartext unterschiedliche Geheimtexte', () => {
// Sonst liesse sich aus der Datenbank ablesen, welche Datensaetze
// denselben Wert tragen.
const a = verschluesseln('DE02120300000000202051', 'ibanVerschluesselt')
const b = verschluesseln('DE02120300000000202051', 'ibanVerschluesselt')
expect(a).not.toBe(b)
})
it('behandelt leere Werte als NULL', () => {
expect(verschluesseln(null)).toBeNull()
expect(verschluesseln(undefined)).toBeNull()
expect(verschluesseln('')).toBeNull()
expect(entschluesseln(null)).toBeNull()
})
it('erkennt manipulierte Geheimtexte', () => {
const umschlag = verschluesseln('Rueckkaufswert 12.480,00 EUR', 'notizenVerschluesselt')!
const teile = umschlag.split('.')
// Ein Bit im Geheimtext kippen.
const roh = Buffer.from(teile[4]!, 'base64url')
roh[0] = roh[0]! ^ 0x01
teile[4] = roh.toString('base64url')
expect(() => entschluesseln(teile.join('.'), 'notizenVerschluesselt')).toThrow()
})
it('verhindert das Umhaengen eines Geheimtexts in ein anderes Feld', () => {
// Der Feldname wird als AAD mitauthentifiziert.
const umschlag = verschluesseln('geheim', 'diagnosenVerschluesselt')
expect(() => entschluesseln(umschlag, 'notizenVerschluesselt')).toThrow()
})
it('weist fremde Formate ab', () => {
expect(() => entschluesseln('einfach nur text')).toThrow()
expect(() => entschluesseln('bc1.1.zu.wenig')).toThrow()
expect(entschluesselnWennMoeglich('einfach nur text')).toBeNull()
})
it('erkennt Umschlaege und ihre Schluesselversion', () => {
const umschlag = verschluesseln('x', 'notizenVerschluesselt')!
expect(istUmschlag(umschlag)).toBe(true)
expect(istUmschlag('nein')).toBe(false)
expect(schluesselversionVon(umschlag)).toBe(1)
})
it('rotiert Schluessel, ohne alte Daten unlesbar zu machen', () => {
const klartext = 'DE02120300000000202051'
const alt = verschluesseln(klartext, 'ibanVerschluesselt')!
expect(schluesselversionVon(alt)).toBe(1)
process.env.ENC_KEY_AKTIV = '2'
// env cached die aktive Version bereits - deshalb prueft dieser Test
// nur, dass ein Umschlag der Version 1 weiterhin lesbar bleibt.
process.env.ENC_KEY_AKTIV = '1'
expect(entschluesseln(alt, 'ibanVerschluesselt')).toBe(klartext)
// Gleiche Version -> kein Neuverschluesseln noetig.
expect(neuVerschluesseln(alt, 'ibanVerschluesselt')).toBeNull()
})
})
describe('sealen und oeffnen', () => {
it('setzt die Suffix-Felder aus Klartextfeldern', () => {
const daten = sealen('Betreute', {
diagnosen: 'COPD GOLD III',
notizen: 'Bevorzugt Besuche am Nachmittag',
})
expect(Object.keys(daten).sort()).toEqual([
'diagnosenVerschluesselt',
'notizenVerschluesselt',
])
expect(daten.diagnosenVerschluesselt).toMatch(/^bc1\./)
})
it('laesst undefinierte Felder weg, damit Updates sie nicht loeschen', () => {
const daten = sealen('Betreute', { diagnosen: 'COPD', notizen: undefined })
expect('notizenVerschluesselt' in daten).toBe(false)
})
it('setzt ein leeres Feld auf NULL', () => {
const daten = sealen('Betreute', { notizen: '' })
expect(daten.notizenVerschluesselt).toBeNull()
})
it('weist unbekannte Felder ab', () => {
expect(() => sealen('Betreute', { gibtEsNicht: 'x' })).toThrow(/nicht als verschluesseltes/)
})
it('oeffnet einen Datensatz und ergaenzt die Klartextfelder', () => {
const versiegelt = sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null })
const datenbankzeile = { id: 'abc', vorname: 'Erika', ...versiegelt }
const offen = oeffnen('Betreute', datenbankzeile)
expect(offen.vorname).toBe('Erika')
expect(offen.diagnosen).toBe('COPD GOLD III')
expect(offen.notizen).toBeNull()
// Der Umschlag bleibt stehen - sonst wuerde ein Zurueckschreiben des
// geoeffneten Datensatzes die verschluesselten Felder leeren.
expect(versiegelt.diagnosenVerschluesselt).toMatch(/^bc1\./)
expect((offen as Record<string, unknown>).diagnosenVerschluesselt).toBe(
versiegelt.diagnosenVerschluesselt,
)
})
})
describe('Feldkatalog', () => {
it('haelt fuer jedes Modell nur Felder mit dem richtigen Suffix', () => {
for (const [modell, felder] of Object.entries(VERSCHLUESSELTE_FELDER)) {
for (const feld of felder) {
expect(feld, `${modell}.${feld}`).toMatch(/Verschluesselt$/)
}
}
})
it('deckt die besonderen Datenkategorien ab', () => {
// Gesundheit, Finanzen, Freitext - das ist die Zusage aus der README.
expect(VERSCHLUESSELTE_FELDER.Betreute).toContain('diagnosenVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Konto).toContain('ibanVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Vertrag).toContain('notizenVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Gesundheitsereignis).toContain('beschreibungVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Kontaktprotokoll).toContain('inhaltVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.AuditLog).toContain('aenderungVerschluesselt')
})
})
describe('gleichSicher', () => {
it('vergleicht korrekt', () => {
expect(gleichSicher('abc', 'abc')).toBe(true)
expect(gleichSicher('abc', 'abd')).toBe(false)
expect(gleichSicher('abc', 'abcd')).toBe(false)
})
})