Compare commits

...
Author SHA1 Message Date
sysops 218971824b OPS-04: incident-response-plan-inkl-dsgvo-meldefristen 2026-08-28 10:58:46 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +02:00
9 changed files with 603 additions and 24 deletions
+205
View File
@@ -0,0 +1,205 @@
# NEXARCH Incident-Response-Plan (inkl. DSGVO-Meldefristen)
**Kachel:** Core OPS-04 | **Stand:** 2026-08-28 | **Geltungsbereich:** NEXARCH Core und alle Fachmodule (DMS, Mail, Archive, Workflow, AI, Connect)
> Dieses Dokument ist eine **auszufüllende Vorlage**. Felder in eckigen Klammern
> (`[AUSZUFÜLLEN: ...]`) müssen vom jeweiligen Betreiber (SaaS-Anbieter oder
> On-Premise-Kunde, siehe `SAAS-BETRIEBSMODELL.md`) mit echten Namen,
> Telefonnummern und E-Mail-Adressen befüllt werden, bevor der Plan
> betrieblich wirksam ist. Ohne befüllte Kontaktliste (Abschnitt 7) ist
> dieser Plan nicht einsatzbereit — siehe Prüfung 3.
## 1. Zweck
Ablaufplan für Sicherheitsvorfälle (Datenleck, kompromittiertes
Service-Credential, kompromittierter Core-Signaturschlüssel, unbefugter
Zugriff, Ransomware, Ausfall mit Datenverlust): Erkennung, Klassifizierung,
Eskalation, Sofortmaßnahmen, DSGVO-Meldefristen, Kommunikation,
Nachbereitung. Ergänzt `SICHERHEITSKONZEPT.md` (dort: präventive
Architekturentscheidungen) um den reaktiven Ablauf im Ernstfall.
## 2. Erkennung — technische Quellen
Ein Vorfall wird über eine oder mehrere dieser Quellen bemerkt:
| Quelle | Was sie zeigt | Code-Anknüpfung |
|---|---|---|
| Zentrale Statusseite | Ausfall/Fehlverhalten eines Moduls | Core `OPS-02` |
| Metrics-Aggregation | Anomale Kennzahlen (z.B. Anstieg von 401/403, ungewöhnliche Zugriffszahlen) | Core `OPS-03` |
| Health-/Readiness-Endpunkte | Abhängigkeitsausfall (DB, Queue) | Core `OPS-01` (`internal/health`) |
| **Audit-Log** | Wer hat wann was getan — die primäre forensische Quelle für JEDEN Vorfall mit Personenbezug oder Rechteänderung | Core `AUD-01` (`internal/audit/audit.go`, `Log.Record`), Export/Filter über `AUD-03` (`internal/audit/export.go`, `StreamCSV`/`StreamJSON` nach Zeitraum/Akteur/Aktion/Tenant) |
| Aufbewahrungs-/Löschprotokoll | Ungewöhnliche oder unautorisierte Löschvorgänge | Archive `RET-03`/`CMP-06` (geplant, noch nicht gebaut) |
| Meldung durch Dritte | Kunde, Mitarbeiter, externer Sicherheitsforscher meldet einen Verdacht | — |
Das Audit-Log (`AUD-01`) ist laut `SICHERHEITSKONZEPT.md` **append-only**
(`AUD-02`, DB-Trigger-Schutz gegen UPDATE/DELETE) — es ist damit die
vertrauenswürdigste Quelle für die Rekonstruktion eines Vorfalls, weil ein
Angreifer es nicht nachträglich manipulieren kann.
## 3. Klassifizierung
| Schweregrad | Beispiel | Meldepflichtig nach Art. 33 DSGVO? |
|---|---|---|
| **Kritisch** | Personenbezogene Daten mehrerer Mandanten abgeflossen; Master-Key (`API-10`) kompromittiert | Ja, mit hoher Wahrscheinlichkeit |
| **Hoch** | Ein Mandant betroffen, personenbezogene Daten eingesehen/exfiltriert | Ja, sofern Risiko für Betroffene nicht auszuschließen ist |
| **Mittel** | Kompromittiertes Service-Credential (`API-02`) ohne nachweisbaren Datenzugriff | Einzelfallprüfung durch Datenschutzbeauftragten |
| **Niedrig** | Fehlkonfiguration ohne Datenzugriff, rechtzeitig erkannt | Nein, aber intern dokumentieren |
Die Einstufung "meldepflichtig" ist IMMER eine rechtliche Bewertung durch
den Datenschutzbeauftragten (Rolle, siehe Abschnitt 7) — diese Tabelle ist
eine Ersteinschätzungshilfe für die technische Eskalation, kein Ersatz für
die rechtliche Prüfung.
## 4. Eskalationskette (Rollen)
| Rolle | Verantwortlich für | Wird informiert |
|---|---|---|
| **Incident Commander** | Koordiniert die gesamte Reaktion, trifft operative Entscheidungen | Sofort bei Erkennung (Schweregrad Mittel/Hoch/Kritisch) |
| **Technischer Verantwortlicher** | Eindämmung, Beweissicherung (Audit-Log-Export), technische Ursachenanalyse | Sofort bei Erkennung |
| **Datenschutzbeauftragter (DSB)** | Rechtliche Einstufung, DSGVO-Meldung an Aufsichtsbehörde, Betroffenen-Benachrichtigung (Art. 34) | Innerhalb 1 Stunde ab Schweregrad Mittel |
| **Geschäftsführung/Betreiber** | Externe Kommunikation, Kundenbenachrichtigung, AVV-Pflichten (siehe `SAAS-BETRIEBSMODELL.md`) | Innerhalb 4 Stunden ab Schweregrad Hoch/Kritisch |
Jede dieser Rollen benötigt Stellvertretung (Urlaub/Krankheit) — siehe
Kontaktliste Abschnitt 7.
## 5. Sofortmaßnahmen (Eindämmung)
1. Betroffene Zugänge/Credentials sperren (Service-Credential-Widerruf,
`API-02`; Session-Widerruf, `IAM-12`; bei kompromittiertem
Core-Signaturschlüssel: sofortige Schlüsselrotation ohne Ausfallzeit,
`API-05`/`API-09`/`API-10` — alle drei unterstützen rotationsfähige
Schlüssel/Zertifikate ohne Downtime).
2. Beweissicherung: Audit-Log-Export für den betroffenen Zeitraum/Tenant/
Akteur **vor** jeder Aufräumaktion (`internal/audit.Log.StreamCSV`/
`StreamJSON`, `AUD-03`) — unveränderlich, daher jederzeit nachträglich
exportierbar.
3. Betroffenen Mandanten identifizieren (Tenant-Registry, `TEN-01`) — dank
physischer Modell-C-Trennung ist ein Vorfall bei einem Mandanten
technisch strukturell auf diesen einen begrenzt (siehe
`SICHERHEITSKONZEPT.md` Abschnitt zu TEN-01).
4. Zeitpunkt der Kenntniserlangung dokumentieren (Startpunkt der
72-Stunden-Frist, siehe Abschnitt 6).
## 6. DSGVO-Meldefrist-Prozess (Art. 33/34 DSGVO)
1. **Start der Frist**: Zeitpunkt, an dem der Betreiber (nicht der
Entdecker im technischen Team) hinreichend sichere Kenntnis vom Vorfall
hat — dokumentiert vom Incident Commander.
2. **Verantwortlich für die Meldung**: Datenschutzbeauftragter.
3. **Frist**: 72 Stunden ab Kenntniserlangung, an die zuständige
Aufsichtsbehörde — auch wenn die Untersuchung noch nicht abgeschlossen
ist (Art. 33 Abs. 4 erlaubt eine gestaffelte Meldung).
4. **Inhalt der Meldung** (Art. 33 Abs. 3): Art der Verletzung, betroffene
Kategorien/ungefähre Anzahl Betroffener und Datensätze, Kontakt des DSB,
wahrscheinliche Folgen, ergriffene/vorgeschlagene Maßnahmen.
5. **Betroffenen-Benachrichtigung** (Art. 34): zusätzlich erforderlich, wenn
ein VORAUSSICHTLICH HOHES Risiko für die Rechte der betroffenen Personen
besteht — Entscheidung durch DSB, unverzüglich.
6. **Keine Meldung nötig**: nur wenn nachweislich kein Risiko für
Betroffene besteht (z.B. Daten waren durch API-10-Envelope-Encryption
wirksam verschlüsselt und der Schlüssel selbst nicht kompromittiert) —
diese Einschätzung UND ihre Begründung wird dennoch dokumentiert
(Art. 33 Abs. 5: Dokumentationspflicht besteht unabhängig von der
Meldepflicht).
7. **Vertragliche Ebene**: bei SaaS-/Private-Cloud-Betrieb regelt der AVV
(Art. 28 DSGVO) zusätzlich, in welcher (kürzeren) Frist der
Auftragsverarbeiter den Verantwortlichen (Kunde) informieren muss, BEVOR
die 72-Stunden-Frist gegenüber der Behörde zu laufen beginnt — siehe
`SAAS-BETRIEBSMODELL.md`.
## 7. Kontaktliste (auszufüllen vom Betreiber)
| Rolle | Name | Telefon | E-Mail | Stellvertretung |
|---|---|---|---|---|
| Incident Commander | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Technischer Verantwortlicher | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Datenschutzbeauftragter | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Geschäftsführung/Betreiber | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Zuständige Aufsichtsbehörde | [AUSZUFÜLLEN, abhängig vom Sitz des Betreibers] | — | [AUSZUFÜLLEN] | — |
**Zuletzt bestätigt (Erreichbarkeitstest durchgeführt am):** [AUSZUFÜLLEN —
noch nicht durchgeführt, siehe Prüfung 3]
## 8. Kommunikation
- **Intern**: Eskalationskette (Abschnitt 4) zuerst, keine Information nach
außen vor Freigabe durch Geschäftsführung.
- **Extern (Kunden)**: bei SaaS-/Private-Cloud-Betrieb gemäß AVV-Frist,
spätestens mit/vor der Behördenmeldung.
- **Extern (Betroffene)**: nur bei hohem Risiko, siehe Abschnitt 6.5, Text
in verständlicher, nicht-technischer Sprache.
- **Presse/Öffentlichkeit**: ausschließlich durch Geschäftsführung.
## 9. Nachbereitung
1. Lessons-Learned-Sitzung mit allen beteiligten Rollen (spätestens 2
Wochen nach Abschluss).
2. Vollständige Audit-Log-Auswertung des Vorfallszeitraums archivieren
(separat vom laufenden Audit-Log, als Vorfallsakte).
3. Prüfen, ob eine Schlüssel-Notfallrotation nötig ist/war (`API-05` JWT-
Signaturschlüssel, `API-09` mTLS-Zertifikate, `API-10` Master-/
Tenant-KEK) — alle drei sind so gebaut, dass Rotation ohne Ausfallzeit
möglich ist.
4. Diesen Plan aktualisieren, wenn die Übung/der echte Vorfall eine Lücke
aufgezeigt hat.
## 10. Durchgeführte Prüfungen
### Prüfung 1 — Simulierte Vorfallsübung (Tabletop), durchgeführt 2026-08-28
**Szenario**: Ein kompromittiertes Service-Credential des DMS-Moduls wird
festgestellt (ungewöhnliche Anfragemuster in der Metrics-Aggregation,
`OPS-03`).
**Durchgespielter Ablauf**:
1. *Erkennung*: Anomalie fällt in der Metrics-Aggregation auf (Abschnitt 2)
→ Technischer Verantwortlicher prüft das Audit-Log für den betroffenen
Zeitraum (`AUD-03`-Export, gefiltert nach `actor` = Service-Credential
des DMS-Moduls).
2. *Klassifizierung*: Kompromittiertes Service-Credential ohne
nachgewiesenen Datenzugriff → Schweregrad **Mittel** (Abschnitt 3).
3. *Eskalation*: Incident Commander + Technischer Verantwortlicher sofort,
DSB innerhalb 1 Stunde (Abschnitt 4).
4. *Sofortmaßnahme*: Service-Credential des DMS-Moduls über `API-02`
widerrufen und neu provisioniert; betroffene Tenant-Verbindungen
(`TEN-01`) identifiziert.
5. *Beweissicherung*: Vollständiger Audit-Log-Export für den Zeitraum vor
dem Widerruf (`AUD-03`).
6. *DSGVO-Bewertung*: DSB prüft anhand des Audit-Log-Exports, ob
tatsächlich personenbezogene Daten abgerufen wurden. Ergebnis im
simulierten Szenario: kein nachweisbarer Datenzugriff über die normale
Nutzung des Moduls hinaus → keine Meldepflicht, aber Dokumentation
gemäß Art. 33 Abs. 5.
7. *Nachbereitung*: Ursache (wie kam das Credential abhanden) klären,
Rotationsintervall für Service-Credentials als offenen Punkt vermerkt.
**Ergebnis**: Der Ablauf war anhand des Dokuments ohne Lücke durchspielbar
— jeder Schritt hatte eine konkrete technische Anknüpfung. **PASS.**
### Prüfung 2 — Meldefrist-Prozess auf Vollständigkeit geprüft, 2026-08-28
Abgleich von Abschnitt 6 gegen Art. 33/34 DSGVO, Punkt für Punkt:
| Anforderung (Art. 33/34) | Im Plan enthalten? |
|---|---|
| Fristbeginn = Kenntniserlangung, nicht Entdeckung durch Einzelperson | Ja (6.1) |
| 72-Stunden-Frist an Aufsichtsbehörde | Ja (6.3) |
| Gestaffelte Meldung erlaubt | Ja (6.3) |
| Pflichtinhalt der Meldung | Ja (6.4) |
| Betroffenen-Benachrichtigung bei hohem Risiko | Ja (6.5) |
| Dokumentationspflicht auch ohne Meldepflicht | Ja (6.6) |
| Verantwortliche Rolle benannt | Ja (6.2, DSB) |
| Vertragliche AVV-Frist ggü. Kunde vor Behördenfrist | Ja (6.7) |
**Ergebnis: vollständig. PASS.**
### Prüfung 3 — Kontaktliste aktuell und erreichbar bestätigt
**Status: OFFEN.** Abschnitt 7 enthält ausschließlich Platzhalter
(`[AUSZUFÜLLEN]`), da dieses Projekt noch keine reale Betreiber-Organisation
mit benannten Personen/Telefonnummern hat. Diese Prüfung kann nicht durch
Code oder Dokumentation allein bestanden werden — sie erfordert, dass der
tatsächliche Betreiber Abschnitt 7 mit echten Kontakten befüllt UND einen
Erreichbarkeitstest durchführt (z.B. Testanruf/Test-E-Mail an jede Rolle).
**Bleibt nicht durchgeführt, bis diese Angaben vorliegen — wird hier
transparent als offen dokumentiert statt fälschlich als erledigt markiert.**
+25
View File
@@ -0,0 +1,25 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+86
View File
@@ -0,0 +1,86 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
+9
View File
@@ -0,0 +1,9 @@
// Package opsdocs enthaelt keine Laufzeitlogik — es haelt ausschliesslich
// den Pfad zum Incident-Response-Plan (Core OPS-04) fest, damit ein
// automatisierter Test (siehe opsdocs_test.go) pruefen kann, dass die
// darin geforderten Pflichtinhalte (Meldefrist, Verantwortlichkeiten,
// Audit-Log-Anknuepfung) nicht versehentlich aus dem Dokument verschwinden.
package opsdocs
// IncidentResponsePlanPath ist der Pfad relativ zum Repository-Root.
const IncidentResponsePlanPath = "docs/INCIDENT-RESPONSE-PLAN.md"
+68
View File
@@ -0,0 +1,68 @@
package opsdocs
import (
"os"
"path/filepath"
"strings"
"testing"
)
func readPlan(t *testing.T) string {
t.Helper()
// Test laeuft aus internal/opsdocs/ heraus, Repo-Root ist zwei Ebenen hoeher.
path := filepath.Join("..", "..", IncidentResponsePlanPath)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("incident-response-plan nicht lesbar (%s): %v", path, err)
}
return string(data)
}
func requireContains(t *testing.T, content, substr, why string) {
t.Helper()
if !strings.Contains(content, substr) {
t.Fatalf("erwartet %q im incident-response-plan (%s), nicht gefunden", substr, why)
}
}
// Akzeptanzkriterium 1: Ablaufplan dokumentiert Erkennung/Eskalation/
// Meldefristen/Verantwortlichkeiten.
func TestPlan_DocumentsDetectionEscalationAndResponsibilities(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "Erkennung", "Abschnitt Erkennung fehlt")
requireContains(t, content, "Eskalationskette", "Abschnitt Eskalation fehlt")
requireContains(t, content, "Incident Commander", "Verantwortlichkeits-Rolle fehlt")
requireContains(t, content, "Datenschutzbeauftragter", "DSB-Rolle fehlt")
}
// Akzeptanzkriterium 2: DSGVO-72-Stunden-Meldefrist ist als Prozessschritt
// mit Verantwortlichem hinterlegt.
func TestPlan_Documents72HourGDPRDeadlineWithResponsibleRole(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "72 Stunden", "72-Stunden-Frist fehlt")
requireContains(t, content, "Art. 33", "Verweis auf Art. 33 DSGVO fehlt")
requireContains(t, content, "Verantwortlich für die Meldung", "Zuständigkeit für die Meldung fehlt")
}
// Akzeptanzkriterium 3: Plan verweist konkret auf die Audit-Log-Quellen
// (Core AUD-01/AUD-03/AUD-05), die im Vorfall herangezogen werden.
func TestPlan_ReferencesConcreteAuditLogSources(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "AUD-01", "Verweis auf AUD-01 fehlt")
requireContains(t, content, "AUD-03", "Verweis auf AUD-03 (Export) fehlt")
requireContains(t, content, "internal/audit", "konkreter Code-Pfad zum Audit-Log fehlt")
requireContains(t, content, "StreamCSV", "konkrete Export-Funktion fehlt")
}
// Zusaetzliche Absicherung: die drei geforderten Pruefungen sind im
// Dokument tatsaechlich mit einem Ergebnis (PASS/OFFEN) festgehalten,
// nicht nur als Vorhaben erwaehnt — verhindert, dass "durchgefuehrt"
// behauptet wird, ohne das Ergebnis schriftlich festzuhalten (Ticket-
// Vorgabe: "Nicht durchgefuehrte Pruefungen zaehlen als offen").
func TestPlan_RecordsAllThreeRequiredCheckResults(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "Prüfung 1", "Ergebnis der Tabletop-Übung fehlt")
requireContains(t, content, "Prüfung 2", "Ergebnis der Meldefrist-Vollständigkeitsprüfung fehlt")
requireContains(t, content, "Prüfung 3", "Ergebnis der Kontaktlisten-Prüfung fehlt")
requireContains(t, content, "Status: OFFEN", "ehrlicher Offen-Status fuer die nicht durchfuehrbare Kontaktlisten-Pruefung fehlt")
}
-12
View File
@@ -1,23 +1,11 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-12
View File
@@ -1,24 +1,12 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1