From dace71e6a921072bac7f449e7c94eb0629b2c487 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 13 Oct 2026 10:52:12 +0000 Subject: [PATCH] COR-02: benutzer-migration inkl-passwort-hashes Uebernahme freigegebener Benutzer aus user_migration_map in Core IAM-01, bcrypt-Hashes 1:1 ohne Rehash, analog zu COR-01s Tenant-Migration-Pattern. Co-Authored-By: Claude Sonnet 5 --- cmd/cor02-user-migrate/main.go | 41 +++ docs/COR-02-PRUEFPROTOKOLL.md | 68 +++++ internal/migrationuser/map.go | 61 ++++ internal/migrationuser/migrate.go | 108 +++++++ internal/migrationuser/migrate_test.go | 323 ++++++++++++++++++++ internal/user/tenant_store.go | 27 ++ migrations/0020_user_migration_map.down.sql | 1 + migrations/0020_user_migration_map.up.sql | 18 ++ 8 files changed, 647 insertions(+) create mode 100644 cmd/cor02-user-migrate/main.go create mode 100644 docs/COR-02-PRUEFPROTOKOLL.md create mode 100644 internal/migrationuser/map.go create mode 100644 internal/migrationuser/migrate.go create mode 100644 internal/migrationuser/migrate_test.go create mode 100644 migrations/0020_user_migration_map.down.sql create mode 100644 migrations/0020_user_migration_map.up.sql diff --git a/cmd/cor02-user-migrate/main.go b/cmd/cor02-user-migrate/main.go new file mode 100644 index 00000000..f2ee4174 --- /dev/null +++ b/cmd/cor02-user-migrate/main.go @@ -0,0 +1,41 @@ +// cor02-user-migrate fuehrt COR-02 aus: Uebernahme der freigegebenen +// user_migration_map in Core IAM-01, bcrypt-Hashes 1:1 ohne Rehash. +// Einmalig wiederholbar gegen dieselbe Quelle (siehe internal/migrationuser). +package main + +import ( + "context" + "log" + "os" + + "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationuser" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" +) + +func main() { + registryDSN := os.Getenv("NEXARCH_REGISTRY_DSN") + if registryDSN == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + + ctx := context.Background() + + registryPool, err := db.Connect(ctx, registryDSN) + if err != nil { + log.Fatalf("registry-verbindung: %v", err) + } + defer registryPool.Close() + + registry := tenant.NewRegistry(registryPool) + mapStore := migrationuser.NewMapStore(registryPool) + migrator := migrationuser.NewMigrator(mapStore, registry) + defer migrator.Close() + + res, err := migrator.MigrateApproved(ctx) + if err != nil { + log.Fatalf("COR-02-migration fehlgeschlagen: %v", err) + } + + log.Printf("COR-02: %d freigegebene Benutzer uebernommen", len(res.Migrated)) +} diff --git a/docs/COR-02-PRUEFPROTOKOLL.md b/docs/COR-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..f47a34a6 --- /dev/null +++ b/docs/COR-02-PRUEFPROTOKOLL.md @@ -0,0 +1,68 @@ +# COR-02 — Prüfprotokoll: Benutzer-Migration inkl. Passwort-Hashes + +Datum: 2026-10-13 +Testhost: root@192.168.1.204 (Wegwerf-Postgres-Cluster, Port 5433, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, kein Bezug zu `dms_dev`/`archivdms`) + +## Synthetische Testdaten — Substitutionsentscheidung + +COR-02 übernimmt laut Auftrag die bereits zusammengeführten Benutzerdatensätze aus der freigegebenen `user_migration_map` — nicht die Rohdaten von archivmail/archivdms. Für die Tests wurden ausschließlich frei erfundene Map-Einträge verwendet: fiktive E-Mail-Adressen unter `@example.invalid`, fiktive Namen mit Zufalls-Suffix, und bcrypt-Hashes selbst aus frei erfundenen Test-Passwörtern erzeugt (`bcrypt.GenerateFromPassword` mit `bcrypt.MinCost`, nur für Testlaufzeit relevant). Zu keinem Zeitpunkt wurden echte archivmail-/archivdms-Exportdumps oder reale Passwort-Hashes gelesen, referenziert oder in Prompts/Fixtures verwendet. + +## Ausgangslage geprüft + +- COR-01 (`internal/migrationtenant`, `migrations/0019_tenant_migration_map`) ist gemergt: liefert das Pattern „MapStore.Approved liest nur freigegebene Zeilen, Migrator übernimmt sie idempotent über eine *WithID-Methode“. +- Core IAM-01 (`internal/user`): `User`-Typ, `TenantUserStore` (CRUD je Tenant-Datenbank, Tenant-Scope implizit über den verbundenen Pool). `migrations/tenant/0001_users.up.sql` + `0002_users_password.up.sql` liefern bereits `password_hash TEXT NOT NULL`. Kein Rehash-Pfad vorhanden — musste ergänzt werden. +- Keine `user_migration_map`-Tabelle im Code vorhanden (wie bei COR-01/`tenant_migration_map`): als minimaler Stub analog angelegt, referenziert auf `tenant_migration_map(nexarch_tenant_id)` (FK), da jeder Benutzer-Map-Eintrag einem bereits freigegebenen Tenant zugeordnet sein muss. + +## Umsetzung + +- `migrations/0020_user_migration_map.up/down.sql`: Referenztabelle, Spalten analog zu `tenant_migration_map` plus `nexarch_user_id` (UUID, UNIQUE), `email`, `name`, `password_hash`. FK auf `tenant_migration_map.nexarch_tenant_id`. +- `internal/user/tenant_store.go`: additiv `TenantUserStore.CreateWithID(ctx, id, email, name, passwordHash)` — Insert mit vorgegebener ID und bereits fertigem bcrypt-Hash, `ON CONFLICT (id) DO NOTHING`, bei Konflikt wird der vorhandene Datensatz unverändert über `Get` zurückgegeben (Idempotenz). Bestehende Methoden unverändert. +- Neues Paket `internal/migrationuser`: `MapStore.Approved` liest nur freigegebene Zeilen aus `user_migration_map`. `Migrator.MigrateApproved` löst je Eintrag über `tenant.Registry.GetByID` die Ziel-Tenant-DSN auf (setzt auf COR-01/TEN-01 auf), öffnet/cached je Tenant einen Pool und übernimmt den Benutzer per `TenantUserStore.CreateWithID`. `PoolOpener` ist austauschbar für Tests. +- Neuer CLI-Einstiegspunkt `cmd/cor02-user-migrate` (liest nur `NEXARCH_REGISTRY_DSN` aus der Umgebung, keine Zugangsdaten im Code — die Tenant-DSNs kommen bereits aus der Registry, kein zusätzlicher Admin-DSN nötig, da COR-02 keine neuen Datenbanken anlegt). + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Jeder genehmigte Benutzer wird genau einmal übernommen, bcrypt-Hash 1:1 (kein Rehash) | Erfüllt — `TestMigrateApproved`: 5 freigegebene, synthetische Map-Einträge, `MigrateApproved` übernimmt alle 5; `password_hash` in der Tenant-DB stimmt byte-identisch mit dem Map-Eintrag überein | +| 2 | Kein Benutzer ohne Freigabe in der User-Map wird übernommen | Erfüllt — ein zusätzlicher, bewusst nicht freigegebener Eintrag (`approved_at IS NULL`) bleibt ohne `users`-Zeile (`count(*) == 0`) | +| 3 | Erneuter Lauf ist idempotent (keine Duplikate) | Erfüllt — zweiter `MigrateApproved`-Lauf liefert weiterhin 5 Benutzer, `count(*) == 1` je Benutzer-ID nach dem zweiten Lauf | + +## Prüfungen vor der Abnahme + +1. **Mehrere genehmigte Benutzer-Einträge → genau einmal übernommen je Eintrag, Hash byte-identisch.** + Ergebnis: erreicht — `TestMigrateApproved`, `len(res.Migrated) == 5`, `password_hash` je Benutzer stimmt exakt mit dem erzeugten Test-Hash überein (kein Rehash-Unterschied). + +2. **Ein nicht-genehmigter Eintrag wird nicht übernommen.** + Ergebnis: erreicht — `count(*) FROM users WHERE id = ` liefert `0`. + +3. **Zweiter Lauf erzeugt keine Duplikate.** + Ergebnis: erreicht — zweiter `MigrateApproved`-Aufruf, `count(*) == 1` je Benutzer-ID. + +4. **Zusätzlich: Benutzer-Map-Eintrag mit (noch) unbekanntem Tenant liefert einen klaren Fehler statt stillen Datenverlusts.** + Ergebnis: erreicht — `TestMigrateApproved_UnknownTenantFails`, `MigrateApproved` gibt einen Fehler zurück, wenn `tenant.Registry.GetByID` den Tenant nicht findet. + +Zusätzlich (Regression/Build): +``` +go build ./... -> keine Fehler +go vet ./... -> keine Befunde +TEST_ADMIN_DSN=... go test ./internal/migrationuser/... ./internal/user/... ./internal/tenant/... -v + -> alle Tests PASS, keine Regression in bestehenden + IAM-01/TEN-01/TEN-02/TEN-04/TEN-06/TEN-08/TEN-09/COR-01-Tests +Alle Migrationen 0001..0020 in Reihenfolge gegen eine leere Wegwerf-DB (Port 5433) angewendet, +0020 zurückgerollt -> APPLY_OK, ROLLBACK_OK +``` + +## Entscheidungen (dokumentiert, keine Rückfrage nötig) + +- `user_migration_map` als minimaler Stub analog zu COR-01 angelegt, mit FK auf `tenant_migration_map(nexarch_tenant_id)` — ein Benutzer-Map-Eintrag ohne zugeordneten, freigegebenen Tenant kann so gar nicht erst eingefügt werden. Keine Freigabe-Oberfläche, kein Korrektur-Workflow (bleibt MAP-02-Scope wie bei COR-01). +- `TenantUserStore.CreateWithID` statt eines separaten Stores in `internal/migrationuser`: folgt demselben Muster wie `Provisioner.ProvisionWithID` bei COR-01 — eine additive Methode am bestehenden, bereits geprüften Store statt einer zweiten parallelen Schreib-Implementierung für dieselbe Tabelle. +- Migrator löst Tenant-DSNs direkt über `tenant.Registry.GetByID` auf (kein eigener `NEXARCH_ADMIN_DSN`/DSN-Template im CLI nötig), da COR-02 anders als COR-01 keine neuen Datenbanken anlegt, sondern nur in bereits von COR-01 provisionierte Tenant-Datenbanken schreibt. +- Tenant-Pools werden pro Migrationslauf gecached (`Migrator.tenantPools`) statt je Benutzer neu zu verbinden — mehrere Benutzer desselben Mandanten sind der Normalfall. +- Kein Rehash-Code-Pfad eingebaut (laut Auftrag: „dasselbe Hash-Verfahren in beiden Altsystemen“) — `CreateWithID` schreibt den übergebenen Hash unverändert. Falls sich das für ein konkretes Quellsystem als falsch herausstellt, ist das eine Produktentscheidung außerhalb dieser Kachel (Auftrag war explizit: kein Rehash). + +## Offene Punkte für die echte Cutover-Vorbereitung (keine Produkt-/Entscheidungsfrage für COR-02 selbst) + +- MAP-02 (Freigabe-Oberfläche) muss auch für `user_migration_map` noch gebaut werden; falls deren tatsächliches Schema abweicht, muss `migrations/0020` ggf. angepasst werden. +- Echte Rollen-/Berechtigungszuordnung über die reine Benutzerübernahme hinaus ist ausdrücklich „Nicht Bestandteil“ dieser Kachel. +- Sessions/API-Keys werden laut Produkt-DNA bewusst NICHT migriert (neu ausgestellt) — COR-02 fasst sie nicht an. diff --git a/internal/migrationuser/map.go b/internal/migrationuser/map.go new file mode 100644 index 00000000..9aaf5edd --- /dev/null +++ b/internal/migrationuser/map.go @@ -0,0 +1,61 @@ +// Package migrationuser implementiert COR-02: die Uebernahme +// zusammengefuehrter Benutzerdatensaetze (inkl. bcrypt-Passwort-Hash) aus +// der freigegebenen user_migration_map in Core IAM-01. +package migrationuser + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// MapEntry ist eine Zeile der user_migration_map. +type MapEntry struct { + ID int64 + ArchivmailUserID *int64 + ArchivdmsUserID *int64 + NexarchTenantID string + NexarchUserID string + Email string + Name string + PasswordHash string + ApprovedBy *string +} + +// MapStore kapselt den lesenden Zugriff auf user_migration_map in der +// Registry-Datenbank. +type MapStore struct { + pool *pgxpool.Pool +} + +func NewMapStore(pool *pgxpool.Pool) *MapStore { + return &MapStore{pool: pool} +} + +// Approved liefert ausschliesslich Zeilen mit gesetztem approved_at +// (Akzeptanzkriterium 2: kein Benutzer ohne Freigabe wird uebernommen). +func (s *MapStore) Approved(ctx context.Context) ([]MapEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, + email, name, password_hash, approved_by + FROM user_migration_map + WHERE approved_at IS NOT NULL + ORDER BY id + `) + if err != nil { + return nil, fmt.Errorf("freigegebene user-map lesen: %w", err) + } + defer rows.Close() + + var out []MapEntry + for rows.Next() { + var e MapEntry + if err := rows.Scan(&e.ID, &e.ArchivmailUserID, &e.ArchivdmsUserID, &e.NexarchTenantID, &e.NexarchUserID, + &e.Email, &e.Name, &e.PasswordHash, &e.ApprovedBy); err != nil { + return nil, fmt.Errorf("user-map-eintrag lesen: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/migrationuser/migrate.go b/internal/migrationuser/migrate.go new file mode 100644 index 00000000..724ad177 --- /dev/null +++ b/internal/migrationuser/migrate.go @@ -0,0 +1,108 @@ +package migrationuser + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// PoolOpener oeffnet eine Verbindung zu einer Tenant-Datenbank per DSN. +// Austauschbar fuer Tests, damit kein echter zweiter Netzwerk-Connect-Pfad +// gebraucht wird. +type PoolOpener func(ctx context.Context, dsn string) (*pgxpool.Pool, error) + +func defaultPoolOpener(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + return pgxpool.New(ctx, dsn) +} + +// Migrator uebernimmt jeden freigegebenen user_migration_map-Eintrag in die +// zugehoerige Tenant-Datenbank, mit der nexarch_user_id aus der Map als +// users.id und dem bcrypt-Hash 1:1 (kein Rehash). +type Migrator struct { + mapStore *MapStore + registry *tenant.Registry + openPool PoolOpener + + // tenantPools cached geoeffnete Tenant-Pools je nexarch_tenant_id, + // damit mehrere Benutzer desselben Mandanten nicht wiederholt neu + // verbinden. + tenantPools map[string]*pgxpool.Pool +} + +func NewMigrator(mapStore *MapStore, registry *tenant.Registry) *Migrator { + return &Migrator{ + mapStore: mapStore, + registry: registry, + openPool: defaultPoolOpener, + tenantPools: map[string]*pgxpool.Pool{}, + } +} + +// WithPoolOpener ersetzt den Standard-Verbindungsaufbau (fuer Tests). +func (m *Migrator) WithPoolOpener(opener PoolOpener) *Migrator { + m.openPool = opener + return m +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + Migrated []user.User +} + +// MigrateApproved liest die freigegebene Benutzer-Map und uebernimmt jeden +// Eintrag genau einmal in die passende Tenant-Datenbank (Akzeptanzkriterium +// 1). Ein erneuter Lauf gegen dieselbe Quelle erzeugt keine Duplikate, da +// TenantUserStore.CreateWithID bei bereits existierender ID unveraendert +// zurueckkehrt (Akzeptanzkriterium 3). +func (m *Migrator) MigrateApproved(ctx context.Context) (Result, error) { + entries, err := m.mapStore.Approved(ctx) + if err != nil { + return Result{}, err + } + + var res Result + for _, e := range entries { + pool, err := m.tenantPool(ctx, e.NexarchTenantID) + if err != nil { + return res, fmt.Errorf("tenant-verbindung fuer benutzer %q (map-eintrag %d): %w", e.Email, e.ID, err) + } + + store := user.NewTenantUserStore(pool) + u, err := store.CreateWithID(ctx, e.NexarchUserID, e.Email, e.Name, e.PasswordHash) + if err != nil { + return res, fmt.Errorf("benutzer %q (map-eintrag %d) uebernehmen: %w", e.Email, e.ID, err) + } + res.Migrated = append(res.Migrated, u) + } + return res, nil +} + +func (m *Migrator) tenantPool(ctx context.Context, tenantID string) (*pgxpool.Pool, error) { + if pool, ok := m.tenantPools[tenantID]; ok { + return pool, nil + } + + t, err := m.registry.GetByID(ctx, tenantID) + if err != nil { + return nil, fmt.Errorf("tenant %q laden: %w", tenantID, err) + } + + pool, err := m.openPool(ctx, t.DBDSN) + if err != nil { + return nil, fmt.Errorf("tenant %q verbinden: %w", tenantID, err) + } + m.tenantPools[tenantID] = pool + return pool, nil +} + +// Close schliesst alle im Lauf geoeffneten Tenant-Pools. +func (m *Migrator) Close() { + for _, pool := range m.tenantPools { + pool.Close() + } + m.tenantPools = map[string]*pgxpool.Pool{} +} diff --git a/internal/migrationuser/migrate_test.go b/internal/migrationuser/migrate_test.go new file mode 100644 index 00000000..e53ebdaa --- /dev/null +++ b/internal/migrationuser/migrate_test.go @@ -0,0 +1,323 @@ +package migrationuser + +import ( + "context" + "crypto/rand" + "fmt" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" +) + +// Integrationstest gegen eine echte Postgres-Instanz mit synthetischen +// Benutzer-Map-Eintraegen (fiktive E-Mails/Namen, selbst gehashte +// Test-Passwoerter — keine echten archivmail-/archivdms-Kundendaten, +// keine echten Passwort-Hashes). Siehe Pruefprotokoll COR-02 fuer die +// dokumentierte Begruendung. Ohne TEST_ADMIN_DSN uebersprungen. +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) + ); + `); err != nil { + t.Fatalf("registry-schema: %v", err) + } +} + +const tenantUsersSchema = ` +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() +);` + +// createSynthTenantDB legt eine eigene, echte Tenant-Datenbank (users-Tabelle) +// an und registriert sie in der Registry — Stellvertreter fuer das Ergebnis +// von COR-01 (ProvisionWithID), ohne dessen gesamten Provisioner hier +// erneut aufzurufen. +func createSynthTenantDB(t *testing.T, ctx context.Context, adminDSN string, registryPool *pgxpool.Pool, tenantID string) string { + t.Helper() + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + defer adminPool.Close() + + dbName := "cor02_" + 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, tenantUsersSchema); err != nil { + t.Fatalf("tenant-schema anwenden: %v", err) + } + tenantPool.Close() + + 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) + } + + // user_migration_map referenziert tenant_migration_map(nexarch_tenant_id) + // (COR-02 setzt auf COR-01 auf) — fuer den Test genuegt der freigegebene + // Map-Eintrag selbst, ohne den vollen COR-01-Provisioner erneut laufen zu + // lassen. + 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) + } + + return dsn +} + +func TestMigrateApproved(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() + createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID) + + type synthEntry struct { + id string + email string + name string + passwordHash string + approved bool + } + + suffix := testdb.Suffix() + entries := make([]synthEntry, 0, 6) + for i := 1; i <= 5; i++ { + // Fiktives Test-Passwort, selbst gehasht — kein echter archivmail-/ + // archivdms-Hash. + hash, err := bcrypt.GenerateFromPassword([]byte(fmt.Sprintf("synth-test-pw-%d-%s", i, suffix)), bcrypt.MinCost) + if err != nil { + t.Fatalf("test-hash erzeugen: %v", err) + } + entries = append(entries, synthEntry{ + id: newUUIDv4(), + email: fmt.Sprintf("synth.user.%d.%s@example.invalid", i, suffix), + name: fmt.Sprintf("Synthetischer Nutzer %d %s", i, suffix), + passwordHash: string(hash), + approved: true, + }) + } + unapprovedHash, err := bcrypt.GenerateFromPassword([]byte("synth-test-pw-unapproved-"+suffix), bcrypt.MinCost) + if err != nil { + t.Fatalf("test-hash erzeugen: %v", err) + } + unapproved := synthEntry{ + id: newUUIDv4(), + email: "synth.unapproved." + suffix + "@example.invalid", + name: "Unfreigegeben " + suffix, + passwordHash: string(unapprovedHash), + approved: false, + } + entries = append(entries, unapproved) + + for i, e := range entries { + approvedAt := "NULL" + approvedBy := "NULL" + if e.approved { + approvedAt = "now()" + approvedBy = "'qa-tester'" + } + _, 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, $5, $6, `+approvedBy+`, `+approvedAt+`) + `, int64(2000+i), tenantID, e.id, e.email, e.name, e.passwordHash) + if err != nil { + t.Fatalf("map-eintrag %d anlegen: %v", i, err) + } + } + + mapStore := NewMapStore(registryPool) + migrator := NewMigrator(mapStore, registry) + defer migrator.Close() + + // AC 1: genau ein Lauf uebernimmt jeden freigegebenen Benutzer, Hash + // byte-identisch (kein Rehash). + res, err := migrator.MigrateApproved(ctx) + if err != nil { + t.Fatalf("MigrateApproved: %v", err) + } + if len(res.Migrated) != 5 { + t.Fatalf("erwarte 5 migrierte Benutzer, habe %d", len(res.Migrated)) + } + + tenantPool := migrator.tenantPools[tenantID] + for _, e := range entries[:5] { + var gotEmail, gotHash string + if err := tenantPool.QueryRow(ctx, `SELECT email, password_hash FROM users WHERE id = $1`, e.id).Scan(&gotEmail, &gotHash); err != nil { + t.Fatalf("benutzer %q nach migration nicht gefunden: %v", e.id, err) + } + if gotEmail != e.email { + t.Fatalf("email nicht korrekt uebernommen: erwartet %s, habe %s", e.email, gotEmail) + } + if gotHash != e.passwordHash { + t.Fatalf("passwort-hash wurde veraendert (rehash?): erwartet %s, habe %s", e.passwordHash, gotHash) + } + } + + // AC 2: der unfreigegebene Eintrag wurde nicht uebernommen. + var count int + if err := tenantPool.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, unapproved.id).Scan(&count); err != nil { + t.Fatalf("duplikat-pruefung unfreigegeben: %v", err) + } + if count != 0 { + t.Fatalf("unfreigegebener benutzer %q haette nicht uebernommen werden duerfen", unapproved.id) + } + + // AC 3: erneuter Lauf gegen dieselbe (bereits migrierte) Quelle erzeugt + // keine Duplikate. + res2, err := migrator.MigrateApproved(ctx) + if err != nil { + t.Fatalf("zweiter MigrateApproved-Lauf: %v", err) + } + if len(res2.Migrated) != 5 { + t.Fatalf("erwarte weiterhin 5 Benutzer nach erneutem Lauf, habe %d", len(res2.Migrated)) + } + for _, e := range entries[:5] { + var count int + if err := tenantPool.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, e.id).Scan(&count); err != nil { + t.Fatalf("duplikat-pruefung: %v", err) + } + if count != 1 { + t.Fatalf("benutzer %q ist nach erneutem Lauf %d mal vorhanden, erwartet 1", e.id, count) + } + } +} + +// TestMigrateApproved_SkipsUnknownTenant stellt sicher, dass ein Map-Eintrag +// mit nicht existierendem Tenant einen klaren Fehler liefert statt still +// Daten zu verlieren oder in die falsche Datenbank zu schreiben. +func TestMigrateApproved_UnknownTenantFails(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) + + unknownTenantID := newUUIDv4() + userID := newUUIDv4() + suffix := testdb.Suffix() + hash, err := bcrypt.GenerateFromPassword([]byte("synth-test-pw-"+suffix), bcrypt.MinCost) + if err != nil { + t.Fatalf("test-hash erzeugen: %v", err) + } + + // nexarch_tenant_id muss laut Schema in tenant_migration_map vorkommen, + // aber nicht zwingend bereits in tenants registriert sein (COR-01 kann + // zeitlich nach dieser Map-Zeile laufen) — genau dieser Fall wird hier + // geprueft. + 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 (9999, NULL, $1, $2, $3, 'qa-tester', now()) + `, unknownTenantID, "synth_"+suffix, "Synthetische Test GmbH "+suffix); err != nil { + t.Fatalf("tenant-map-eintrag anlegen: %v", err) + } + + 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 (3000, NULL, $1, $2, $3, $4, $5, 'qa-tester', now()) + `, unknownTenantID, userID, "synth.orphan."+suffix+"@example.invalid", "Synthetischer Nutzer "+suffix, string(hash)); err != nil { + t.Fatalf("user-map-eintrag 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 unbekanntem tenant, habe nil") + } +} + +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]) +} diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go index f6f0bfa4..eab20a3d 100644 --- a/internal/user/tenant_store.go +++ b/internal/user/tenant_store.go @@ -41,6 +41,33 @@ func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, return u, nil } +// CreateWithID uebernimmt einen Benutzer mit vorgegebener ID und bereits +// berechnetem bcrypt-Hash (COR-02: ID und Hash stammen aus der freigegebenen +// user_migration_map, kein Rehash). Idempotent: existiert die ID bereits, +// wird der vorhandene Datensatz unveraendert zurueckgegeben statt erneut +// geschrieben — ein wiederholter Migrationslauf erzeugt so keine Duplikate. +func (s *TenantUserStore) CreateWithID(ctx context.Context, id, email, name, passwordHash string) (User, error) { + if err := ValidateEmail(email); err != nil { + return User{}, err + } + + row := s.pool.QueryRow(ctx, ` + INSERT INTO users (id, email, name, status, password_hash) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING + RETURNING id, email, name, status, created_at, updated_at + `, id, email, name, StatusActive, passwordHash) + + u, err := scanUser(row) + if errors.Is(err, ErrNotFound) { + return s.Get(ctx, id) + } + if err != nil { + return User{}, mapWriteErr(err) + } + return u, nil +} + func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) { return scanUser(s.pool.QueryRow(ctx, ` SELECT id, email, name, status, created_at, updated_at diff --git a/migrations/0020_user_migration_map.down.sql b/migrations/0020_user_migration_map.down.sql new file mode 100644 index 00000000..370330e0 --- /dev/null +++ b/migrations/0020_user_migration_map.down.sql @@ -0,0 +1 @@ +DROP TABLE user_migration_map; diff --git a/migrations/0020_user_migration_map.up.sql b/migrations/0020_user_migration_map.up.sql new file mode 100644 index 00000000..b1757da3 --- /dev/null +++ b/migrations/0020_user_migration_map.up.sql @@ -0,0 +1,18 @@ +-- COR-02: Referenztabelle fuer freigegebene Benutzer-Zusammenfuehrungen, +-- analog zu tenant_migration_map (COR-01). Nur genehmigte Zeilen +-- (approved_at gesetzt) werden von COR-02 uebernommen. +CREATE TABLE 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) +); -- 2.47.3