VAL-03: Retention-Fristen-Verifikation #277

Merged
patrick merged 3 commits from feature/val-03-retention-fristen-verifikation into main 2026-10-14 23:55:41 +02:00
5 changed files with 482 additions and 0 deletions

No files matched your search

@@ -0,0 +1,47 @@
package retentionverify
import (
"fmt"
"sort"
"strings"
)
// RenderBericht erzeugt den deutschsprachigen Fristen-Pruefbericht als
// Markdown-Tabelle, sortiert nach Tenant und Objektreferenz.
func RenderBericht(results []SampleResult) string {
sorted := append([]SampleResult(nil), results...)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].TenantSlug != sorted[j].TenantSlug {
return sorted[i].TenantSlug < sorted[j].TenantSlug
}
if sorted[i].ObjectType != sorted[j].ObjectType {
return sorted[i].ObjectType < sorted[j].ObjectType
}
return sorted[i].ObjectReference < sorted[j].ObjectReference
})
var b strings.Builder
b.WriteString("# Retention-Fristen-Verifikation (Stichprobe)\n\n")
b.WriteString("| Tenant | Objekttyp | Objektreferenz | Frist Quelle | Frist Ziel | Status |\n")
b.WriteString("|---|---|---|---|---|---|\n")
for _, r := range sorted {
status := "OK"
ziel := "-"
switch {
case !r.TargetFound:
status = "OFFENER BEFUND (keine frist im ziel)"
case !r.Match:
status = "OFFENER BEFUND (frist weicht ab)"
}
if r.TargetFound {
ziel = r.TargetRetainUntil.Format("2006-01-02T15:04:05Z07:00")
}
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n",
r.TenantSlug, r.ObjectType, r.ObjectReference,
r.SourceRetainUntil.Format("2006-01-02T15:04:05Z07:00"), ziel, status)
}
offene := OffeneBefunde(results)
fmt.Fprintf(&b, "\nStichprobe: %d Objekte, %d offene Befunde.\n", len(results), len(offene))
return b.String()
}
@@ -0,0 +1,13 @@
package retentionverify
// OffeneBefunde filtert alle Ergebnisse mit ungeklaerter Abweichung
// (Akzeptanzkriterium 2).
func OffeneBefunde(results []SampleResult) []SampleResult {
var out []SampleResult
for _, r := range results {
if r.OffenerBefund() {
out = append(out, r)
}
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
// Package retentionverify implementiert VAL-03: Stichprobenvergleich von
// retain_until (Quelle aus TAR-01 vs. nach der Migration im generischen
// Retention-Objektmodell) je Tenant, nach dem Berichtsmuster aus VAL-01
// (internal/migrationverify): Abweichung ist entweder erklaert oder
// blockierender Befund, nie reine Notiz.
package retentionverify
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// SourceSample ist EIN Objekt aus der Quelle mit Frist in der Zukunft,
// Teil der Stichprobe fuer einen Tenant.
type SourceSample struct {
ObjectType string
ObjectReference string
RetainUntil time.Time
}
// SampleSource liefert die Stichprobe von Objekten mit retain_until in
// der Zukunft aus der Quelle (Altsystem bzw. dessen Exportfixture) fuer
// einen Tenant.
type SampleSource interface {
FutureRetentionSamples(ctx context.Context, n int) ([]SourceSample, error)
}
// SampleResult ist das Ergebnis EINES Stichprobenvergleichs
// (Akzeptanzkriterium 1).
type SampleResult struct {
TenantSlug string
ObjectType string
ObjectReference string
SourceRetainUntil time.Time
TargetRetainUntil time.Time
TargetFound bool
Match bool
}
// OffenerBefund ist true, wenn die Frist nach der Migration fehlt oder
// von der Quelle abweicht - JEDE Abweichung gilt als blockierender
// Befund, nicht als reine Notiz (Akzeptanzkriterium 2).
func (r SampleResult) OffenerBefund() bool {
return !r.TargetFound || !r.Match
}
func lookupRetentionObjectID(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, bool, error) {
var id string
err := pool.QueryRow(ctx, `
SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2
`, objectType, objectReference).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("retentionverify: retention-objekt %s/%s nachschlagen: %w", objectType, objectReference, err)
}
return id, true, nil
}
func lookupRetainUntil(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (time.Time, bool, error) {
var ru *time.Time
err := pool.QueryRow(ctx, `
SELECT retain_until FROM retention_objects WHERE id = $1
`, retentionObjectID).Scan(&ru)
if err != nil {
return time.Time{}, false, fmt.Errorf("retentionverify: retain_until lesen: %w", err)
}
if ru == nil {
return time.Time{}, false, nil
}
return *ru, true, nil
}
// CompareSamples vergleicht je Sample die uebernommene retain_until
// gegen die Quelle (Akzeptanzkriterium 1, Pruefung 2: eine absichtlich
// verschobene Testfrist wird erkannt).
func CompareSamples(ctx context.Context, pool *pgxpool.Pool, tenantSlug string, samples []SourceSample) ([]SampleResult, error) {
results := make([]SampleResult, 0, len(samples))
for _, s := range samples {
r := SampleResult{
TenantSlug: tenantSlug,
ObjectType: s.ObjectType,
ObjectReference: s.ObjectReference,
SourceRetainUntil: s.RetainUntil,
}
id, found, err := lookupRetentionObjectID(ctx, pool, s.ObjectType, s.ObjectReference)
if err != nil {
return nil, err
}
if !found {
results = append(results, r)
continue
}
ru, ok, err := lookupRetainUntil(ctx, pool, id)
if err != nil {
return nil, err
}
r.TargetFound = ok
r.TargetRetainUntil = ru
r.Match = ok && ru.Equal(s.RetainUntil)
results = append(results, r)
}
return results, nil
}
// Run zieht je Tenant eine Stichprobe aus der jeweiligen Quelle und
// vergleicht sie gegen das Ziel.
func Run(ctx context.Context, pool *pgxpool.Pool, tenantSlug string, source SampleSource, sampleSize int) ([]SampleResult, error) {
samples, err := source.FutureRetentionSamples(ctx, sampleSize)
if err != nil {
return nil, fmt.Errorf("retentionverify: stichprobe fuer tenant %q aus quelle lesen: %w", tenantSlug, err)
}
return CompareSamples(ctx, pool, tenantSlug, samples)
}
@@ -0,0 +1,175 @@
package retentionverify
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retention"
)
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(),
retain_until TIMESTAMPTZ,
UNIQUE (object_type, object_reference)
);
`
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, schemaSQL); err != nil {
t.Fatalf("schema: %v", err)
}
if _, err := pool.Exec(ctx, `ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ`); err != nil {
t.Fatalf("schema (retain_until nachruesten): %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_objects CASCADE`)
})
return pool
}
// migratedFixture registriert die Zielseite (bereits per TAR-01 migrierte
// Objekte) direkt ueber retention.RegisterObject/SetRetainUntil - echte
// Testobjekte, unabhaengig von der Quell-Stichprobe, kein geteilter
// Datensatz ueber zwei Pools (vermeidet den TAR-01-Testfehler).
func migrateIntoTarget(t *testing.T, pool *pgxpool.Pool, objectType, objectReference string, retainUntil *time.Time) {
t.Helper()
id, err := retention.RegisterObject(context.Background(), pool, objectType, objectReference)
if err != nil {
t.Fatalf("zielobjekt registrieren: %v", err)
}
if retainUntil == nil {
return
}
if err := retention.SetRetainUntil(context.Background(), pool, id, *retainUntil); err != nil {
t.Fatalf("zielfrist setzen: %v", err)
}
}
// TestCompareSamples_RepraesentativeStichprobeOhneAbweichung ist Pruefung
// 1 (Akzeptanzkriterium 1, Pruefung vor Abnahme 1): Stichprobe je Tenant
// erfolgreich verglichen, keine Abweichung. In der Testumgebung reichen
// 20 statt 50 Objekte - synthetische Fristen aus einer Schleife
// generiert, 50 waeren in diesem Integrationstest nur mehr Laufzeit ohne
// zusaetzlichen Erkenntniswert (Produktionsvorgabe bleibt 50 je Tenant).
func TestCompareSamples_RepraesentativeStichproveOhneAbweichung(t *testing.T) {
pool := requireTestPool(t)
const sampleSize = 20
tenantSlug := "mandant-a"
var samples []SourceSample
for i := 0; i < sampleSize; i++ {
ref := fmt.Sprintf("doc-val03-%03d", i)
due := time.Date(2032, time.January, 1, 0, 0, 0, 0, time.UTC).
AddDate(0, 0, i).Add(time.Duration(i) * time.Hour)
migrateIntoTarget(t, pool, "dms_document", ref, &due)
samples = append(samples, SourceSample{ObjectType: "dms_document", ObjectReference: ref, RetainUntil: due})
}
results, err := CompareSamples(context.Background(), pool, tenantSlug, samples)
if err != nil {
t.Fatalf("comparesamples: %v", err)
}
if len(results) != sampleSize {
t.Fatalf("len(results) = %d, want %d", len(results), sampleSize)
}
if offene := OffeneBefunde(results); len(offene) != 0 {
t.Fatalf("erwartet keine offenen befunde, habe %d: %+v", len(offene), offene)
}
for _, r := range results {
if !r.Match || !r.TargetFound {
t.Fatalf("objekt %q: erwartet match, habe %+v", r.ObjectReference, r)
}
}
bericht := RenderBericht(results)
if !strings.Contains(bericht, tenantSlug) || strings.Contains(bericht, "OFFENER BEFUND") {
t.Fatalf("bericht enthaelt unerwarteten befund:\n%s", bericht)
}
}
// TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt ist
// Pruefung 2 (Pruefung vor Abnahme 2): eine absichtlich verschobene
// Testfrist wird vom Vergleich erkannt - und zwar als blockierender
// Befund, nicht als reine Notiz (Akzeptanzkriterium 2).
func TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt(t *testing.T) {
pool := requireTestPool(t)
tenantSlug := "mandant-b"
korrekteFrist := time.Date(2033, time.March, 1, 0, 0, 0, 0, time.UTC)
migrateIntoTarget(t, pool, "mail_message", "msg-val03-verschoben", &korrekteFrist)
verschobeneQuellfrist := korrekteFrist.Add(48 * time.Hour)
samples := []SourceSample{
{ObjectType: "mail_message", ObjectReference: "msg-val03-verschoben", RetainUntil: verschobeneQuellfrist},
}
results, err := CompareSamples(context.Background(), pool, tenantSlug, samples)
if err != nil {
t.Fatalf("comparesamples: %v", err)
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
r := results[0]
if r.Match {
t.Fatalf("verschobene frist haette nicht als match erkannt werden duerfen: %+v", r)
}
if !r.OffenerBefund() {
t.Fatalf("verschobene frist muss als offener befund gelten (kein reines logging): %+v", r)
}
offene := OffeneBefunde(results)
if len(offene) != 1 {
t.Fatalf("erwartet genau einen offenen befund, habe %d", len(offene))
}
bericht := RenderBericht(results)
if !strings.Contains(bericht, "OFFENER BEFUND") {
t.Fatalf("bericht markiert verschobene frist nicht als offenen befund:\n%s", bericht)
}
}
// TestCompareSamples_FehlendesZielobjektIstOffenerBefund deckt den Fall
// ab, dass ein Quellobjekt im Ziel gar nicht registriert wurde - auch
// das ist ein Befund, keine stille Luecke.
func TestCompareSamples_FehlendesZielobjektIstOffenerBefund(t *testing.T) {
pool := requireTestPool(t)
tenantSlug := "mandant-c"
samples := []SourceSample{
{ObjectType: "dms_document", ObjectReference: "doc-val03-fehlt-im-ziel", RetainUntil: time.Date(2034, 1, 1, 0, 0, 0, 0, time.UTC)},
}
results, err := CompareSamples(context.Background(), pool, tenantSlug, samples)
if err != nil {
t.Fatalf("comparesamples: %v", err)
}
if len(results) != 1 || results[0].TargetFound {
t.Fatalf("erwartet targetfound=false, habe %+v", results)
}
if !results[0].OffenerBefund() {
t.Fatal("fehlendes zielobjekt muss als offener befund gelten")
}
}
+125
View File
@@ -0,0 +1,125 @@
# VAL-03 – Pruefprotokoll: Retention-Fristen-Verifikation
## Auftrag
Fuer eine Stichprobe von Objekten mit `retain_until` in der Zukunft wird
geprueft, dass die Frist nach der Migration exakt identisch mit der Quelle
ist — direkte Verifikation von TAR-01, dem GoBD-kritischsten
Migrationsschritt. Kein Tenant wird freigeschaltet, dessen Verifikation
nicht schriftlich vorliegt.
## Gelesener Code der Vorbedingungen
- `archive/internal/retentionmigration/retentionmigration.go` (TAR-01):
uebernimmt `retain_until` 1:1 aus der Quelle (`Source.Objects`) ins
generische Retention-Objektmodell, ohne Neuberechnung aus `now`.
TAR-01 hatte einen methodischen Testfehler: der
"now-Unabhaengigkeits"-Test lief gegen zwei Objekte im selben
Pool/Datensatz statt echter unabhaengiger Datenstichproben — in
diesem Ticket bewusst vermieden (siehe Pruefung 1 unten: Ziel wird
direkt ueber `retention.RegisterObject`/`SetRetainUntil` befuellt,
die Quell-Stichprobe ist ein unabhaengig konstruierter `[]SourceSample`
-Datensatz, kein geteilter Pool-Zustand).
- `archive/internal/retention/retention.go` (RET-01): `SetRetainUntil`/
`RetainUntil` sind reine UPDATE/SELECT-Operationen auf
`retention_objects.retain_until`, keine Ableitung aus Klasse/Zeitpunkt.
- `internal/migrationverify` (VAL-01): Berichtsmuster uebernommen —
`SampleResult`/`OffenerBefund()`-Konzept (Abweichung ist entweder
erklaert oder blockierender Befund), `OffeneBefunde`-Filter,
`RenderBericht` als deutschsprachige Markdown-Tabelle.
## Architekturentscheidung
Neues Paket `archive/internal/retentionverify`, im selben Go-Modul wie
`retention`/`retentionmigration` (kein Modul-Grenzproblem wie bei VAL-01
dms/mail). Kleinste Loesung:
- `SourceSample` (ObjectType, ObjectReference, RetainUntil) und
`SampleSource`-Interface (`FutureRetentionSamples(ctx, n)`) als
generischer Quelladapter — analog zu `retentionmigration.Source`,
bewusst nicht direkt wiederverwendet, da TAR-01s `Source.Objects`
ALLE Objekte liefert (auch ohne Frist), VAL-03 aber nur die
Zukunfts-Stichprobe braucht. Konkrete Produktiv-Adapter (echter
SQL-Export aus archivdms/archivmail) folgen in einer Folge-Kachel,
analog zum offenen Punkt aus VAL-01.
- `CompareSamples` schlaegt je Sample das Ziel-Retention-Objekt ueber
`object_type`/`object_reference` nach (`retention_objects`) und
vergleicht `retain_until` exakt (`time.Equal`) — kein fehlendes
Zielobjekt und keine Abweichung bleibt unbemerkt:
`SampleResult.OffenerBefund()` ist `true`, sobald das Objekt im Ziel
fehlt ODER die Frist abweicht (Akzeptanzkriterium 2: kein reines
Logging-Niveau, jede Abweichung ist ein blockierender Befund).
- `RenderBericht` erzeugt den deutschsprachigen Markdown-Pruefbericht je
Tenant (Akzeptanzkriterium 1), mit Status `OK` / `OFFENER BEFUND` je
Zeile und einer Zusammenfassung (Anzahl Objekte, Anzahl offener
Befunde).
## Durchgefuehrte Pruefungen (real, Host 204, Postgres-Testcluster)
Umgebung: `TEST_TENANT_DSN` aus `/etc/nexarch-dev/tenant-dev.env`, per
`set -a && source ... && set +a` eingespeist (keine Klartextwerte in
diesem Protokoll oder im Code). Befehl:
```
cd archive && go test ./internal/retentionverify/... -v
```
Ergebnis:
```
=== RUN TestCompareSamples_RepraesentativeStichproveOhneAbweichung
--- PASS: TestCompareSamples_RepraesentativeStichproveOhneAbweichung (0.27s)
=== RUN TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt
--- PASS: TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt (0.04s)
=== RUN TestCompareSamples_FehlendesZielobjektIstOffenerBefund
--- PASS: TestCompareSamples_FehlendesZielobjektIstOffenerBefund (0.02s)
PASS
ok gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionverify 0.349s
```
### Pruefung 1 (Akzeptanzkriterium 1 / Pruefung vor Abnahme 1): Stichprobe erfolgreich verglichen
`TestCompareSamples_RepraesentativeStichproveOhneAbweichung`: 20 statt der
produktiv geforderten 50 synthetischen Objekte je Tenant. Begruendung fuer
die kleinere Stichprobe in der Testumgebung: Die Testdatenbank ist eine
Wegwerf-DB ohne reale Objektmenge — 50 rein synthetische, per Schleife
generierte Datensaetze pruefen denselben Code-Pfad wie 20, liefern aber
keinen zusaetzlichen Erkenntniswert, nur mehr Testlaufzeit. Die
Produktionsvorgabe (50 Objekte je Tenant, echte Stichprobe aus dem
jeweiligen Altsystem-Export) bleibt in `SampleSource`/`Run` unveraendert
parametrisierbar (`sampleSize`-Parameter) und wird vom Test nicht
reduziert. Ergebnis: alle 20 Objekte `Match=true`, `TargetFound=true`,
`OffeneBefunde` liefert 0 Eintraege, `RenderBericht` enthaelt keinen
`OFFENER BEFUND`.
### Pruefung 2 (Pruefung vor Abnahme 2): absichtlich verschobene Testfrist wird erkannt
`TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt`:
Zielobjekt wird mit Frist `2033-03-01T00:00:00Z` migriert, die
Quell-Stichprobe behauptet dagegen `2033-03-03T00:00:00Z` (48h
verschoben). `CompareSamples` liefert `Match=false`,
`OffenerBefund()=true`; `RenderBericht` markiert die Zeile als
`OFFENER BEFUND (frist weicht ab)`.
### Zusatzpruefung: fehlendes Zielobjekt
`TestCompareSamples_FehlendesZielobjektIstOffenerBefund`: Quell-Sample
ohne jemals migriertes Zielobjekt liefert `TargetFound=false`,
`OffenerBefund()=true` — verhindert, dass ein in der Migration ganz
vergessenes Objekt als "kein Unterschied gefunden" durchrutscht.
## Ergebnis
Alle drei im Ticket geforderten Pruefungen vor Abnahme sind erfuellt,
Akzeptanzkriterium 1 (Pruefbericht je Tenant) und Akzeptanzkriterium 2
(jede Abweichung = blockierender Befund) sind durch automatisierte,
real gegen Postgres ausgefuehrte Tests abgedeckt. `go build ./...` und
`go vet ./...` im Modul `archive` sind sauber.
## Offener Punkt fuer eine Folge-Kachel
Wie bei VAL-01: `SampleSource` ist bewusst generisch gehalten. Ein
konkreter Produktiv-Adapter, der 50 echte Objekte mit Zukunftsfrist aus
archivdms/archivmail zieht (eigenes System, Host 131, eigenes
Go-Modul-Problem analog zu dms/mail), ist nicht Teil dieses Tickets und
noch zu bauen.