diff --git a/archive/internal/retentionverify/render.go b/archive/internal/retentionverify/render.go new file mode 100644 index 00000000..1bcc0881 --- /dev/null +++ b/archive/internal/retentionverify/render.go @@ -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() +} diff --git a/archive/internal/retentionverify/report.go b/archive/internal/retentionverify/report.go new file mode 100644 index 00000000..6dbc3cf5 --- /dev/null +++ b/archive/internal/retentionverify/report.go @@ -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 +} diff --git a/archive/internal/retentionverify/verify.go b/archive/internal/retentionverify/verify.go new file mode 100644 index 00000000..96a0bcde --- /dev/null +++ b/archive/internal/retentionverify/verify.go @@ -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) +} diff --git a/archive/internal/retentionverify/verify_test.go b/archive/internal/retentionverify/verify_test.go new file mode 100644 index 00000000..5f10fa1a --- /dev/null +++ b/archive/internal/retentionverify/verify_test.go @@ -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") + } +} diff --git a/docs/VAL-03-PRUEFPROTOKOLL.md b/docs/VAL-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..f4c5e50b --- /dev/null +++ b/docs/VAL-03-PRUEFPROTOKOLL.md @@ -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.