Files
nexarch/internal/migration/role/migrate_test.go
T
patrickandClaude Sonnet 5.5 5b8c20777d refactor: Pruefgate-Pakete nach Funktion benannt, migration* gruppiert
- dms/ai/workflow: qa0x-Pakete nach internal/gates/<thema> verschoben, Paketname = Thema
- internal/migration{acl,audit,...} -> internal/migration/{acl,audit,...}
- Importpfade, Gate-Skripte und zwei relative Migrationspfade angepasst
- keine Logikaenderung; mail-Gates bewusst unveraendert (pfadabhaengig)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:44:21 +01:00

347 lines
12 KiB
Go

package migrationrole
import (
"context"
"crypto/rand"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
// Integrationstest gegen eine echte Postgres-Instanz mit frei erfundenen,
// synthetischen Rollen-Map-Eintraegen (fiktive Benutzer-IDs, fiktive
// source_role-Werte) -- keine echten archivmail-/archivdms-Exportdaten.
// Siehe Pruefprotokoll COR-03 fuer die dokumentierte Substitutionsentscheidung.
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 tenants (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
previous_status TEXT,
deletion_scheduled_at TIMESTAMPTZ,
retention_block_reason TEXT,
retention_checked_at TIMESTAMPTZ
);
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(),
CONSTRAINT user_migration_map_source_present
CHECK (archivmail_user_id IS NOT NULL OR archivdms_user_id IS NOT NULL)
);
CREATE TABLE IF NOT EXISTS role_migration_map (
id BIGSERIAL PRIMARY KEY,
user_migration_map_id BIGINT NOT NULL UNIQUE REFERENCES user_migration_map(id),
source_system TEXT NOT NULL CHECK (source_system IN ('archivmail', 'archivdms')),
source_role TEXT NOT NULL,
granted_by TEXT,
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,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
role TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`
func createSynthTenantDB(t *testing.T, ctx context.Context, adminDSN string, registryPool *pgxpool.Pool, tenantID string) (string, *pgxpool.Pool) {
t.Helper()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
dbName := "cor03_" + 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)
}
if _, err := tenantPool.Exec(ctx, tenantSchema); err != nil {
t.Fatalf("tenant-schema anwenden: %v", err)
}
slug := "synth_" + testdb.Suffix()
if _, err := registryPool.Exec(ctx, `
INSERT INTO tenants (id, slug, name, db_name, db_dsn, status)
VALUES ($1, $2, $3, $4, $5, 'active')
`, tenantID, slug, "Synthetische Test GmbH", dbName, dsn); err != nil {
t.Fatalf("tenant registrieren: %v", err)
}
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, $1, $2, $3, 'qa-tester', now())
`, tenantID, slug, "Synthetische Test GmbH"); err != nil {
t.Fatalf("tenant-map-eintrag anlegen: %v", err)
}
t.Cleanup(func() { tenantPool.Close() })
return dsn, tenantPool
}
type synthUser struct {
userMapID int64
nexarchUserID string
}
// insertSynthUser legt den Map-Eintrag UND (wie COR-02 es fuer den echten
// Benutzer bereits getan haette) die zugehoerige users-Zeile in der
// Tenant-Datenbank an -- role_assignments referenziert users(id) per FK.
func insertSynthUser(t *testing.T, ctx context.Context, registryPool, tenantPool *pgxpool.Pool, tenantID, userSeq string, idx int) synthUser {
t.Helper()
userID := newUUIDv4()
email := fmt.Sprintf("synth.role.user.%d.%s@example.invalid", idx, userSeq)
name := fmt.Sprintf("Synthetischer Rollen-Nutzer %d %s", idx, userSeq)
if _, err := tenantPool.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, 'synth-hash')
`, userID, email, name); err != nil {
t.Fatalf("tenant-user %d anlegen: %v", idx, err)
}
var mapID int64
err := registryPool.QueryRow(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, $5, 'synth-hash', 'qa-tester', now())
RETURNING id
`, int64(4000+idx), tenantID, userID, email, name).Scan(&mapID)
if err != nil {
t.Fatalf("user-map-eintrag %d anlegen: %v", idx, err)
}
return synthUser{userMapID: mapID, nexarchUserID: userID}
}
// TestMigrateApproved_ArchivmailAndArchivdms deckt AC 1/2 ab: je eine
// archivmail- und eine archivdms-Quellrolle, beide auf eine bekannte
// Core-Rolle gemappt, werden korrekt zugewiesen.
func TestMigrateApproved_ArchivmailAndArchivdms(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)
registry := tenant.NewRegistry(registryPool)
tenantID := newUUIDv4()
suffix := testdb.Suffix()
_, tenantPool := createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID)
mailAdmin := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 1)
dmsReader := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 2)
if _, err := registryPool.Exec(ctx, `
INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at)
VALUES ($1, 'archivmail', 'Admin', 'altsystem-import', 'qa-tester', now())
`, mailAdmin.userMapID); err != nil {
t.Fatalf("role-map (archivmail) anlegen: %v", err)
}
if _, err := registryPool.Exec(ctx, `
INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at)
VALUES ($1, 'archivdms', 'Leser', 'altsystem-import', 'qa-tester', now())
`, dmsReader.userMapID); err != nil {
t.Fatalf("role-map (archivdms) anlegen: %v", err)
}
mapStore := NewMapStore(registryPool)
migrator := NewMigrator(mapStore, registry)
defer migrator.Close()
res, err := migrator.MigrateApproved(ctx)
if err != nil {
t.Fatalf("MigrateApproved: %v", err)
}
if len(res.Assigned) != 2 {
t.Fatalf("erwarte 2 zugewiesene rollen, habe %d", len(res.Assigned))
}
store := rbac.NewStore(tenantPool)
gotMail, err := store.Get(ctx, mailAdmin.nexarchUserID)
if err != nil {
t.Fatalf("rollenzuweisung (archivmail) lesen: %v", err)
}
if gotMail.Role != rbac.RoleTenantAdmin {
t.Fatalf("erwarte tenant_admin fuer archivmail-rolle 'Admin', habe %s", gotMail.Role)
}
gotDms, err := store.Get(ctx, dmsReader.nexarchUserID)
if err != nil {
t.Fatalf("rollenzuweisung (archivdms) lesen: %v", err)
}
if gotDms.Role != rbac.RoleUser {
t.Fatalf("erwarte user fuer archivdms-gruppe 'Leser', habe %s", gotDms.Role)
}
// erneuter Lauf bleibt idempotent (ON CONFLICT DO UPDATE in rbac.Store.Assign).
if _, err := migrator.MigrateApproved(ctx); err != nil {
t.Fatalf("zweiter MigrateApproved-Lauf: %v", err)
}
var count int
if err := tenantPool.QueryRow(ctx, `SELECT count(*) FROM role_assignments WHERE user_id = $1`, mailAdmin.nexarchUserID).Scan(&count); err != nil {
t.Fatalf("duplikat-pruefung: %v", err)
}
if count != 1 {
t.Fatalf("erwarte genau 1 rollenzuweisung nach erneutem lauf, habe %d", count)
}
}
// TestMigrateApproved_UnmappedRoleFails deckt AC 3 ab: eine unbekannte
// archivmail-/archivdms-Quellrolle darf niemals stillschweigend eine Rolle
// erhalten.
func TestMigrateApproved_UnmappedRoleFails(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)
registry := tenant.NewRegistry(registryPool)
tenantID := newUUIDv4()
suffix := testdb.Suffix()
_, tenantPool := createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID)
unknown := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 1)
if _, err := registryPool.Exec(ctx, `
INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at)
VALUES ($1, 'archivmail', 'super-freitext-rolle-die-es-nicht-gibt', 'altsystem-import', 'qa-tester', now())
`, unknown.userMapID); err != nil {
t.Fatalf("role-map (unbekannt) anlegen: %v", err)
}
mapStore := NewMapStore(registryPool)
migrator := NewMigrator(mapStore, registry)
defer migrator.Close()
if _, err := migrator.MigrateApproved(ctx); err == nil {
t.Fatal("erwarte fehler bei unbekannter quellrolle, habe nil")
}
}
// TestMigrateApproved_UnapprovedSkipped deckt den Freigabe-Gate ab: ein
// Role-Map-Eintrag ohne approved_at erhaelt keine Rolle.
func TestMigrateApproved_UnapprovedSkipped(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)
registry := tenant.NewRegistry(registryPool)
tenantID := newUUIDv4()
suffix := testdb.Suffix()
_, tenantPool := createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID)
unapproved := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 1)
if _, err := registryPool.Exec(ctx, `
INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at)
VALUES ($1, 'archivdms', 'Standard', 'altsystem-import', NULL, NULL)
`, unapproved.userMapID); err != nil {
t.Fatalf("role-map (unfreigegeben) anlegen: %v", err)
}
mapStore := NewMapStore(registryPool)
migrator := NewMigrator(mapStore, registry)
defer migrator.Close()
res, err := migrator.MigrateApproved(ctx)
if err != nil {
t.Fatalf("MigrateApproved: %v", err)
}
if len(res.Assigned) != 0 {
t.Fatalf("erwarte 0 zugewiesene rollen (kein freigegebener eintrag), habe %d", len(res.Assigned))
}
}
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])
}