Compare commits

...
2 Commits
Author SHA1 Message Date
Florian Wilde 9365645f72 fix: Bindeadresse war in der Vorlage stillschweigend vorbelegt
.env.example setzte APP_BIND=127.0.0.1 als fertigen Wert, mitten zwischen
lauter anderen fertigen Werten. Wer die Datei kopiert und seinen Reverse
Proxy auf einem anderen Server betreibt, kommt damit nicht an die
Anwendung heran - und der Fehler sieht aus, als laege es am Proxy.

Der sichere Standard bleibt 127.0.0.1. Er wird nur nicht mehr als
"schon erledigt" praesentiert, sondern als Entscheidung mit drei
benannten Faellen: Proxy daneben, Proxy woanders mit privatem Netz,
Proxy woanders ohne. Beim dritten Fall steht die Firewallregel dabei.

Die Installationsanleitung nennt APP_BIND jetzt schon in Schritt 3, wo
die .env ausgefuellt wird - nicht erst in Schritt 7, wo der Proxy
drankommt. Wer der Anleitung von oben nach unten folgt, stolpert sonst
erst vier Schritte spaeter darueber.

Dazu ein eigener Abschnitt in der Fehlersuche mit den zwei curl-Proben,
die den Unterschied zwischen falscher Bindeadresse und Firewall zeigen -
und dem Hinweis, dass dafuer kein Neubau noetig ist.

Am Programm aendert sich nichts.

Etappe: 8
Phase: Haertung
2026-08-22 14:22:38 +02:00
Florian Wilde 232543e2aa fix: Leere Schluesselvariable legte die Verschluesselung lahm
docker-compose.yml reicht ENC_KEY_V2 immer an den Behaelter durch - mit
leerem Wert, wenn in der .env keiner steht. src/lib/env.ts erkannte
Schluessel aber am Variablennamen statt am Inhalt, fand also ein leeres
ENC_KEY_V2 und brach ab mit "Umgebungsvariable ENC_KEY_V2 fehlt".

Die Meldung war doppelt irrefuehrend: Sie nannte einen Schluessel, den
niemand wollte, und sie erschien an einer Stelle, die damit nichts zu tun
hat - beim Einrichten des zweiten Faktors, als Serverfehler ohne Text.

Betroffen war jede Stelle, die verschluesselte Felder schreibt oder
liest, also praktisch alles ausser der Anmeldung selbst. Die kam durch,
weil sie ohne Verschluesselung auskommt - der Fehler trat deshalb erst
auf der naechsten Seite auf, und der Zusammenhang war nicht zu sehen.

Leere Schluesselvariablen werden jetzt uebersprungen. Sieben Tests decken
die Auswertung ab, darunter genau diese Konstellation.

Die Meldung beim Schluesselwechsel nennt jetzt ausserdem, welche
Versionen vorhanden sind, und die richtige Reihenfolge: erst den neuen
Schluessel eintragen, dann ENC_KEY_AKTIV umstellen.

Dazu scripts/anmeldung-pruefen.mjs. Das Anmeldeformular sagt bewusst
nicht, woran eine Anmeldung scheiterte - sonst liesse 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, zusammen mit
dem Zustand des Zugangs, dem Stand der Sperre und der AUTH_URL.

Etappe: 8
Phase: Haertung
2026-08-22 14:00:25 +02:00
9 changed files with 439 additions and 15 deletions
+23 -7
View File
@@ -19,15 +19,31 @@ AUTH_URL=http://localhost:3000
# Hinter einem Reverse Proxy auf true setzen.
AUTH_TRUST_HOST=true
# --- Erreichbarkeit ---------------------------------------------------------
# Auf welcher Adresse und welchem Port horcht die Anwendung?
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
#
# Proxy auf DEMSELBEN Server: 127.0.0.1 (Standard, nichts zu tun)
# Proxy auf einem ANDEREN: die Adresse des privaten Netzes bzw. des
# WireGuard-Tunnels, z.B. 10.0.0.5
# Auf welcher Adresse soll die Anwendung erreichbar sein? Das haengt davon
# ab, WO dein Reverse Proxy laeuft. Waehl genau eine Zeile aus:
#
# 0.0.0.0 nur, wenn eine Firewall den Port ausschliesslich fuer die IP des
# Proxy-Servers oeffnet. Die Anwendung beendet kein TLS.
# Proxy auf DEMSELBEN Server
# 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_PORT=3000
+80 -1
View File
@@ -26,6 +26,83 @@ Wiederherstellung*.
_Noch nichts._
## [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
### Behoben
@@ -219,6 +296,8 @@ 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.3...HEAD
[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.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/releases/tag/v1.0.0
+2
View File
@@ -91,6 +91,8 @@ services:
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
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}
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
# leere Schluesselvariablen - siehe src/lib/env.ts.
ENC_KEY_V2: ${ENC_KEY_V2:-}
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
+43
View File
@@ -205,6 +205,14 @@ S3_SECRET_ACCESS_KEY=<aus Schritt 2>
# --- Sicherung ---
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
@@ -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
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
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.0.1",
"version": "1.0.3",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
+165
View File
@@ -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
View File
@@ -58,14 +58,25 @@ function einmal<T>(fn: () => T): () => T {
const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
const schluessel = new Map<number, Buffer>()
// ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln
// 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+)$/)
if (treffer) {
schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name))
}
if (!treffer) continue
// 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) {
throw new Error(
'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' +
@@ -78,8 +89,11 @@ const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
const holeAktiveVersion = einmal((): number => {
const version = zahl('ENC_KEY_AKTIV', 1)
if (!holeVerschluesselungsschluessel().has(version)) {
const vorhanden = [...holeVerschluesselungsschluessel().keys()].sort().join(', ')
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
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.0.1'
export const VERSION = '1.0.3'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+105
View File
@@ -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/)
})
})