Compare commits

...
Author SHA1 Message Date
sysops 21278f1405 feat(archive): RET-06-API Aufbewahrungsfristen-Konfigurations-Backend
Board-Entscheidung: Backend-API zuerst, echtes Next.js-Frontend als
separates Folgeticket - vermeidet Pseudo-Frontend-Protokoll.
internal/retentionapi: 4 Endpunkte (anlegen/aendern, deaktivieren,
liste, vorschau), Vorschau nutzt dieselbe ListExpiringObjects-Funktion
wie RET-02s periodischer Job (keine Doppel-Implementierung).
RequireRole ist AUSDRUECKLICH kein RBAC-02-Ersatz, sondern ein
dokumentiertes Provisorium (Header-Check) - RBAC-02 ist reiner
Core-interner Go-Code ohne HTTP-Schnittstelle fuer andere Module,
derselbe Befund wie FDN-03/FDN-09. Provisorium real getestet inkl.
Negativfall (403 ohne/mit falscher Rolle). retention_class_rules um
active-Flag erweitert (deaktivieren ohne Historienverlust). Real auf
131 deployed und per curl end-to-end verifiziert.
2026-08-30 02:09:37 +02:00
sysops 2c9a7482b6 feat(archive): RET-02 Aufbewahrungsfristen-Engine
internal/retentionengine: Frist je Aufbewahrungsklasse als natives
Postgres-INTERVAL, Stichtagsberechnung an Postgres delegiert statt
eigener Kalenderrechnung (Schaltjahr/Monatsende-Referenzwerte real
verifiziert: 2024-02-29+1y=2025-02-28, 2026-01-31+1mo=2026-02-28).
Periodischer Job (ListExpiringObjects) beschraenkt sich per DISTINCT ON
auf die juengste Klassenzuordnung je Objekt - sonst wuerden Objekte mit
mehrfach geaenderter Klasse (RET-01-Historisierung) doppelt auftauchen,
real mit einem Zwei-Zuordnungen-Testobjekt bewiesen. Scope bewusst eng
gehalten: keine RET-05-Anbindung, keine Vernichtungslogik - das ist
Ticket-Scope, dependsOn ist nur RET-01.
2026-08-30 01:54:48 +02:00
14 changed files with 977 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
// 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/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"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
mux := http.NewServeMux()
retentionapi.Mount(mux, pool)
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)
}
}
+69
View File
@@ -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).
+91
View File
@@ -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.
+48
View File
@@ -0,0 +1,48 @@
// 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 (
"net/http"
"strings"
)
// requiredRoleHeader ist der Header-Name des PROVISORISCHEN Rollen-
// Checks (siehe RequireRole-Dokumentation).
const requiredRoleHeader = "X-Admin-Roles"
// RequireRole ist ein PROVISORISCHER Rollen-Check, KEIN RBAC-02-Aufruf.
//
// Core RBAC-02 (internal/policy, Enforcer.Authorize) ist reiner
// Go-Code innerhalb des Core-Moduls, hat 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. Bis ein Core-seitiger HTTP-Wrapper um
// RBAC-02 existiert (eigenes, künftiges Core-Ticket, z. B. RBAC-06),
// prüft dieser Middleware NUR einen einfachen, selbst gesetzten Header
// (`X-Admin-Roles`, kommagetrennt) auf das Vorhandensein der
// geforderten Rolle — KEINE echte Autorisierung gegen Core, leicht zu
// fälschen von jedem, der den Header selbst setzen kann. Muss ersetzt
// werden, sobald der Core-HTTP-Wrapper existiert.
func RequireRole(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
roles := r.Header.Get(requiredRoleHeader)
if !containsRole(roles, requiredRole) {
http.Error(w, "zugriff verweigert: rolle "+requiredRole+" erforderlich (provisorischer check, siehe RequireRole)", http.StatusForbidden)
return
}
next(w, r)
}
}
func containsRole(commaSeparated, role string) bool {
for _, r := range strings.Split(commaSeparated, ",") {
if strings.TrimSpace(r) == role {
return true
}
}
return false
}
@@ -0,0 +1,60 @@
package retentionapi
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestRequireRole_MissingRoleReturns403 ist die vom Nutzer geforderte
// Negativpruefung fuer den provisorischen Rollen-Check: keine/falsche
// Rolle => 403, sonst waere der Check nicht pruefbar.
func TestRequireRole_MissingRoleReturns403(t *testing.T) {
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()
// Kein Header gesetzt.
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 gesetzt.
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req.Header.Set("X-Admin-Roles", "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)
}
}
// TestRequireRole_CorrectRoleAllowsAccess ist der positive Gegentest.
func TestRequireRole_CorrectRoleAllowsAccess(t *testing.T) {
handler := RequireRole(adminRole, 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("X-Admin-Roles", "irgendwas_anderes, 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)
}
}
+98
View File
@@ -0,0 +1,98 @@
package retentionapi
import (
"encoding/json"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
)
const adminRole = "archive_admin"
// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter
// dem provisorischen Rollen-Check (siehe authz.go) — Akzeptanzkriterium
// 3: Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
func Mount(mux *http.ServeMux, pool *pgxpool.Pool) {
mux.HandleFunc("POST /retention-classes", RequireRole(adminRole, configureHandler(pool)))
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRole(adminRole, deactivateHandler(pool)))
mux.HandleFunc("GET /retention-classes", RequireRole(adminRole, listHandler(pool)))
mux.HandleFunc("GET /retention-classes/preview", RequireRole(adminRole, 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("X-Admin-Roles", adminRole)
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)
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)
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)
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 @@
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,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