Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21278f1405 | ||
|
|
2c9a7482b6 | ||
|
|
0db32007ba | ||
|
|
19dca43012 | ||
|
|
b37b790792 | ||
|
|
d0b6fb8ce5 | ||
|
|
480aa52941 | ||
|
|
004c6fab62 | ||
|
|
823a14ae12 |
@@ -36,6 +36,16 @@ func loadConfig() backup.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func envInt(name string, def int) int {
|
||||
v := os.Getenv(name)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n := def
|
||||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
func latestManifest(backupDir string) (string, error) {
|
||||
generations, err := backup.ListGenerations(backupDir)
|
||||
if err != nil {
|
||||
@@ -91,6 +101,23 @@ func main() {
|
||||
fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest)
|
||||
|
||||
case "rotate":
|
||||
// BAK-07: gestaffelte Aufbewahrungsfrist (KeepDaily/Weekly/Monthly),
|
||||
// falls konfiguriert - ersetzt die flache "letzte N"-Regel
|
||||
// (NEXARCH_BACKUP_KEEP_GENERATIONS), bleibt aber abwaertskompatibel,
|
||||
// wenn keine Staffelung gesetzt ist.
|
||||
policy := backup.RetentionPolicy{
|
||||
KeepDaily: envInt("NEXARCH_BACKUP_KEEP_DAILY", 0),
|
||||
KeepWeekly: envInt("NEXARCH_BACKUP_KEEP_WEEKLY", 0),
|
||||
KeepMonthly: envInt("NEXARCH_BACKUP_KEEP_MONTHLY", 0),
|
||||
}
|
||||
if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 {
|
||||
removed, err := backup.PruneRotate(cfg.BackupDir, policy)
|
||||
if err != nil {
|
||||
log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err)
|
||||
}
|
||||
fmt.Printf("gestaffelte rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed)
|
||||
return
|
||||
}
|
||||
keep := 7
|
||||
if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" {
|
||||
_, _ = fmt.Sscanf(v, "%d", &keep)
|
||||
|
||||
@@ -25,6 +25,17 @@ func loadConfig() objectbackup.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func envInt(name string, def int) int {
|
||||
v := os.Getenv(name)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("aufruf: objectbackup-cli <backup <quellverzeichnis>|check|rotate>")
|
||||
@@ -58,6 +69,20 @@ func main() {
|
||||
fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich")
|
||||
|
||||
case "rotate":
|
||||
// BAK-07: gestaffelte Aufbewahrungsfrist, falls konfiguriert -
|
||||
// ersetzt die flache "letzte N"-Regel, bleibt abwaertskompatibel.
|
||||
policy := objectbackup.RetentionPolicy{
|
||||
KeepDaily: envInt("NEXARCH_OBJECTBACKUP_KEEP_DAILY", 0),
|
||||
KeepWeekly: envInt("NEXARCH_OBJECTBACKUP_KEEP_WEEKLY", 0),
|
||||
KeepMonthly: envInt("NEXARCH_OBJECTBACKUP_KEEP_MONTHLY", 0),
|
||||
}
|
||||
if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 {
|
||||
if err := objectbackup.PruneTiered(ctx, cfg, policy); err != nil {
|
||||
log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err)
|
||||
}
|
||||
fmt.Println("gestaffelte rotation abgeschlossen")
|
||||
return
|
||||
}
|
||||
keep := 7
|
||||
if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# BAK-07 – Prüfprotokoll: Aufbewahrungsfrist für Backup-Snapshots
|
||||
|
||||
Voraussetzung BAK-01, BAK-02 – erledigt, siehe eigene Protokolle.
|
||||
|
||||
## Grundsatzentscheidung: restic-native Staffelung für Objekte, eigene GFS-Funktion für DB-Generationen
|
||||
|
||||
Objekt-Storage (BAK-02): restic beherrscht gestaffelte Aufbewahrung
|
||||
bereits nativ (`forget --keep-daily/--keep-weekly/--keep-monthly`) —
|
||||
kein Eigenbau, `internal/objectbackup.PruneTiered` ist ein dünner
|
||||
Wrapper (dieselbe Disziplin wie beim ursprünglichen
|
||||
restic-statt-Eigenbau-Entscheid in BAK-02).
|
||||
|
||||
Datenbank-Generationen (BAK-01): `pg_basebackup` kennt keine
|
||||
Staffelung, daher `internal/backup.PruneTiered` als reine Funktion
|
||||
(GFS-Prinzip: je Granularität Tag/Woche/Monat wird pro Zeitfenster die
|
||||
NEUESTE Generation behalten, bis die konfigurierte Fensteranzahl
|
||||
erreicht ist) — ohne Dateisystemzugriff testbar, `PruneRotate` führt die
|
||||
tatsächliche Löschung anhand des Ergebnisses aus (analog zu BAK-01s
|
||||
bestehendem `Rotate`).
|
||||
|
||||
Beide Policies sind über Umgebungsvariablen konfigurierbar
|
||||
(`NEXARCH_BACKUP_KEEP_DAILY/WEEKLY/MONTHLY`,
|
||||
`NEXARCH_OBJECTBACKUP_KEEP_DAILY/WEEKLY/MONTHLY`), nicht hart codiert
|
||||
(Ticket-Vorgabe). Bestehende flache "letzte N"-Rotation
|
||||
(`NEXARCH_*_KEEP_GENERATIONS`/`KEEP_SNAPSHOTS`) bleibt als Fallback
|
||||
erhalten, falls keine Staffelung gesetzt ist — kein Bruch für bestehende
|
||||
Deployments.
|
||||
|
||||
## Sicherheitsnetz für Akzeptanzkriterium 3 (Legal-Hold-Kollision)
|
||||
|
||||
`internal/backup.PruneTiered` behält STRUKTURELL immer die neueste
|
||||
Generation, unabhängig von der konfigurierten Staffelung — ein
|
||||
Dokument, das in der neuesten (immer behaltenen) Sicherung enthalten
|
||||
ist, bleibt also immer wiederherstellbar, unabhängig von einer
|
||||
laufenden RET-03/CMP-06-Aufbewahrungssperre (die dieses Ticket, Archive
|
||||
BAK-Modul, ohnehin nicht direkt anfasst — Sperren sind DMS/RET-Eigentum,
|
||||
kein Import-Ziel für Archive per "kein Umbau angrenzender Bereiche").
|
||||
Für Objekt-Storage ist die äquivalente Eigenschaft restics eigene
|
||||
Garantie: `forget --prune` entfernt niemals Datenblöcke, die ein
|
||||
BEHALTENER Snapshot noch referenziert (Content-defined Chunking +
|
||||
Referenzzählung) — real bewiesen, nicht nur behauptet (siehe Prüfung 3).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `internal/backup.RetentionPolicy`/`PruneTiered` (`retention.go`) —
|
||||
reine GFS-Funktion.
|
||||
- `internal/backup.PruneRotate` (`rotate.go`) — führt `PruneTiered`
|
||||
tatsächlich gegen das Sicherungsverzeichnis aus.
|
||||
- `internal/objectbackup.RetentionPolicy`/`PruneTiered` (`restic.go`) —
|
||||
Wrapper um restics native Staffelung.
|
||||
- `cmd/backup-cli rotate` / `cmd/objectbackup-cli rotate` — nutzen die
|
||||
gestaffelte Policy, wenn konfiguriert, sonst weiterhin die flache
|
||||
Regel (Abwärtskompatibilität).
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Prune-Test mit simulierten Snapshots über mehrere Altersstufen ergibt korrekte Staffelung | **bestanden** — `TestPruneTiered_KeepsCorrectStaggering` (Datenbank, reine Funktion, simulierte Generationen über Tage/Wochen/Monate) UND `TestPruneTiered_KeepsCorrectStaggering_RealSnapshots` (Objekt-Storage, ECHTE restic-Snapshots mit erzwungenem Zeitstempel `restic backup --time`, `KeepDaily=3` reduziert 10 reale Snapshots korrekt auf 3) |
|
||||
| 2 | Automatisierte Löschung abgelaufener Snapshots im Test nachgewiesen | **bestanden** — `TestPruneRotate_DeletesOnlyExpiredGenerations`: reales Verzeichnis, abgelaufene Generation tatsächlich vom Dateisystem entfernt, aktuelle unangetastet; real auf 131: `backup-cli rotate` und `objectbackup-cli rotate` über systemd ausgelöst, Journal zeigt „gestaffelte rotation abgeschlossen“ |
|
||||
| 3 | Legal-Hold-Test bestätigt, dass Pruning nicht mit Dokumenten-Retention kollidiert | **bestanden** — `TestPruneTiered_AlwaysKeepsNewest` (Datenbank-Sicherheitsnetz: neueste Generation immer behalten, selbst bei Policy 0/0/0) UND `TestPruneTiered_KeptSnapshotStillFullyRestorable` (Objekt-Storage, real: 5 ältere Snapshots eines Dokuments weggeprunt, verbleibender Snapshot danach vollständig und inhaltlich korrekt wiederhergestellt, zusätzlich `restic check --read-data` bestätigt Datenintegrität nach dem Pruning) |
|
||||
|
||||
Zusätzlich: `TestPruneTiered_DeterministicForIdenticalInput`.
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- `backup-cli`, `objectbackup-cli` neu gebaut mit der gestaffelten
|
||||
Rotations-Logik
|
||||
- `/etc/nexarch/archive-backup.env` und
|
||||
`/etc/nexarch/archive-objectbackup.env` um
|
||||
`KEEP_DAILY=14`/`KEEP_WEEKLY=12`/`KEEP_MONTHLY=12` ergänzt
|
||||
- Beide `*-rotate.service` real über `systemctl start` ausgelöst —
|
||||
Journal bestätigt den neuen Pfad ("gestaffelte rotation
|
||||
abgeschlossen"/"... entfernt: [...]"), nicht mehr die alte flache
|
||||
Regel
|
||||
|
||||
## 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 -> 7/7 Pakete mit Tests ok, 0 Fehlschläge
|
||||
```
|
||||
|
||||
## Bekannte Lücke: keine Kohärenz zwischen DB- und Objekt-Rotation
|
||||
|
||||
Beide Rotationswege laufen als UNABHÄNGIGE systemd-Timer mit eigener
|
||||
Kadenz und eigener Policy-Auswertung — nichts stellt sicher, dass der
|
||||
älteste noch erreichbare restic-Snapshot und die älteste noch
|
||||
erreichbare DB-Generation denselben Zeitpunkt abdecken. Die aktuelle
|
||||
Konfiguration (`KEEP_DAILY=14`/`KEEP_WEEKLY=12`/`KEEP_MONTHLY=12` auf
|
||||
beiden Seiten identisch) ist eine ZUFÄLLIGE Übereinstimmung, kein
|
||||
getestetes oder erzwungenes Invariant — ein restic-Snapshot ohne
|
||||
zeitlich passende DB-Generation wäre ein inkonsistenter Restore-Punkt
|
||||
(Objekte ohne zugehörige `file_revisions`-Metadaten oder umgekehrt).
|
||||
Nicht Teil dieses Tickets (BAK-07 fordert nur je Sicherungsart eine
|
||||
konfigurierbare Staffelung, kein Cross-Artefakt-Alignment) — als
|
||||
dokumentierter Folgepunkt festgehalten, nicht stillschweigend
|
||||
übergangen: ein künftiges Ticket müsste entweder die Policies fest
|
||||
koppeln oder bei Restore-Zeitpunkt-Wahl (BAK-03) explizit warnen, wenn
|
||||
kein zeitlich passendes Gegenstück mehr existiert.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt — sowohl mit simulierten (Datenbank) als
|
||||
auch mit tatsächlich zeitversetzt erzeugten, realen restic-Snapshots
|
||||
(Objekt-Storage). Ein realer Formatfehler beim Testaufbau gefunden und
|
||||
behoben (restics `--time`-Flag erwartet sein eigenes Format, nicht
|
||||
RFC3339). Beide Rotations-Wege real über systemd auf 192.168.1.131
|
||||
ausgelöst, nicht nur isolierter Testcode.
|
||||
@@ -0,0 +1,64 @@
|
||||
# QA-04 – Abnahmeprotokoll: Prüfgate Backup & Restore
|
||||
|
||||
Voraussetzung BAK-04, BAK-05, BAK-06, BAK-07 – alle Fertig, siehe
|
||||
jeweilige Prüfprotokolle. Gate fasst deren Ergebnisse zusammen und
|
||||
fordert einen ZUSÄTZLICHEN, eigenständigen Nachweis: ein realer
|
||||
Restore-Lauf plus Reconciliation, ausgeführt als Abnahme-Handlung,
|
||||
nicht nur als Entwicklungs-Test.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Zielwert | Messwert | Bewertung |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Realer Restore-Testlauf erfolgreich und protokolliert | Beide Restore-Arten (DB + Objekt) laufen ohne Fehler, real gegen 192.168.1.131 | **Nachhol-Prüfung** (dedizierter Abnahme-Lauf für dieses Gate, NICHT identisch mit BAK-06s eigenem Entwicklungstest): `systemctl start nexarch-archive-restoretest.service` (2026-08-29 22:54:53 UTC): DB-Restore `erfolg=true` (Quelle `20260829T222054Z`), Objekt-Restore `erfolg=true` (Quelle `4af7bf18`); Ergebnis in `/var/nexarch-archiv/restoretest/history.log` protokolliert | **bestanden** |
|
||||
| 2 | Reconciliation nach dem Testlauf liefert einen sauberen Bericht | `missing_in_storage`/`orphaned_in_storage` beide leer | **Nachhol-Prüfung**, im selben Abnahme-Durchgang direkt nach Prüfung 1 ausgelöst: `systemctl start nexarch-archive-reconcile.service` (2026-08-29 22:55:02 UTC): `{"missing_in_storage": null, "orphaned_in_storage": null, "existing_in_storage": null}` | **bestanden** |
|
||||
| 3 | Offene Restrisiken sind schriftlich benannt | Vollständige, ehrliche Liste (siehe unten) | 5 Punkte identifiziert und dokumentiert | **bestanden** |
|
||||
|
||||
## Offene Restrisiken (Akzeptanzkriterium/Pflichtprüfung 3)
|
||||
|
||||
1. **Keine Kohärenz zwischen DB- und Objekt-Rotation** (BAK-07). Beide
|
||||
Rotationswege laufen unabhängig, ohne Garantie, dass der älteste noch
|
||||
erreichbare restic-Snapshot und die älteste noch erreichbare
|
||||
DB-Generation zeitlich zusammenpassen. Aktuell identische
|
||||
Keep-Werte sind Zufall, kein erzwungenes Invariant. **Ausdrücklich
|
||||
NICHT gelöst**: ein Restore-Punkt ist nur dann belastbar, wenn zum
|
||||
selben Zeitpunkt sowohl ein restic-Snapshot als auch eine
|
||||
DB-Generation existieren — das ist aktuell nicht sichergestellt.
|
||||
2. **BAK-04-Rollenrechte nicht automatisiert für produktive Mandanten.**
|
||||
`nexarch_tenantbackup` braucht je Mandant manuell/administrativ
|
||||
eingerichteten Lesezugriff (Rollenmitgliedschaft), bis TEN-01/TEN-07
|
||||
dies automatisiert bereitstellen. Aktuell nur für die Test-Tenant-DB
|
||||
eingerichtet.
|
||||
3. **BAK-08 deckt keine Storage-Provider-Lücke.** Der Scrub-Job erkennt
|
||||
Abweichungen nur bei Objekten, die gelesen und erneut geprüft werden
|
||||
können — ersetzt keine storage-seitige WORM-/Versionierungsstrategie
|
||||
und keine Zugriffs-/Audit-Logs des Storage-Providers. Bei extern
|
||||
eingebundenem, nicht-kompatiblem Kunden-Storage (Betriebsmodus 3,
|
||||
ohne Versioning/Object Lock/Audit-Logs) bleibt eine technisch nicht
|
||||
schließbare Lücke (aus dem BAK-08-Ticket selbst übernommen, hier
|
||||
erneut benannt statt stillschweigend vorausgesetzt).
|
||||
4. **Core FDN-03/FDN-09-Wiring-Lücke** (aus früheren Prüfprotokollen
|
||||
bekannt, nicht Archive-Scope): Core-seitige Handler für Speicher-
|
||||
Nutzungsmeldung und Tenant-KEK-Abruf existieren, sind aber in keinem
|
||||
laufenden Core-Dienst registriert. Betrifft indirekt BAK-08s
|
||||
OPS-05-Anbindung (funktioniert unabhängig davon, aber der breitere
|
||||
Meldeweg für Speicher-Nutzung bleibt lückenhaft).
|
||||
5. **`existing_in_storage`/Reconciliation-Basis aktuell leer im
|
||||
Testsystem.** Der saubere Bericht dieses Gates (Prüfung 2) beweist
|
||||
Abwesenheit von Abweichungen, nicht Abdeckung eines befüllten
|
||||
Bestands — `dms_tenant_test` enthält aktuell keine Testdaten (von
|
||||
früheren Testläufen geleert). Ein Gate-Wiederholungslauf mit echtem
|
||||
Datenbestand vor Produktivbetrieb wird empfohlen.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Prüfungen real durchgeführt und dokumentiert.
|
||||
Fünf Restrisiken benannt, keines davon blockiert die Freigabe der
|
||||
Backup-Funktionen, alle sind entweder bereits als Folgeticket-Kandidaten
|
||||
dokumentiert (1, 4) oder liegen strukturell außerhalb des
|
||||
Archive-Moduls (2, 3) bzw. sind ein Hinweis für den Produktivbetrieb (5).
|
||||
|
||||
## Unterschriften
|
||||
|
||||
- **Umsetzung:** Claude (Agent), 2026-08-30 — alle Prüfungen durchgeführt, Protokoll erstellt.
|
||||
- **Gegenzeichnung geprüft:** Betreiber, 2026-08-30 — unter den drei Bedingungen (Rotations-Kohärenz als offenes Restrisiko benannt, Nachhol-Prüfung explizit gekennzeichnet, Zwei-Namen-Unterschrift) bestätigt.
|
||||
@@ -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,89 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetentionPolicy ist BAK-07s gestaffelte Aufbewahrungsfrist für
|
||||
// Datenbank-Sicherungsgenerationen — unabhängig von RET-02s
|
||||
// Dokumenten-Retention (Akzeptanzkriterium 1). Ein Feldwert 0 bedeutet
|
||||
// "diese Stufe nicht anwenden", dieselbe Konvention wie
|
||||
// objectbackup.RetentionPolicy (BAK-02), damit beide Backup-Arten
|
||||
// gleich konfiguriert werden.
|
||||
type RetentionPolicy struct {
|
||||
KeepDaily int
|
||||
KeepWeekly int
|
||||
KeepMonthly int
|
||||
}
|
||||
|
||||
// PruneTiered wählt aus generations (IDs im NewGenerationID-Format) die
|
||||
// zu BEHALTENDEN nach gestaffelter GFS-Regel: je Granularität
|
||||
// (Tag/Woche/Monat) wird pro Zeitfenster die NEUESTE Generation
|
||||
// behalten, bis die konfigurierte Anzahl Fenster erreicht ist — reine
|
||||
// Funktion, keine Dateisystem-/Netzwerkzugriffe, dadurch ohne echte
|
||||
// Infrastruktur testbar (Rotate in rotate.go führt die tatsächliche
|
||||
// Löschung anhand des Ergebnisses aus).
|
||||
//
|
||||
// Sicherheitsnetz für Akzeptanzkriterium 3 ("mindestens ein aktueller
|
||||
// Snapshot bleibt erhalten"): die NEUESTE Generation wird IMMER
|
||||
// behalten, unabhängig von der konfigurierten Staffelung — selbst bei
|
||||
// versehentlich auf 0 gesetzter Policy geht nie die letzte Sicherung
|
||||
// verloren.
|
||||
func PruneTiered(generations []string, policy RetentionPolicy) (keep, remove []string) {
|
||||
type parsed struct {
|
||||
id string
|
||||
t time.Time
|
||||
}
|
||||
var items []parsed
|
||||
for _, id := range generations {
|
||||
t, err := time.Parse("20060102T150405Z", id)
|
||||
if err != nil {
|
||||
continue // unbekanntes Format wird ignoriert, nicht geloescht
|
||||
}
|
||||
items = append(items, parsed{id: id, t: t})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].t.After(items[j].t) }) // neueste zuerst
|
||||
|
||||
keepSet := make(map[string]bool)
|
||||
if len(items) > 0 {
|
||||
keepSet[items[0].id] = true // Sicherheitsnetz: immer die neueste
|
||||
}
|
||||
|
||||
applyTier := func(bucketKey func(time.Time) string, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
count := 0
|
||||
for _, it := range items {
|
||||
if count >= n {
|
||||
break
|
||||
}
|
||||
key := bucketKey(it.t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
count++
|
||||
keepSet[it.id] = true
|
||||
}
|
||||
}
|
||||
|
||||
applyTier(func(t time.Time) string { return t.Format("2006-01-02") }, policy.KeepDaily)
|
||||
applyTier(func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
}, policy.KeepWeekly)
|
||||
applyTier(func(t time.Time) string { return t.Format("2006-01") }, policy.KeepMonthly)
|
||||
|
||||
for _, it := range items {
|
||||
if keepSet[it.id] {
|
||||
keep = append(keep, it.id)
|
||||
} else {
|
||||
remove = append(remove, it.id)
|
||||
}
|
||||
}
|
||||
return keep, remove
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func genID(t time.Time) string { return NewGenerationID(t) }
|
||||
|
||||
// TestPruneTiered_KeepsCorrectStaggering ist Pruefung 1: Prune-Test mit
|
||||
// simulierten Snapshots ueber mehrere Altersstufen ergibt korrekte
|
||||
// Staffelung.
|
||||
func TestPruneTiered_KeepsCorrectStaggering(t *testing.T) {
|
||||
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
var generations []string
|
||||
// 20 taegliche generationen der letzten 20 Tage.
|
||||
for i := 0; i < 20; i++ {
|
||||
generations = append(generations, genID(now.Add(-time.Duration(i)*24*time.Hour)))
|
||||
}
|
||||
// zusaetzlich aeltere, wochenweise verteilte generationen (Wochen 4-15
|
||||
// zurueck), damit KeepWeekly etwas zu greifen hat, das KeepDaily nicht
|
||||
// schon abdeckt.
|
||||
for w := 4; w <= 15; w++ {
|
||||
generations = append(generations, genID(now.Add(-time.Duration(w*7)*24*time.Hour)))
|
||||
}
|
||||
// und einige monatealte generationen.
|
||||
for m := 3; m <= 10; m++ {
|
||||
generations = append(generations, genID(now.AddDate(0, -m, 0)))
|
||||
}
|
||||
|
||||
policy := RetentionPolicy{KeepDaily: 7, KeepWeekly: 4, KeepMonthly: 6}
|
||||
keep, remove := PruneTiered(generations, policy)
|
||||
|
||||
if len(keep)+len(remove) != len(generations) {
|
||||
t.Fatalf("keep+remove = %d, want %d (gesamtzahl)", len(keep)+len(remove), len(generations))
|
||||
}
|
||||
// Genau die 7 juengsten taeglichen generationen muessen behalten sein.
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, k := range keep {
|
||||
keepSet[k] = true
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
id := genID(now.Add(-time.Duration(i) * 24 * time.Hour))
|
||||
if !keepSet[id] {
|
||||
t.Fatalf("generation %q (tag %d) haette per KeepDaily behalten werden muessen", id, i)
|
||||
}
|
||||
}
|
||||
// Zu weit zurueckliegende taegliche generationen (ausserhalb aller
|
||||
// Stufen) duerfen NICHT behalten sein.
|
||||
tooOld := genID(now.Add(-19 * 24 * time.Hour))
|
||||
if keepSet[tooOld] {
|
||||
t.Fatalf("generation %q haette NICHT behalten werden duerfen (zu alt fuer jede stufe)", tooOld)
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
t.Fatal("erwartet mindestens eine behaltene generation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneTiered_AlwaysKeepsNewest ist das Sicherheitsnetz fuer
|
||||
// Akzeptanzkriterium 3 (mindestens ein aktueller Snapshot bleibt immer
|
||||
// erhalten) - selbst bei Policy 0/0/0 (versehentlich deaktiviert).
|
||||
func TestPruneTiered_AlwaysKeepsNewest(t *testing.T) {
|
||||
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
|
||||
generations := []string{genID(now), genID(now.Add(-48 * time.Hour))}
|
||||
|
||||
keep, remove := PruneTiered(generations, RetentionPolicy{})
|
||||
|
||||
if len(keep) != 1 || keep[0] != genID(now) {
|
||||
t.Fatalf("erwartet genau die neueste generation behalten, habe keep=%v", keep)
|
||||
}
|
||||
if len(remove) != 1 {
|
||||
t.Fatalf("erwartet 1 entfernte generation, habe %d", len(remove))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneTiered_DeterministicForIdenticalInput - zwei Laeufe mit
|
||||
// identischer Eingabe liefern dasselbe Ergebnis.
|
||||
func TestPruneTiered_DeterministicForIdenticalInput(t *testing.T) {
|
||||
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
|
||||
var generations []string
|
||||
for i := 0; i < 10; i++ {
|
||||
generations = append(generations, genID(now.Add(-time.Duration(i)*24*time.Hour)))
|
||||
}
|
||||
policy := RetentionPolicy{KeepDaily: 3}
|
||||
|
||||
keep1, remove1 := PruneTiered(generations, policy)
|
||||
keep2, remove2 := PruneTiered(generations, policy)
|
||||
|
||||
if len(keep1) != len(keep2) || len(remove1) != len(remove2) {
|
||||
t.Fatal("unterschiedliches ergebnis zwischen zwei laeufen mit identischer eingabe")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneRotate_DeletesOnlyExpiredGenerations ist Pruefung 2:
|
||||
// automatisierte Loeschung abgelaufener Snapshots im Test nachgewiesen -
|
||||
// real gegen echte Verzeichnisse.
|
||||
func TestPruneRotate_DeletesOnlyExpiredGenerations(t *testing.T) {
|
||||
backupDir := t.TempDir()
|
||||
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
var recent, expired string
|
||||
for i := 0; i < 3; i++ {
|
||||
id := genID(now.Add(-time.Duration(i) * 24 * time.Hour))
|
||||
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recent = id
|
||||
}
|
||||
expired = genID(now.Add(-60 * 24 * time.Hour))
|
||||
if err := os.MkdirAll(filepath.Join(backupDir, expired, FullBackupDirName), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed, err := PruneRotate(backupDir, RetentionPolicy{KeepDaily: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("prunerotate: %v", err)
|
||||
}
|
||||
if len(removed) != 1 || removed[0] != expired {
|
||||
t.Fatalf("erwartet genau die abgelaufene generation %q entfernt, habe %v", expired, removed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupDir, recent)); err != nil {
|
||||
t.Fatalf("juengste generation faelschlich entfernt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupDir, expired)); !os.IsNotExist(err) {
|
||||
t.Fatal("abgelaufene generation wurde nicht tatsaechlich vom dateisystem entfernt")
|
||||
}
|
||||
}
|
||||
@@ -54,3 +54,23 @@ func Rotate(backupDir string, keep int) (removed []string, err error) {
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// PruneRotate wendet BAK-07s gestaffelte RetentionPolicy (PruneTiered,
|
||||
// siehe retention.go) tatsächlich an — entfernt alle nicht mehr zu
|
||||
// behaltenden Generationsverzeichnisse. Automatisierte Löschung nach
|
||||
// Ablauf der Frist (Akzeptanzkriterium 2), unabhängig von RET-02s
|
||||
// Dokumenten-Retention.
|
||||
func PruneRotate(backupDir string, policy RetentionPolicy) (removed []string, err error) {
|
||||
generations, err := ListGenerations(backupDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, toRemove := PruneTiered(generations, policy)
|
||||
for _, gen := range toRemove {
|
||||
if err := os.RemoveAll(filepath.Join(backupDir, gen)); err != nil {
|
||||
return removed, fmt.Errorf("backup: generation %q entfernen: %w", gen, err)
|
||||
}
|
||||
removed = append(removed, gen)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,42 @@ func Forget(ctx context.Context, cfg Config, keepLast int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetentionPolicy ist eine gestaffelte Aufbewahrungsfrist nach
|
||||
// restic/Borg-Vorbild (BAK-07, unabhängig von RET-02s Dokumenten-
|
||||
// Retention): täglich/wöchentlich/monatlich je eine konfigurierbare
|
||||
// Anzahl Snapshots behalten, statt einer flachen "letzte N"-Regel
|
||||
// (Forget/keepLast) oder hart codierter Staffelung. Ein Feldwert 0
|
||||
// bedeutet "diese Stufe nicht anwenden" (restics eigene Konvention bei
|
||||
// `--keep-*`).
|
||||
type RetentionPolicy struct {
|
||||
KeepDaily int
|
||||
KeepWeekly int
|
||||
KeepMonthly int
|
||||
}
|
||||
|
||||
// PruneTiered wendet policy über restics NATIVE `--keep-daily`/
|
||||
// `--keep-weekly`/`--keep-monthly`-Staffelung an (kein Eigenbau der
|
||||
// Bucket-Logik — restic beherrscht das bereits robust) und gibt den
|
||||
// belegten Speicherplatz nicht mehr referenzierter Daten frei
|
||||
// (`--prune`). Snapshots, die keiner Stufe zugeordnet werden, entfallen
|
||||
// automatisch — restics übliche GFS-Semantik.
|
||||
func PruneTiered(ctx context.Context, cfg Config, policy RetentionPolicy) error {
|
||||
args := []string{"forget", "--prune"}
|
||||
if policy.KeepDaily > 0 {
|
||||
args = append(args, "--keep-daily", fmt.Sprintf("%d", policy.KeepDaily))
|
||||
}
|
||||
if policy.KeepWeekly > 0 {
|
||||
args = append(args, "--keep-weekly", fmt.Sprintf("%d", policy.KeepWeekly))
|
||||
}
|
||||
if policy.KeepMonthly > 0 {
|
||||
args = append(args, "--keep-monthly", fmt.Sprintf("%d", policy.KeepMonthly))
|
||||
}
|
||||
if _, err := run(ctx, cfg, args...); err != nil {
|
||||
return fmt.Errorf("objectbackup: gestaffelte rotation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore stellt snapshotID nach targetDir wieder her (`restic restore`).
|
||||
// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell
|
||||
// nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package objectbackup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// backupAt erstellt einen Snapshot mit ERZWUNGENEM Zeitstempel
|
||||
// (restics `backup --time`) - so lassen sich mehrere Altersstufen real
|
||||
// simulieren, ohne tatsaechlich tagelang zu warten.
|
||||
func backupAt(t *testing.T, cfg Config, sourceDir string, at time.Time) {
|
||||
t.Helper()
|
||||
// restics --time erwartet SEIN EIGENES Format ("2006-01-02 15:04:05"),
|
||||
// nicht RFC3339 - real erst hier festgestellt (restic verweigert
|
||||
// RFC3339 mit einem Parse-Fehler).
|
||||
output, err := run(context.Background(), cfg, "backup", sourceDir, "--time", at.Format("2006-01-02 15:04:05"), "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("backup (zeitstempel %s): %v (ausgabe: %s)", at, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneTiered_KeepsCorrectStaggering_RealSnapshots ist Pruefung 1
|
||||
// fuer den Objekt-Storage-Teil: reale, gestaffelt datierte Snapshots,
|
||||
// restics eigene Staffelung liefert die erwartete Anzahl verbleibender
|
||||
// Snapshots.
|
||||
func TestPruneTiered_KeepsCorrectStaggering_RealSnapshots(t *testing.T) {
|
||||
cfg := setupTest(t)
|
||||
sourceDir := t.TempDir()
|
||||
writeFile(t, sourceDir, "datei.txt", "inhalt")
|
||||
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < 10; i++ {
|
||||
backupAt(t, cfg, sourceDir, now.Add(-time.Duration(i)*24*time.Hour))
|
||||
}
|
||||
if got, err := SnapshotCount(context.Background(), cfg); err != nil || got != 10 {
|
||||
t.Fatalf("erwartet 10 snapshots vor dem prune, habe %d (err=%v)", got, err)
|
||||
}
|
||||
|
||||
if err := PruneTiered(context.Background(), cfg, RetentionPolicy{KeepDaily: 3}); err != nil {
|
||||
t.Fatalf("prunetiered: %v", err)
|
||||
}
|
||||
|
||||
got, err := SnapshotCount(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotcount: %v", err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Fatalf("erwartet 3 verbleibende snapshots nach KeepDaily=3, habe %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneTiered_KeptSnapshotStillFullyRestorable ist der reale Beweis
|
||||
// fuer Akzeptanzkriterium 3 (Pruefung 3, Legal-Hold-Analog): Pruning
|
||||
// aelterer Snapshots darf niemals Daten beschaedigen, die ein
|
||||
// VERBLEIBENDER (aktueller) Snapshot noch braucht - genau die
|
||||
// Eigenschaft, die verhindert, dass Pruning mit einer laufenden
|
||||
// Dokumenten-Aufbewahrungssperre kollidiert: solange das Dokument in
|
||||
// einem behaltenen Snapshot steckt, bleibt es nach dem Pruning
|
||||
// vollstaendig wiederherstellbar.
|
||||
func TestPruneTiered_KeptSnapshotStillFullyRestorable(t *testing.T) {
|
||||
cfg := setupTest(t)
|
||||
sourceDir := t.TempDir()
|
||||
content := "unveraendertes dokument, muss nach dem pruning vollstaendig lesbar bleiben"
|
||||
writeFile(t, sourceDir, "gehaltenes-dokument.txt", content)
|
||||
|
||||
now := time.Now().UTC()
|
||||
// mehrere aeltere snapshots desselben unveraenderten inhalts (dedupliziert)
|
||||
for i := 5; i >= 1; i-- {
|
||||
backupAt(t, cfg, sourceDir, now.Add(-time.Duration(i)*24*time.Hour))
|
||||
}
|
||||
// EIN aktueller, zu behaltender snapshot
|
||||
backupAt(t, cfg, sourceDir, now)
|
||||
|
||||
// erwarte 5 aeltere zu entfernen, den juengsten zu behalten
|
||||
if err := PruneTiered(context.Background(), cfg, RetentionPolicy{KeepDaily: 1}); err != nil {
|
||||
t.Fatalf("prunetiered: %v", err)
|
||||
}
|
||||
remaining, err := SnapshotCount(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotcount: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Fatalf("erwartet 1 verbleibenden snapshot, habe %d", remaining)
|
||||
}
|
||||
|
||||
latestID := latestSnapshotShortID(t, cfg)
|
||||
targetDir := t.TempDir()
|
||||
if err := Restore(context.Background(), cfg, latestID, targetDir); err != nil {
|
||||
t.Fatalf("restore nach pruning: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(targetDir, sourceDir, "gehaltenes-dokument.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("wiederhergestelltes dokument lesen: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("dokumentinhalt nach pruning beschaedigt: %q, want %q", got, content)
|
||||
}
|
||||
|
||||
// Zusaetzlich: restics eigene Integritaetspruefung bestaetigt, dass
|
||||
// das Pruning kein von behaltenen Snapshots benoetigtes Datenblock
|
||||
// entfernt hat.
|
||||
if err := Check(context.Background(), cfg, true); err != nil {
|
||||
t.Fatalf("check nach pruning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func latestSnapshotShortID(t *testing.T, cfg Config) string {
|
||||
t.Helper()
|
||||
output, err := run(context.Background(), cfg, "snapshots", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("snapshots: %v", err)
|
||||
}
|
||||
var snapshots []snapshotEntry
|
||||
if err := json.Unmarshal(output, &snapshots); err != nil {
|
||||
t.Fatalf("snapshot-liste dekodieren: %v", err)
|
||||
}
|
||||
if len(snapshots) == 0 {
|
||||
t.Fatal("keine snapshots vorhanden")
|
||||
}
|
||||
return snapshots[len(snapshots)-1].ShortID
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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,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,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
|
||||
Reference in New Issue
Block a user