MAI-04: reconciliation-reports-archivierung
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s

Migriert historische Reconciliation-Reports aus archivmail als
reinen Nachweis nach Archive BAK-05 (neue Tabelle
reconciliation_report_archive statt wormstore, da chattr +i auf
dem ZFS-Testhost nicht durchsetzbar ist). Tenant-Zuordnung
strukturell ueber die Zieldatenbankverbindung (Modell C).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-10-14 20:03:48 +00:00
1 parent 1bd7e2a953
commit 0696906daf
5 files changed
+448

No files matched your search

@@ -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
}
@@ -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")
}
}
@@ -0,0 +1 @@
DROP TABLE reconciliation_report_archive;
@@ -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()
);
+123
View File
@@ -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.