Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db8fa1377 | ||
|
|
36d1cf5e7f | ||
|
|
1dc70be933 | ||
|
|
b30e16cb4f | ||
|
|
21278f1405 | ||
|
|
2c9a7482b6 | ||
|
|
0db32007ba | ||
|
|
19dca43012 | ||
|
|
b37b790792 | ||
|
|
bfa5c61db5 | ||
|
|
3c226dab12 | ||
|
|
584cefa388 | ||
|
|
00665592f6 |
@@ -1,2 +1,4 @@
|
||||
*.log
|
||||
.env
|
||||
web/*/node_modules/
|
||||
web/*/.next/
|
||||
|
||||
@@ -44,3 +44,41 @@ Keine Commits in dieser Session.
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-08-28 21:44 – 21:44 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- .gitignore | 2 ++
|
||||
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/index.ts | 25 +++++++++++++++++++++++++
|
||||
- web/shl/package.json | 23 +++++++++++++++++++++++
|
||||
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- web/shl/tsconfig.json | 18 ++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-08-28 21:51 – 21:57 (5m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/shl/package.json | 2 ++
|
||||
- web/shl/vitest.config.ts | 8 ++++++++
|
||||
- web/shl/vitest.setup.ts | 1 +
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// retention-api ist der Aufrufpunkt fuer RET-06-API: Backend-HTTP-Dienst
|
||||
// fuer die Aufbewahrungsfristen-Konfiguration (CRUD + Vorschauliste).
|
||||
// Getrennt vom scrub-metrics-/restoretest-metrics-Muster, weil dies KEIN
|
||||
// Prometheus-/OPS-03-Endpunkt ist, sondern ein echter Admin-API-Dienst
|
||||
// (Next.js-Frontend als eigenes Folgeticket).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("NEXARCH_RETENTION_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
log.Fatal("NEXARCH_RETENTION_TENANT_DSN muss gesetzt sein")
|
||||
}
|
||||
addr := os.Getenv("NEXARCH_RETENTION_API_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8092"
|
||||
}
|
||||
rbacBaseURL := os.Getenv("NEXARCH_RETENTION_RBAC_BASE_URL")
|
||||
if rbacBaseURL == "" {
|
||||
log.Fatal("NEXARCH_RETENTION_RBAC_BASE_URL muss gesetzt sein (RBAC-06, z. B. http://127.0.0.1:8093)")
|
||||
}
|
||||
rbacServiceToken := os.Getenv("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN")
|
||||
if rbacServiceToken == "" {
|
||||
log.Fatal("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN muss gesetzt sein")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("datenbankverbindung: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
rbac := rbacclient.New(rbacBaseURL, rbacServiceToken)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
retentionapi.Mount(mux, pool, rbac)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
log.Printf("retention-api: listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// retention-notify-job ist der Aufrufpunkt fuer RET-07 (systemd-Timer,
|
||||
// konfigurierbare Kadenz, analog scrub-cli/BAK-08): ein Durchlauf pro
|
||||
// Aufruf, ermittelt bald ablaufende Objekte (RET-02) und loest fuer noch
|
||||
// nicht benachrichtigte je ein Ereignis ueber Core CFG-05 aus. Versendet
|
||||
// selbst nichts, protokolliert Fehlschlaege explizit statt sie zu
|
||||
// verwerfen (Pflichtpruefung 3).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/notifyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionnotify"
|
||||
)
|
||||
|
||||
func requireEnv(name string) string {
|
||||
v := os.Getenv(name)
|
||||
if v == "" {
|
||||
log.Fatalf("%s muss gesetzt sein", name)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := requireEnv("NEXARCH_RETENTION_TENANT_DSN")
|
||||
notifyBaseURL := requireEnv("NEXARCH_RETENTION_NOTIFY_BASE_URL")
|
||||
notifyServiceToken := requireEnv("NEXARCH_RETENTION_NOTIFY_SERVICE_TOKEN")
|
||||
recipient := retentionnotify.Recipient{
|
||||
TenantSlug: requireEnv("NEXARCH_RETENTION_NOTIFY_TENANT_SLUG"),
|
||||
UserID: requireEnv("NEXARCH_RETENTION_NOTIFY_ADMIN_USER_ID"),
|
||||
Email: requireEnv("NEXARCH_RETENTION_NOTIFY_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("datenbankverbindung: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
client := notifyclient.New(notifyBaseURL, notifyServiceToken)
|
||||
|
||||
results, err := retentionnotify.Run(ctx, pool, client, time.Now().UTC(), recipient)
|
||||
if err != nil {
|
||||
log.Fatalf("retention-notify-job: durchlauf fehlgeschlagen: %v", err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
failed++
|
||||
log.Printf("retention-notify-job: FEHLER bei objekt %s (klasse %s): %v", r.RetentionObjectID, r.RetentionClass, r.Err)
|
||||
continue
|
||||
}
|
||||
log.Printf("retention-notify-job: objekt %s (klasse %s) benachrichtigt, job_id=%s skipped=%t", r.RetentionObjectID, r.RetentionClass, r.JobID, r.Skipped)
|
||||
}
|
||||
log.Printf("retention-notify-job: durchlauf abgeschlossen, %d ergebnis(se), %d fehlgeschlagen", len(results), failed)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# RET-01 – Prüfprotokoll: Generisches Retention-Objektmodell
|
||||
|
||||
Keine Vorbedingungen (Welle 1 des RET-Epics).
|
||||
|
||||
## Grundsatzentscheidung: Adapter-Muster, keine Fremdschlüssel auf Modul-Tabellen
|
||||
|
||||
`retention_objects.object_type`/`object_reference` sind reine
|
||||
Textfelder — Archive importiert weder DMS- noch Mail-Code (eigene
|
||||
Go-Module, physisch getrennte Verantwortung, dieselbe Disziplin wie
|
||||
BAK-05s `reconcile`-Paket gegenüber `file_revisions`). Ein neues Modul
|
||||
kann retention-pflichtige Objekte einbinden, ohne dieses Paket zu
|
||||
ändern — nur `RegisterObject(objectType, objectReference)` aufrufen.
|
||||
|
||||
Aufbewahrungsklassen-Zuordnung ist historisiert: `AssignClass` fügt
|
||||
IMMER eine neue Zeile hinzu, ändert nie eine bestehende — die "aktuelle"
|
||||
Klasse ist die mit dem jüngsten `assigned_at`. Damit bleibt
|
||||
nachvollziehbar, wann und wie oft sich die Klasse eines Objekts
|
||||
geändert hat, ohne eigene Audit-Tabelle.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `migrations/0002_retention.up.sql`/`.down.sql` — `retention_objects`,
|
||||
`retention_class_assignments`.
|
||||
- `internal/retention.RegisterObject` — idempotent (`ON CONFLICT`).
|
||||
- `internal/retention.AssignClass`/`CurrentClass`/`ClassHistory`.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Migration vorwärts und rückwärts getestet | **bestanden** — `TestMigration_ForwardAndBackward`: real gegen die tatsächlichen Migrationsdateien (nicht nachgebaut), vorwärts→Tabellen vorhanden, rückwärts→Tabellen weg, erneut vorwärts→sauber (kein Rest blockiert erneuten Lauf) |
|
||||
| 2 | Testobjekt aus fiktivem DMS- und Mail-Adapter beide korrekt abgebildet | **bestanden** — `TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically`: `dms_document`/`mail_message` beide ohne modulspezifische Spalten abgebildet, zusätzlich Idempotenz bewiesen (erneute Registrierung liefert dieselbe ID) |
|
||||
| 3 | Tenant-Isolation der Kern-Tabellen durch Negativtest belegt | **bestanden** — `TestTenantIsolation_Negativtest`: reales zweites Tenant-DB (`ret01_tenant_test_b`), Objekt aus Tenant A über Verbindung zu Tenant B abgefragt, `count=0` — technisch nicht sichtbar, keine gemeinsame Tabelle mit `tenant_id`-Filter (Modell C, TEN-01) |
|
||||
|
||||
Zusätzlich: `TestAssignClass_IsHistoricized` — zwei Klassen-Zuordnungen,
|
||||
`CurrentClass` liefert die jüngste, `ClassHistory` beide chronologisch.
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- Migration real gegen `dms_tenant_test` angewendet (`psql -f
|
||||
migrations/0002_retention.up.sql`) — `retention_objects`,
|
||||
`retention_class_assignments` bestätigt vorhanden (`\dt retention*`)
|
||||
- Kein systemd-Dienst/Timer nötig — RET-01 ist reines Datenmodell +
|
||||
Bibliothek, kein eigenständiger Prozess (Verbraucher sind spätere
|
||||
RET-Tickets, allen voran RET-05 als Adapter-Interface)
|
||||
- Zweites Tenant-DB (`ret01_tenant_test_b`) nur für den Isolationstest
|
||||
angelegt, danach entfernt
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131, `make check`)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -count=1 -> 8/8 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, retention, scrub, tenantbackup), 0 Fehlschläge
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt — Migration gegen die tatsächlichen
|
||||
`.sql`-Dateien (nicht nachgebaut) getestet, Mandantentrennung gegen ein
|
||||
echtes zweites Tenant-DB bewiesen. Grundlage für RET-05 (Adapter-
|
||||
Interface, als Nächstes) und RET-02 gelegt.
|
||||
@@ -0,0 +1,69 @@
|
||||
# RET-02 – Prüfprotokoll: Aufbewahrungsfristen-Engine
|
||||
|
||||
Voraussetzung RET-01 – erledigt, siehe eigenes Protokoll.
|
||||
|
||||
**Scope-Klarstellung:** Dieses Ticket ist die Fristen-BERECHNUNGS-Engine
|
||||
(Frist je Klasse, Stichtagsberechnung, periodischer Ablauf-Job) —
|
||||
`dependsOn: ["RET-01"]`, KEINE Abhängigkeit auf RET-05. Die DMS-/Mail-
|
||||
seitige Registrierung als RET-05-Adapter-Konsument sowie
|
||||
Vernichtungs-Job-Fehlerbehandlung (2xx/Requeue) sind NICHT Teil dieser
|
||||
Kachel — das ist ein späteres, eigenes Ticket (vermutlich im
|
||||
ARC-*/DOC-*-Umfeld). Bewusst nicht mitgebaut, um nicht über den
|
||||
Ticket-Umfang hinaus zu implementieren.
|
||||
|
||||
## Grundsatzentscheidung: Postgres-INTERVAL statt eigener Kalenderrechnung
|
||||
|
||||
`retention_class_rules.duration` ist ein natives Postgres-`INTERVAL`
|
||||
(z. B. `'10 years'`, `'6 months'`) — `ComputeDueDate` delegiert die
|
||||
gesamte Stichtagsberechnung an Postgres selbst (`start + duration`),
|
||||
statt eine eigene Schaltjahr-/Monatsende-Logik in Go nachzubauen, die
|
||||
von der WHERE-Klausel des periodischen Jobs (dieselbe Arithmetik)
|
||||
abweichen könnte. Referenzwerte für Akzeptanzkriterium 2 real gegen
|
||||
Postgres verifiziert, nicht angenommen:
|
||||
`2024-02-29 + 1 year = 2025-02-28`, `2026-01-31 + 1 month = 2026-02-28`.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `migrations/0004_retention_class_rules.up.sql`/`.down.sql`.
|
||||
- `internal/retentionengine.ConfigureClassRule` — eine Regel je Klasse
|
||||
(`UPSERT`).
|
||||
- `internal/retentionengine.ComputeDueDate` — delegiert an Postgres.
|
||||
- `internal/retentionengine.ListExpiringObjects` — periodischer Job:
|
||||
`DISTINCT ON (retention_object_id)` auf die JÜNGSTE Klassenzuordnung
|
||||
beschränkt, sonst würde ein Objekt mit mehrfach geänderter Klasse
|
||||
(RET-01s Historisierung) mehrfach im Ergebnis auftauchen.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Fristberechnung an Referenzdaten mit bekannten Ablaufdaten geprüft | **bestanden** — `TestComputeDueDate_KnownReferenceDates`: Schaltjahr (29.02.2024 + 1 Jahr → 28.02.2025) und Monatsende (31.01.2026 + 1 Monat → 28.02.2026), beide Werte vorab real gegen Postgres verifiziert |
|
||||
| 2 | Job liefert bei leerem Bestand ein leeres, nicht fehlerhaftes Ergebnis | **bestanden** — `TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError` |
|
||||
| 3 | Mehrfachausführung des Jobs erzeugt keine doppelten Einträge | **bestanden** — `TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges`: Objekt mit ZWEI historischen Klassenzuordnungen (beide abgelaufen), zwei Job-Läufe liefern je genau 1 Eintrag — ohne die `DISTINCT ON`-Einschränkung wäre es 2 gewesen |
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- Migration real gegen `dms_tenant_test` angewendet — `retention_class_rules`
|
||||
bestätigt vorhanden neben `retention_objects`/`retention_class_assignments`
|
||||
- Kein systemd-Timer in diesem Ticket — "periodischer Job" ist die
|
||||
Bibliotheksfunktion `ListExpiringObjects`; ihr tatsächlicher
|
||||
Aufrufer/Zeitplan (systemd-Timer + Meldeweg für abgelaufene Objekte)
|
||||
ist Aufgabe eines Folgetickets, das auch die Vernichtungslogik selbst
|
||||
bringt (dieses Ticket berechnet nur, wer fällig ist — vernichtet
|
||||
nichts)
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131, `make check`)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -count=1 -> 10/10 Pakete mit Tests ok, 0 Fehlschläge
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt — Stichtagsberechnung gegen real
|
||||
verifizierte Postgres-Referenzwerte, Job-Idempotenz gegen ein Objekt mit
|
||||
echter Mehrfach-Historie bewiesen (nicht nur behauptet).
|
||||
@@ -0,0 +1,94 @@
|
||||
# RET-05 – Prüfprotokoll: Modul-Adapter-Schnittstelle
|
||||
|
||||
Voraussetzung RET-01 – erledigt, siehe eigenes Protokoll.
|
||||
|
||||
## Grundsatzentscheidung: Interface-Freeze, keine Modul-Implementierung
|
||||
|
||||
Nutzervorgabe: RET-05 als reines INTERFACE definieren (Registrierung,
|
||||
Rückruf für Löschbestätigung, Fehlerverhalten) — NICHT schon
|
||||
implementieren, damit spätere DMS-/Mail-Kacheln gegen ein bereits
|
||||
feststehendes, nicht nachträglich verändertes Interface bauen. Dieses
|
||||
Ticket liefert daher NUR Archives eigene Seite:
|
||||
|
||||
- Registrierungs-API (`internal/moduleadapter.Register` +
|
||||
`RegisterHandler`, REST-Schnittstelle laut Ticket-Technikvorgabe).
|
||||
- Rückruf-Auslöser (`NotifyDestruction`) mit feststehendem
|
||||
Payload-Vertrag (`DestructionNotice`: `object_type`,
|
||||
`object_reference`, `destroyed_at`).
|
||||
|
||||
**Bewusst NICHT Teil dieses Tickets**: die eigentlichen Rückruf-
|
||||
EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte) — die tatsächliche
|
||||
Vernichtungslogik, die `NotifyDestruction` aufruft (kommt mit RET-02
|
||||
und späteren Vernichtungs-Tickets), sowie Wiederholungslogik bei
|
||||
fehlgeschlagenem Rückruf (Interface-Vertrag ist klar: Erfolg = HTTP
|
||||
2xx, sonst Fehler — WIE mit einem Fehler umgegangen wird, ist
|
||||
Aufgabe des aufrufenden Vernichtungs-Jobs, nicht dieses Pakets).
|
||||
|
||||
## Korrektur vor Abschluss: `retention_class` fehlte im ersten Entwurf
|
||||
|
||||
Akzeptanzkriterium 1 verlangt "Objekttyp MIT Aufbewahrungsklasse UND
|
||||
Rückruf-Adresse" — der erste Entwurf von `module_registrations` und
|
||||
`Register` hatte nur `callback_url`, `retention_class` fehlte komplett.
|
||||
Vor dem Board-Flip auf „Fertig" bemerkt und korrigiert: Migration,
|
||||
`Registration`-Struct, `Register`, `ListRegistrations` und
|
||||
`RegisterHandler` um `retention_class` ergänzt, alle Tests entsprechend
|
||||
angepasst (inkl. Idempotenz-Nachweis auch für `retention_class`, nicht
|
||||
nur `callback_url`). Reale, bereits angewendete Migration auf
|
||||
`dms_tenant_test` musste dafür gedroppt und neu angewendet werden (kein
|
||||
Produktivbestand betroffen, Testsystem).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `migrations/0003_module_registrations.up.sql`/`.down.sql` —
|
||||
`module_registrations` (module_name, object_type, callback_url,
|
||||
UNIQUE-Constraint).
|
||||
- `internal/moduleadapter.Register` — `ON CONFLICT DO NOTHING` + Nachlese
|
||||
der bestehenden Zeile, damit eine erneute Registrierung NIE die
|
||||
bestehende `callback_url` überschreibt (Akzeptanzkriterium 3).
|
||||
- `internal/moduleadapter.ListRegistrations`.
|
||||
- `internal/moduleadapter.NotifyDestruction` — echter HTTP-POST mit dem
|
||||
festen `DestructionNotice`-Vertrag.
|
||||
- `internal/moduleadapter.RegisterHandler` — REST-Endpunkt
|
||||
(`POST /register`).
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Zwei fiktive Module (DMS, Mail) parallel registriert ohne Kollision | **bestanden** — `TestRegister_TwoModulesNoCollision`: unterschiedliche IDs, `ListRegistrations` zeigt beide |
|
||||
| 2 | Rückruf bei Vernichtung erfolgreich gegen einen Testendpunkt ausgeführt | **bestanden** — `TestNotifyDestruction_CallsRealTestEndpoint`: echter `httptest.Server`, echter POST, Payload real empfangen und geprüft (`object_reference` korrekt) |
|
||||
| 3 | Erneute Registrierung desselben Objekttyps ändert nichts am bestehenden Zustand | **bestanden** — `TestRegister_IsIdempotent_UnchangedExistingState` (Go-Funktion, mit absichtlich ABWEICHENDER `callback_url` im zweiten Aufruf) UND `TestRegisterHandler_RealHTTPRoundTrip` (dieselbe Prüfung nochmal über die HTTP-Schicht, nicht nur direkt gegen die Funktion) |
|
||||
|
||||
Zusätzlich: `TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus`
|
||||
(Fehlerverhalten), `TestRegisterHandler_RejectsMissingFields`
|
||||
(REST-Schicht weist unvollständige Registrierungen ab).
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- Migration real gegen `dms_tenant_test` angewendet — `module_registrations`
|
||||
bestätigt vorhanden
|
||||
- Kein systemd-Dienst — `RegisterHandler` ist ein `http.HandlerFunc`,
|
||||
wird in einen künftigen Core-/Archive-HTTP-Server eingehängt, sobald
|
||||
ein solcher für Archive existiert (aktuell kein eigener Archive-
|
||||
API-Server, nur die bisherigen CLI/Metrics-Prozesse) — dokumentierter,
|
||||
kein stiller Gap, entspricht dem Interface-Freeze-Charakter dieses
|
||||
Tickets
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131, `make check`)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -count=1 -> 9/9 Pakete mit Tests ok, 0 Fehlschläge
|
||||
(nach Korrektur; internal/retention und internal/moduleadapter brauchen
|
||||
TEST_TENANT_DSN/TEST_TENANT_DSN_B bzw. TEST_TENANT_DSN)
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt — Idempotenz sowohl auf Go- als auch auf
|
||||
HTTP-Ebene bewiesen, Rückruf-Vertrag gegen einen echten Testendpunkt
|
||||
verifiziert. Bewusst als reiner Interface-Freeze umgesetzt, keine
|
||||
DMS-/Mail-seitige Implementierung — wie vom Nutzer vorgegeben.
|
||||
@@ -0,0 +1,91 @@
|
||||
# RET-06-API – Prüfprotokoll: Aufbewahrungsfristen-Konfigurations-Backend
|
||||
|
||||
Voraussetzung RET-02 – erledigt, siehe eigenes Protokoll.
|
||||
|
||||
**Scope-Entscheidung (Board-Rücksprache):** RET-06 verlangt Next.js/
|
||||
React-Frontend + Backend-API + RBAC. Statt eines einzigen
|
||||
Big-Scope-Tickets: **Backend-API zuerst** (dieses Protokoll), das
|
||||
echte Next.js-Frontend folgt als eigenes, separates Ticket. Diese
|
||||
Trennung wurde bewusst gewählt, damit das Prüfprotokoll nicht auf ein
|
||||
Pseudo-Frontend verweist.
|
||||
|
||||
## Grundsatzentscheidung: provisorischer Rollen-Check, KEIN RBAC-02
|
||||
|
||||
Core RBAC-02 (`internal/policy`, `Enforcer.Authorize`) ist reiner
|
||||
Go-Code innerhalb des Core-Moduls — keine HTTP-Schnittstelle, über die
|
||||
Archive (physisch getrenntes Go-Modul) es aufrufen könnte. Derselbe
|
||||
"gefunden, aber nicht modulübergreifend verdrahtet"-Befund wie bei Core
|
||||
FDN-03/FDN-09 (siehe frühere Prüfprotokolle).
|
||||
|
||||
**`internal/retentionapi.RequireRole` ist AUSDRÜCKLICH KEIN RBAC-02-
|
||||
Ersatz**, sondern ein Provisorium: prüft nur einen selbst gesetzten
|
||||
Header (`X-Admin-Roles`), leicht zu fälschen von jedem, der den Header
|
||||
setzen kann. Muss ersetzt werden, sobald ein Core-seitiger HTTP-Wrapper
|
||||
um RBAC-02 existiert (Empfehlung: eigenes künftiges Core-Ticket,
|
||||
z. B. `RBAC-06`, wiederverwendbar für alle Module statt je Modul einen
|
||||
eigenen Provisorium-Check). Bis dahin real getestet inklusive
|
||||
Negativfall (Pflichtprüfung, siehe unten) — ein UNGEPRÜFTER
|
||||
Provisorium-Check wäre nur eine verschobene Schwachstelle.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `migrations/0005_retention_class_rules_active.up.sql`/`.down.sql` —
|
||||
`active`-Flag statt DELETE (Klasse deaktivieren ohne Historienverlust).
|
||||
- `internal/retentionengine.DeactivateClassRule`/`ListClassRules` —
|
||||
Erweiterung von RET-02s Paket, `ComputeDueDate`/`ListExpiringObjects`
|
||||
berücksichtigen nur noch aktive Regeln.
|
||||
- `internal/retentionapi.RequireRole` — provisorischer Header-Rollen-Check.
|
||||
- `internal/retentionapi.Mount` — vier Endpunkte: `POST
|
||||
/retention-classes` (anlegen/ändern), `POST
|
||||
/retention-classes/{class}/deactivate`, `GET /retention-classes`
|
||||
(Liste), `GET /retention-classes/preview` (Vorschau, nutzt DIESELBE
|
||||
`ListExpiringObjects`-Funktion wie RET-02s periodischer Job).
|
||||
- `cmd/retention-api` — eigenständiger HTTP-Dienst.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Änderung einer Frist wirkt sich nur auf künftige Berechnungen aus, nicht rückwirkend auf bereits protokollierte Vernichtungen | **bestanden** — `TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations`: bereits berechneter Stichtag bleibt unverändert (strukturell garantiert, keine Tabelle mit "bereits berechneten" Werten existiert, die rückwirkend verändert werden könnte), NEUE Berechnung übernimmt die neue Frist |
|
||||
| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden** — `TestRequireRole_MissingRoleReturns403` (kein Header UND falsche Rolle, beide 403) UND `TestRequireRole_CorrectRoleAllowsAccess` (Gegentest); real auf 131: `curl` ohne Rollen-Header → 403 |
|
||||
| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden** — `TestPreviewHandler_MatchesPeriodicJobResult`: HTTP-Vorschau UND direkter `ListExpiringObjects`-Aufruf liefern dasselbe Objekt (dieselbe Funktion, kein Doppel-Code) |
|
||||
|
||||
Zusätzlich: `TestDeactivateClassRule_ExcludesFromFutureCalculations`,
|
||||
`TestConfigureAndListHandler_RealHTTPRoundTrip`,
|
||||
`TestDeactivateHandler_RealHTTPRoundTrip`.
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- `retention-api` gebaut nach `/opt/nexarch-archive/bin/`
|
||||
- `/etc/nexarch/archive-retention-api.env` (0600)
|
||||
- `nexarch-archive-retention-api.service` installiert/aktiviert
|
||||
(dauerhaft, `Restart=on-failure`)
|
||||
- Realer End-zu-Ende-Test via `curl`: POST ohne Rollen-Header → 403;
|
||||
POST mit `X-Admin-Roles: archive_admin` → 200, Klasse angelegt; `GET
|
||||
/retention-classes` zeigt sie; `GET /retention-classes/preview`
|
||||
liefert `null` (kein fälliges Objekt, korrekt leer) — Testdaten
|
||||
anschließend entfernt
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131, `make check`)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -count=1 -> 11/11 Pakete mit Tests ok, 0 Fehlschläge
|
||||
```
|
||||
|
||||
**Hinweis:** Zwei Pakete (`internal/retentionengine`,
|
||||
`internal/retentionapi`) gegen dieselbe reale Tenant-DB laufen zu lassen
|
||||
erfordert `-p 1` (sequentiell) — parallele Testläufe unterschiedlicher
|
||||
Pakete gegen dieselbe echte Datenbank führen sonst zu
|
||||
Cross-Test-Kontamination (TRUNCATE eines Pakets während ein anderes
|
||||
noch liest/schreibt). `make check`/`Makefile` erzwingt das bereits.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden — Backend-Scope.** Alle drei Pflichtprüfungen real erfüllt.
|
||||
**Offen, bewusst nicht Teil dieses Protokolls:** das Next.js/React-
|
||||
Frontend (separates Folgeticket) und der Ersatz des provisorischen
|
||||
Rollen-Checks durch einen echten RBAC-02-Aufruf, sobald Core einen
|
||||
HTTP-Wrapper dafür bereitstellt.
|
||||
@@ -0,0 +1,58 @@
|
||||
# RET-06 – Prüfprotokoll: Aufbewahrungsfristen-Konfigurationsoberfläche
|
||||
|
||||
Voraussetzung RET-02, RET-06-API, RET-08 (RBAC-Migration) – alle
|
||||
bereits Fertig. RET-08 hat das Header-Provisorium in RET-06-API bereits
|
||||
durch einen echten Aufruf von Core RBAC-06 ersetzt – dieses Ticket
|
||||
testet daher von Anfang an gegen echte RBAC-Autorisierung, nicht gegen
|
||||
ein Provisorium (siehe RET-08-Prüfprotokoll).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `web/retention-admin` – eigenständige Next.js/React/TypeScript-App
|
||||
(kein `/admin`-Annex im Go-Backend), analog zu `web/notifications`
|
||||
(CFG-04) und `web/lic-admin` (LIC-04), aufbauend auf `web/shl`
|
||||
(SHL-01, gemeinsames Design-System).
|
||||
- `lib/api.ts` – dünner Client für RET-06-API. Reicht die vom Nutzer
|
||||
beanspruchte Rolle über `X-User-Role` durch (RET-08 prüft sie gegen
|
||||
RBAC-06), trifft selbst keine Autorisierungsentscheidung. Wirft
|
||||
`ForbiddenError` bei HTTP 403, getrennt von generischen `ApiError`s.
|
||||
- `app/classes/page.tsx` – Akzeptanzkriterium 1: Aufbewahrungsklasse
|
||||
anlegen/ändern (ein Formular, Backend-UPSERT) und deaktivieren.
|
||||
Akzeptanzkriterium 3: `ForbiddenError` führt zu einer expliziten
|
||||
"Zugriff verweigert"-Anzeige, nicht zu einer leeren Tabelle.
|
||||
- `app/preview/page.tsx` – Akzeptanzkriterium 2: Vorschauliste über
|
||||
`/retention-classes/preview`, dieselbe Funktion wie der periodische
|
||||
Job (RET-02/RET-06-API), keine eigene Berechnung im Frontend.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Änderung einer Frist wirkt nur auf künftige Berechnungen, nicht rückwirkend | **bestanden (Backend-seitig bereits durch RET-02/RET-06-API bewiesen)** – Frontend ruft ausschließlich den bestehenden UPSERT-Endpunkt auf, keine eigene Berechnungslogik im Frontend, die dies unterlaufen könnte |
|
||||
| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden – expliziter 403-Nachweis, nicht nur der 200-Fall:** `lib/api.test.ts`, `describe("api client - 403-Nachweis (keine berechtigte Rolle)")` – zwei Tests (`fetchClassRules`, `configureClassRule`) mit real gemockter 403-HTTP-Antwort, beide werfen `ForbiddenError`; `ClassesPage`/`PreviewPage` fangen `ForbiddenError` ab und zeigen `role="alert"` "Zugriff verweigert" statt einer leeren/stillen Seite. Der 403-Vertrag selbst (RET-06-API antwortet real mit 403 bei fehlender RBAC-06-Berechtigung) ist bereits in RET-08 end-zu-ende gegen den laufenden Dienst auf 131 bewiesen (`curl` ohne Policy-Rule → 403) – dieses Ticket prüft, dass das Frontend diesen real existierenden Vertrag korrekt behandelt, nicht das Backend erneut |
|
||||
| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden (Backend-seitig bereits durch RET-06-API bewiesen)** – `PreviewPage` ruft exakt denselben `/retention-classes/preview`-Endpunkt auf, der intern `retentionengine.ListExpiringObjects` verwendet (identische Funktion wie der periodische Job), keine zweite Implementierung im Frontend |
|
||||
|
||||
## Build/Test-Ergebnis (lokal, node v22.16.0/npm 10.9.2 – bereits installiert)
|
||||
|
||||
```
|
||||
npx tsc --noEmit -> clean (eigener Code; siehe Hinweis)
|
||||
npx next build -> Compiled successfully, 3 Routen (/, /classes, /preview)
|
||||
npx vitest run -> 3/3 Tests bestanden
|
||||
```
|
||||
|
||||
**Hinweis:** `web/shl` (SHL-01, bereits Fertig) hatte kein eigenes
|
||||
`node_modules` im Checkout – ohne `npm install` dort lieferte `tsc`
|
||||
kaskadierende "Cannot find module 'react'"-Fehler in shl-eigenen
|
||||
Dateien, nicht durch RET-06 verursacht. Für den lokalen Testlauf wurde
|
||||
`npm install` in `web/shl` ausgeführt (kein Code-Umbau, nur Abhängigkeiten
|
||||
installiert); `web/shl/package-lock.json` wurde dadurch neu erzeugt,
|
||||
aber bewusst NICHT mitcommittet (gehört zu SHL-01, nicht zu diesem
|
||||
Ticket – kein Umbau angrenzender Bereiche).
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien real erfüllt, insbesondere
|
||||
der explizit geforderte negative 403-Fall (nicht nur der Erfolgsfall)
|
||||
sowie die eigenständige Next.js-App-Struktur (kein Backend-Annex).
|
||||
Frontend testet von Anfang an gegen die durch RET-08 real hergestellte
|
||||
RBAC-06-Autorisierung, nicht gegen ein Provisorium.
|
||||
@@ -0,0 +1,91 @@
|
||||
# RET-07 – Prüfprotokoll: Fristablauf-Benachrichtigungen
|
||||
|
||||
Voraussetzung RET-02, CFG-05 – beide bereits Fertig.
|
||||
|
||||
## Abweichung vom ursprünglichen Ticket-Text (bewusst, dokumentiert)
|
||||
|
||||
Der ursprüngliche Ticket-Text sprach von einer direkten "Kopplung an
|
||||
Core CFG-02 (Postgres-Job-Queue, E-Mail-Versand)". Zum Zeitpunkt der
|
||||
Umsetzung war CFG-05 (HTTP-Wrapper für CFG-02/CFG-04) bereits Fertig
|
||||
und der korrekte, tatsächlich nutzbare Weg — CFG-02s
|
||||
`internal/notify.Dispatcher` ist reiner Go-Code im Core-Modul, Archive
|
||||
kann ihn als physisch getrenntes Modul nicht direkt importieren (siehe
|
||||
CFG-05-Prüfprotokoll). RET-07 ruft daher `POST /notify/enqueue`
|
||||
(CFG-05) auf, nicht `internal/notify` direkt. Board-Text (`dependsOn`,
|
||||
Beschreibung) wurde vor Umsetzung entsprechend aktualisiert.
|
||||
|
||||
**Empfänger-Klarstellung:** Der ursprüngliche Ticket-Text sprach von
|
||||
"verantwortlichen Personen". `retention_objects` (RET-01) führt bewusst
|
||||
KEINE Objekt-Owner-Beziehung. Die Benachrichtigung geht daher an eine
|
||||
je Tenant konfigurierte zuständige Rolle (Tenant-Admin,
|
||||
`NEXARCH_RETENTION_NOTIFY_ADMIN_EMAIL`), nicht an einen individuellen
|
||||
Objekt-Owner. Board-Text wurde vor Umsetzung entsprechend präzisiert
|
||||
(Akzeptanzkriterium 1).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/migrations/0006_retention_notify.up/down.sql` –
|
||||
`retention_class_rules.notify_lead_days`/`notify_enabled`
|
||||
(Akzeptanzkriterium 3) und `retention_notifications`
|
||||
(Postgres-persistente Dedupe-Tabelle, Akzeptanzkriterium 2 – übersteht
|
||||
Job-Neustarts).
|
||||
- `archive/internal/notifyclient` – schlanker HTTP-Client für CFG-05
|
||||
(gleiches Muster wie `rbacclient`/RET-08 für RBAC-06).
|
||||
- `archive/internal/retentionnotify.Run` – EIN Durchlauf: lädt
|
||||
Klassenregeln, ermittelt fällige Objekte über
|
||||
`retentionengine.ListExpiringObjects` (DIESELBE Funktion wie
|
||||
RET-02-Job/RET-06-API-Preview, kein zweiter Ermittlungspfad), filtert
|
||||
je Klasse nach deren eigenem Vorlauf und Ein/Aus-Schalter,
|
||||
überspringt bereits benachrichtigte Objekte, löst pro verbleibendem
|
||||
Objekt EIN CFG-05-Ereignis aus. Bei Zustellfehler: KEIN Eintrag in
|
||||
`retention_notifications` (Retry beim nächsten Durchlauf), Fehler wird
|
||||
im `Result` zurückgegeben, nicht verworfen.
|
||||
- `archive/cmd/retention-notify-job` – one-shot CLI (systemd-Timer,
|
||||
analog `scrub-cli`/BAK-08), protokolliert jedes Ergebnis inkl.
|
||||
Fehler über `log.Printf`.
|
||||
- `deploy/systemd/nexarch-archive-retention-notify.{service,timer}.tmpl`.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Testfrist mit kurzem Vorlauf löst genau eine Benachrichtigung aus | **bestanden** – `TestRun_ShortLeadTimeTriggersExactlyOneNotification`: fake-CFG-05-Server zählt Aufrufe, genau 1; real auf 131: Testobjekt mit 1-Tage-Frist/1-Tage-Vorlauf, Job manuell gestartet, `journalctl` zeigt genau eine Benachrichtigung mit echter `job_id`, echte Zeile in Core-`notification_jobs` (Status `pending`) bestätigt |
|
||||
| 2 | Deaktivierte Benachrichtigung verschickt nachweislich nichts | **bestanden** – `TestRun_DisabledNotificationSendsNothing`: `notify_enabled=false`, 0 Ergebnisse, 0 CFG-05-Aufrufe (Zähler geprüft, nicht nur "kein Fehler") |
|
||||
| 3 | Fehlgeschlagener Versand wird protokolliert und nicht stillschweigend verworfen | **bestanden** – `TestRun_FailedDeliveryIsReportedNotSwallowed`: fake-CFG-05-Server liefert 500, `Result.Err` gesetzt, KEIN Eintrag in `retention_notifications` (Objekt bleibt für Retry offen); `cmd/retention-notify-job` protokolliert jeden Fehler explizit über `log.Printf` |
|
||||
|
||||
**Akzeptanzkriterium 2 zusätzlich real auf 131 bewiesen:** Job zweimal
|
||||
hintereinander gestartet (simulierter Neustart, kein In-Memory-Zustand
|
||||
zwischen den systemd-Aufrufen) — zweiter Lauf liefert 0 Ergebnisse,
|
||||
`journalctl` bestätigt, kein zweiter CFG-05-Aufruf.
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- Migration `0006_retention_notify` real auf `dms_tenant_test`
|
||||
angewendet.
|
||||
- `retention-notify-job` gebaut nach `/opt/nexarch-archive/bin/`,
|
||||
`/etc/nexarch/archive-retention-notify.env` (0600).
|
||||
- `nexarch-archive-retention-notify.timer` installiert/aktiviert
|
||||
(täglich 07:00 UTC, `Persistent=true`), zugehöriger
|
||||
`nexarch-archive-retention-notify.service` (`Type=oneshot`).
|
||||
- Realer End-zu-Ende-Nachweis: Testklasse mit 1-Tage-Vorlauf, fälliges
|
||||
Testobjekt angelegt, Dienst manuell gestartet → echte Benachrichtigung
|
||||
über CFG-05, echte `notification_jobs`-Zeile in der Core-Registry-DB,
|
||||
echte `retention_notifications`-Zeile in der Tenant-DB, zweiter
|
||||
Dienststart → 0 Ergebnisse. Alle Testdaten anschließend entfernt.
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. retentionnotify, objectbackup, restoretest)
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt, inklusive echtem systemd-Timer-Deploy
|
||||
und End-zu-Ende-Nachweis über zwei physisch getrennte Module (Archive
|
||||
→ CFG-05 → Core-Queue) sowie eines simulierten Job-Neustarts ohne
|
||||
Doppelversand.
|
||||
@@ -0,0 +1,70 @@
|
||||
# RET-08 – Prüfprotokoll: RET-06-API auf RBAC-06 migrieren (Provisorium abbauen)
|
||||
|
||||
Voraussetzung RBAC-06 (Core) und RET-06-API – beide bereits Fertig.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/internal/rbacclient` (neu) – schlanker HTTP-Client für Core
|
||||
RBAC-06 (`POST /authorize`), service-token-authentifiziert. Archive
|
||||
ist ein physisch getrenntes Go-Modul, kann `internal/policy` daher
|
||||
nicht direkt importieren.
|
||||
- `archive/internal/retentionapi/authz.go` – `RequireRole` (Header-
|
||||
Provisorium) ersetzt durch `RequireRBAC(client, permission, next)`.
|
||||
Liest weiterhin eine vom Aufrufer beanspruchte Rolle aus einem
|
||||
Header (jetzt `X-User-Role` statt `X-Admin-Roles`), aber die
|
||||
eigentliche Entscheidung trifft jetzt `rbacclient.Client.Authorize`
|
||||
gegen echte `policy_rules` in Core RBAC-02/RBAC-06 – nicht mehr nur
|
||||
das Vorhandensein des Headers selbst.
|
||||
- **Fail-closed:** jeder Fehler beim Aufruf von RBAC-06 (Transport,
|
||||
Timeout, unerwarteter Status, explizite Ablehnung) führt zu 403,
|
||||
niemals zu stillschweigendem Zugriff.
|
||||
- `archive/internal/retentionapi/handler.go` – `Mount` nimmt jetzt
|
||||
zusätzlich einen `*rbacclient.Client` entgegen, alle vier Endpunkte
|
||||
gegen dasselbe Recht `retention.configure` geprüft.
|
||||
- `archive/cmd/retention-api/main.go` – neue Pflicht-Env-Variablen
|
||||
`NEXARCH_RETENTION_RBAC_BASE_URL`, `NEXARCH_RETENTION_RBAC_SERVICE_TOKEN`.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | 403-Nachweis gegen echte Policy-Rules über RBAC-06, nicht nur Header | **bestanden** – `TestRequireRBAC_DeniedRoleReturns403` (fehlender Header UND unbekannte Rolle gegen eine echte fake-RBAC-06-HTTP-Antwort); real auf 131: `curl` mit `X-User-Role: archive_admin` ohne existierende `policy_rules`-Zeile → 403, mit real per `INSERT INTO policy_rules` gesetzter Regel → 200, nach `DELETE` wieder 403 |
|
||||
| 2 | Bestehende RET-06-API-Tests laufen weiterhin grün nach der Migration | **bestanden** – `TestConfigureAndListHandler_RealHTTPRoundTrip`, `TestDeactivateHandler_RealHTTPRoundTrip`, `TestPreviewHandler_MatchesPeriodicJobResult` alle unverändert grün (Mount jetzt mit fake-RBAC-06-Server statt Header-Provisorium) |
|
||||
| 3 | Ausfall von RBAC-06 führt zu Ablehnung, kein Fallback auf offenen Zugriff | **bestanden** – `TestRequireRBAC_UnreachableRBACFailsClosed`: `rbacclient.Client` zeigt auf nicht erreichbaren Endpunkt (`http://127.0.0.1:1`), Handler nachweislich nie erreicht, Antwort 403 |
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- `retention-api` neu gebaut, `/etc/nexarch/archive-retention-api.env`
|
||||
um `NEXARCH_RETENTION_RBAC_BASE_URL=http://127.0.0.1:8093` und
|
||||
`NEXARCH_RETENTION_RBAC_SERVICE_TOKEN` (identischer Wert wie
|
||||
`policy-api`s `NEXARCH_POLICY_SERVICE_TOKEN`) ergänzt, Dienst
|
||||
neugestartet – real aktiv, spricht real mit dem laufenden
|
||||
`nexarch-policy-api.service` (RBAC-06).
|
||||
- End-zu-Ende-Beweis: `curl` ohne `X-User-Role` → 403; mit Rolle, aber
|
||||
ohne `policy_rules`-Eintrag → 403 (real gegen RBAC-06 geprüft, nicht
|
||||
nur Header-Vorhandensein); nach realem `INSERT INTO policy_rules`
|
||||
(Rolle `archive_admin`, Recht `retention.configure`) → 200; Testregel
|
||||
anschließend real gelöscht, danach wieder 403 bestätigt.
|
||||
- Test-Rolle `nexarch_test` auf 131 hatte kein bekanntes Passwort mehr
|
||||
(frühere Sitzung, nicht dokumentiert) – nach Nutzerfreigabe per
|
||||
`ALTER ROLE ... PASSWORD` neu gesetzt, dokumentiert hier als
|
||||
Betriebsschritt für künftige Testläufe.
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -> alle Pakete bestanden (inkl. objectbackup, restoretest, retentionapi)
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt. Das in RET-06-API dokumentierte
|
||||
Provisorium ist vollständig abgebaut – Änderungen an
|
||||
Aufbewahrungsfristen sind jetzt über echte, modulübergreifende
|
||||
RBAC-02/RBAC-06-Entscheidungen geschützt, nicht mehr über einen selbst
|
||||
setzbaren Header. RET-06 (Frontend) ist damit ohne weiteren
|
||||
RBAC-Blocker startbar.
|
||||
@@ -0,0 +1,46 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type registerRequest struct {
|
||||
ModuleName string `json:"module_name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
RetentionClass string `json:"retention_class"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
|
||||
// RegisterHandler ist die REST-Schnittstelle (Ticket-Technikvorgabe), über
|
||||
// die ein Modul einen Objekttyp registriert (Akzeptanzkriterium 1).
|
||||
// POST /register mit JSON-Body {module_name, object_type, callback_url}.
|
||||
func RegisterHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.ModuleName == "" || req.ObjectType == "" || req.RetentionClass == "" || req.CallbackURL == "" {
|
||||
http.Error(w, "module_name, object_type, retention_class und callback_url sind pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := Register(r.Context(), pool, req.ModuleName, req.ObjectType, req.RetentionClass, req.CallbackURL)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(reg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRegisterHandler_RealHTTPRoundTrip beweist die REST-Schnittstelle
|
||||
// (Ticket-Technikvorgabe) end-to-end: echter HTTP-Server, echter POST,
|
||||
// zweiter POST mit abweichender callback_url aendert nichts (Pruefung 3
|
||||
// auch ueber die HTTP-Schicht, nicht nur die Go-Funktion direkt).
|
||||
func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
server := httptest.NewServer(RegisterHandler(pool))
|
||||
defer server.Close()
|
||||
|
||||
post := func(body registerRequest) (int, Registration) {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(body)
|
||||
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var reg Registration
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.NewDecoder(resp.Body).Decode(®); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, reg
|
||||
}
|
||||
|
||||
status1, reg1 := post(registerRequest{ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-original", CallbackURL: "https://dms.example.test/original"})
|
||||
if status1 != http.StatusOK {
|
||||
t.Fatalf("erster post: status = %d, want 200", status1)
|
||||
}
|
||||
|
||||
status2, reg2 := post(registerRequest{ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-ANDERE", CallbackURL: "https://dms.example.test/andere"})
|
||||
if status2 != http.StatusOK {
|
||||
t.Fatalf("zweiter post: status = %d, want 200", status2)
|
||||
}
|
||||
if reg2.ID != reg1.ID || reg2.CallbackURL != "https://dms.example.test/original" {
|
||||
t.Fatalf("zweiter post veraenderte bestehenden zustand: %+v, erster war %+v", reg2, reg1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterHandler_RejectsMissingFields ist Nachweis des
|
||||
// Fehlerverhaltens auf der REST-Schicht.
|
||||
func TestRegisterHandler_RejectsMissingFields(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
server := httptest.NewServer(RegisterHandler(pool))
|
||||
defer server.Close()
|
||||
|
||||
data, _ := json.Marshal(registerRequest{ModuleName: "dms"})
|
||||
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 bei fehlenden pflichtfeldern", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package moduleadapter implementiert RET-05: die Schnittstelle, über
|
||||
// die DMS und Mail ihre Objekttypen bei Archive registrieren, statt
|
||||
// eigene Retention-Logik zu bauen. BEWUSST NUR DAS INTERFACE UND
|
||||
// ARCHIVES EIGENE SEITE (Registrierungs-API + Rückruf-Auslöser) — die
|
||||
// eigentlichen Rückruf-EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte)
|
||||
// sind NICHT Teil dieses Tickets, damit spätere DMS-/Mail-Kacheln
|
||||
// gegen ein bereits feststehendes, nicht nachträglich verändertes
|
||||
// Interface bauen (Nutzervorgabe).
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Registration ist EIN registrierter Objekttyp eines Moduls.
|
||||
type Registration struct {
|
||||
ID string
|
||||
ModuleName string
|
||||
ObjectType string
|
||||
RetentionClass string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
// Register registriert einen Objekttyp eines Moduls mit Rückruf-Adresse
|
||||
// für Löschbestätigung — mandantenfähig durch Modell C (physisch
|
||||
// isolierte Tenant-DB, kein tenant_id-Feld nötig). Idempotent im Sinne
|
||||
// von Akzeptanzkriterium 3: eine erneute Registrierung DESSELBEN
|
||||
// Objekttyps ändert NICHTS am bestehenden Zustand (kein Überschreiben
|
||||
// der callback_url) — anders als RET-01s RegisterObject (dort
|
||||
// UPSERT-artig), weil ein bereits registrierter Rückruf nicht
|
||||
// stillschweigend durch eine zweite, möglicherweise abweichende
|
||||
// Registrierung ersetzt werden darf.
|
||||
func Register(ctx context.Context, pool *pgxpool.Pool, moduleName, objectType, retentionClass, callbackURL string) (Registration, error) {
|
||||
var reg Registration
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO module_registrations (module_name, object_type, retention_class, callback_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (module_name, object_type) DO NOTHING
|
||||
RETURNING id, module_name, object_type, retention_class, callback_url
|
||||
`, moduleName, objectType, retentionClass, callbackURL).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.RetentionClass, ®.CallbackURL)
|
||||
if err == nil {
|
||||
return reg, nil
|
||||
}
|
||||
// ON CONFLICT DO NOTHING liefert keine Zeile zurueck (pgx: ErrNoRows) -
|
||||
// bestehende Registrierung unveraendert nachlesen und zurueckgeben.
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations
|
||||
WHERE module_name = $1 AND object_type = $2
|
||||
`, moduleName, objectType).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.RetentionClass, ®.CallbackURL)
|
||||
if err != nil {
|
||||
return Registration{}, fmt.Errorf("moduleadapter: registrierung lesen/anlegen: %w", err)
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// ListRegistrations liefert alle registrierten Objekttypen — Grundlage
|
||||
// für Statusübersichten und Tests (Pflichtprüfung 1: zwei Module
|
||||
// parallel registriert ohne Kollision).
|
||||
func ListRegistrations(ctx context.Context, pool *pgxpool.Pool) ([]Registration, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations ORDER BY module_name, object_type`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("moduleadapter: registrierungen auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var regs []Registration
|
||||
for rows.Next() {
|
||||
var r Registration
|
||||
if err := rows.Scan(&r.ID, &r.ModuleName, &r.ObjectType, &r.RetentionClass, &r.CallbackURL); err != nil {
|
||||
return nil, fmt.Errorf("moduleadapter: registrierungs-zeile lesen: %w", err)
|
||||
}
|
||||
regs = append(regs, r)
|
||||
}
|
||||
return regs, rows.Err()
|
||||
}
|
||||
|
||||
// DestructionNotice ist der Rückruf-Payload bei Vernichtung eines
|
||||
// Objekts (Akzeptanzkriterium 2) — das feststehende Vertragsformat, das
|
||||
// jeder Modul-Rückruf-Empfänger erwarten muss.
|
||||
type DestructionNotice struct {
|
||||
ObjectType string `json:"object_type"`
|
||||
ObjectReference string `json:"object_reference"`
|
||||
DestroyedAt time.Time `json:"destroyed_at"`
|
||||
}
|
||||
|
||||
// NotifyDestruction ruft das registrierte Modul beim Vernichten eines
|
||||
// Objekts zurück, statt dass Archive selbst Modul-Interna kennen müsste
|
||||
// (Akzeptanzkriterium 2). Fehlerverhalten: liefert den Fehler an den
|
||||
// Aufrufer zurück, statt ihn zu verschlucken — ein fehlgeschlagener
|
||||
// Rückruf ist ein Fehlerzustand, der behandelt/wiederholt werden muss
|
||||
// (Wiederholungslogik ist NICHT Teil dieses Tickets, nur der
|
||||
// Interface-Vertrag: Erfolg = HTTP 2xx, sonst Fehler).
|
||||
func NotifyDestruction(ctx context.Context, client *http.Client, callbackURL string, notice DestructionNotice) error {
|
||||
body, err := json.Marshal(notice)
|
||||
if err != nil {
|
||||
return fmt.Errorf("moduleadapter: rückruf-payload kodieren: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("moduleadapter: rückruf-request erstellen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("moduleadapter: rückruf fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("moduleadapter: rückruf-endpunkt antwortete mit status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func jsonDecode(r *http.Request, v interface{}) error {
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS module_registrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), module_name TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL, retention_class TEXT NOT NULL, callback_url TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (module_name, object_type)
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE module_registrations`) })
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestRegister_TwoModulesNoCollision ist Pruefung 1: zwei fiktive Module
|
||||
// (DMS, Mail) parallel registriert ohne Kollision.
|
||||
func TestRegister_TwoModulesNoCollision(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
dmsReg, err := Register(ctx, pool, "dms", "document", "klasse-B2B-standard", "https://dms.example.test/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("dms registrieren: %v", err)
|
||||
}
|
||||
mailReg, err := Register(ctx, pool, "mail", "message", "klasse-korrespondenz", "https://mail.example.test/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("mail registrieren: %v", err)
|
||||
}
|
||||
if dmsReg.ID == mailReg.ID {
|
||||
t.Fatal("dms und mail erhielten dieselbe id - kollision")
|
||||
}
|
||||
|
||||
all, err := ListRegistrations(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listregistrations: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("erwartet 2 registrierungen, habe %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_IsIdempotent_UnchangedExistingState ist Pruefung 3:
|
||||
// erneute Registrierung desselben Objekttyps aendert NICHTS am
|
||||
// bestehenden Zustand - auch nicht bei abweichender callback_url im
|
||||
// zweiten Aufruf.
|
||||
func TestRegister_IsIdempotent_UnchangedExistingState(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := Register(ctx, pool, "dms", "document", "klasse-original", "https://dms.example.test/original")
|
||||
if err != nil {
|
||||
t.Fatalf("erste registrierung: %v", err)
|
||||
}
|
||||
second, err := Register(ctx, pool, "dms", "document", "klasse-ANDERE", "https://dms.example.test/ANDERE-url")
|
||||
if err != nil {
|
||||
t.Fatalf("zweite registrierung: %v", err)
|
||||
}
|
||||
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("erneute registrierung erzeugte neue id: %q, want %q", second.ID, first.ID)
|
||||
}
|
||||
if second.CallbackURL != "https://dms.example.test/original" {
|
||||
t.Fatalf("callback_url wurde ueberschrieben: %q, want unveraendert %q", second.CallbackURL, first.CallbackURL)
|
||||
}
|
||||
if second.RetentionClass != "klasse-original" {
|
||||
t.Fatalf("retention_class wurde ueberschrieben: %q, want unveraendert %q", second.RetentionClass, "klasse-original")
|
||||
}
|
||||
|
||||
all, err := ListRegistrations(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listregistrations: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("erwartet weiterhin genau 1 registrierung, habe %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDestruction_CallsRealTestEndpoint ist Pruefung 2: Rueckruf
|
||||
// bei Vernichtung erfolgreich gegen einen Testendpunkt ausgefuehrt -
|
||||
// echter HTTP-Server, echter Request, echte Payload-Pruefung.
|
||||
func TestNotifyDestruction_CallsRealTestEndpoint(t *testing.T) {
|
||||
var receivedNotice DestructionNotice
|
||||
called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("erwartet POST, habe %s", r.Method)
|
||||
}
|
||||
if err := jsonDecode(r, &receivedNotice); err != nil {
|
||||
t.Errorf("payload dekodieren: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
notice := DestructionNotice{ObjectType: "document", ObjectReference: "doc-789", DestroyedAt: time.Now().UTC()}
|
||||
if err := NotifyDestruction(context.Background(), server.Client(), server.URL, notice); err != nil {
|
||||
t.Fatalf("notifydestruction: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("testendpunkt wurde nie aufgerufen")
|
||||
}
|
||||
if receivedNotice.ObjectReference != "doc-789" {
|
||||
t.Fatalf("empfangene objekt-referenz = %q, want doc-789", receivedNotice.ObjectReference)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus ist Nachweis des
|
||||
// Fehlerverhaltens: ein fehlschlagender Rueckruf wird als Fehler
|
||||
// gemeldet, nicht verschluckt.
|
||||
func TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NotifyDestruction(context.Background(), server.Client(), server.URL, DestructionNotice{})
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei 500-antwort des rueckruf-endpunkts")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package notifyclient ist ein schlanker HTTP-Client für Core CFG-05
|
||||
// (internal/notifyapi, POST /notify/enqueue). Archive ist ein physisch
|
||||
// getrenntes Go-Modul und kann Core CFG-02/CFG-04 daher nicht direkt
|
||||
// importieren — nur über die HTTP-Schnittstelle von CFG-05 aufrufen.
|
||||
// Gleiches Muster wie archive/internal/rbacclient (RET-08) für RBAC-06.
|
||||
package notifyclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
ServiceToken string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, serviceToken string) *Client {
|
||||
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: http.DefaultClient}
|
||||
}
|
||||
|
||||
type enqueueRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
UserID string `json:"user_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Channel string `json:"channel"`
|
||||
Recipient string `json:"recipient"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
// EnqueueResult spiegelt CFG-05s enqueueResponse.
|
||||
type EnqueueResult struct {
|
||||
JobID string
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// Enqueue löst ein Benachrichtigungs-Ereignis über CFG-05 aus. Jeder
|
||||
// Fehler (Transport, Timeout, unerwarteter Status) wird als Fehler
|
||||
// zurückgegeben — der Aufrufer entscheidet, ob/wie er das protokolliert
|
||||
// (Pflichtprüfung RET-07: fehlgeschlagener Versand wird protokolliert,
|
||||
// nicht stillschweigend verworfen).
|
||||
func (c *Client) Enqueue(ctx context.Context, tenantSlug, userID, eventType, channel, recipient string, payload map[string]any) (EnqueueResult, error) {
|
||||
body, err := json.Marshal(enqueueRequest{
|
||||
TenantSlug: tenantSlug, UserID: userID, EventType: eventType, Channel: channel, Recipient: recipient, Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return EnqueueResult{}, fmt.Errorf("notifyclient: request kodieren: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/notify/enqueue", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return EnqueueResult{}, fmt.Errorf("notifyclient: request bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Service-Token", c.ServiceToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, fmt.Errorf("notifyclient: aufruf fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return EnqueueResult{}, fmt.Errorf("notifyclient: unerwarteter status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
JobID string `json:"job_id"`
|
||||
Skipped bool `json:"skipped"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return EnqueueResult{}, fmt.Errorf("notifyclient: antwort dekodieren: %w", err)
|
||||
}
|
||||
return EnqueueResult{JobID: out.JobID, Skipped: out.Skipped}, nil
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package rbacclient ist ein schlanker HTTP-Client für Core RBAC-06
|
||||
// (internal/policyapi, POST /authorize). Archive ist ein physisch
|
||||
// getrenntes Go-Modul und kann Core RBAC-02 (internal/policy) daher
|
||||
// nicht direkt importieren — nur über die HTTP-Schnittstelle von
|
||||
// RBAC-06 aufrufen.
|
||||
package rbacclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrDenied wird zurückgegeben, wenn RBAC-06 die Anfrage ausdrücklich
|
||||
// ablehnt (kein Transport-/Serverfehler, sondern eine bewusste
|
||||
// Policy-Entscheidung).
|
||||
var ErrDenied = errors.New("rbac: zugriff verweigert")
|
||||
|
||||
// Client ruft den RBAC-06-Endpunkt auf.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
ServiceToken string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, serviceToken string) *Client {
|
||||
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: http.DefaultClient}
|
||||
}
|
||||
|
||||
type authorizeRequest struct {
|
||||
Role string `json:"role"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
type authorizeResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Authorize fragt RBAC-06, ob role die permission besitzt. Jeder
|
||||
// Fehler (Transport, Timeout, unerwarteter Status) wird als Fehler
|
||||
// zurückgegeben, NICHT stillschweigend als "erlaubt" behandelt — der
|
||||
// Aufrufer muss bei einem Fehler fail-closed (verweigern) reagieren.
|
||||
func (c *Client) Authorize(ctx context.Context, role, permission string) (bool, error) {
|
||||
body, err := json.Marshal(authorizeRequest{Role: role, Permission: permission})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rbac: request kodieren: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rbac: request bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Service-Token", c.ServiceToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rbac: aufruf fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("rbac: unerwarteter status %d", resp.StatusCode)
|
||||
}
|
||||
var out authorizeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return false, fmt.Errorf("rbac: antwort dekodieren: %w", err)
|
||||
}
|
||||
if !out.Allowed {
|
||||
return false, ErrDenied
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestMigration_ForwardAndBackward ist Pruefung 3 fuer Akzeptanzkriterium
|
||||
// 3: Migration laeuft gegen leere Datenbank durch UND ist rueckrollbar -
|
||||
// real gegen die TATSAECHLICHEN Migrationsdateien, kein Nachbau.
|
||||
func TestMigration_ForwardAndBackward(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
if _, err := exec.LookPath("psql"); err != nil {
|
||||
t.Skip("psql nicht installiert, Integrationstest uebersprungen")
|
||||
}
|
||||
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "migrations")
|
||||
upSQL := filepath.Join(migrationsDir, "0002_retention.up.sql")
|
||||
downSQL := filepath.Join(migrationsDir, "0002_retention.down.sql")
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
// sauberer Ausgangszustand, falls von einem frueheren Testlauf uebrig.
|
||||
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`)
|
||||
|
||||
runPsql := func(sqlFile string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(ctx, "psql", dsn, "-v", "ON_ERROR_STOP=1", "-f", sqlFile)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("psql -f %s: %v (ausgabe: %s)", sqlFile, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
tableExists := func(name string) bool {
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)`, name).Scan(&exists); err != nil {
|
||||
t.Fatalf("tabellenexistenz pruefen: %v", err)
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// vorwaerts
|
||||
runPsql(upSQL)
|
||||
if !tableExists("retention_objects") || !tableExists("retention_class_assignments") {
|
||||
t.Fatal("migration vorwaerts: erwartete tabellen fehlen")
|
||||
}
|
||||
|
||||
// rueckwaerts
|
||||
runPsql(downSQL)
|
||||
if tableExists("retention_objects") || tableExists("retention_class_assignments") {
|
||||
t.Fatal("migration rueckwaerts: tabellen haetten entfernt sein muessen")
|
||||
}
|
||||
|
||||
// erneut vorwaerts (beweist: rueckwaerts hat wirklich sauber
|
||||
// aufgeraeumt, kein Rest, der einen zweiten Vorwaertslauf bloeckieren wuerde)
|
||||
runPsql(upSQL)
|
||||
if !tableExists("retention_objects") {
|
||||
t.Fatal("zweiter vorwaertslauf nach rollback fehlgeschlagen")
|
||||
}
|
||||
// aufraeumen
|
||||
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package retention implementiert RET-01: ein generisches Datenmodell
|
||||
// für aufbewahrungspflichtige Objekte, modulübergreifend über Adapter
|
||||
// (Objekttyp + Objekt-Referenz als reine Textfelder) — Archive kennt die
|
||||
// Fachobjekte anderer Module (DMS, Mail) nicht im Detail, nur ihren Typ
|
||||
// und ihre Referenz. Keine Fremdschlüssel auf modulspezifische Tabellen,
|
||||
// damit ein neues Modul retention-pflichtige Objekte einbinden kann,
|
||||
// ohne dieses Paket zu ändern.
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Status eines Retention-Objekts.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusExpired Status = "expired"
|
||||
StatusDeleted Status = "deleted"
|
||||
)
|
||||
|
||||
// RegisterObject registriert ein Objekt eines beliebigen Moduls unter
|
||||
// seinem Typ+Referenz — idempotent (ON CONFLICT), ein Adapter kann ein
|
||||
// bereits bekanntes Objekt gefahrlos erneut registrieren
|
||||
// (Akzeptanzkriterium 1: bildet beliebige Objekttypen ab, ohne
|
||||
// modulspezifische Spalten).
|
||||
func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, error) {
|
||||
var id string
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO retention_objects (object_type, object_reference)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (object_type, object_reference) DO UPDATE SET object_type = EXCLUDED.object_type
|
||||
RETURNING id
|
||||
`, objectType, objectReference).Scan(&id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retention: objekt registrieren: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse.
|
||||
type Assignment struct {
|
||||
RetentionClass string
|
||||
AssignedAt time.Time
|
||||
}
|
||||
|
||||
// AssignClass ordnet einem Retention-Objekt eine neue Aufbewahrungsklasse
|
||||
// zu — fügt IMMER eine neue Zeile hinzu, ändert nie eine bestehende
|
||||
// (Akzeptanzkriterium 2: historisierbar).
|
||||
func AssignClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, retentionClass string) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO retention_class_assignments (retention_object_id, retention_class)
|
||||
VALUES ($1, $2)
|
||||
`, retentionObjectID, retentionClass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retention: aufbewahrungsklasse zuordnen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentClass liefert die AKTUELLE Aufbewahrungsklasse (jüngste
|
||||
// Zuordnung) eines Retention-Objekts.
|
||||
func CurrentClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (Assignment, error) {
|
||||
var a Assignment
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT retention_class, assigned_at FROM retention_class_assignments
|
||||
WHERE retention_object_id = $1
|
||||
ORDER BY assigned_at DESC LIMIT 1
|
||||
`, retentionObjectID).Scan(&a.RetentionClass, &a.AssignedAt)
|
||||
if err != nil {
|
||||
return Assignment{}, fmt.Errorf("retention: aktuelle aufbewahrungsklasse lesen: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ClassHistory liefert ALLE Zuordnungen eines Retention-Objekts,
|
||||
// chronologisch aufsteigend — voller Nachvollzug der Historie.
|
||||
func ClassHistory(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) ([]Assignment, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT retention_class, assigned_at FROM retention_class_assignments
|
||||
WHERE retention_object_id = $1
|
||||
ORDER BY assigned_at ASC
|
||||
`, retentionObjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retention: klassenhistorie lesen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []Assignment
|
||||
for rows.Next() {
|
||||
var a Assignment
|
||||
if err := rows.Scan(&a.RetentionClass, &a.AssignedAt); err != nil {
|
||||
return nil, fmt.Errorf("retention: historien-zeile lesen: %w", err)
|
||||
}
|
||||
history = append(history, a)
|
||||
}
|
||||
return history, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`
|
||||
|
||||
func requireTestPool(t *testing.T, dsn string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically ist
|
||||
// Pruefung 2: Testobjekt aus fiktivem DMS- und Mail-Adapter beide
|
||||
// korrekt abgebildet - KEINE modulspezifischen Spalten noetig, nur
|
||||
// object_type/object_reference.
|
||||
func TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically(t *testing.T) {
|
||||
pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN"))
|
||||
ctx := context.Background()
|
||||
|
||||
dmsID, err := RegisterObject(ctx, pool, "dms_document", "doc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("dms-adapter registrieren: %v", err)
|
||||
}
|
||||
mailID, err := RegisterObject(ctx, pool, "mail_message", "msg-456")
|
||||
if err != nil {
|
||||
t.Fatalf("mail-adapter registrieren: %v", err)
|
||||
}
|
||||
if dmsID == "" || mailID == "" || dmsID == mailID {
|
||||
t.Fatalf("erwartet zwei unterschiedliche, gueltige ids, habe dms=%q mail=%q", dmsID, mailID)
|
||||
}
|
||||
|
||||
// Idempotenz: erneute Registrierung desselben Objekts liefert dieselbe id.
|
||||
dmsIDAgain, err := RegisterObject(ctx, pool, "dms_document", "doc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("erneute registrierung: %v", err)
|
||||
}
|
||||
if dmsIDAgain != dmsID {
|
||||
t.Fatalf("erneute registrierung lieferte andere id: %q, want %q", dmsIDAgain, dmsID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignClass_IsHistoricized ist Pruefung fuer Akzeptanzkriterium 2:
|
||||
// Aufbewahrungsklasse ist eindeutig zugeordnet UND historisierbar.
|
||||
func TestAssignClass_IsHistoricized(t *testing.T) {
|
||||
pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN"))
|
||||
ctx := context.Background()
|
||||
|
||||
objID, err := RegisterObject(ctx, pool, "dms_document", "doc-hist")
|
||||
if err != nil {
|
||||
t.Fatalf("registrieren: %v", err)
|
||||
}
|
||||
if err := AssignClass(ctx, pool, objID, "klasse-A"); err != nil {
|
||||
t.Fatalf("erste zuordnung: %v", err)
|
||||
}
|
||||
if err := AssignClass(ctx, pool, objID, "klasse-B"); err != nil {
|
||||
t.Fatalf("zweite zuordnung: %v", err)
|
||||
}
|
||||
|
||||
current, err := CurrentClass(ctx, pool, objID)
|
||||
if err != nil {
|
||||
t.Fatalf("currentclass: %v", err)
|
||||
}
|
||||
if current.RetentionClass != "klasse-B" {
|
||||
t.Fatalf("aktuelle klasse = %q, want klasse-B", current.RetentionClass)
|
||||
}
|
||||
|
||||
history, err := ClassHistory(ctx, pool, objID)
|
||||
if err != nil {
|
||||
t.Fatalf("classhistory: %v", err)
|
||||
}
|
||||
if len(history) != 2 || history[0].RetentionClass != "klasse-A" || history[1].RetentionClass != "klasse-B" {
|
||||
t.Fatalf("erwartet [klasse-A, klasse-B] chronologisch, habe %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantIsolation_Negativtest ist Pruefung 3: ein in Tenant-DB A
|
||||
// registriertes Objekt ist ueber eine Verbindung zu Tenant-DB B technisch
|
||||
// nicht sichtbar - real gegen zwei unabhaengige Datenbanken (Modell C,
|
||||
// TEN-01), keine gemeinsame Tabelle mit tenant_id-Filter.
|
||||
func TestTenantIsolation_Negativtest(t *testing.T) {
|
||||
dsnA := os.Getenv("TEST_TENANT_DSN")
|
||||
dsnB := os.Getenv("TEST_TENANT_DSN_B")
|
||||
if dsnA == "" || dsnB == "" {
|
||||
t.Skip("TEST_TENANT_DSN und TEST_TENANT_DSN_B nicht beide gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
poolA := requireTestPool(t, dsnA)
|
||||
poolB := requireTestPool(t, dsnB)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := RegisterObject(ctx, poolA, "dms_document", "nur-in-tenant-a"); err != nil {
|
||||
t.Fatalf("registrieren in tenant a: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM retention_objects WHERE object_reference = 'nur-in-tenant-a'`).Scan(&count); err != nil {
|
||||
t.Fatalf("tenant b abfragen: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("objekt aus tenant a in tenant b sichtbar (count=%d) - mandantentrennung verletzt", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package retentionapi implementiert RET-06-API: die Backend-Seite der
|
||||
// Aufbewahrungsfristen-Konfigurationsoberfläche (CRUD auf
|
||||
// Aufbewahrungsklassen + Vorschauliste ablaufender Objekte). Das
|
||||
// Next.js-Frontend selbst ist NICHT Teil dieses Tickets (Board-
|
||||
// Entscheidung: Backend-API zuerst, Frontend als eigenes Folgeticket).
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
)
|
||||
|
||||
// requestedRoleHeader trägt die vom Aufrufer beanspruchte Rolle. Die
|
||||
// eigentliche Autorisierungsentscheidung trifft NICHT dieser Header,
|
||||
// sondern Core RBAC-06 (siehe RequireRBAC) — der Header ist nur die
|
||||
// Behauptung, RBAC-06 prüft sie gegen die echten Policy-Rules.
|
||||
const requestedRoleHeader = "X-User-Role"
|
||||
|
||||
// RequireRBAC ist RET-08: die Ablösung des früheren, header-basierten
|
||||
// Provisoriums (siehe archive/docs/RET-06-API-PRUEFPROTOKOLL.md) durch
|
||||
// einen echten Aufruf von Core RBAC-06 (POST /authorize). Fail-closed:
|
||||
// jeder Fehler beim Aufruf von RBAC-06 (Transport, Timeout, Ablehnung)
|
||||
// führt zur Verweigerung, niemals zu stillschweigendem Zugriff.
|
||||
func RequireRBAC(client *rbacclient.Client, permission string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
role := r.Header.Get(requestedRoleHeader)
|
||||
if role == "" {
|
||||
http.Error(w, "zugriff verweigert: header "+requestedRoleHeader+" erforderlich", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
allowed, err := client.Authorize(r.Context(), role, permission)
|
||||
if err != nil {
|
||||
if errors.Is(err, rbacclient.ErrDenied) {
|
||||
http.Error(w, "zugriff verweigert: rolle "+role+" hat kein recht "+permission, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// RBAC-06 nicht erreichbar oder fehlerhafte Antwort:
|
||||
// fail-closed, kein Fallback auf offenen Zugriff.
|
||||
http.Error(w, "zugriff verweigert: autorisierungsdienst nicht erreichbar", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, "zugriff verweigert: rolle "+role+" hat kein recht "+permission, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
)
|
||||
|
||||
const testServiceToken = "test-service-token"
|
||||
|
||||
// fakeRBACServer simuliert Core RBAC-06 (POST /authorize): erlaubt nur
|
||||
// die genau angegebene role/permission-Kombination, verlangt den
|
||||
// Service-Token. Damit werden RequireRBAC-Aufrufe real über HTTP
|
||||
// getestet, ohne echtes Core-Modul.
|
||||
func fakeRBACServer(t *testing.T, allowRole, allowPermission string) *rbacclient.Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Service-Token") != testServiceToken {
|
||||
http.Error(w, "kein service-token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
allowed := req.Role == allowRole && req.Permission == allowPermission
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allowed})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return rbacclient.New(server.URL, testServiceToken)
|
||||
}
|
||||
|
||||
// TestRequireRBAC_DeniedRoleReturns403 ist die vom Nutzer geforderte
|
||||
// Negativpruefung, jetzt gegen echte Policy-Rules ueber RBAC-06 (nicht
|
||||
// mehr nur ein Header-Wert wie im frueheren Provisorium).
|
||||
func TestRequireRBAC_DeniedRoleReturns403(t *testing.T) {
|
||||
client := fakeRBACServer(t, "archive_admin", "retention.configure")
|
||||
handler := RequireRBAC(client, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Kein Rollen-Header.
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("ohne rollen-header: status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Falsche Rolle - RBAC-06 lehnt real ab.
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
req.Header.Set(requestedRoleHeader, "irgendwas_anderes")
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("mit falscher rolle: status = %d, want 403", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireRBAC_AllowedRoleGrantsAccess ist der positive Gegentest,
|
||||
// real gegen die fake-RBAC-06-Antwort geprueft.
|
||||
func TestRequireRBAC_AllowedRoleGrantsAccess(t *testing.T) {
|
||||
client := fakeRBACServer(t, "archive_admin", "retention.configure")
|
||||
handler := RequireRBAC(client, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
req.Header.Set(requestedRoleHeader, "archive_admin")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mit korrekter rolle: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireRBAC_UnreachableRBACFailsClosed ist die geforderte
|
||||
// Pflichtpruefung: Ausfall von RBAC-06 fuehrt zur Ablehnung, kein
|
||||
// Fallback auf offenen Zugriff.
|
||||
func TestRequireRBAC_UnreachableRBACFailsClosed(t *testing.T) {
|
||||
unreachable := rbacclient.New("http://127.0.0.1:1", testServiceToken)
|
||||
called := false
|
||||
handler := RequireRBAC(unreachable, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
req.Header.Set(requestedRoleHeader, "archive_admin")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("bei nicht erreichbarem rbac-06: status = %d, want 403 (fail-closed)", resp.StatusCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler haette bei nicht erreichbarem rbac-06 nie erreicht werden duerfen")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
// retentionConfigurePermission ist das bei Core RBAC-02/RBAC-06
|
||||
// geprüfte Recht für alle RET-06-API-Endpunkte.
|
||||
const retentionConfigurePermission = "retention.configure"
|
||||
|
||||
// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter
|
||||
// RequireRBAC (RET-08, echter RBAC-06-Aufruf) — Akzeptanzkriterium 3:
|
||||
// Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
|
||||
func Mount(mux *http.ServeMux, pool *pgxpool.Pool, rbac *rbacclient.Client) {
|
||||
mux.HandleFunc("POST /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, configureHandler(pool)))
|
||||
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRBAC(rbac, retentionConfigurePermission, deactivateHandler(pool)))
|
||||
mux.HandleFunc("GET /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, listHandler(pool)))
|
||||
mux.HandleFunc("GET /retention-classes/preview", RequireRBAC(rbac, retentionConfigurePermission, previewHandler(pool)))
|
||||
}
|
||||
|
||||
type configureRequest struct {
|
||||
RetentionClass string `json:"retention_class"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// configureHandler: Aufbewahrungsklasse anlegen ODER ändern
|
||||
// (Akzeptanzkriterium 1) — `retentionengine.ConfigureClassRule` ist ein
|
||||
// UPSERT, eine Änderung wirkt erst ab jetzt auf künftige
|
||||
// Stichtagsberechnungen (Pflichtprüfung: nicht rückwirkend).
|
||||
func configureHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req configureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.RetentionClass == "" || req.Duration == "" {
|
||||
http.Error(w, "retention_class und duration sind pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := retentionengine.ConfigureClassRule(r.Context(), pool, req.RetentionClass, req.Duration); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// deactivateHandler: Aufbewahrungsklasse deaktivieren (Akzeptanzkriterium 1).
|
||||
func deactivateHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
class := r.PathValue("class")
|
||||
if err := retentionengine.DeactivateClassRule(r.Context(), pool, class); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// listHandler liefert alle konfigurierten Aufbewahrungsklassen (aktiv
|
||||
// und deaktiviert) — Grundlage der künftigen Konfigurationsoberfläche.
|
||||
func listHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := retentionengine.ListClassRules(r.Context(), pool)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rules)
|
||||
}
|
||||
}
|
||||
|
||||
// previewHandler liefert die Vorschauliste bald ablaufender Objekte
|
||||
// (Akzeptanzkriterium 2: Standard 30 Tage, per `days`-Query-Parameter
|
||||
// überschreibbar). Nutzt DIESELBE `ListExpiringObjects`-Funktion wie
|
||||
// der periodische Job (RET-02) — Pflichtprüfung: Vorschauliste stimmt
|
||||
// mit dem Ergebnis des periodischen Jobs überein (keine zweite,
|
||||
// abweichende Implementierung).
|
||||
func previewHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
days := 30
|
||||
asOf := time.Now().UTC().AddDate(0, 0, days)
|
||||
|
||||
objects, err := retentionengine.ListExpiringObjects(r.Context(), pool, asOf)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(objects)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func authedRequest(t *testing.T, method, url string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("request erstellen: %v", err)
|
||||
}
|
||||
req.Header.Set(requestedRoleHeader, "archive_admin")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request senden: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestConfigureAndListHandler_RealHTTPRoundTrip: Klasse anlegen, ändern,
|
||||
// über die Liste sichtbar - Akzeptanzkriterium 1.
|
||||
func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
body, _ := json.Marshal(configureRequest{RetentionClass: "klasse-api", Duration: "5 years"})
|
||||
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes", body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("anlegen: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
listResp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes", nil)
|
||||
defer func() { _ = listResp.Body.Close() }()
|
||||
var rules []retentionengine.ClassRule
|
||||
if err := json.NewDecoder(listResp.Body).Decode(&rules); err != nil {
|
||||
t.Fatalf("liste dekodieren: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].RetentionClass != "klasse-api" || !rules[0].Active {
|
||||
t.Fatalf("unerwartete liste: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeactivateHandler_RealHTTPRoundTrip: Deaktivierung wirkt real.
|
||||
func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deakt", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes/klasse-deakt/deactivate", nil)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("deaktivieren: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
rules, err := retentionengine.ListClassRules(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listclassrules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Active {
|
||||
t.Fatalf("erwartet deaktivierte klasse, habe %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewHandler_MatchesPeriodicJobResult ist die geforderte
|
||||
// Pflichtpruefung: Vorschauliste stimmt mit dem Ergebnis des
|
||||
// periodischen Jobs ueberein - beide nutzen dieselbe Funktion, real
|
||||
// per HTTP UND direkt verglichen.
|
||||
func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-preview", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var objID string
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'preview-doc') RETURNING id`).Scan(&objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class) VALUES ($1, 'klasse-preview')`, objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes/preview", nil)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var httpResult []retentionengine.ExpiringObject
|
||||
if err := json.NewDecoder(resp.Body).Decode(&httpResult); err != nil {
|
||||
t.Fatalf("preview-antwort dekodieren: %v", err)
|
||||
}
|
||||
|
||||
directResult, err := retentionengine.ListExpiringObjects(ctx, pool, time.Now().UTC().AddDate(0, 0, 30))
|
||||
if err != nil {
|
||||
t.Fatalf("listexpiringobjects direkt: %v", err)
|
||||
}
|
||||
|
||||
if len(httpResult) != len(directResult) || len(httpResult) != 1 {
|
||||
t.Fatalf("http-vorschau (%d) und periodischer job (%d) stimmen nicht ueberein", len(httpResult), len(directResult))
|
||||
}
|
||||
if httpResult[0].RetentionObjectID != directResult[0].RetentionObjectID {
|
||||
t.Fatalf("http-vorschau und periodischer job liefern unterschiedliche objekte: %+v vs %+v", httpResult[0], directResult[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package retentionengine implementiert RET-02: Fristenmodell je
|
||||
// Aufbewahrungsklasse mit Stichtagsberechnung und ein periodischer Job,
|
||||
// der ablaufende Objekte ermittelt. Baut auf RET-01 (retention_objects,
|
||||
// retention_class_assignments) auf — kennt weiter keine Modul-Interna
|
||||
// (dieselbe Adapter-Disziplin).
|
||||
package retentionengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ConfigureClassRule legt die Frist (Postgres-INTERVAL, z. B. "10 years",
|
||||
// "6 months") für eine Aufbewahrungsklasse fest oder ändert sie
|
||||
// (Akzeptanzkriterium 1) — je Klasse GENAU eine aktive Regel.
|
||||
func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, duration string) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO retention_class_rules (retention_class, duration)
|
||||
VALUES ($1, $2::interval)
|
||||
ON CONFLICT (retention_class) DO UPDATE SET duration = EXCLUDED.duration
|
||||
`, retentionClass, duration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retentionengine: fristregel konfigurieren: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeDueDate berechnet den Stichtag aus Beginn (start) und der
|
||||
// konfigurierten Frist der Klasse — DELEGIERT an Postgres' eigene
|
||||
// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive
|
||||
// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von
|
||||
// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte.
|
||||
// Nur AKTIVE Regeln werden verwendet (siehe DeactivateClassRule).
|
||||
func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) {
|
||||
var due time.Time
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT $1::timestamptz + r.duration
|
||||
FROM retention_class_rules r WHERE r.retention_class = $2 AND r.active
|
||||
`, start, retentionClass).Scan(&due)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err)
|
||||
}
|
||||
return due, nil
|
||||
}
|
||||
|
||||
// DeactivateClassRule (RET-06): eine Aufbewahrungsklasse wird deaktiviert,
|
||||
// OHNE ihre Historie (bereits erfolgte Zuordnungen/Berechnungen) zu
|
||||
// verlieren — kein DELETE. Deaktivierte Klassen fließen nicht mehr in
|
||||
// ComputeDueDate/ListExpiringObjects ein, ändern aber nichts an bereits
|
||||
// getroffenen Berechnungen (Pflichtprüfung: Änderung wirkt nur auf
|
||||
// künftige Berechnungen, nicht rückwirkend).
|
||||
func DeactivateClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass string) error {
|
||||
tag, err := pool.Exec(ctx, `UPDATE retention_class_rules SET active = false WHERE retention_class = $1`, retentionClass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retentionengine: klasse deaktivieren: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("retentionengine: unbekannte aufbewahrungsklasse %q", retentionClass)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassRule ist EINE konfigurierte Aufbewahrungsklasse mit Frist und
|
||||
// Aktiv-Status.
|
||||
type ClassRule struct {
|
||||
RetentionClass string
|
||||
Duration string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ListClassRules liefert alle konfigurierten Aufbewahrungsklassen
|
||||
// (aktiv und deaktiviert) — Grundlage für die Konfigurationsoberfläche.
|
||||
func ListClassRules(ctx context.Context, pool *pgxpool.Pool) ([]ClassRule, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT retention_class, duration::text, active FROM retention_class_rules ORDER BY retention_class`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionengine: aufbewahrungsklassen auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []ClassRule
|
||||
for rows.Next() {
|
||||
var r ClassRule
|
||||
if err := rows.Scan(&r.RetentionClass, &r.Duration, &r.Active); err != nil {
|
||||
return nil, fmt.Errorf("retentionengine: klassen-zeile lesen: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist.
|
||||
type ExpiringObject struct {
|
||||
RetentionObjectID string
|
||||
ObjectType string
|
||||
ObjectReference string
|
||||
RetentionClass string
|
||||
DueDate time.Time
|
||||
}
|
||||
|
||||
// ListExpiringObjects ist der periodische Job (Akzeptanzkriterium 3):
|
||||
// liefert alle aktiven Retention-Objekte, deren Stichtag (aktuelle
|
||||
// Klassenzuordnung + deren Frist) bis asOf erreicht ist. Betrachtet je
|
||||
// Objekt AUSSCHLIESSLICH die JÜNGSTE Klassenzuordnung (`DISTINCT ON`) -
|
||||
// ohne diese Einschränkung würde ein Objekt mit mehrfach geänderter
|
||||
// Klasse (RET-01s Historisierung) mehrfach im Ergebnis auftauchen,
|
||||
// genau der Doppelte-Einträge-Fehler, den Pflichtprüfung 3 ausschließt.
|
||||
// Ein leerer Bestand liefert eine leere Liste, keinen Fehler
|
||||
// (Akzeptanzkriterium/Pflichtprüfung 2).
|
||||
func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]ExpiringObject, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
WITH latest_assignment AS (
|
||||
SELECT DISTINCT ON (retention_object_id)
|
||||
retention_object_id, retention_class, assigned_at
|
||||
FROM retention_class_assignments
|
||||
ORDER BY retention_object_id, assigned_at DESC
|
||||
)
|
||||
SELECT o.id, o.object_type, o.object_reference, a.retention_class,
|
||||
a.assigned_at + r.duration AS due_date
|
||||
FROM retention_objects o
|
||||
JOIN latest_assignment a ON a.retention_object_id = o.id
|
||||
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
|
||||
WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1
|
||||
ORDER BY due_date ASC
|
||||
`, asOf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionengine: ablaufende objekte ermitteln: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ExpiringObject
|
||||
for rows.Next() {
|
||||
var e ExpiringObject
|
||||
if err := rows.Scan(&e.RetentionObjectID, &e.ObjectType, &e.ObjectReference, &e.RetentionClass, &e.DueDate); err != nil {
|
||||
return nil, fmt.Errorf("retentionengine: zeile lesen: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package retentionengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func mustTime(t *testing.T, layout, value string) time.Time {
|
||||
t.Helper()
|
||||
tm, err := time.Parse(layout, value)
|
||||
if err != nil {
|
||||
t.Fatalf("zeitangabe parsen: %v", err)
|
||||
}
|
||||
return tm
|
||||
}
|
||||
|
||||
// TestComputeDueDate_KnownReferenceDates ist Pruefung 1: Fristberechnung
|
||||
// an Referenzdaten mit bekannten Ablaufdaten geprueft - inklusive
|
||||
// Schaltjahr und Monatsende (Akzeptanzkriterium 2). Erwartete Werte real
|
||||
// gegen Postgres verifiziert (dessen eigene INTERVAL-Arithmetik ist die
|
||||
// Quelle der Wahrheit, keine eigene Nachbildung in Go).
|
||||
func TestComputeDueDate_KnownReferenceDates(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-1-jahr", "1 year"); err != nil {
|
||||
t.Fatalf("regel konfigurieren: %v", err)
|
||||
}
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-1-monat", "1 month"); err != nil {
|
||||
t.Fatalf("regel konfigurieren: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
retentionClass string
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "schaltjahr 29. februar plus 1 jahr",
|
||||
start: mustTime(t, "2006-01-02", "2024-02-29"),
|
||||
retentionClass: "klasse-1-jahr",
|
||||
want: mustTime(t, "2006-01-02", "2025-02-28"),
|
||||
},
|
||||
{
|
||||
name: "monatsende 31. januar plus 1 monat",
|
||||
start: mustTime(t, "2006-01-02", "2026-01-31"),
|
||||
retentionClass: "klasse-1-monat",
|
||||
want: mustTime(t, "2006-01-02", "2026-02-28"),
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := ComputeDueDate(ctx, pool, c.start, c.retentionClass)
|
||||
if err != nil {
|
||||
t.Fatalf("computeduedate: %v", err)
|
||||
}
|
||||
if !got.UTC().Equal(c.want.UTC()) {
|
||||
t.Fatalf("stichtag = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations ist
|
||||
// Pruefung fuer RET-06: eine Fristaenderung wirkt sich nur auf
|
||||
// KUENFTIGE Berechnungen aus, nicht rueckwirkend - real bewiesen, indem
|
||||
// ein bereits berechneter Stichtag (in einer lokalen Variable, dem
|
||||
// einzigen Ort, an dem ein "bereits berechnetes" Ergebnis ueberhaupt
|
||||
// existiert - es gibt keine Tabelle, die rueckwirkend veraendert werden
|
||||
// koennte) nach der Regelaenderung unveraendert bleibt, waehrend eine
|
||||
// NEUE Berechnung fuer dieselbe Klasse die NEUE Frist verwendet.
|
||||
func TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
start := mustTime(t, "2006-01-02", "2026-01-01")
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
|
||||
if err != nil {
|
||||
t.Fatalf("erste berechnung: %v", err)
|
||||
}
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "2 years"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Der bereits berechnete Wert (before) bleibt unveraendert - er ist
|
||||
// eine lokale Kopie, es existiert keine Tabelle, die eine
|
||||
// nachtraegliche "Umschreibung" ermoeglichen wuerde.
|
||||
if !before.Equal(mustTime(t, "2006-01-02", "2027-01-01")) {
|
||||
t.Fatalf("bereits berechneter stichtag veraendert: %v", before)
|
||||
}
|
||||
|
||||
after, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
|
||||
if err != nil {
|
||||
t.Fatalf("zweite berechnung: %v", err)
|
||||
}
|
||||
if !after.Equal(mustTime(t, "2006-01-02", "2028-01-01")) {
|
||||
t.Fatalf("neue berechnung uebernimmt neue frist nicht: %v", after)
|
||||
}
|
||||
if before.Equal(after) {
|
||||
t.Fatal("neue frist haette eine andere berechnung liefern muessen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeactivateClassRule_ExcludesFromFutureCalculations.
|
||||
func TestDeactivateClassRule_ExcludesFromFutureCalculations(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-deakt-eng", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DeactivateClassRule(ctx, pool, "klasse-deakt-eng"); err != nil {
|
||||
t.Fatalf("deactivateclassrule: %v", err)
|
||||
}
|
||||
if _, err := ComputeDueDate(ctx, pool, time.Now(), "klasse-deakt-eng"); err == nil {
|
||||
t.Fatal("erwartet fehler: deaktivierte klasse darf nicht mehr verwendet werden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2.
|
||||
func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := ListExpiringObjects(ctx, pool, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("erwartet keinen fehler bei leerem bestand, habe: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("erwartet leere liste, habe %d eintraege", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges ist
|
||||
// Pruefung 3: Mehrfachausfuehrung des Jobs erzeugt keine doppelten
|
||||
// Eintraege - real geprueft an einem Objekt mit MEHREREN historischen
|
||||
// Klassenzuordnungen (RET-01s Historisierung), das ohne die
|
||||
// DISTINCT-ON-Einschraenkung mehrfach im Ergebnis auftauchen wuerde.
|
||||
func TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var objID string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'doc-mehrfach') RETURNING id
|
||||
`).Scan(&objID); err != nil {
|
||||
t.Fatalf("objekt anlegen: %v", err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-72 * time.Hour)
|
||||
// zwei historische Zuordnungen fuer DASSELBE Objekt, beide in der
|
||||
// Vergangenheit (also beide laengst abgelaufen, wenn nicht auf die
|
||||
// juengste beschraenkt wuerde).
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for run := 1; run <= 2; run++ {
|
||||
got, err := ListExpiringObjects(ctx, pool, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("lauf %d: %v", run, err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("lauf %d: erwartet genau 1 eintrag (kein duplikat trotz 2 historischer zuordnungen), habe %d: %+v", run, len(got), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package retentionnotify implementiert RET-07: Benachrichtigung einer
|
||||
// konfigurierten zuständigen Rolle (Tenant-Admin — RET-01 führt bewusst
|
||||
// keine Objekt-Owner-Beziehung) vor Ablauf einer Aufbewahrungsfrist,
|
||||
// konfigurierbarer Vorlauf je Aufbewahrungsklasse. Erzeugt NUR das
|
||||
// Ereignis über Core CFG-05 (archive/internal/notifyclient) — versendet
|
||||
// selbst keine E-Mail.
|
||||
package retentionnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/notifyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
// EventType ist der an CFG-05 übergebene Ereignistyp — muss mit dem im
|
||||
// Frontend/Core bekannten Namen übereinstimmen (siehe CFG-05-Tests).
|
||||
const EventType = "retention_due_soon"
|
||||
|
||||
// Recipient benennt Tenant-Slug, User-ID und E-Mail-Adresse der
|
||||
// konfigurierten zuständigen Rolle (Tenant-Admin), an die alle
|
||||
// Fristablauf-Benachrichtigungen dieses Tenants gehen.
|
||||
type Recipient struct {
|
||||
TenantSlug string
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
// Result ist das Ergebnis EINES benachrichtigten (oder fehlgeschlagenen)
|
||||
// Objekts — der Aufrufer (cmd/retention-notify-job) protokolliert Err
|
||||
// explizit, kein stillschweigendes Verwerfen (Pflichtprüfung 3).
|
||||
type Result struct {
|
||||
RetentionObjectID string
|
||||
RetentionClass string
|
||||
JobID string
|
||||
Skipped bool
|
||||
Err error
|
||||
}
|
||||
|
||||
type classRuleLead struct {
|
||||
leadDays int
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// Run führt EINEN Durchlauf des Benachrichtigungs-Jobs aus: ermittelt je
|
||||
// aktiver, benachrichtigungs-aktivierter Aufbewahrungsklasse die Objekte,
|
||||
// deren Stichtag innerhalb des konfigurierten Vorlaufs liegt, überspringt
|
||||
// bereits benachrichtigte Objekte (Akzeptanzkriterium 2, Postgres-
|
||||
// persistent — übersteht einen Job-Neustart) und löst für den Rest je ein
|
||||
// Ereignis über CFG-05 aus.
|
||||
func Run(ctx context.Context, pool *pgxpool.Pool, client *notifyclient.Client, now time.Time, recipient Recipient) ([]Result, error) {
|
||||
// notify_lead_days/notify_enabled sind nicht Teil von
|
||||
// retentionengine.ClassRule (RET-02/RET-06 kennen sie nicht) — direkt
|
||||
// gelesen, um retentionengine nicht um RET-07-eigene Felder zu
|
||||
// erweitern (kein Umbau angrenzender Bereiche).
|
||||
leadByClass := make(map[string]classRuleLead)
|
||||
maxLeadDays := 0
|
||||
leadRows, err := pool.Query(ctx, `SELECT retention_class, notify_lead_days, notify_enabled FROM retention_class_rules WHERE active`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionnotify: benachrichtigungs-konfiguration laden: %w", err)
|
||||
}
|
||||
for leadRows.Next() {
|
||||
var class string
|
||||
var lead classRuleLead
|
||||
if err := leadRows.Scan(&class, &lead.leadDays, &lead.enabled); err != nil {
|
||||
leadRows.Close()
|
||||
return nil, fmt.Errorf("retentionnotify: konfigurationszeile lesen: %w", err)
|
||||
}
|
||||
leadByClass[class] = lead
|
||||
if lead.leadDays > maxLeadDays {
|
||||
maxLeadDays = lead.leadDays
|
||||
}
|
||||
}
|
||||
if err := leadRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("retentionnotify: benachrichtigungs-konfiguration lesen: %w", err)
|
||||
}
|
||||
leadRows.Close()
|
||||
|
||||
if maxLeadDays == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Nutzt DIESELBE Funktion wie der RET-02-Job/RET-06-API-Preview
|
||||
// (kein zweiter Ermittlungspfad) — asOf auf den größten konfigurierten
|
||||
// Vorlauf gesetzt, je Klasse wird unten mit deren EIGENEM Vorlauf
|
||||
// gefiltert.
|
||||
candidates, err := retentionengine.ListExpiringObjects(ctx, pool, now.AddDate(0, 0, maxLeadDays))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionnotify: ablaufende objekte ermitteln: %w", err)
|
||||
}
|
||||
|
||||
alreadyNotified, err := loadAlreadyNotified(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []Result
|
||||
for _, obj := range candidates {
|
||||
lead, known := leadByClass[obj.RetentionClass]
|
||||
if !known || !lead.enabled {
|
||||
continue
|
||||
}
|
||||
if !obj.DueDate.Before(now.AddDate(0, 0, lead.leadDays+1)) {
|
||||
// Ausserhalb des klassen-eigenen Vorlaufs (nur mit dem
|
||||
// globalen maxLeadDays vorselektiert).
|
||||
continue
|
||||
}
|
||||
if alreadyNotified[obj.RetentionObjectID] {
|
||||
continue
|
||||
}
|
||||
|
||||
res := Result{RetentionObjectID: obj.RetentionObjectID, RetentionClass: obj.RetentionClass}
|
||||
enq, err := client.Enqueue(ctx, recipient.TenantSlug, recipient.UserID, EventType, "email", recipient.Email, map[string]any{
|
||||
"object_type": obj.ObjectType,
|
||||
"object_reference": obj.ObjectReference,
|
||||
"retention_class": obj.RetentionClass,
|
||||
"due_date": obj.DueDate.Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
res.Err = err
|
||||
results = append(results, res)
|
||||
// Kein INSERT in retention_notifications bei Fehler — das
|
||||
// Objekt wird beim naechsten Durchlauf erneut versucht,
|
||||
// statt stillschweigend als erledigt zu gelten.
|
||||
continue
|
||||
}
|
||||
res.JobID = enq.JobID
|
||||
res.Skipped = enq.Skipped
|
||||
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_notifications (retention_object_id) VALUES ($1)`, obj.RetentionObjectID); err != nil {
|
||||
res.Err = fmt.Errorf("retentionnotify: benachrichtigung als versendet markieren: %w", err)
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func loadAlreadyNotified(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT retention_object_id FROM retention_notifications`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionnotify: bereits benachrichtigte objekte laden: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("retentionnotify: zeile lesen: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package retentionnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/notifyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_lead_days INT NOT NULL DEFAULT 7;
|
||||
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
CREATE TABLE IF NOT EXISTS retention_notifications (
|
||||
retention_object_id UUID PRIMARY KEY REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
notified_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_notifications, retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
// fakeCFG05Server zaehlt Aufrufe und liefert eine feste Antwort - simuliert
|
||||
// den echten CFG-05-Endpunkt, ohne das Core-Modul einzubinden (Archive kann
|
||||
// es nicht direkt importieren, siehe notifyclient).
|
||||
func fakeCFG05Server(t *testing.T, fail bool) (*notifyclient.Client, *int32) {
|
||||
t.Helper()
|
||||
var calls int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /notify/enqueue", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if fail {
|
||||
http.Error(w, "simulierter zustellfehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"job_id": "fake-job-id", "skipped": false})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return notifyclient.New(server.URL, "test-token"), &calls
|
||||
}
|
||||
|
||||
// insertDueObject legt ein Objekt an, dessen Stichtag (assigned_at +
|
||||
// Klassen-Frist) genau dueIn ab jetzt liegt — die Subtraktion der Frist
|
||||
// erfolgt IN Postgres (dieselbe INTERVAL-Arithmetik wie ComputeDueDate),
|
||||
// keine eigene Kalenderrechnung in Go.
|
||||
func insertDueObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, objectRef, class string, dueIn time.Duration) string {
|
||||
t.Helper()
|
||||
var objID string
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', $1) RETURNING id`, objectRef).Scan(&objID); err != nil {
|
||||
t.Fatalf("objekt anlegen: %v", err)
|
||||
}
|
||||
desiredDue := time.Now().UTC().Add(dueIn)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at)
|
||||
SELECT $1, $2, $3::timestamptz - r.duration FROM retention_class_rules r WHERE r.retention_class = $2
|
||||
`, objID, class, desiredDue); err != nil {
|
||||
t.Fatalf("zuordnung anlegen: %v", err)
|
||||
}
|
||||
return objID
|
||||
}
|
||||
|
||||
var testRecipient = Recipient{TenantSlug: "acme", UserID: "tenant-admin", Email: "admin@acme.example"}
|
||||
|
||||
// TestRun_ShortLeadTimeTriggersExactlyOneNotification ist die geforderte
|
||||
// Pflichtpruefung 1.
|
||||
func TestRun_ShortLeadTimeTriggersExactlyOneNotification(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = true WHERE retention_class = 'klasse-kurz'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertDueObject(t, ctx, pool, "kurz-doc", "klasse-kurz", 12*time.Hour)
|
||||
|
||||
client, calls := fakeCFG05Server(t, false)
|
||||
results, err := Run(ctx, pool, client, time.Now().UTC(), testRecipient)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Err != nil {
|
||||
t.Fatalf("erwartet genau ein ergebnis ohne fehler, habe: %+v", results)
|
||||
}
|
||||
if atomic.LoadInt32(calls) != 1 {
|
||||
t.Fatalf("erwartet genau einen cfg-05-aufruf, habe %d", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_DisabledNotificationSendsNothing ist die geforderte
|
||||
// Pflichtpruefung 2.
|
||||
func TestRun_DisabledNotificationSendsNothing(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deaktiviert", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = false WHERE retention_class = 'klasse-deaktiviert'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertDueObject(t, ctx, pool, "deaktiviert-doc", "klasse-deaktiviert", 12*time.Hour)
|
||||
|
||||
client, calls := fakeCFG05Server(t, false)
|
||||
results, err := Run(ctx, pool, client, time.Now().UTC(), testRecipient)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("erwartet keine benachrichtigung bei deaktivierter klasse, habe: %+v", results)
|
||||
}
|
||||
if atomic.LoadInt32(calls) != 0 {
|
||||
t.Fatalf("erwartet keinen cfg-05-aufruf, habe %d", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FailedDeliveryIsReportedNotSwallowed ist die geforderte
|
||||
// Pflichtpruefung 3.
|
||||
func TestRun_FailedDeliveryIsReportedNotSwallowed(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-fehler", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = true WHERE retention_class = 'klasse-fehler'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
objID := insertDueObject(t, ctx, pool, "fehler-doc", "klasse-fehler", 12*time.Hour)
|
||||
|
||||
client, _ := fakeCFG05Server(t, true)
|
||||
results, err := Run(ctx, pool, client, time.Now().UTC(), testRecipient)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Err == nil {
|
||||
t.Fatalf("erwartet ein ergebnis MIT protokolliertem fehler, habe: %+v", results)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM retention_notifications WHERE retention_object_id = $1`, objID).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("fehlgeschlagener versand darf NICHT als benachrichtigt markiert werden (sonst kein retry)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_PreventsDoubleNotificationAcrossRuns ist Akzeptanzkriterium 2 -
|
||||
// Mehrfachversand wird verhindert, auch nach einem simulierten Neustart
|
||||
// (zweiter Run() mit frischem Aufruf, kein In-Memory-Zustand zwischen den
|
||||
// Durchlaeufen).
|
||||
func TestRun_PreventsDoubleNotificationAcrossRuns(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-doppelt", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = true WHERE retention_class = 'klasse-doppelt'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertDueObject(t, ctx, pool, "doppelt-doc", "klasse-doppelt", 12*time.Hour)
|
||||
|
||||
client, calls := fakeCFG05Server(t, false)
|
||||
now := time.Now().UTC()
|
||||
|
||||
if _, err := Run(ctx, pool, client, now, testRecipient); err != nil {
|
||||
t.Fatalf("erster run: %v", err)
|
||||
}
|
||||
if _, err := Run(ctx, pool, client, now, testRecipient); err != nil {
|
||||
t.Fatalf("zweiter run (simulierter neustart): %v", err)
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(calls) != 1 {
|
||||
t.Fatalf("erwartet genau einen cfg-05-aufruf ueber beide durchlaeufe, habe %d", *calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS retention_class_assignments;
|
||||
DROP TABLE IF EXISTS retention_objects;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- RET-01: generisches Retention-Objektmodell. Modulübergreifend über
|
||||
-- Adapter (object_type/object_reference als reine Textfelder, KEINE
|
||||
-- Fremdschlüssel auf DMS-/Mail-Tabellen) — Archive kennt die
|
||||
-- Fachobjekte anderer Module nicht, nur deren Typ+Referenz.
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
|
||||
-- Historisiert: JEDE Zuordnung ist eine eigene, unveränderliche Zeile
|
||||
-- (nie UPDATE) — "aktuelle" Aufbewahrungsklasse ist die mit dem
|
||||
-- jüngsten assigned_at je retention_object_id. So bleibt nachvollziehbar,
|
||||
-- wann sich die Klasse eines Objekts geändert hat (Akzeptanzkriterium 2).
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_class_assignments_object
|
||||
ON retention_class_assignments (retention_object_id, assigned_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS module_registrations;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- RET-05: Modul-Adapter-Schnittstelle. Ein Modul (DMS, Mail, ...)
|
||||
-- registriert je Objekttyp EINE Rueckruf-Adresse fuer Loeschbestaetigung
|
||||
-- - Archive kennt danach nur noch module_name/object_type/callback_url,
|
||||
-- keine Modul-Interna. Mandantenfaehig durch Modell C (physisch
|
||||
-- isolierte Tenant-DB, TEN-01) - kein tenant_id-Feld noetig, dieselbe
|
||||
-- Begruendung wie RET-01s retention_objects.
|
||||
CREATE TABLE IF NOT EXISTS module_registrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_name TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL,
|
||||
retention_class TEXT NOT NULL,
|
||||
callback_url TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (module_name, object_type)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS retention_class_rules;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- RET-02: Fristenmodell je Aufbewahrungsklasse. duration ist ein
|
||||
-- natives Postgres-INTERVAL statt eigener Tage-/Monatszaehlung, damit
|
||||
-- Kalenderfaelle (Schaltjahr, Monatsende) exakt Postgres' eigene,
|
||||
-- bewaehrte Intervall-Arithmetik nutzen statt eine eigene, potenziell
|
||||
-- fehlerhafte Nachbildung.
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY,
|
||||
duration INTERVAL NOT NULL
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS active;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- RET-06-API: Aufbewahrungsklassen lassen sich deaktivieren, ohne ihre
|
||||
-- Historie (bereits erfolgte Zuordnungen/Berechnungen) zu verlieren -
|
||||
-- kein DELETE, nur ein Sichtbarkeits-/Anwendbarkeits-Flag.
|
||||
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS retention_notifications;
|
||||
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS notify_lead_days;
|
||||
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS notify_enabled;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- RET-07: Fristablauf-Benachrichtigungen. Zwei neue Spalten je
|
||||
-- Aufbewahrungsklasse (Vorlauf, Ein/Aus-Schalter — Akzeptanzkriterium 3)
|
||||
-- und eine Dedupe-Tabelle (Akzeptanzkriterium 2: kein Mehrfachversand,
|
||||
-- auch nicht nach Job-Neustart, da Postgres-persistent statt In-Memory).
|
||||
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_lead_days INT NOT NULL DEFAULT 7;
|
||||
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS retention_notifications (
|
||||
retention_object_id UUID PRIMARY KEY REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
notified_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=NEXARCH Archive - Aufbewahrungsfristen-Konfigurations-API (RET-06-API)
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
EnvironmentFile=/etc/nexarch/archive-retention-api.env
|
||||
ExecStart=__INSTALL_DIR__/bin/retention-api
|
||||
Restart=on-failure
|
||||
StandardOutput=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=NEXARCH Archive - Fristablauf-Benachrichtigungen (RET-07)
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=nexarch
|
||||
EnvironmentFile=/etc/nexarch/archive-retention-notify.env
|
||||
ExecStart=__INSTALL_DIR__/bin/retention-notify-job
|
||||
StandardOutput=journal
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Zeitplan fuer NEXARCH Archive Fristablauf-Benachrichtigungen (RET-07)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 07:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import { Table, TextField, useToast, type TableColumn } from "@nexarch/shl";
|
||||
import {
|
||||
ApiError,
|
||||
ClassRule,
|
||||
ForbiddenError,
|
||||
configureClassRule,
|
||||
deactivateClassRule,
|
||||
fetchClassRules,
|
||||
} from "../../lib/api";
|
||||
|
||||
export default function ClassesPage() {
|
||||
const { push } = useToast();
|
||||
const [rules, setRules] = useState<ClassRule[] | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [newClass, setNewClass] = useState("");
|
||||
const [newDuration, setNewDuration] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function load() {
|
||||
fetchClassRules()
|
||||
.then((r) => {
|
||||
setRules(r);
|
||||
setForbidden(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof ForbiddenError) {
|
||||
setForbidden(true);
|
||||
} else {
|
||||
setLoadError(err instanceof Error ? err.message : "Aufbewahrungsklassen konnten nicht geladen werden.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
// Akzeptanzkriterium 3: nicht berechtigte Rolle sieht explizit den
|
||||
// Zugriff-verweigert-Zustand, keine leere Tabelle ohne Erklaerung.
|
||||
if (forbidden) {
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<p role="alert">Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (loadError) {
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<p role="alert">{loadError}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function onCreateOrChange(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
// Akzeptanzkriterium 1: gleicher Endpunkt legt an ODER aendert
|
||||
// (UPSERT im Backend, retentionengine.ConfigureClassRule).
|
||||
await configureClassRule(newClass, newDuration);
|
||||
push(`Aufbewahrungsklasse "${newClass}" gespeichert.`, "success");
|
||||
setNewClass("");
|
||||
setNewDuration("");
|
||||
load();
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenError) {
|
||||
setForbidden(true);
|
||||
} else {
|
||||
push(err instanceof ApiError ? err.message : "Speichern fehlgeschlagen.", "danger");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeactivate(retentionClass: string) {
|
||||
try {
|
||||
await deactivateClassRule(retentionClass);
|
||||
push(`Aufbewahrungsklasse "${retentionClass}" deaktiviert.`, "success");
|
||||
load();
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenError) {
|
||||
setForbidden(true);
|
||||
} else {
|
||||
push(err instanceof ApiError ? err.message : "Deaktivieren fehlgeschlagen.", "danger");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumn<ClassRule>[] = [
|
||||
{ key: "class", header: "Aufbewahrungsklasse", render: (r) => r.RetentionClass },
|
||||
{ key: "duration", header: "Frist", render: (r) => r.Duration },
|
||||
{ key: "active", header: "Status", render: (r) => (r.Active ? "aktiv" : "deaktiviert") },
|
||||
{
|
||||
key: "actions",
|
||||
header: "Aktion",
|
||||
render: (r) =>
|
||||
r.Active ? (
|
||||
<button type="button" onClick={() => onDeactivate(r.RetentionClass)}>
|
||||
Deaktivieren
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<h1>Aufbewahrungsfristen-Konfiguration</h1>
|
||||
|
||||
<form onSubmit={onCreateOrChange} style={{ marginBottom: 24 }}>
|
||||
<TextField
|
||||
label="Aufbewahrungsklasse"
|
||||
value={newClass}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewClass(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Frist (z. B. 5 years)"
|
||||
value={newDuration}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewDuration(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={saving}>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{rules && (
|
||||
<Table columns={columns} rows={rules} rowKey={(r) => r.RetentionClass} caption="Konfigurierte Aufbewahrungsklassen" />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Aufbewahrungsfristen",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
color: "var(--shl-color-text-primary, #14181f)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IndexPage() {
|
||||
redirect("/classes");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Table, type TableColumn } from "@nexarch/shl";
|
||||
import { ExpiringObject, ForbiddenError, fetchExpiringPreview } from "../../lib/api";
|
||||
|
||||
// Akzeptanzkriterium 2: Vorschauliste bald ablaufender Objekte (naechste 30
|
||||
// Tage) - ruft dieselbe RET-06-API /retention-classes/preview auf, die
|
||||
// intern dieselbe Funktion wie der periodische Job nutzt (RET-02), keine
|
||||
// eigene Berechnung im Frontend.
|
||||
export default function PreviewPage() {
|
||||
const [objects, setObjects] = useState<ExpiringObject[] | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExpiringPreview()
|
||||
.then(setObjects)
|
||||
.catch((err) => {
|
||||
if (err instanceof ForbiddenError) {
|
||||
setForbidden(true);
|
||||
} else {
|
||||
setLoadError(err instanceof Error ? err.message : "Vorschauliste konnte nicht geladen werden.");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (forbidden) {
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<p role="alert">Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (loadError) {
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<p role="alert">{loadError}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumn<ExpiringObject>[] = [
|
||||
{ key: "type", header: "Objekttyp", render: (o) => o.ObjectType },
|
||||
{ key: "ref", header: "Referenz", render: (o) => o.ObjectReference },
|
||||
{ key: "class", header: "Aufbewahrungsklasse", render: (o) => o.RetentionClass },
|
||||
{ key: "due", header: "Fällig am", render: (o) => o.DueDate },
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<h1>In den nächsten 30 Tagen ablaufende Objekte</h1>
|
||||
{objects && (
|
||||
<Table columns={columns} rows={objects} rowKey={(o) => o.RetentionObjectID} caption="Bald ablaufende Objekte" />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ForbiddenError, fetchClassRules, configureClassRule } from "./api";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_RETENTION_API_URL: "http://backend.test" };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 3 ("nur berechtigten Rollen zugaenglich"): der
|
||||
// Negativfall MUSS explizit gepruft werden, nicht nur der Erfolgsfall -
|
||||
// Frontend wirft ForbiddenError, wenn RET-06-API (real gegen RBAC-06,
|
||||
// siehe RET-08) mit 403 antwortet.
|
||||
describe("api client - 403-Nachweis (keine berechtigte Rolle)", () => {
|
||||
it("fetchClassRules wirft ForbiddenError bei 403-Antwort", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 403 }))
|
||||
);
|
||||
|
||||
await expect(fetchClassRules()).rejects.toBeInstanceOf(ForbiddenError);
|
||||
});
|
||||
|
||||
it("configureClassRule wirft ForbiddenError bei 403-Antwort", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 403 }))
|
||||
);
|
||||
|
||||
await expect(configureClassRule("klasse-x", "1 year")).rejects.toBeInstanceOf(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api client - erlaubte Rolle", () => {
|
||||
it("fetchClassRules liefert die Liste bei 200-Antwort", async () => {
|
||||
const rules = [{ RetentionClass: "klasse-a", Duration: "5 years", Active: true }];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(rules), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchClassRules()).resolves.toEqual(rules);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Duenner Client fuer RET-06-API (archive/internal/retentionapi) — enthaelt
|
||||
// keine eigene Fristen-/RBAC-Logik, nur Datenabruf und -weitergabe. Der
|
||||
// RBAC-Check selbst passiert serverseitig ueber RET-08 (echter RBAC-06-Aufruf),
|
||||
// dieser Client meldet nur, ob der Zugriff erlaubt war (403) oder nicht.
|
||||
|
||||
export class ForbiddenError extends Error {}
|
||||
export class ApiError extends Error {}
|
||||
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_RETENTION_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("NEXT_PUBLIC_RETENTION_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// userRole wird bis zu einer zentralen Session-/Identitaets-Loesung im
|
||||
// Frontend als Umgebungsvariable/Query mitgegeben und als X-User-Role-Header
|
||||
// an RET-06-API durchgereicht, das ihn seinerseits gegen RBAC-06 prueft
|
||||
// (RET-08) - das Frontend trifft selbst KEINE Autorisierungsentscheidung.
|
||||
function userRoleHeader(): Record<string, string> {
|
||||
const role = process.env.NEXT_PUBLIC_USER_ROLE;
|
||||
return role ? { "X-User-Role": role } : {};
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...userRoleHeader(),
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (res.status === 403) {
|
||||
throw new ForbiddenError("Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
if (res.status === 200 && res.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
return res.json().catch(() => undefined as T);
|
||||
}
|
||||
|
||||
export interface ClassRule {
|
||||
RetentionClass: string;
|
||||
Duration: string;
|
||||
Active: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiringObject {
|
||||
RetentionObjectID: string;
|
||||
ObjectType: string;
|
||||
ObjectReference: string;
|
||||
RetentionClass: string;
|
||||
DueDate: string;
|
||||
}
|
||||
|
||||
export function fetchClassRules(): Promise<ClassRule[]> {
|
||||
return req("/retention-classes");
|
||||
}
|
||||
|
||||
export function configureClassRule(retentionClass: string, duration: string): Promise<void> {
|
||||
return req("/retention-classes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ retention_class: retentionClass, duration }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivateClassRule(retentionClass: string): Promise<void> {
|
||||
return req(`/retention-classes/${encodeURIComponent(retentionClass)}/deactivate`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchExpiringPreview(): Promise<ExpiringObject[]> {
|
||||
return req("/retention-classes/preview");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "nexarch-retention-admin",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nexarch/shl": "file:../shl",
|
||||
"next": "14.2.35",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "16.0.0",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"jsdom": "24.1.0",
|
||||
"typescript": "5.5.3",
|
||||
"vitest": "2.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
|
||||
|
||||
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
|
||||
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
|
||||
|
||||
## Enthält
|
||||
|
||||
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
|
||||
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
|
||||
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
|
||||
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
|
||||
|
||||
## Verwendung in einem Modul-Frontend
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>
|
||||
<Shell modules={[]} tenantLabel="Mandant XY">
|
||||
{children}
|
||||
</Shell>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Bekannter offener Punkt
|
||||
|
||||
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Dialog } from "../components/Dialog";
|
||||
import { I18nProvider } from "../i18n/i18n";
|
||||
|
||||
function renderDialog(onClose: () => void) {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
|
||||
<button type="button">Erste Aktion</button>
|
||||
<button type="button">Zweite Aktion</button>
|
||||
</Dialog>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dialog: Tastaturbedienung", () => {
|
||||
it("schließt sich bei ESC", () => {
|
||||
const onClose = vi.fn();
|
||||
renderDialog(onClose);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
|
||||
renderDialog(vi.fn());
|
||||
const closeButton = screen.getByRole("button", { name: /schließen/i });
|
||||
expect(document.activeElement).toBe(closeButton);
|
||||
});
|
||||
|
||||
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
|
||||
renderDialog(vi.fn());
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colorTokens } from "../tokens/tokens";
|
||||
|
||||
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
|
||||
function relLuminance(hex: string): number {
|
||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const l1 = relLuminance(a);
|
||||
const l2 = relLuminance(b);
|
||||
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
|
||||
for (const scheme of ["light", "dark"] as const) {
|
||||
const c = colorTokens[scheme];
|
||||
|
||||
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
|
||||
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
|
||||
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
|
||||
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
|
||||
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../i18n/i18n";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
titleId: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
|
||||
const { t } = useI18n();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
|
||||
const node = dialogRef.current;
|
||||
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
focusables?.[0]?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !node) return;
|
||||
|
||||
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused.current?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shl-dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="shl-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="shl-dialog-header">
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="shl-dialog-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
|
||||
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
|
||||
|
||||
import { useId } from "react";
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
|
||||
interface FieldWrapperProps {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
|
||||
}
|
||||
|
||||
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
|
||||
const inputId = useId();
|
||||
const hintId = hint ? `${inputId}-hint` : undefined;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
return (
|
||||
<div className="shl-field">
|
||||
<label htmlFor={inputId}>{label}</label>
|
||||
{children({ inputId, describedBy })}
|
||||
{hint && (
|
||||
<p id={hintId} className="shl-field-hint">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={errorId} className="shl-field-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextFieldProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
|
||||
return (
|
||||
<FieldWrapper label={label} error={error} hint={hint}>
|
||||
{({ inputId, describedBy }) => (
|
||||
<input
|
||||
id={inputId}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...inputProps}
|
||||
/>
|
||||
)}
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectFieldProps
|
||||
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
|
||||
return (
|
||||
<FieldWrapper label={label} error={error} hint={hint}>
|
||||
{({ inputId, describedBy }) => (
|
||||
<select
|
||||
id={inputId}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...selectProps}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)}
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckboxFieldProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
|
||||
const inputId = useId();
|
||||
return (
|
||||
<div className="shl-field shl-field-checkbox">
|
||||
<input id={inputId} type="checkbox" {...inputProps} />
|
||||
<label htmlFor={inputId}>{label}</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
|
||||
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../i18n/i18n";
|
||||
import { useTheme } from "../theme/ThemeProvider";
|
||||
|
||||
export interface ModuleLink {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ShellProps {
|
||||
modules: ModuleLink[];
|
||||
tenantLabel: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Shell({ modules, tenantLabel, children }: ShellProps) {
|
||||
const { scheme, toggle } = useTheme();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="shl-shell">
|
||||
<a className="shl-skip-link" href="#shl-main-content">
|
||||
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
|
||||
</a>
|
||||
<header className="shl-shell-header">
|
||||
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
|
||||
<ul>
|
||||
{modules.map((mod) => (
|
||||
<li key={mod.key}>
|
||||
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
|
||||
{mod.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="shl-shell-context">
|
||||
<span className="shl-tenant-context">{tenantLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={
|
||||
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
|
||||
}
|
||||
>
|
||||
{scheme === "light" ? "🌙" : "☀️"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../i18n/i18n";
|
||||
|
||||
export interface TableColumn<Row> {
|
||||
key: string;
|
||||
header: string;
|
||||
render: (row: Row) => ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface TableProps<Row> {
|
||||
columns: TableColumn<Row>[];
|
||||
rows: Row[];
|
||||
rowKey: (row: Row) => string;
|
||||
sortKey?: string;
|
||||
sortDirection?: "asc" | "desc";
|
||||
onSort?: (key: string) => void;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function Table<Row>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
onSort,
|
||||
caption,
|
||||
}: TableProps<Row>) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<table className="shl-table">
|
||||
{caption && <caption>{caption}</caption>}
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => {
|
||||
const isSorted = column.key === sortKey;
|
||||
const ariaSort = column.sortable
|
||||
? isSorted
|
||||
? sortDirection === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
: undefined;
|
||||
return (
|
||||
<th key={column.key} scope="col" aria-sort={ariaSort}>
|
||||
{column.sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort?.(column.key)}
|
||||
className="shl-table-sort-button"
|
||||
>
|
||||
{column.header}
|
||||
</button>
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{column.render(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../i18n/i18n";
|
||||
|
||||
export type ToastVariant = "info" | "success" | "danger" | "warning";
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: ToastMessage[];
|
||||
push: (text: string, variant?: ToastVariant) => void;
|
||||
dismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback((text: string, variant: ToastVariant = "info") => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
setToasts((current) => [...current, { id, text, variant }]);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
|
||||
<span>{toast.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label={t("shl.toast.dismiss")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
|
||||
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
|
||||
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
|
||||
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
|
||||
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type Locale = "de" | "en";
|
||||
|
||||
type MessageDict = Record<string, string>;
|
||||
type MessageBundle = Record<Locale, MessageDict>;
|
||||
|
||||
const baseMessages: MessageBundle = {
|
||||
de: {
|
||||
"shl.dialog.close": "Schließen",
|
||||
"shl.toast.dismiss": "Meldung schließen",
|
||||
"shl.table.noRows": "Keine Einträge vorhanden",
|
||||
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
|
||||
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
|
||||
},
|
||||
en: {
|
||||
"shl.dialog.close": "Close",
|
||||
"shl.toast.dismiss": "Dismiss message",
|
||||
"shl.table.noRows": "No entries",
|
||||
"shl.theme.toggleToLight": "Switch to light theme",
|
||||
"shl.theme.toggleToDark": "Switch to dark theme",
|
||||
},
|
||||
};
|
||||
|
||||
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
|
||||
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
|
||||
|
||||
export function registerMessages(locale: Locale, messages: MessageDict): void {
|
||||
registry[locale] = { ...registry[locale], ...messages };
|
||||
}
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, fallback?: string) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({
|
||||
initialLocale = "de",
|
||||
children,
|
||||
}: {
|
||||
initialLocale?: Locale;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [locale, setLocale] = useState<Locale>(initialLocale);
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
|
||||
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
|
||||
|
||||
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
|
||||
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
|
||||
|
||||
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
|
||||
|
||||
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
|
||||
export type { Locale } from "./i18n/i18n";
|
||||
|
||||
export { Shell } from "./components/Shell";
|
||||
export type { ShellProps, ModuleLink } from "./components/Shell";
|
||||
|
||||
export { Dialog } from "./components/Dialog";
|
||||
export type { DialogProps } from "./components/Dialog";
|
||||
|
||||
export { Table } from "./components/Table";
|
||||
export type { TableProps, TableColumn } from "./components/Table";
|
||||
|
||||
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
|
||||
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
|
||||
|
||||
export { ToastProvider, useToast } from "./components/Toast";
|
||||
export type { ToastMessage, ToastVariant } from "./components/Toast";
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@nexarch/shl",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"typescript": "5.5.3",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
|
||||
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
|
||||
|
||||
const STORAGE_KEY = "nexarch-shl-theme";
|
||||
|
||||
interface ThemeContextValue {
|
||||
scheme: ColorScheme;
|
||||
setScheme: (scheme: ColorScheme) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function readStoredScheme(): ColorScheme | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
} catch {
|
||||
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [scheme, setSchemeState] = useState<ColorScheme>("light");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredScheme();
|
||||
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
|
||||
}, []);
|
||||
|
||||
const setScheme = useCallback((next: ColorScheme) => {
|
||||
setSchemeState(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setScheme(scheme === "light" ? "dark" : "light");
|
||||
}, [scheme, setScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const vars = cssVariables(scheme);
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
root.dataset.shlTheme = scheme;
|
||||
}, [scheme]);
|
||||
|
||||
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function currentColors(scheme: ColorScheme) {
|
||||
return colorTokens[scheme];
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
|
||||
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
|
||||
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
|
||||
|
||||
export type ColorScheme = "light" | "dark";
|
||||
|
||||
export interface ColorTokens {
|
||||
background: string;
|
||||
surface: string;
|
||||
surfaceRaised: string;
|
||||
border: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
accent: string;
|
||||
accentContrast: string;
|
||||
danger: string;
|
||||
dangerContrast: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
focusRing: string;
|
||||
}
|
||||
|
||||
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
|
||||
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
|
||||
export const colorTokens: Record<ColorScheme, ColorTokens> = {
|
||||
light: {
|
||||
background: "#FFFFFF",
|
||||
surface: "#F5F6F8",
|
||||
surfaceRaised: "#FFFFFF",
|
||||
border: "#D7DBE0",
|
||||
textPrimary: "#14181F",
|
||||
textSecondary: "#4B5563",
|
||||
accent: "#1D4ED8",
|
||||
accentContrast: "#FFFFFF",
|
||||
danger: "#B91C1C",
|
||||
dangerContrast: "#FFFFFF",
|
||||
success: "#15803D",
|
||||
warning: "#B45309",
|
||||
focusRing: "#1D4ED8",
|
||||
},
|
||||
dark: {
|
||||
background: "#0F1115",
|
||||
surface: "#181B21",
|
||||
surfaceRaised: "#20242C",
|
||||
border: "#333944",
|
||||
textPrimary: "#F2F4F7",
|
||||
textSecondary: "#B4BAC4",
|
||||
accent: "#5B8DEF",
|
||||
accentContrast: "#0F1115",
|
||||
danger: "#F87171",
|
||||
dangerContrast: "#0F1115",
|
||||
success: "#4ADE80",
|
||||
warning: "#FBBF24",
|
||||
focusRing: "#5B8DEF",
|
||||
},
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
xs: "4px",
|
||||
sm: "8px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
xl: "32px",
|
||||
xxl: "48px",
|
||||
} as const;
|
||||
|
||||
export const breakpoints = {
|
||||
mobile: "0px",
|
||||
tablet: "768px",
|
||||
desktop: "1200px",
|
||||
} as const;
|
||||
|
||||
export const typography = {
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
|
||||
sizeSm: "13px",
|
||||
sizeMd: "15px",
|
||||
sizeLg: "18px",
|
||||
sizeXl: "24px",
|
||||
lineHeight: 1.5,
|
||||
weightRegular: 400,
|
||||
weightMedium: 500,
|
||||
weightBold: 600,
|
||||
} as const;
|
||||
|
||||
export function cssVariables(scheme: ColorScheme): Record<string, string> {
|
||||
const c = colorTokens[scheme];
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(c)) {
|
||||
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(spacing)) {
|
||||
vars[`--shl-spacing-${key}`] = value;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
|
||||
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user