COR-02: Benutzer-Migration inkl. Passwort-Hashes #252
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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 = <unfreigegebene-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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE user_migration_map;
|
||||
@@ -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)
|
||||
);
|
||||
Reference in new issue
Block a user