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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
8.3 KiB
Go
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)
|
|
}
|
|
}
|