Files
nexarch/internal/migrationverify/postgres_test.go
patrickandClaude Sonnet 5 628be89c9a
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
Core-Schnittstellen-Vertragstests / contract-tests (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
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (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
Supply-Chain-Scan (Go) / govulncheck (push) 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 (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (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
VAL-01: zeilenzahl-abgleich-kerntabelle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 20:07:29 +00:00

253 lines
8.3 KiB
Go

package migrationverify
import (
"context"
"crypto/rand"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
// Integrationstest gegen eine echte Postgres-Instanz (Host 204,
// TEST_ADMIN_DSN) mit synthetischen Tenant-Fixtures — keine echten
// archivdms-/archivmail-Daten, siehe VAL-01-Pruefprotokoll.
func setupRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS tenant_migration_map (
id BIGSERIAL PRIMARY KEY,
archivmail_tenant_id BIGINT,
archivdms_tenant_id BIGINT,
nexarch_tenant_id UUID NOT NULL UNIQUE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
approved_by TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tenant_migration_map_source_present
CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL)
);
CREATE TABLE IF NOT EXISTS user_migration_map (
id BIGSERIAL PRIMARY KEY,
archivmail_user_id BIGINT,
archivdms_user_id BIGINT,
nexarch_tenant_id UUID NOT NULL REFERENCES tenant_migration_map(nexarch_tenant_id),
nexarch_user_id UUID NOT NULL UNIQUE,
email TEXT NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
approved_by TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
}
const tenantSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE
);
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL
);`
// createSynthTenantDB legt eine eigene, echte Tenant-Datenbank mit
// users/documents-Tabellen an (Stellvertreter fuer COR-01/DOK-01-Ziel).
func createSynthTenantDB(t *testing.T, ctx context.Context, adminDSN string) (dsn string) {
t.Helper()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
dbName := "val01_" + testdb.Suffix()
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("tenant-db anlegen: %v", err)
}
t.Cleanup(func() {
cleanupPool, err := pgxpool.New(context.Background(), adminDSN)
if err == nil {
_, _ = cleanupPool.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
cleanupPool.Close()
}
})
dsn = strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
tenantPool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("tenant-db verbinden: %v", err)
}
defer tenantPool.Close()
if _, err := tenantPool.Exec(ctx, tenantSchema); err != nil {
t.Fatalf("tenant-schema anwenden: %v", err)
}
return dsn
}
func newUUIDv4() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung deckt die
// beiden Pruefungen vor Abnahme aus VAL-01 in einem Lauf ab: Bericht fuer
// mindestens 3 Test-Tenants, und eine absichtlich eingefuegte
// Zeilenzahl-Abweichung (mandant-b bekommt zwei documents-Zeilen weniger
// im Ziel als in der Quelle, ohne Erklaerung) wird erkannt.
func TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
registryPool := testdb.Pool(t, adminDSN)
setupRegistrySchema(t, ctx, registryPool)
type fixture struct {
slug string
tenantID string
pool *pgxpool.Pool
approvedUsers int
documentsSource int
documentsInTarget int
}
names := []string{"mandant-a", "mandant-b", "mandant-c"}
var fixtures []fixture
tenantIDBySlug := map[string]string{}
for i, name := range names {
dsn := createSynthTenantDB(t, ctx, adminDSN)
tenantPool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("tenant pool: %v", err)
}
t.Cleanup(tenantPool.Close)
tenantID := newUUIDv4()
slug := testdb.Slug(name)
if _, err := registryPool.Exec(ctx, `
INSERT INTO tenant_migration_map (archivmail_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, approved_by, approved_at)
VALUES ($1, NULL, $2, $3, $4, 'qa-tester', now())
`, int64(1000+i), tenantID, slug, name); err != nil {
t.Fatalf("tenant-map anlegen: %v", err)
}
tenantIDBySlug[slug] = tenantID
approvedUsers := 4
for u := 0; u < approvedUsers; u++ {
email := fmt.Sprintf("synth.%d.%d.%s@example.invalid", i, u, testdb.Suffix())
if _, err := registryPool.Exec(ctx, `
INSERT INTO user_migration_map (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, email, name, password_hash, approved_by, approved_at)
VALUES ($1, NULL, $2, $3, $4, 'Synth', 'x', 'qa-tester', now())
`, int64(2000+i*100+u), tenantID, newUUIDv4(), email); err != nil {
t.Fatalf("user-map-eintrag anlegen: %v", err)
}
if _, err := tenantPool.Exec(ctx, `INSERT INTO users (email) VALUES ($1)`, email); err != nil {
t.Fatalf("tenant-user anlegen: %v", err)
}
}
documentsSource := 10
documentsInTarget := documentsSource
if name == "mandant-b" {
// Absichtlich eingefuegte Testabweichung (Pruefung 2): zwei
// Dokumente fehlen im Ziel, ohne Erklaerung.
documentsInTarget = documentsSource - 2
}
for d := 0; d < documentsInTarget; d++ {
if _, err := tenantPool.Exec(ctx, `INSERT INTO documents (title) VALUES ($1)`, fmt.Sprintf("Dokument %d", d)); err != nil {
t.Fatalf("tenant-dokument anlegen: %v", err)
}
}
fixtures = append(fixtures, fixture{
slug: slug, tenantID: tenantID, pool: tenantPool,
approvedUsers: approvedUsers, documentsSource: documentsSource, documentsInTarget: documentsInTarget,
})
}
pools := map[string]*pgxpool.Pool{}
var slugs []string
documentSourceCounts := MapCountProvider{}
for _, f := range fixtures {
pools[f.slug] = f.pool
slugs = append(slugs, f.slug)
documentSourceCounts[f.slug] = TenantCounts{TenantSlug: f.slug, Documents: int64(f.documentsSource)}
}
usersSource := RegistryApprovedUserCountProvider{RegistryPool: registryPool, TenantIDBySlug: tenantIDBySlug}
source := CombineUsers(usersSource, documentSourceCounts)
target := PostgresTenantCountProvider{Resolver: func(ctx context.Context, slug string) (*pgxpool.Pool, error) {
pool, ok := pools[slug]
if !ok {
return nil, fmt.Errorf("kein pool fuer %q", slug)
}
return pool, nil
}}
reports, err := Run(ctx, slugs, source, target, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(reports) != len(fixtures)*3 {
t.Fatalf("erwarte %d Reports, habe %d", len(fixtures)*3, len(reports))
}
byKey := map[string]TableReport{}
for _, r := range reports {
byKey[r.TenantSlug+"/"+string(r.Table)] = r
}
for _, f := range fixtures {
usersReport := byKey[f.slug+"/users"]
if usersReport.SourceCount != int64(f.approvedUsers) || usersReport.TargetCount != int64(f.approvedUsers) {
t.Fatalf("%s/users: erwarte quelle=ziel=%d, habe %+v", f.slug, f.approvedUsers, usersReport)
}
if usersReport.OffenerBefund() {
t.Fatalf("%s/users sollte ohne Abweichung OK sein", f.slug)
}
docReport := byKey[f.slug+"/documents"]
if docReport.SourceCount != int64(f.documentsSource) || docReport.TargetCount != int64(f.documentsInTarget) {
t.Fatalf("%s/documents: erwarte quelle=%d ziel=%d, habe %+v", f.slug, f.documentsSource, f.documentsInTarget, docReport)
}
if f.slug == fixtures[1].slug {
if !docReport.OffenerBefund() {
t.Fatalf("absichtlich eingefuegte Testabweichung bei %s/documents wurde nicht erkannt", f.slug)
}
} else if docReport.OffenerBefund() {
t.Fatalf("%s/documents sollte ohne Abweichung OK sein, habe %+v", f.slug, docReport)
}
}
bericht := RenderBericht(reports)
if !strings.Contains(bericht, "OFFENER BEFUND") {
t.Fatal("bericht sollte die eingefuegte Abweichung als OFFENER BEFUND ausweisen")
}
if got := len(OffeneBefunde(reports)); got != 1 {
t.Fatalf("erwarte genau einen offenen Befund (mandant-b/documents), habe %d", got)
}
}