Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7780739a1a | ||
|
|
9365645f72 | ||
|
|
232543e2aa |
+23
-7
@@ -19,15 +19,31 @@ AUTH_URL=http://localhost:3000
|
|||||||
# Hinter einem Reverse Proxy auf true setzen.
|
# Hinter einem Reverse Proxy auf true setzen.
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
# --- Erreichbarkeit ---------------------------------------------------------
|
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
|
||||||
# Auf welcher Adresse und welchem Port horcht die Anwendung?
|
|
||||||
#
|
#
|
||||||
# Proxy auf DEMSELBEN Server: 127.0.0.1 (Standard, nichts zu tun)
|
# Auf welcher Adresse soll die Anwendung erreichbar sein? Das haengt davon
|
||||||
# Proxy auf einem ANDEREN: die Adresse des privaten Netzes bzw. des
|
# ab, WO dein Reverse Proxy laeuft. Waehl genau eine Zeile aus:
|
||||||
# WireGuard-Tunnels, z.B. 10.0.0.5
|
|
||||||
#
|
#
|
||||||
# 0.0.0.0 nur, wenn eine Firewall den Port ausschliesslich fuer die IP des
|
# Proxy auf DEMSELBEN Server
|
||||||
# Proxy-Servers oeffnet. Die Anwendung beendet kein TLS.
|
# APP_BIND=127.0.0.1
|
||||||
|
# Von aussen kommt niemand heran, nur der Proxy nebenan. Sicherste
|
||||||
|
# Variante - aber ein Proxy auf einem anderen Rechner erreicht die
|
||||||
|
# Anwendung damit NICHT.
|
||||||
|
#
|
||||||
|
# Proxy auf einem ANDEREN Server, privates Netz oder WireGuard vorhanden
|
||||||
|
# APP_BIND=10.0.0.5 <- die Adresse DIESES Servers in dem Netz
|
||||||
|
# Erste Wahl, wenn der Proxy woanders steht.
|
||||||
|
#
|
||||||
|
# Proxy auf einem ANDEREN Server, kein privates Netz
|
||||||
|
# APP_BIND=0.0.0.0
|
||||||
|
# Nur zusammen mit einer Firewallregel, die den Port ausschliesslich
|
||||||
|
# fuer die IP des Proxy-Servers oeffnet:
|
||||||
|
# ufw allow from <IP-des-Proxys> to any port 3000 proto tcp
|
||||||
|
# ufw deny 3000/tcp
|
||||||
|
#
|
||||||
|
# Zwischen Proxy und Anwendung laeuft unverschluesseltes HTTP - die
|
||||||
|
# Anwendung beendet kein TLS. Dieser Weg darf nie durchs offene Netz gehen.
|
||||||
|
# Einzelheiten: docs/INSTALLATION.md, Schritt 7.
|
||||||
APP_BIND=127.0.0.1
|
APP_BIND=127.0.0.1
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|
||||||
|
|||||||
+136
-1
@@ -26,6 +26,138 @@ Wiederherstellung*.
|
|||||||
|
|
||||||
_Noch nichts._
|
_Noch nichts._
|
||||||
|
|
||||||
|
## [1.0.4] – 2026-08-22
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Die Ersteinrichtung ließ sich nicht abschließen.** Schritt 3 meldete
|
||||||
|
„Bitte prüfe die markierten Felder" — markierte aber keines. Man kam an
|
||||||
|
dieser Stelle nicht weiter.
|
||||||
|
|
||||||
|
Ursache: Das kurze Formular der Ersteinrichtung schickt neun Felder, das
|
||||||
|
vollständige unter *Stammdaten* alle. Beide gehen durch dasselbe Schema,
|
||||||
|
und die optionalen Felder waren zwar `nullable`, aber nicht `optional` —
|
||||||
|
ein Feld, das gar nicht abgeschickt wurde, ist `undefined`, nicht
|
||||||
|
`null`. Beanstandet wurden also zwei Dutzend Felder, die auf dem
|
||||||
|
Bildschirm nie standen.
|
||||||
|
|
||||||
|
- **Eine Meldung ohne zugehöriges Feld kann nicht mehr unsichtbar
|
||||||
|
bleiben.** Beanstandet die Prüfung ein Feld, das das Formular gar nicht
|
||||||
|
gesendet hat, sagt die Anwendung das jetzt ausdrücklich und schreibt es
|
||||||
|
ins Serverlog — statt es als Eingabefehler auszugeben, den niemand
|
||||||
|
finden kann. Das ist ein Fehler im Programm, und er soll auch so
|
||||||
|
aussehen.
|
||||||
|
|
||||||
|
- **Eine betreute Person ließ sich nicht endgültig löschen.** Beim Löschen
|
||||||
|
setzt Postgres den Verweis im Audit-Log auf NULL (`ON DELETE SET NULL`),
|
||||||
|
und genau dieses UPDATE hat der Unveränderlichkeits-Trigger abgewiesen.
|
||||||
|
Betroffen war damit auch das Leeren des Papierkorbs nach 30 Tagen und
|
||||||
|
das Entfernen des Demofalls.
|
||||||
|
|
||||||
|
Der Trigger erlaubt jetzt genau eine Ausnahme: das Nullen der beiden
|
||||||
|
Fremdschlüssel `betreuteId` und `userId`. Jede andere Änderung bleibt
|
||||||
|
verboten, auch das Setzen eines Fremdschlüssels auf einen **anderen**
|
||||||
|
Wert. Der Nachweis bleibt vollständig — Zeitpunkt, Aktion, Bezeichnung,
|
||||||
|
die verschlüsselte Änderung und die Momentaufnahmen von Name und
|
||||||
|
E-Mail sind unberührt. Es verschwindet nur der Verweis auf einen
|
||||||
|
Datensatz, den es nicht mehr gibt.
|
||||||
|
|
||||||
|
**Beim Update nötig:** das SQL-Skript erneut ausführen, sonst bleibt der
|
||||||
|
alte Trigger stehen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app node node_modules/prisma/build/index.js db execute --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Sieben neue Tests (`tests/stammdaten.test.ts`) prüfen genau die
|
||||||
|
Formularinhalte der Ersteinrichtung, dazu das vollständige Formular mit
|
||||||
|
leeren Feldern und die weiterhin verlangten Pflichtangaben.
|
||||||
|
- Der Ablauf ist zusätzlich im Browser durchgespielt worden: Anmeldung,
|
||||||
|
zweiter Faktor, Schritt 3, Landung auf dem Dashboard — und die daraus
|
||||||
|
abgeleiteten Pflichtfristen samt Ausschlussfrist für die
|
||||||
|
Aufwandspauschale.
|
||||||
|
- Der geänderte Trigger ist gegen eine laufende Datenbank geprüft:
|
||||||
|
Löschen gelingt, Fälschen und Löschen von Einträgen scheitert weiterhin.
|
||||||
|
|
||||||
|
## [1.0.3] – 2026-08-22
|
||||||
|
|
||||||
|
Keine Änderung am Programm — nur an der Konfigurationsvorlage und der
|
||||||
|
Anleitung. Ein Neubau des Abbilds ist nicht nötig.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **`.env.example` band die Anwendung stillschweigend an `127.0.0.1`.**
|
||||||
|
Der Wert stand dort als fertig ausgefüllte Zeile zwischen lauter anderen
|
||||||
|
fertigen Zeilen. Wer die Datei kopiert hat und seinen Reverse Proxy auf
|
||||||
|
einem **anderen** Server betreibt, kam nicht an die Anwendung heran —
|
||||||
|
und der Fehler sah aus, als läge es am Proxy.
|
||||||
|
|
||||||
|
Jetzt steht dort eine Entscheidung mit drei benannten Fällen statt eines
|
||||||
|
Vorgabewerts. Der sichere Standard bleibt `127.0.0.1`; er wird nur nicht
|
||||||
|
mehr als „schon erledigt" präsentiert.
|
||||||
|
|
||||||
|
Für bestehende Installationen genügt eine Zeile in der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_BIND=10.0.0.5 # Adresse, über die der Proxy diesen Server erreicht
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **`docs/INSTALLATION.md`** nennt `APP_BIND` jetzt schon in Schritt 3, wo
|
||||||
|
die `.env` ausgefüllt wird — nicht erst in Schritt 7, wo der Proxy
|
||||||
|
drankommt. Dazu ein eigener Abschnitt in der Fehlersuche: „Der Proxy
|
||||||
|
erreicht die Anwendung nicht", mit den zwei `curl`-Proben, die den
|
||||||
|
Unterschied zwischen falscher Bindeadresse und Firewall zeigen.
|
||||||
|
|
||||||
|
## [1.0.2] – 2026-08-22
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Die Einrichtung des zweiten Faktors brach mit einem Serverfehler ab**
|
||||||
|
(„Application error: a server-side exception has occurred"). Im
|
||||||
|
Serverlog stand `Umgebungsvariable ENC_KEY_V2 fehlt` — obwohl niemand
|
||||||
|
einen zweiten Schlüssel wollte.
|
||||||
|
|
||||||
|
Ursache: `docker-compose.yml` reicht `ENC_KEY_V2` immer an den Behälter
|
||||||
|
durch, mit **leerem Wert**, wenn in der `.env` keiner steht. Die
|
||||||
|
Anwendung erkannte Schlüssel aber am Variablennamen statt am Inhalt,
|
||||||
|
fand also ein leeres `ENC_KEY_V2` und brach ab. Leere
|
||||||
|
Schlüsselvariablen werden jetzt übersprungen.
|
||||||
|
|
||||||
|
Betroffen war jede Stelle, die verschlüsselte Felder schreibt oder
|
||||||
|
liest — also praktisch alles außer der reinen Anmeldung. Die Anmeldung
|
||||||
|
ging durch, weil sie ohne Verschlüsselung auskommt; der Fehler trat
|
||||||
|
deshalb erst auf der nächsten Seite auf.
|
||||||
|
|
||||||
|
- **Die Meldung beim Schlüsselwechsel** nennt jetzt, welche Versionen
|
||||||
|
tatsächlich vorhanden sind, und die richtige Reihenfolge: erst den
|
||||||
|
neuen Schlüssel eintragen, dann `ENC_KEY_AKTIV` umstellen.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **`scripts/anmeldung-pruefen.mjs`** — beantwortet die Frage, warum eine
|
||||||
|
Anmeldung nicht klappt. Das Formular sagt bewusst nicht, woran es lag,
|
||||||
|
sonst ließe sich daran ablesen, welche Adressen es gibt. Auf dem Server
|
||||||
|
darf man es wissen: Jeder Versuch wird mit Grund protokolliert, und das
|
||||||
|
Skript liest ihn vor — dazu den Zustand des Zugangs, den Stand der
|
||||||
|
Login-Sperre und die eingestellte `AUTH_URL`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app node scripts/anmeldung-pruefen.mjs deine@adresse.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Es ändert nichts, es liest nur.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Sieben neue Tests (`tests/umgebung.test.ts`) decken die
|
||||||
|
Schlüsselauswertung ab, darunter genau die Konstellation aus dem
|
||||||
|
Fehlerbericht: gesetztes `ENC_KEY_V1`, leeres `ENC_KEY_V2`.
|
||||||
|
|
||||||
## [1.0.1] – 2026-08-22
|
## [1.0.1] – 2026-08-22
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
@@ -219,6 +351,9 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.1...HEAD
|
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...HEAD
|
||||||
|
[1.0.4]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.3...v1.0.4
|
||||||
|
[1.0.3]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...v1.0.3
|
||||||
|
[1.0.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.1...v1.0.2
|
||||||
[1.0.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.0...v1.0.1
|
[1.0.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.0...v1.0.1
|
||||||
[1.0.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/releases/tag/v1.0.0
|
[1.0.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/releases/tag/v1.0.0
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ services:
|
|||||||
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
|
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
|
||||||
AUTH_TRUST_HOST: 'true'
|
AUTH_TRUST_HOST: 'true'
|
||||||
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
|
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
|
||||||
|
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
|
||||||
|
# leere Schluesselvariablen - siehe src/lib/env.ts.
|
||||||
ENC_KEY_V2: ${ENC_KEY_V2:-}
|
ENC_KEY_V2: ${ENC_KEY_V2:-}
|
||||||
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
|
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
|
||||||
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
|
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ S3_SECRET_ACCESS_KEY=<aus Schritt 2>
|
|||||||
|
|
||||||
# --- Sicherung ---
|
# --- Sicherung ---
|
||||||
SICHERUNG_PASSWORT=<aus Schritt 2>
|
SICHERUNG_PASSWORT=<aus Schritt 2>
|
||||||
|
|
||||||
|
# --- Erreichbarkeit ---
|
||||||
|
# Steht dein Proxy auf einem ANDEREN Server, muss hier die Adresse hinein,
|
||||||
|
# ueber die er diesen Server erreicht. Bleibt es bei 127.0.0.1, kommt er
|
||||||
|
# NICHT heran - und der Fehler sieht aus, als sei der Proxy schuld.
|
||||||
|
# Welcher Wert der richtige ist, steht in Schritt 7a.
|
||||||
|
APP_BIND=127.0.0.1
|
||||||
|
APP_PORT=3000
|
||||||
```
|
```
|
||||||
|
|
||||||
> **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die
|
> **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die
|
||||||
@@ -875,6 +883,41 @@ Fehlt eine Umgebungsvariable, sagt die Anwendung beim Start, welche.
|
|||||||
Die `.env` liegt nicht neben der `docker-compose.yml`, oder die Zeile ist
|
Die `.env` liegt nicht neben der `docker-compose.yml`, oder die Zeile ist
|
||||||
leer. Prüf mit dem Befehl aus Schritt 3.
|
leer. Prüf mit dem Befehl aus Schritt 3.
|
||||||
|
|
||||||
|
### Der Proxy erreicht die Anwendung nicht („502", „Connection refused")
|
||||||
|
|
||||||
|
Fast immer steht in der `.env` noch `APP_BIND=127.0.0.1`, während der
|
||||||
|
Proxy auf einem anderen Server läuft. Dann horcht die Anwendung nur auf
|
||||||
|
der Loopback-Adresse — erreichbar ausschließlich vom Anwendungsserver
|
||||||
|
selbst.
|
||||||
|
|
||||||
|
Prüf, worauf sie tatsächlich horcht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
# unter PORTS muss die Adresse stehen, die der Proxy erreicht,
|
||||||
|
# NICHT 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Und ob sie überhaupt antwortet — erst lokal, dann vom Proxy-Server aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# auf dem Anwendungsserver
|
||||||
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
|
|
||||||
|
# auf dem Proxy-Server
|
||||||
|
curl -s http://<APP_BIND-Adresse>:3000/api/gesundheit
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwortet der erste, aber nicht der zweite, ist `APP_BIND` falsch oder
|
||||||
|
eine Firewall dazwischen. Setz den richtigen Wert (Schritt 7a) und
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Neubau des Abbilds ist dafür **nicht** nötig — es ist nur eine
|
||||||
|
Umgebungsänderung, der Behälter wird neu erzeugt.
|
||||||
|
|
||||||
### Port 3000 ist schon belegt
|
### Port 3000 ist schon belegt
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "1.0.1",
|
"version": "1.0.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -25,6 +25,42 @@ BEGIN
|
|||||||
RETURN OLD;
|
RETURN OLD;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Eine einzige Ausnahme beim UPDATE: das Nullen der beiden
|
||||||
|
* Fremdschluessel.
|
||||||
|
*
|
||||||
|
* Wird eine betreute Person oder ein Benutzer endgueltig geloescht
|
||||||
|
* (Papierkorb nach 30 Tagen), setzt Postgres wegen ON DELETE SET NULL
|
||||||
|
* die Spalten betreuteId bzw. userId auf NULL. Ohne diese Ausnahme
|
||||||
|
* scheitert das Loeschen - und der Papierkorb liesse sich nie leeren.
|
||||||
|
*
|
||||||
|
* Der Nachweis bleibt dabei vollstaendig: Zeitpunkt, Aktion, Entitaet,
|
||||||
|
* Bezeichnung und die verschluesselte Aenderung sind unberuehrt, ebenso
|
||||||
|
* die Momentaufnahmen userEmail und userName. Es verschwindet nur der
|
||||||
|
* Verweis auf einen Datensatz, den es nicht mehr gibt.
|
||||||
|
*
|
||||||
|
* Jede andere Aenderung wird abgewiesen - auch das Zurueckschreiben
|
||||||
|
* eines Fremdschluessels auf einen anderen Wert.
|
||||||
|
*/
|
||||||
|
IF TG_OP = 'UPDATE'
|
||||||
|
AND NEW.id = OLD.id
|
||||||
|
AND NEW.zeitpunkt = OLD.zeitpunkt
|
||||||
|
AND NEW.aktion = OLD.aktion
|
||||||
|
AND NEW.entitaet = OLD.entitaet
|
||||||
|
AND NEW."entitaetId" IS NOT DISTINCT FROM OLD."entitaetId"
|
||||||
|
AND NEW.bezeichnung IS NOT DISTINCT FROM OLD.bezeichnung
|
||||||
|
AND NEW."userEmail" IS NOT DISTINCT FROM OLD."userEmail"
|
||||||
|
AND NEW."userName" IS NOT DISTINCT FROM OLD."userName"
|
||||||
|
AND NEW."aenderungVerschluesselt" IS NOT DISTINCT FROM OLD."aenderungVerschluesselt"
|
||||||
|
AND NEW."ipHash" IS NOT DISTINCT FROM OLD."ipHash"
|
||||||
|
AND NEW."userAgent" IS NOT DISTINCT FROM OLD."userAgent"
|
||||||
|
-- Die beiden Fremdschluessel duerfen sich aendern, aber NUR nach NULL.
|
||||||
|
AND (NEW."betreuteId" IS NULL OR NEW."betreuteId" IS NOT DISTINCT FROM OLD."betreuteId")
|
||||||
|
AND (NEW."userId" IS NULL OR NEW."userId" IS NOT DISTINCT FROM OLD."userId")
|
||||||
|
THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
RAISE EXCEPTION
|
RAISE EXCEPTION
|
||||||
'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.',
|
'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.',
|
||||||
TG_OP
|
TG_OP
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Warum klappt die Anmeldung nicht?
|
||||||
|
*
|
||||||
|
* node --env-file=.env scripts/anmeldung-pruefen.mjs <e-mail>
|
||||||
|
* docker compose exec app node scripts/anmeldung-pruefen.mjs <e-mail>
|
||||||
|
*
|
||||||
|
* Die Anwendung sagt am Anmeldeformular bewusst nicht, woran es lag - sonst
|
||||||
|
* liesse sich daran ablesen, welche Adressen es gibt. Auf dem Server darf
|
||||||
|
* man es wissen: Jeder Versuch wird mit Grund protokolliert, und dieses
|
||||||
|
* Skript liest ihn vor.
|
||||||
|
*
|
||||||
|
* Es aendert nichts. Es liest nur.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const email = (process.argv[2] ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.error('Aufruf: node scripts/anmeldung-pruefen.mjs <e-mail>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new PrismaClient()
|
||||||
|
|
||||||
|
function zeit(d) {
|
||||||
|
return d ? new Date(d).toLocaleString('de-DE') : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klartext zu den Gruenden, die src/auth.ts protokolliert. */
|
||||||
|
const GRUND = {
|
||||||
|
unbekannt: 'Diese E-Mail-Adresse gibt es nicht (Tippfehler? andere Adresse?)',
|
||||||
|
'passwort-falsch': 'Das Passwort stimmt nicht',
|
||||||
|
'code-falsch': 'Der Code aus der Authenticator-App stimmt nicht',
|
||||||
|
gesperrt: 'Abgewiesen, weil zu diesem Zeitpunkt bereits gesperrt war',
|
||||||
|
'konto-inaktiv': 'Der Zugang ist deaktiviert oder im Papierkorb',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benutzer = await db.user.findUnique({ where: { email } })
|
||||||
|
|
||||||
|
console.log('\n══ Zugang ' + '═'.repeat(50))
|
||||||
|
|
||||||
|
if (!benutzer) {
|
||||||
|
console.log(` Es gibt KEINEN Zugang mit der Adresse ${email}.`)
|
||||||
|
const alle = await db.user.findMany({
|
||||||
|
select: { email: true, aktiv: true, geloeschtAm: true },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
})
|
||||||
|
console.log(
|
||||||
|
alle.length
|
||||||
|
? '\n Vorhandene Adressen:\n' +
|
||||||
|
alle.map((u) => ` ${u.email}${u.geloeschtAm ? ' (im Papierkorb)' : u.aktiv ? '' : ' (deaktiviert)'}`).join('\n')
|
||||||
|
: '\n Es gibt ueberhaupt keinen Zugang. Leg ihn im Browser unter /ersteinrichtung an.',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const zweiterFaktor = benutzer.totpAktiviertAm
|
||||||
|
? `eingerichtet am ${zeit(benutzer.totpAktiviertAm)}`
|
||||||
|
: benutzer.totpSecretVerschluesselt
|
||||||
|
? 'vorbereitet, aber NICHT bestaetigt - beim Anmelden wird noch kein Code verlangt'
|
||||||
|
: 'noch nicht eingerichtet - beim Anmelden wird noch kein Code verlangt'
|
||||||
|
|
||||||
|
console.log(` Name: ${benutzer.name}`)
|
||||||
|
console.log(` Rolle: ${benutzer.rolle}`)
|
||||||
|
console.log(` Aktiv: ${benutzer.aktiv ? 'ja' : 'NEIN - Anmeldung nicht moeglich'}`)
|
||||||
|
console.log(` Papierkorb: ${benutzer.geloeschtAm ? 'JA seit ' + zeit(benutzer.geloeschtAm) : 'nein'}`)
|
||||||
|
console.log(` Zweiter Faktor: ${zweiterFaktor}`)
|
||||||
|
console.log(` Wiederherstellung: ${benutzer.wiederherstellungscodes.length} Codes uebrig`)
|
||||||
|
console.log(` Passwort gesetzt: ${zeit(benutzer.passwortGeaendertAm)}`)
|
||||||
|
console.log(` Letzte Anmeldung: ${zeit(benutzer.letzteAnmeldung)}`)
|
||||||
|
|
||||||
|
const zugaenge = await db.betreuungsZugang.count({ where: { userId: benutzer.id } })
|
||||||
|
const betreute = await db.betreute.count({ where: { geloeschtAm: null } })
|
||||||
|
console.log(` Betreute Personen: ${zugaenge} zugeordnet, ${betreute} insgesamt angelegt`)
|
||||||
|
|
||||||
|
if (zugaenge === 0 && betreute === 0) {
|
||||||
|
console.log(
|
||||||
|
'\n Hinweis: Nach dem Anmelden landest du zuerst bei der\n' +
|
||||||
|
' Ersteinrichtung - erst zweiter Faktor, dann betreute Person.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Versuche der letzten Tage -------------------------------------------
|
||||||
|
const versuche = await db.loginVersuch.findMany({
|
||||||
|
where: { email },
|
||||||
|
orderBy: { zeitpunkt: 'desc' },
|
||||||
|
take: 15,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('\n══ Letzte Anmeldeversuche ' + '═'.repeat(35))
|
||||||
|
|
||||||
|
if (versuche.length === 0) {
|
||||||
|
console.log(
|
||||||
|
' Keine. Es ist also NIE ein Versuch beim Server angekommen.\n\n' +
|
||||||
|
' Das heisst: Das Formular hat gar nicht abgeschickt, oder es lief\n' +
|
||||||
|
' auf eine andere Adresse. Pruef AUTH_URL - siehe unten.',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
for (const v of versuche) {
|
||||||
|
const kennzeichen = v.erfolg ? 'OK ' : 'FEHLER'
|
||||||
|
const grund = v.erfolg ? 'angemeldet' : (GRUND[v.grund] ?? v.grund ?? 'unbekannter Grund')
|
||||||
|
console.log(` ${zeit(v.zeitpunkt).padEnd(20)} ${kennzeichen} ${grund}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sperre ---------------------------------------------------------------
|
||||||
|
const fenster = Number.parseInt(process.env.LOGIN_FENSTER_SEKUNDEN ?? '900', 10)
|
||||||
|
const grenze = Number.parseInt(process.env.LOGIN_MAX_VERSUCHE ?? '5', 10)
|
||||||
|
const seit = new Date(Date.now() - fenster * 1000)
|
||||||
|
|
||||||
|
const offen = await db.loginVersuch.count({
|
||||||
|
where: { email, erfolg: false, zeitpunkt: { gte: seit } },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('\n══ Sperre ' + '═'.repeat(51))
|
||||||
|
console.log(` ${offen} von ${grenze} Fehlversuchen im laufenden Fenster (${fenster / 60} Minuten)`)
|
||||||
|
|
||||||
|
if (offen >= grenze) {
|
||||||
|
console.log(
|
||||||
|
'\n GESPERRT. Warte das Fenster ab, oder setz die Zaehler zurueck:\n' +
|
||||||
|
' docker compose exec db psql -U cockpit betreuungs_cockpit \\\n' +
|
||||||
|
' -c "DELETE FROM login_versuch WHERE erfolg = false;"',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Umgebung -------------------------------------------------------------
|
||||||
|
console.log('\n══ Umgebung ' + '═'.repeat(49))
|
||||||
|
console.log(` AUTH_URL: ${process.env.AUTH_URL ?? 'NICHT GESETZT'}`)
|
||||||
|
console.log(
|
||||||
|
'\n Diese Adresse muss EXAKT die sein, die du im Browser aufrufst -\n' +
|
||||||
|
' gleiches Schema (https), gleicher Hostname, kein Schraegstrich am\n' +
|
||||||
|
' Ende. Weicht sie ab, wird das Sitzungs-Cookie fuer die falsche\n' +
|
||||||
|
' Herkunft gesetzt: Die Anmeldung landet ohne Fehlermeldung wieder\n' +
|
||||||
|
' auf dem Formular, und oben steht kein einziger Fehlversuch.\n',
|
||||||
|
)
|
||||||
|
} catch (fehler) {
|
||||||
|
// Ein Prisma-Stacktrace hilft hier niemandem weiter.
|
||||||
|
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
|
||||||
|
|
||||||
|
if (/Can't reach database server|ECONNREFUSED/i.test(meldung)) {
|
||||||
|
// Das Passwort in der Verbindungszeichenkette gehoert nicht auf den Schirm.
|
||||||
|
const url = (process.env.DATABASE_URL ?? 'NICHT GESETZT').replace(/:[^:@/]+@/, ':***@')
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'Die Datenbank ist nicht erreichbar.',
|
||||||
|
'',
|
||||||
|
` DATABASE_URL: ${url}`,
|
||||||
|
'',
|
||||||
|
' Im Docker-Betrieb gehoert dieses Skript IN den Behaelter:',
|
||||||
|
' docker compose exec app node scripts/anmeldung-pruefen.mjs <e-mail>',
|
||||||
|
'',
|
||||||
|
' Laeuft die Datenbank ueberhaupt?',
|
||||||
|
' docker compose ps',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.error(`\nFehler: ${meldung}\n`)
|
||||||
|
}
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
await db.$disconnect()
|
||||||
|
}
|
||||||
+19
-5
@@ -58,14 +58,25 @@ function einmal<T>(fn: () => T): () => T {
|
|||||||
|
|
||||||
const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
|
const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
|
||||||
const schluessel = new Map<number, Buffer>()
|
const schluessel = new Map<number, Buffer>()
|
||||||
|
|
||||||
// ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln
|
// ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln
|
||||||
// stehen, verschluesselt wird immer mit der aktiven Version.
|
// stehen, verschluesselt wird immer mit der aktiven Version.
|
||||||
for (const [name] of Object.entries(process.env)) {
|
for (const [name, wert] of Object.entries(process.env)) {
|
||||||
const treffer = name.match(/^ENC_KEY_V(\d+)$/)
|
const treffer = name.match(/^ENC_KEY_V(\d+)$/)
|
||||||
if (treffer) {
|
if (!treffer) continue
|
||||||
schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name))
|
|
||||||
}
|
// Eine LEERE Variable ist kein Schluessel.
|
||||||
|
//
|
||||||
|
// docker compose reicht ENC_KEY_V2 immer durch - mit leerem Wert, wenn
|
||||||
|
// in der .env keiner steht. Wuerde hier allein der Name zaehlen, waere
|
||||||
|
// jede Installation ohne zweiten Schluessel kaputt, und zwar mit der
|
||||||
|
// irrefuehrenden Meldung "ENC_KEY_V2 fehlt" - obwohl niemand einen
|
||||||
|
// zweiten Schluessel wollte.
|
||||||
|
if (!wert || wert.trim() === '') continue
|
||||||
|
|
||||||
|
schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schluessel.size === 0) {
|
if (schluessel.size === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' +
|
'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' +
|
||||||
@@ -78,8 +89,11 @@ const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
|
|||||||
const holeAktiveVersion = einmal((): number => {
|
const holeAktiveVersion = einmal((): number => {
|
||||||
const version = zahl('ENC_KEY_AKTIV', 1)
|
const version = zahl('ENC_KEY_AKTIV', 1)
|
||||||
if (!holeVerschluesselungsschluessel().has(version)) {
|
if (!holeVerschluesselungsschluessel().has(version)) {
|
||||||
|
const vorhanden = [...holeVerschluesselungsschluessel().keys()].sort().join(', ')
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt.`,
|
`ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt oder leer. ` +
|
||||||
|
`Vorhanden sind die Versionen: ${vorhanden}. Beim Rotieren zuerst den ` +
|
||||||
|
`neuen Schluessel eintragen, danach ENC_KEY_AKTIV umstellen.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -8,21 +8,36 @@ import { z } from 'zod'
|
|||||||
* ist, nicht was falsch war.
|
* ist, nicht was falsch war.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Leere Zeichenketten aus einem Formular sollen zu null werden, nicht zu "". */
|
/**
|
||||||
|
* Optionales Textfeld.
|
||||||
|
*
|
||||||
|
* Drei Eingaben muessen zu null werden:
|
||||||
|
* "" das Feld war da, blieb aber leer
|
||||||
|
* null ausdruecklich geleert
|
||||||
|
* undefined das Feld war im Formular ueberhaupt nicht dabei
|
||||||
|
*
|
||||||
|
* Der letzte Fall ist der wichtige: Das kurze Formular der Ersteinrichtung
|
||||||
|
* schickt nur eine Handvoll Felder, das vollstaendige unter Stammdaten
|
||||||
|
* alle. Beide gehen durch dasselbe Schema. Ohne .optional() wuerde die
|
||||||
|
* Ersteinrichtung an zwei Dutzend Feldern scheitern, die gar nicht auf dem
|
||||||
|
* Bildschirm stehen - und die Meldung liesse sich keinem Feld zuordnen.
|
||||||
|
*/
|
||||||
const optionalerText = (max = 200) =>
|
const optionalerText = (max = 200) =>
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(max, `Bitte höchstens ${max} Zeichen.`)
|
.max(max, `Bitte höchstens ${max} Zeichen.`)
|
||||||
.transform((wert) => (wert === '' ? null : wert))
|
.optional()
|
||||||
.nullable()
|
.nullable()
|
||||||
|
.transform((wert) => (wert === undefined || wert === '' ? null : wert))
|
||||||
|
|
||||||
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
|
/** HTML-Datumsfelder liefern "" oder "2026-01-15" - oder gar nichts. */
|
||||||
const optionalesDatum = z
|
const optionalesDatum = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.transform((wert) => (wert === '' ? null : wert))
|
.optional()
|
||||||
.nullable()
|
.nullable()
|
||||||
|
.transform((wert) => (wert === undefined || wert === '' ? null : wert))
|
||||||
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
|
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
|
||||||
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
|
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Muss zur "version" in der package.json passen. */
|
/** Muss zur "version" in der package.json passen. */
|
||||||
export const VERSION = '1.0.1'
|
export const VERSION = '1.0.4'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||||
|
|||||||
@@ -53,6 +53,49 @@ function felderAusFehler(fehler: z.ZodError): Record<string, string> {
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Antwort auf einen Pruefungsfehler.
|
||||||
|
*
|
||||||
|
* Der wichtige Teil ist die Unterscheidung: Beanstandet die Pruefung ein
|
||||||
|
* Feld, das gar nicht abgeschickt wurde, dann passt das Formular nicht zum
|
||||||
|
* Schema - und die Meldung liesse sich auf dem Bildschirm keinem Feld
|
||||||
|
* zuordnen. Der Nutzer saehe "Bitte pruefe die markierten Felder", ohne
|
||||||
|
* dass irgendetwas markiert waere, und kaeme nie weiter.
|
||||||
|
*
|
||||||
|
* Das ist ein Fehler im Programm, keiner in der Eingabe. Er wird deshalb
|
||||||
|
* benannt und ins Serverlog geschrieben, statt ihn dem Nutzer als
|
||||||
|
* Eingabefehler unterzuschieben.
|
||||||
|
*/
|
||||||
|
function antwortAufFehler(fehler: z.ZodError, formular: FormData): BetreuteZustand {
|
||||||
|
const felder = felderAusFehler(fehler)
|
||||||
|
const gesendet = new Set(formular.keys())
|
||||||
|
const nichtZuordenbar = Object.entries(felder).filter(([feld]) => !gesendet.has(feld))
|
||||||
|
|
||||||
|
if (nichtZuordenbar.length > 0) {
|
||||||
|
const liste = nichtZuordenbar.map(([feld, meldung]) => `${feld}: ${meldung}`).join('; ')
|
||||||
|
console.error(
|
||||||
|
`[stammdaten] Das Formular passt nicht zum Schema. Beanstandet wurden ` +
|
||||||
|
`Felder, die es gar nicht abgeschickt hat: ${liste}`,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
fehler:
|
||||||
|
'Das Formular passt nicht zur Prüfung — betroffen sind Felder, die ' +
|
||||||
|
'hier gar nicht stehen: ' +
|
||||||
|
nichtZuordenbar.map(([feld]) => feld).join(', ') +
|
||||||
|
'. Das ist ein Fehler in der Anwendung, nicht in deiner Eingabe. ' +
|
||||||
|
'Einzelheiten stehen im Serverlog.',
|
||||||
|
felder,
|
||||||
|
werte: werteMerken(formular),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fehler: 'Bitte prüfe die markierten Felder.',
|
||||||
|
felder,
|
||||||
|
werte: werteMerken(formular),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function werteMerken(formular: FormData): Record<string, string> {
|
function werteMerken(formular: FormData): Record<string, string> {
|
||||||
const werte: Record<string, string> = {}
|
const werte: Record<string, string> = {}
|
||||||
for (const [name, wert] of formular.entries()) {
|
for (const [name, wert] of formular.entries()) {
|
||||||
@@ -77,13 +120,7 @@ export async function betreuteAnlegen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
|
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
|
||||||
if (!gelesen.success) {
|
if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
|
||||||
return {
|
|
||||||
fehler: 'Bitte prüfe die markierten Felder.',
|
|
||||||
felder: felderAusFehler(gelesen.error),
|
|
||||||
werte: werteMerken(formular),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { offen, verschluesselt } = aufteilen(gelesen.data)
|
const { offen, verschluesselt } = aufteilen(gelesen.data)
|
||||||
|
|
||||||
@@ -134,13 +171,7 @@ export async function betreuteAendern(
|
|||||||
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
||||||
|
|
||||||
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
|
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
|
||||||
if (!gelesen.success) {
|
if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
|
||||||
return {
|
|
||||||
fehler: 'Bitte prüfe die markierten Felder.',
|
|
||||||
felder: felderAusFehler(gelesen.error),
|
|
||||||
werte: werteMerken(formular),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { offen, verschluesselt } = aufteilen(gelesen.data)
|
const { offen, verschluesselt } = aufteilen(gelesen.data)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { BetreuteSchema, formularLesen } from '@/lib/validierung/betreute'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingabepruefung der Stammdaten.
|
||||||
|
*
|
||||||
|
* Anlass für diese Datei war ein Fehler im Betrieb: Die Ersteinrichtung
|
||||||
|
* ließ sich nicht abschließen. Das Formular meldete „Bitte prüfe die
|
||||||
|
* markierten Felder", markierte aber keines — weil die Meldungen zu
|
||||||
|
* Feldern gehörten, die dort gar nicht stehen.
|
||||||
|
*
|
||||||
|
* Ursache: Das kurze Formular der Ersteinrichtung schickt neun Felder,
|
||||||
|
* das vollständige unter Stammdaten alle. Beide gehen durch dasselbe
|
||||||
|
* Schema, und die optionalen Felder waren zwar `nullable`, aber nicht
|
||||||
|
* `optional` — ein fehlendes Feld ist `undefined`, nicht `null`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Die Felder, die das kurze Formular der Ersteinrichtung tatsächlich schickt. */
|
||||||
|
function ersteinrichtungsFormular(): FormData {
|
||||||
|
const f = new FormData()
|
||||||
|
f.set('vorname', 'Franziska')
|
||||||
|
f.set('nachname', 'Wilde')
|
||||||
|
f.set('geburtsdatum', '1963-09-23')
|
||||||
|
f.set('betreuungsgericht', 'Lichtenfels')
|
||||||
|
f.set('aktenzeichenGericht', 'XVII 355/25')
|
||||||
|
f.set('bestellungAm', '2025-11-07')
|
||||||
|
f.append('aufgabenkreise', 'VERMOEGENSSORGE')
|
||||||
|
f.append('aufgabenkreise', 'GESUNDHEITSSORGE')
|
||||||
|
f.set('befreiterBetreuer', 'on')
|
||||||
|
f.set('ehrenamtlich', 'on')
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Ersteinrichtung', () => {
|
||||||
|
it('nimmt das kurze Formular an, obwohl es die meisten Felder nicht schickt', () => {
|
||||||
|
const gelesen = BetreuteSchema.safeParse(formularLesen(ersteinrichtungsFormular()))
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder = Object.keys(gelesen.error.flatten().fieldErrors).join(', ')
|
||||||
|
throw new Error(`Abgelehnt wegen: ${felder}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(gelesen.data.vorname).toBe('Franziska')
|
||||||
|
expect(gelesen.data.nachname).toBe('Wilde')
|
||||||
|
expect(gelesen.data.geburtsdatum.toISOString()).toBe('1963-09-23T00:00:00.000Z')
|
||||||
|
expect(gelesen.data.bestellungAm?.toISOString()).toBe('2025-11-07T00:00:00.000Z')
|
||||||
|
expect(gelesen.data.aufgabenkreise).toEqual(['VERMOEGENSSORGE', 'GESUNDHEITSSORGE'])
|
||||||
|
expect(gelesen.data.befreiterBetreuer).toBe(true)
|
||||||
|
expect(gelesen.data.ehrenamtlich).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('macht aus nicht gesendeten Feldern null, nicht undefined', () => {
|
||||||
|
const gelesen = BetreuteSchema.parse(formularLesen(ersteinrichtungsFormular()))
|
||||||
|
|
||||||
|
// Diese Felder standen nie auf dem Bildschirm. Sie muessen als null in
|
||||||
|
// der Datenbank landen, sonst schreibt Prisma sie gar nicht.
|
||||||
|
expect(gelesen.heimName).toBeNull()
|
||||||
|
expect(gelesen.diagnosen).toBeNull()
|
||||||
|
expect(gelesen.geburtsname).toBeNull()
|
||||||
|
expect(gelesen.heimEinzugAm).toBeNull()
|
||||||
|
expect(gelesen.bestellungBis).toBeNull()
|
||||||
|
expect(gelesen.aktenzeichenBezirk).toBeNull()
|
||||||
|
expect(gelesen.pflegegrad).toBeNull()
|
||||||
|
expect(gelesen.schwerbehinderungGdB).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('haelt auch das vollstaendige Formular aus', () => {
|
||||||
|
const f = ersteinrichtungsFormular()
|
||||||
|
// So kommt es aus dem Bearbeitungsformular: alle Felder da, viele leer.
|
||||||
|
for (const feld of [
|
||||||
|
'anrede', 'geburtsname', 'geburtsort',
|
||||||
|
'heimName', 'heimStrasse', 'heimPlz', 'heimOrt', 'heimZimmer', 'heimTelefon',
|
||||||
|
'fruehereStrasse', 'frueherePlz', 'fruehererOrt',
|
||||||
|
'krankenkasse', 'krankenversichertennummer', 'pflegekasse',
|
||||||
|
'schwerbehinderungMerkzeichen', 'diagnosen', 'ermittelterWille',
|
||||||
|
'aufgabenkreiseFreitext', 'aktenzeichenBezirk', 'aktenzeichenSozialamt',
|
||||||
|
'rentenversicherungsnummer', 'steuerId', 'notizen',
|
||||||
|
]) {
|
||||||
|
f.set(feld, '')
|
||||||
|
}
|
||||||
|
for (const feld of ['heimEinzugAm', 'pflegegradSeit', 'pflegegradBescheidVom', 'bestellungBis']) {
|
||||||
|
f.set(feld, '')
|
||||||
|
}
|
||||||
|
f.set('pflegegrad', '')
|
||||||
|
f.set('schwerbehinderungGdB', '')
|
||||||
|
|
||||||
|
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
|
||||||
|
expect(gelesen.success, 'Leere Felder duerfen nicht abgelehnt werden').toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt ausgefuellte Angaben unveraendert an', () => {
|
||||||
|
const f = ersteinrichtungsFormular()
|
||||||
|
f.set('heimName', ' Seniorenzentrum St. Katharina ')
|
||||||
|
f.set('pflegegrad', '3')
|
||||||
|
f.set('diagnosen', 'COPD GOLD III')
|
||||||
|
|
||||||
|
const gelesen = BetreuteSchema.parse(formularLesen(f))
|
||||||
|
// Fuehrende und nachlaufende Leerzeichen fliegen raus.
|
||||||
|
expect(gelesen.heimName).toBe('Seniorenzentrum St. Katharina')
|
||||||
|
expect(gelesen.pflegegrad).toBe(3)
|
||||||
|
expect(gelesen.diagnosen).toBe('COPD GOLD III')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('besteht weiterhin auf Vorname, Nachname und Geburtsdatum', () => {
|
||||||
|
const f = new FormData()
|
||||||
|
f.set('vorname', '')
|
||||||
|
f.set('nachname', '')
|
||||||
|
f.set('geburtsdatum', '')
|
||||||
|
|
||||||
|
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
|
||||||
|
expect(gelesen.success).toBe(false)
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder = gelesen.error.flatten().fieldErrors
|
||||||
|
expect(Object.keys(felder).sort()).toEqual(['geburtsdatum', 'nachname', 'vorname'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein Geburtsdatum in der Zukunft ab', () => {
|
||||||
|
const f = ersteinrichtungsFormular()
|
||||||
|
f.set('geburtsdatum', '2099-01-01')
|
||||||
|
|
||||||
|
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
|
||||||
|
expect(gelesen.success).toBe(false)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
expect(gelesen.error.flatten().fieldErrors.geburtsdatum?.[0]).toMatch(/Zukunft/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein unfertiges Datum ab, statt es stillschweigend zu verwerfen', () => {
|
||||||
|
const f = ersteinrichtungsFormular()
|
||||||
|
f.set('bestellungAm', '2025-11')
|
||||||
|
|
||||||
|
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
|
||||||
|
expect(gelesen.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswertung der Umgebungsvariablen.
|
||||||
|
*
|
||||||
|
* Der Anlass für diese Datei war ein Fehler im Betrieb: docker compose
|
||||||
|
* reicht `ENC_KEY_V2` immer durch — mit leerem Wert, wenn in der .env
|
||||||
|
* keiner steht. Die Schlüsselsammlung erkannte Schlüssel aber am
|
||||||
|
* Variablennamen statt am Inhalt, fand also ein leeres `ENC_KEY_V2` und
|
||||||
|
* brach ab. Die Meldung lautete „ENC_KEY_V2 fehlt", obwohl niemand einen
|
||||||
|
* zweiten Schlüssel wollte.
|
||||||
|
*
|
||||||
|
* Jede Prüfung hier lädt `@/lib/env` frisch, weil die Werte beim ersten
|
||||||
|
* Zugriff gemerkt werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ECHT = Buffer.alloc(32, 7).toString('base64')
|
||||||
|
const ECHT_ZWEI = Buffer.alloc(32, 9).toString('base64')
|
||||||
|
|
||||||
|
let vorher: NodeJS.ProcessEnv
|
||||||
|
|
||||||
|
async function frischesEnv() {
|
||||||
|
// Leert die Modulregistrierung, damit der naechste Import das Modul neu
|
||||||
|
// auswertet - die Schluessel werden sonst nur beim ersten Mal gelesen.
|
||||||
|
vi.resetModules()
|
||||||
|
const { env } = await import('@/lib/env')
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vorher = { ...process.env }
|
||||||
|
for (const name of Object.keys(process.env)) {
|
||||||
|
if (/^ENC_KEY_V\d+$/.test(name)) delete process.env[name]
|
||||||
|
}
|
||||||
|
delete process.env.ENC_KEY_AKTIV
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = vorher
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Verschlüsselungsschlüssel aus der Umgebung', () => {
|
||||||
|
it('nimmt einen einzelnen Schlüssel an', async () => {
|
||||||
|
process.env.ENC_KEY_V1 = ECHT
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect([...env.verschluesselungsschluessel.keys()]).toEqual([1])
|
||||||
|
expect(env.aktiveSchluesselversion).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('überspringt eine leere Schlüsselvariable', async () => {
|
||||||
|
// Genau das setzt docker compose, wenn in der .env kein zweiter
|
||||||
|
// Schlüssel steht: ENC_KEY_V2: ${ENC_KEY_V2:-}
|
||||||
|
process.env.ENC_KEY_V1 = ECHT
|
||||||
|
process.env.ENC_KEY_V2 = ''
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect([...env.verschluesselungsschluessel.keys()]).toEqual([1])
|
||||||
|
expect(env.aktiveSchluesselversion).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('überspringt eine Variable, in der nur Leerzeichen stehen', async () => {
|
||||||
|
process.env.ENC_KEY_V1 = ECHT
|
||||||
|
process.env.ENC_KEY_V2 = ' '
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect([...env.verschluesselungsschluessel.keys()]).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt beide Schlüssel an, wenn wirklich rotiert wird', async () => {
|
||||||
|
process.env.ENC_KEY_V1 = ECHT
|
||||||
|
process.env.ENC_KEY_V2 = ECHT_ZWEI
|
||||||
|
process.env.ENC_KEY_AKTIV = '2'
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect([...env.verschluesselungsschluessel.keys()].sort()).toEqual([1, 2])
|
||||||
|
expect(env.aktiveSchluesselversion).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt beim Rotieren deutlich, wenn der neue Schlüssel fehlt', async () => {
|
||||||
|
process.env.ENC_KEY_V1 = ECHT
|
||||||
|
process.env.ENC_KEY_V2 = ''
|
||||||
|
process.env.ENC_KEY_AKTIV = '2'
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect(() => env.aktiveSchluesselversion).toThrow(/ENC_KEY_AKTIV=2/)
|
||||||
|
// Die Meldung nennt, was da ist, und die richtige Reihenfolge.
|
||||||
|
expect(() => env.aktiveSchluesselversion).toThrow(/Vorhanden sind die Versionen: 1/)
|
||||||
|
expect(() => env.aktiveSchluesselversion).toThrow(/zuerst den neuen Schluessel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beschwert sich, wenn gar kein Schlüssel gesetzt ist', async () => {
|
||||||
|
process.env.ENC_KEY_V2 = ''
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect(() => env.verschluesselungsschluessel).toThrow(/mindestens ENC_KEY_V1/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist einen Schlüssel falscher Länge ab', async () => {
|
||||||
|
process.env.ENC_KEY_V1 = Buffer.alloc(16, 1).toString('base64')
|
||||||
|
|
||||||
|
const env = await frischesEnv()
|
||||||
|
expect(() => env.verschluesselungsschluessel).toThrow(/32 Bytes/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user