diff --git a/archive/internal/reconcilereportmigration/reconcilereportmigration.go b/archive/internal/reconcilereportmigration/reconcilereportmigration.go new file mode 100644 index 00000000..8033e722 --- /dev/null +++ b/archive/internal/reconcilereportmigration/reconcilereportmigration.go @@ -0,0 +1,123 @@ +// Package reconcilereportmigration implementiert MAI-04: Übernahme +// historischer Reconciliation-Reports aus archivmail als Nachweis in +// Archive BAK-05 (archive/internal/reconcile) — kein aktiver +// Datenbestand, rein historischer Beleg. +// +// Tenant-Zuordnung ist strukturell über die Zieldatenbankverbindung +// garantiert (Modell C, Core TEN-01: eine physisch isolierte Datenbank +// je Mandant, wie bereits in archive/internal/reconcile.ListDBStorageKeys) +// — kein tenant_slug-Feld in reconciliation_report_archive nötig, jede +// Verbindung spricht nur mit genau einer Mandanten-Datenbank. +package reconcilereportmigration + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// SourceReport ist eine Zeile aus archivmails historischer +// Reconciliation-Report-Tabelle (SQL-Export). IndexedAt wird absichtlich +// nicht übernommen (Produkt-DNA: kein Übernehmen von indexed_at- +// Statusfeldern). +type SourceReport struct { + SourceReportID string + GeneratedAt time.Time + ReportJSON []byte + IndexedAt *int64 +} + +// Source kapselt den lesenden SQL-Export-Zugriff auf archivmails +// historische Reconciliation-Reports. +type Source interface { + Reports(ctx context.Context) ([]SourceReport, error) +} + +// Migrator überträgt historische Reports einer Quelle in genau eine +// Ziel-Mandantendatenbank. +type Migrator struct { + source Source + pool *pgxpool.Pool +} + +func NewMigrator(source Source, pool *pgxpool.Pool) *Migrator { + return &Migrator{source: source, pool: pool} +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + ReportsMigrated int + ReportsSkipped int +} + +// ErrNotFound meldet, dass unter der angefragten Quell-ID kein +// archivierter Report gefunden wurde. +var ErrNotFound = errors.New("reconcilereportmigration: report nicht gefunden") + +// MigrateAll überträgt alle Reports (Akzeptanzkriterium 1). Ein bereits +// migrierter Report (gleiche source_report_id bereits vorhanden) wird +// übersprungen, statt erneut geschrieben zu werden (Idempotenz). +func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) { + var res Result + reports, err := m.source.Reports(ctx) + if err != nil { + return res, fmt.Errorf("reconcilereportmigration: reports aus quelle lesen: %w", err) + } + + for _, r := range reports { + migrated, err := m.migrateReport(ctx, r) + if err != nil { + return res, err + } + if migrated { + res.ReportsMigrated++ + } else { + res.ReportsSkipped++ + } + } + return res, nil +} + +func (m *Migrator) migrateReport(ctx context.Context, r SourceReport) (migrated bool, err error) { + tag, err := m.pool.Exec(ctx, ` + INSERT INTO reconciliation_report_archive (source_report_id, generated_at, report_json) + VALUES ($1, $2, $3) + ON CONFLICT (source_report_id) DO NOTHING + `, r.SourceReportID, r.GeneratedAt, r.ReportJSON) + if err != nil { + return false, fmt.Errorf("reconcilereportmigration: report %q speichern: %w", r.SourceReportID, err) + } + return tag.RowsAffected() > 0, nil +} + +// ArchivedReport ist ein bereits migrierter, auffindbarer Report +// (Akzeptanzkriterium 1). +type ArchivedReport struct { + SourceReportID string + GeneratedAt time.Time + ReportJSON []byte + MigratedAt time.Time +} + +// FindBySourceID macht einen migrierten Report in der Zieldatenbank +// auffindbar. Die Tenant-Zuordnung ergibt sich daraus, mit welcher +// Mandantendatenbank pool verbunden ist. +func FindBySourceID(ctx context.Context, pool *pgxpool.Pool, sourceReportID string) (ArchivedReport, error) { + var ar ArchivedReport + ar.SourceReportID = sourceReportID + err := pool.QueryRow(ctx, ` + SELECT generated_at, report_json, migrated_at + FROM reconciliation_report_archive WHERE source_report_id = $1 + `, sourceReportID).Scan(&ar.GeneratedAt, &ar.ReportJSON, &ar.MigratedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ArchivedReport{}, ErrNotFound + } + return ArchivedReport{}, fmt.Errorf("reconcilereportmigration: report %q lesen: %w", sourceReportID, err) + } + return ar, nil +} diff --git a/archive/internal/reconcilereportmigration/reconcilereportmigration_test.go b/archive/internal/reconcilereportmigration/reconcilereportmigration_test.go new file mode 100644 index 00000000..e702f992 --- /dev/null +++ b/archive/internal/reconcilereportmigration/reconcilereportmigration_test.go @@ -0,0 +1,194 @@ +package reconcilereportmigration + +import ( + "context" + "encoding/json" + "errors" + "os" + "reflect" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// fakeSource ist eine rein synthetische archivmail-Quelle — keine echten +// Kunden-/Mandantendaten (DSGVO-Hartregel). +type fakeSource struct { + reports []SourceReport +} + +func (f *fakeSource) Reports(_ context.Context) ([]SourceReport, error) { + return f.reports, nil +} + +func setupPool(t *testing.T, envVar string) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv(envVar) + if dsn == "" { + t.Skipf("%s nicht gesetzt, Integrationstest uebersprungen", envVar) + } + 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 TABLE IF NOT EXISTS reconciliation_report_archive ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_report_id TEXT NOT NULL UNIQUE, + generated_at TIMESTAMPTZ NOT NULL, + report_json JSONB NOT NULL, + migrated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM reconciliation_report_archive WHERE source_report_id LIKE 'mai04test_%'`) + }) + return pool +} + +func synthReports() []SourceReport { + oldStatus := int64(1700000000) + return []SourceReport{ + { + SourceReportID: "mai04test_r1", + GeneratedAt: time.Date(2021, 3, 1, 5, 0, 0, 0, time.UTC), + ReportJSON: []byte(`{"missing_in_storage":[],"orphaned_in_storage":[],"existing_in_storage":[{"storage_key":"synth-key-1"}]}`), + IndexedAt: &oldStatus, + }, + { + SourceReportID: "mai04test_r2", + GeneratedAt: time.Date(2021, 4, 1, 5, 0, 0, 0, time.UTC), + ReportJSON: []byte(`{"missing_in_storage":[{"storage_key":"synth-key-2"}],"orphaned_in_storage":[],"existing_in_storage":[]}`), + }, + } +} + +// TestMigrateAll_ReportsAreFindableWithOriginalContent ist +// Akzeptanzkriterium 1 und Prüfung 1 (Stichprobenvergleich vor/nach +// Migration): jeder historische Report ist nach Migration auffindbar, +// mit unverändertem Inhalt. +func TestMigrateAll_ReportsAreFindableWithOriginalContent(t *testing.T) { + pool := setupPool(t, "TEST_TENANT_DSN") + ctx := context.Background() + src := &fakeSource{reports: synthReports()} + m := NewMigrator(src, pool) + + res, err := m.MigrateAll(ctx) + if err != nil { + t.Fatalf("migrateall: %v", err) + } + if res.ReportsMigrated != 2 || res.ReportsSkipped != 0 { + t.Fatalf("erwartet 2 migriert/0 übersprungen, habe %+v", res) + } + + for _, want := range synthReports() { + got, err := FindBySourceID(ctx, pool, want.SourceReportID) + if err != nil { + t.Fatalf("report %q nicht auffindbar: %v", want.SourceReportID, err) + } + var gotDecoded, wantDecoded any + if err := json.Unmarshal(got.ReportJSON, &gotDecoded); err != nil { + t.Fatalf("report %q: migrierten inhalt dekodieren: %v", want.SourceReportID, err) + } + if err := json.Unmarshal(want.ReportJSON, &wantDecoded); err != nil { + t.Fatalf("report %q: erwarteten inhalt dekodieren: %v", want.SourceReportID, err) + } + if !reflect.DeepEqual(gotDecoded, wantDecoded) { + t.Fatalf("report %q inhalt weicht ab: habe %s, erwartet %s", want.SourceReportID, got.ReportJSON, want.ReportJSON) + } + if !got.GeneratedAt.Equal(want.GeneratedAt) { + t.Fatalf("report %q generated_at weicht ab: habe %v, erwartet %v", want.SourceReportID, got.GeneratedAt, want.GeneratedAt) + } + } +} + +// TestMigrateAll_SecondRunSkipsAlreadyMigratedReports beweist Idempotenz: +// ein erneuter Lauf gegen dieselbe Quelle überschreibt nichts. +func TestMigrateAll_SecondRunSkipsAlreadyMigratedReports(t *testing.T) { + pool := setupPool(t, "TEST_TENANT_DSN") + ctx := context.Background() + src := &fakeSource{reports: synthReports()} + m := NewMigrator(src, pool) + + if _, err := m.MigrateAll(ctx); err != nil { + t.Fatalf("erster lauf: %v", err) + } + + res, err := m.MigrateAll(ctx) + if err != nil { + t.Fatalf("zweiter lauf: %v", err) + } + if res.ReportsMigrated != 0 || res.ReportsSkipped != 2 { + t.Fatalf("erwartet 0 migriert/2 übersprungen beim zweiten lauf, habe %+v", res) + } +} + +// TestFindBySourceID_UnknownIDReturnsErrNotFound. +func TestFindBySourceID_UnknownIDReturnsErrNotFound(t *testing.T) { + pool := setupPool(t, "TEST_TENANT_DSN") + ctx := context.Background() + + _, err := FindBySourceID(ctx, pool, "mai04test_unknown") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("erwartet ErrNotFound, habe: %v", err) + } +} + +// TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase ist +// Akzeptanzkriterium 1 (korrekte Tenant-Zuordnung): ein in Tenant-A +// migrierter Report ist über die Datenbankverbindung von Tenant B NICHT +// auffindbar — die Trennung ist strukturell (Modell C, eine physisch +// isolierte Datenbank je Mandant), keine Anwendungslogik könnte sie +// versehentlich umgehen. +func TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase(t *testing.T) { + poolA := setupPool(t, "TEST_TENANT_DSN") + poolB := setupPool(t, "TEST_TENANT_DSN_B") + ctx := context.Background() + + src := &fakeSource{reports: synthReports()} + m := NewMigrator(src, poolA) + if _, err := m.MigrateAll(ctx); err != nil { + t.Fatalf("migrateall gegen tenant a: %v", err) + } + + if _, err := FindBySourceID(ctx, poolA, "mai04test_r1"); err != nil { + t.Fatalf("report in tenant a nicht auffindbar: %v", err) + } + if _, err := FindBySourceID(ctx, poolB, "mai04test_r1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("erwartet ErrNotFound in tenant b, habe: %v", err) + } +} + +// TestSourceReport_IndexedAtIsNeverPersisted beweist Produkt-DNA: kein +// Übernehmen von indexed_at-Statusfeldern — die Spalte existiert in der +// Zieltabelle bewusst nicht. +func TestSourceReport_IndexedAtIsNeverPersisted(t *testing.T) { + pool := setupPool(t, "TEST_TENANT_DSN") + ctx := context.Background() + src := &fakeSource{reports: synthReports()} + m := NewMigrator(src, pool) + + if _, err := m.MigrateAll(ctx); err != nil { + t.Fatalf("migrateall: %v", err) + } + + var columnExists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'reconciliation_report_archive' AND column_name = 'indexed_at' + ) + `).Scan(&columnExists) + if err != nil { + t.Fatalf("spaltenprüfung: %v", err) + } + if columnExists { + t.Fatal("reconciliation_report_archive darf keine indexed_at-spalte haben") + } +} diff --git a/archive/migrations/0018_reconciliation_report_archive.down.sql b/archive/migrations/0018_reconciliation_report_archive.down.sql new file mode 100644 index 00000000..e31cd81c --- /dev/null +++ b/archive/migrations/0018_reconciliation_report_archive.down.sql @@ -0,0 +1 @@ +DROP TABLE reconciliation_report_archive; diff --git a/archive/migrations/0018_reconciliation_report_archive.up.sql b/archive/migrations/0018_reconciliation_report_archive.up.sql new file mode 100644 index 00000000..2b753d7a --- /dev/null +++ b/archive/migrations/0018_reconciliation_report_archive.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE reconciliation_report_archive ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_report_id TEXT NOT NULL UNIQUE, + generated_at TIMESTAMPTZ NOT NULL, + report_json JSONB NOT NULL, + migrated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/docs/MAI-04-PRUEFPROTOKOLL.md b/docs/MAI-04-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..bc2fee1d --- /dev/null +++ b/docs/MAI-04-PRUEFPROTOKOLL.md @@ -0,0 +1,123 @@ +# MAI-04 – Prüfprotokoll: Reconciliation-Reports-Archivierung + +## Grundsatzentscheidung: Zieltabelle statt Objekt-Storage + +Archive BAK-05 (`archive/internal/reconcile`) erzeugt bislang nur einen +JSON-Report auf stdout, ohne Persistenz (siehe `BAK-05-PRUEFPROTOKOLL.md`). +Für MAI-04 ("rein historischer Beleg", keine Live-Erzeugung) genügt +daher eine eigene, kleine Archivtabelle statt Erweiterung der +reinen Vergleichsfunktion `Reconcile`. + +Gegen `archive/internal/wormstore` (ebenfalls als mögliches Ziel +geprüft, "Nachweis" liegt dort nahe) entschieden: wormstore setzt +`chattr +i` auf dem Basisverzeichnis voraus (Storage-Object-Lock) — auf +Host 204 läuft das Dateisystem auf ZFS, `chattr +i` schlägt dort mit +"Operation not permitted" fehl (verifiziert). Ein Test gegen wormstore +wäre auf diesem Host strukturell übersprungen worden (siehe +`wormLockDir`-Skip-Logik), nicht real ausgeführt — verstößt gegen die +Vorgabe "alle Prüfungen real durchführen". Die neue Tabelle +`reconciliation_report_archive` (reine SQL-Persistenz, analog zu +`archive/internal/complianceapi`s `compliance_reports`) ist auf jedem +Postgres real testbar. + +## Tenant-Zuordnung strukturell über die Zieldatenbank + +Wie `archive/internal/reconcile.ListDBStorageKeys` (BAK-05) läuft die +Migration gegen genau EINE Mandantendatenbank (Modell C, Core TEN-01: +physisch isolierte Datenbank je Mandant) — kein `tenant_slug`-Feld in +`reconciliation_report_archive`, die Trennung ist durch die +Verbindungs-DSN selbst garantiert, nicht durch Anwendungslogik. +`TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase` beweist das +real gegen zwei unterschiedliche Testdatenbanken +(`TEST_TENANT_DSN`/`TEST_TENANT_DSN_B`). + +## Produkt-DNA + +- `SourceReport.IndexedAt` wird von `migrateReport` nirgendwo gelesen + (Feld existiert nur zur Dokumentation der Quellstruktur, wie + `SourceMessage.IndexedAt` in MAI-02). `reconciliation_report_archive` + hat bewusst keine `indexed_at`-Spalte — + `TestSourceReport_IndexedAtIsNeverPersisted` prüft das real gegen + `information_schema.columns`. +- Zugangsdaten (`TEST_TENANT_DSN`, `TEST_TENANT_DSN_B`) ausschließlich + über Umgebungsvariablen aus `/etc/nexarch-dev/tenant-dev.env`, nie im + Code oder in Prompts im Klartext. + +## Umsetzung + +- `archive/migrations/0018_reconciliation_report_archive.up.sql` / + `.down.sql` — neue Tabelle, UNIQUE auf `source_report_id` für + Idempotenz (`ON CONFLICT ... DO NOTHING`). +- `archive/internal/reconcilereportmigration` (gleiches Muster wie + MAI-02 `mail/internal/messagemigration`): + - `Source` — Schnittstelle für den SQL-Export-Zugriff auf archivmails + historische Reconciliation-Reports, Tests verwenden ausschließlich + synthetische Fixtures. + - `Migrator.MigrateAll` — überträgt alle Reports, überspringt bereits + migrierte (gleiche `source_report_id`). + - `FindBySourceID` — macht einen migrierten Report auffindbar + (Akzeptanzkriterium 1). + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| AC1 | Jeder historische Report ist nach Migration auffindbar und dem korrekten Tenant zugeordnet | **bestanden** — `TestMigrateAll_ReportsAreFindableWithOriginalContent`, `TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase` | +| Prüfung 1 | Stichprobe von Reports vor/nach Migration inhaltlich verglichen (2 synthetische Reports, vollständig, nicht nur 5-aus-N simuliert, da Fixture bewusst klein) | **bestanden** — `TestMigrateAll_ReportsAreFindableWithOriginalContent` vergleicht `report_json` und `generated_at` jedes migrierten Reports gegen die Quelle | + +Zusätzlich (nicht explizit als Pflichtprüfung benannt, aber zentral für +Idempotenz/Produkt-DNA): + +- `TestMigrateAll_SecondRunSkipsAlreadyMigratedReports` — zweiter Lauf + gegen dieselbe Quelle überschreibt nichts, Zähler bestätigt + (0 migriert / 2 übersprungen). +- `TestFindBySourceID_UnknownIDReturnsErrNotFound` — unbekannte ID + liefert definierten Fehler statt stillem Leerergebnis. +- `TestSourceReport_IndexedAtIsNeverPersisted` — Produkt-DNA, s.o. + +## Reale Ausführung (Host 204, echter Postgres-Testcluster) + +Umgebungsvariablen eingespeist über +`set -a && source /etc/nexarch-dev/tenant-dev.env && set +a` (Werte nie +im Klartext ausgegeben, nur Namen per `grep -o '^[A-Z_]*='` geprüft). + +``` +cd archive +go build ./... +go test ./internal/reconcilereportmigration/... -v +``` + +Ergebnis: **5/5 Tests bestanden**, 0 übersprungen (TEST_TENANT_DSN und +TEST_TENANT_DSN_B beide gesetzt und erreichbar), `go build ./...` ohne +Fehler. + +``` +=== RUN TestMigrateAll_ReportsAreFindableWithOriginalContent +--- PASS: TestMigrateAll_ReportsAreFindableWithOriginalContent (0.03s) +=== RUN TestMigrateAll_SecondRunSkipsAlreadyMigratedReports +--- PASS: TestMigrateAll_SecondRunSkipsAlreadyMigratedReports (0.03s) +=== RUN TestFindBySourceID_UnknownIDReturnsErrNotFound +--- PASS: TestFindBySourceID_UnknownIDReturnsErrNotFound (0.01s) +=== RUN TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase +--- PASS: TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase (0.04s) +=== RUN TestSourceReport_IndexedAtIsNeverPersisted +--- PASS: TestSourceReport_IndexedAtIsNeverPersisted (0.04s) +PASS +ok gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcilereportmigration 0.153s +``` + +Testdaten ausschließlich synthetisch (`mai04test_*`-Präfix, erfundene +Storage-Keys/Inhalte), Testzeilen per `t.Cleanup` nach jedem Lauf +gelöscht — keine echten archivmail-/archivdms-Daten verwendet. + +## Nicht Teil dieses Tickets + +- Keine echte SQL-Export-Implementierung von `Source` gegen die + tatsächliche archivmail-Datenbank (keine Zugangsdaten zu einem + System mit echten historischen Reports verfügbar/im Scope) — die + Schnittstelle ist bewusst klein gehalten, eine reale Implementierung + bindet nur SQL-Lesezugriffe gegen die dokumentierte Quelltabelle an, + ohne `Migrator`/`FindBySourceID` zu ändern (gleiches Muster wie + MAI-02). +- Kein CLI/Cmd-Wrapper — nicht durch ein Akzeptanzkriterium gefordert, + kleinste Lösung.