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
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
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)
|
||
})
|
||
})
|