diff --git a/docs/MAP-04-PRUEFPROTOKOLL.md b/docs/MAP-04-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..838b9931 --- /dev/null +++ b/docs/MAP-04-PRUEFPROTOKOLL.md @@ -0,0 +1,48 @@ +# MAP-04 Prüfprotokoll – User-Migration-Map & Freigabe-Workflow + +Datum: 2026-10-14 +Umgebung: Host 204 (`root@192.168.1.204`), echter Postgres-Testcluster via `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env` (Werte nie im Klartext ausgegeben, nur per `set -a && source ... && set +a` eingespeist). Alle Testdaten synthetisch (`*.example.invalid`-E-Mails, selbst erzeugte Test-Hashes), keine echten archivmail-/archivdms-Kundendaten. + +## Umsetzung + +- Neues Paket `internal/usermigrationapproval` (Store, `Entry`, `AuditEntry`), 1:1 nach dem Muster von MAP-02 (`internal/tenantapproval`), eine Ebene tiefer (Benutzer statt Tenant): + - `InsertCandidates` legt `usermatch.Candidate`-Zeilen (MAP-03) als `status='pending'` in `user_migration_map` an (einseitige Kandidaten ohne jede Quelle werden verworfen). + - `Pending()` / `Approved()` / `Get()` als Lesezugriffe; `Approved()` ist exakt der Filter, den COR-02 (`internal/migrationuser.MapStore.Approved`, `WHERE approved_at IS NOT NULL`) bereits nutzt. + - `Approve(id, approvedBy, note)` / `Reject(id, rejectedBy, note)` protokollieren jede Entscheidung transaktional in `user_migration_map_audit` (Zeitpunkt `performed_at`, handelnde Person `performed_by`). +- Migration `migrations/0025_user_migration_map_approval.{up,down}.sql`: erweitert die bestehende `user_migration_map` (COR-02-Tabelle aus 0020/0023) additiv um `status`/`rejected_by`/`rejected_at` plus Check-Constraints, analog zu `0024_tenant_migration_map_approval.up.sql` (MAP-02). Neue Tabelle `user_migration_map_audit`. Keine bestehende Spalte verändert oder entfernt — COR-02 liest weiterhin unverändert `approved_at IS NOT NULL`. +- Keine UI gebaut: MAP-02 (direktes Vorbild) hat ebenfalls keine Freigabe-Oberfläche implementiert, nur den Store/CLI-Zugriffspfad (`cmd/map03-user-match` liest `tenantapproval.Approved()` direkt). MAP-04 hält sich an denselben Umfang — die "Technische Grundlage: Freigabe-Oberfläche" bleibt wie bei MAP-02 ein nicht ticketierter Folgeschritt, hier dokumentiert statt stillschweigend übersprungen. +- COR-02 (`internal/migrationuser`) nicht verändert: sein Lesezugriff (`approved_at IS NOT NULL`) ist bereits exakt der einzige, über den freigegebene Benutzer übernommen werden (Akzeptanzkriterium 3 war durch COR-02 bereits erfüllt, MAP-04 liefert jetzt den davorliegenden Freigabe-Workflow, der diese Spalte überhaupt kontrolliert befüllt). + +## Tests (real gegen Postgres ausgeführt, `go test ./internal/usermigrationapproval/... -v`) + +| Test | Prüft | Ergebnis | +|---|---|---| +| `TestApprove_RequiresExplicitDecisionAndLogsIt` | AK1: explizite, protokollierte Freigabe (Status, `approved_by`, `approved_at`, Audit-Log-Eintrag mit Zeitpunkt+Person) | PASS | +| `TestUnapprovedMerge_StaysRejectedWhileBothAccountsRemainSeparatelyMigratable` | AK2 / sicherheitskritische Prüfung 1: Zusammenführungs-Kandidat ohne Freigabe wird vom COR-02-Downstream-Filter nachweislich nicht erfasst; nach `Reject` bleiben beide Konten als zwei einseitige, unabhängig freigebbare Kandidaten migrierbar statt fälschlich zusammengeführt | PASS | +| `TestApproved_LiefertNurFreigegebeneEintraege` | AK3: `Approved()` liefert ausschließlich freigegebene Zeilen, nie pending/rejected | PASS | +| `TestApproved_StichprobeVon10GegenQuellenGeprueft` | Prüfung 3: Stichprobe von 10 freigegebenen Zuordnungen 1:1 gegen die ursprüngliche Kandidatenliste (archivmail-/archivdms-ID, Tenant, Name) verglichen | PASS | +| `TestInsertCandidates_DiscardsCandidatesWithoutSource` | Robustheit: Kandidat ganz ohne Quelle wird nicht als Map-Zeile angelegt | PASS | + +Lauf-Ergebnis: +``` +ok gitea.perlbach24.de/scripte/nexarch/internal/usermigrationapproval 0.470s +``` + +Regressionslauf angrenzender Pakete (unverändert, real gegen Postgres): +``` +ok gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval 0.440s +ok gitea.perlbach24.de/scripte/nexarch/internal/usermatch 0.079s +ok gitea.perlbach24.de/scripte/nexarch/internal/migrationuser 3.627s +``` +`go build ./...` und `go vet ./...` über das gesamte Repo: fehlerfrei. + +## Migrations-Prüfung (Prüfung 2 sinngemäß: Protokoll vollständig und nachvollziehbar) + +Migration `0025_user_migration_map_approval` real gegen eine Wegwerf-Datenbank (`map04_sqltest`, danach gelöscht) nach vollständiger Vorkette (`0001`, `0019`, `0020`, `0023`, `0024`) angewendet: +- `up.sql`: `ALTER TABLE`/`CREATE TABLE`/`CREATE INDEX` fehlerfrei. +- `down.sql`: vollständiger Rollback (`DROP TABLE`, `ALTER TABLE ... DROP COLUMN/CONSTRAINT`) fehlerfrei. + +## Nicht Bestandteil dieser Kachel + +- Freigabe-Oberfläche (UI) — wie bei MAP-02 nur als Store/API vorbereitet, kein Frontend gebaut (konsistent mit dem direkten Vorbild). +- Anpassung von MAP-03 (`cmd/map03-user-match`), um `usermigrationapproval.InsertCandidates` statt/zusätzlich zu `WriteCandidatesCSV` aufzurufen — MAP-03 ist laut Ticket bereits fertig und liefert die CSV als "bisherige dünne Schnittstelle"; die Verdrahtung zu einem schreibenden Aufruf wäre ein eigener Integrationsschritt und nicht Teil des MAP-04-Scopes (Freigabe-Workflow selbst). diff --git a/internal/usermigrationapproval/store.go b/internal/usermigrationapproval/store.go new file mode 100644 index 00000000..13fea7ef --- /dev/null +++ b/internal/usermigrationapproval/store.go @@ -0,0 +1,222 @@ +// Package usermigrationapproval implementiert MAP-04: den Freigabe-Workflow +// fuer user_migration_map-Kandidaten aus MAP-03, analog zu MAP-02 +// (internal/tenantapproval) eine Ebene tiefer (Benutzer statt Tenant). Jede +// Benutzer-Zusammenfuehrung erfordert eine explizite, protokollierte +// menschliche Entscheidung (Approve/Reject), bevor COR-02 +// (internal/migrationuser.MapStore.Approved) sie als Quelle nutzen darf. +package usermigrationapproval + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/usermatch" +) + +var ( + ErrNotFound = errors.New("user-map-eintrag nicht gefunden") + ErrAlreadyDecided = errors.New("user-map-eintrag ist bereits freigegeben oder abgelehnt") +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusApproved Status = "approved" + StatusRejected Status = "rejected" +) + +// Entry ist eine Zeile der user_migration_map inklusive Freigabe-Status. +type Entry struct { + ID int64 + ArchivmailUserID *int64 + ArchivdmsUserID *int64 + NexarchTenantID string + NexarchUserID string + Email string + Name string + Status Status + ApprovedBy *string + ApprovedAt *time.Time + RejectedBy *string + RejectedAt *time.Time +} + +// AuditEntry protokolliert eine einzelne Freigabe-Entscheidung. +type AuditEntry struct { + ID int64 + MapID int64 + Action string + PerformedBy string + PerformedAt time.Time + Note string +} + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// InsertCandidates legt MAP-03-Kandidaten als unbestaetigte (status=pending) +// user_migration_map-Zeilen an. password_hash wird mituebernommen, aber nie +// zurueckgeliefert (Entry enthaelt ihn bewusst nicht) -- er bleibt ausserhalb +// der menschlich geprueften Freigabe-Uebersicht, wie bereits bei +// usermatch.WriteCandidatesCSV. Kandidaten ohne jede Quelle werden verworfen. +func (s *Store) InsertCandidates(ctx context.Context, candidates []usermatch.Candidate) ([]int64, error) { + var ids []int64 + for _, c := range candidates { + if c.ArchivmailUserID == nil && c.ArchivdmsUserID == nil { + continue + } + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO user_migration_map + (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, email, name, password_hash, status) + VALUES ($1, $2, $3, gen_random_uuid(), $4, $5, $6, 'pending') + RETURNING id + `, c.ArchivmailUserID, c.ArchivdmsUserID, c.NexarchTenantID, c.Email, c.Name, c.PasswordHash).Scan(&id) + if err != nil { + return nil, fmt.Errorf("kandidat %q anlegen: %w", c.Email, err) + } + ids = append(ids, id) + } + return ids, nil +} + +// Pending liefert alle noch unentschiedenen Eintraege. +func (s *Store) Pending(ctx context.Context) ([]Entry, error) { + return s.list(ctx, `WHERE status = 'pending'`) +} + +// Approved liefert ausschliesslich freigegebene Eintraege -- einzige fuer +// COR-02 nutzbare Quelle (Akzeptanzkriterium 3). +func (s *Store) Approved(ctx context.Context) ([]Entry, error) { + return s.list(ctx, `WHERE status = 'approved'`) +} + +// Get liefert einen einzelnen Eintrag. +func (s *Store) Get(ctx context.Context, id int64) (Entry, error) { + rows, err := s.list(ctx, `WHERE id = $1`, id) + if err != nil { + return Entry{}, err + } + if len(rows) == 0 { + return Entry{}, ErrNotFound + } + return rows[0], nil +} + +func (s *Store) list(ctx context.Context, where string, args ...any) ([]Entry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, email, name, + status, approved_by, approved_at, rejected_by, rejected_at + FROM user_migration_map + `+where+` + ORDER BY id + `, args...) + if err != nil { + return nil, fmt.Errorf("user-map lesen: %w", err) + } + defer rows.Close() + + var out []Entry + for rows.Next() { + var e Entry + var status string + if err := rows.Scan(&e.ID, &e.ArchivmailUserID, &e.ArchivdmsUserID, &e.NexarchTenantID, &e.NexarchUserID, &e.Email, &e.Name, + &status, &e.ApprovedBy, &e.ApprovedAt, &e.RejectedBy, &e.RejectedAt); err != nil { + return nil, fmt.Errorf("user-map-eintrag lesen: %w", err) + } + e.Status = Status(status) + out = append(out, e) + } + return out, rows.Err() +} + +// Approve ist die einzige Operation, die einen Eintrag fuer COR-02 nutzbar +// macht (Akzeptanzkriterium 1/3). approvedBy identifiziert die freigebende +// Person, note ist optional. +func (s *Store) Approve(ctx context.Context, id int64, approvedBy, note string) error { + if approvedBy == "" { + return errors.New("approvedBy darf nicht leer sein") + } + return s.decide(ctx, id, "approved", approvedBy, note, ` + UPDATE user_migration_map + SET status = 'approved', approved_by = $1, approved_at = now() + WHERE id = $2 AND status = 'pending' + `) +} + +// Reject markiert einen Kandidaten dauerhaft als abgelehnt; er bleibt ohne +// approved_at und wird daher nie von COR-02 uebernommen -- die darin +// vorgeschlagene Zusammenfuehrung findet nicht statt (Akzeptanzkriterium 2). +func (s *Store) Reject(ctx context.Context, id int64, rejectedBy, note string) error { + if rejectedBy == "" { + return errors.New("rejectedBy darf nicht leer sein") + } + return s.decide(ctx, id, "rejected", rejectedBy, note, ` + UPDATE user_migration_map + SET status = 'rejected', rejected_by = $1, rejected_at = now() + WHERE id = $2 AND status = 'pending' + `) +} + +func (s *Store) decide(ctx context.Context, id int64, action, actor, note, sql string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("transaktion beginnen: %w", err) + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, sql, actor, id) + if err != nil { + return fmt.Errorf("%s: %w", action, err) + } + if tag.RowsAffected() == 0 { + if _, err := s.Get(ctx, id); errors.Is(err, ErrNotFound) { + return ErrNotFound + } + return ErrAlreadyDecided + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO user_migration_map_audit (map_id, action, performed_by, note) + VALUES ($1, $2, $3, $4) + `, id, action, actor, note); err != nil { + return fmt.Errorf("audit-log fuer %s: %w", action, err) + } + + return tx.Commit(ctx) +} + +// AuditLog liefert das Freigabe-Protokoll eines Eintrags, chronologisch +// (Pruefung 2: Zeitpunkt und handelnde Person je Entscheidung). +func (s *Store) AuditLog(ctx context.Context, mapID int64) ([]AuditEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, map_id, action, performed_by, performed_at, coalesce(note, '') + FROM user_migration_map_audit + WHERE map_id = $1 + ORDER BY performed_at, id + `, mapID) + if err != nil { + return nil, fmt.Errorf("audit-log lesen: %w", err) + } + defer rows.Close() + + var out []AuditEntry + for rows.Next() { + var a AuditEntry + if err := rows.Scan(&a.ID, &a.MapID, &a.Action, &a.PerformedBy, &a.PerformedAt, &a.Note); err != nil { + return nil, fmt.Errorf("audit-log-eintrag lesen: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} diff --git a/internal/usermigrationapproval/store_test.go b/internal/usermigrationapproval/store_test.go new file mode 100644 index 00000000..c6c1c254 --- /dev/null +++ b/internal/usermigrationapproval/store_test.go @@ -0,0 +1,377 @@ +package usermigrationapproval + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" + "gitea.perlbach24.de/scripte/nexarch/internal/usermatch" +) + +func setupSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE tenant_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_tenant_id BIGINT, + archivdms_tenant_id BIGINT, + nexarch_tenant_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + approved_by TEXT, + approved_at TIMESTAMPTZ + ); + 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 DEFAULT gen_random_uuid() UNIQUE, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL DEFAULT '', + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'pending', + rejected_by TEXT, + rejected_at TIMESTAMPTZ, + CONSTRAINT user_migration_map_source_present + CHECK (archivmail_user_id IS NOT NULL OR archivdms_user_id IS NOT NULL), + CONSTRAINT user_migration_map_status_check + CHECK (status IN ('pending', 'approved', 'rejected')), + CONSTRAINT user_migration_map_status_approved_consistent + CHECK (status != 'approved' OR (approved_at IS NOT NULL AND approved_by IS NOT NULL)), + CONSTRAINT user_migration_map_status_rejected_consistent + CHECK (status != 'rejected' OR (rejected_at IS NOT NULL AND rejected_by IS NOT NULL)) + ); + CREATE TABLE user_migration_map_audit ( + id BIGSERIAL PRIMARY KEY, + map_id BIGINT NOT NULL REFERENCES user_migration_map(id), + action TEXT NOT NULL CHECK (action IN ('approved', 'rejected')), + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } +} + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, adminDSN) + setupSchema(t, context.Background(), pool) + return pool +} + +func mustID(n int64) *int64 { return &n } + +// insertApprovedTenant legt einen bereits freigegebenen Tenant-Map-Eintrag +// an (Vorbedingung von MAP-03/MAP-04: der Benutzerabgleich/die +// Benutzerfreigabe laeuft nur innerhalb freigegebener Tenants) und liefert +// dessen nexarch_tenant_id. +func insertApprovedTenant(t *testing.T, ctx context.Context, pool *pgxpool.Pool, slug, name string) string { + t.Helper() + var nexarchTenantID string + if err := pool.QueryRow(ctx, ` + INSERT INTO tenant_migration_map (archivmail_tenant_id, archivdms_tenant_id, slug, name, approved_by, approved_at) + VALUES (1, 2, $1, $2, 'qa-tester', now()) + RETURNING nexarch_tenant_id + `, slug, name).Scan(&nexarchTenantID); err != nil { + t.Fatalf("freigegebenen tenant anlegen: %v", err) + } + return nexarchTenantID +} + +// AC1: jede Benutzer-Zusammenfuehrung erfordert eine explizite, +// protokollierte menschliche Freigabe. +func TestApprove_RequiresExplicitDecisionAndLogsIt(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + nexarchTenantID := insertApprovedTenant(t, ctx, pool, testdb.Slug("map04-ac1"), "Synthetische Test GmbH "+suffix) + + ids, err := store.InsertCandidates(ctx, []usermatch.Candidate{ + {ArchivmailUserID: mustID(1), ArchivdmsUserID: mustID(2), NexarchTenantID: nexarchTenantID, + Email: "synth.ac1." + suffix + "@example.invalid", Name: "Synthetischer Nutzer " + suffix, PasswordHash: "hash-synth"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if len(ids) != 1 { + t.Fatalf("erwarte 1 angelegten kandidaten, habe %d", len(ids)) + } + id := ids[0] + + entry, err := store.Get(ctx, id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if entry.Status != StatusPending || entry.ApprovedAt != nil { + t.Fatalf("neuer kandidat muss pending und unfreigegeben sein, habe status=%s approved_at=%v", entry.Status, entry.ApprovedAt) + } + + if err := store.Approve(ctx, id, "qa-tester", "Pruefung MAP-04"); err != nil { + t.Fatalf("Approve: %v", err) + } + + approved, err := store.Get(ctx, id) + if err != nil { + t.Fatalf("Get nach Approve: %v", err) + } + if approved.Status != StatusApproved || approved.ApprovedAt == nil || approved.ApprovedBy == nil || *approved.ApprovedBy != "qa-tester" { + t.Fatalf("eintrag nach Approve unvollstaendig: %+v", approved) + } + + log, err := store.AuditLog(ctx, id) + if err != nil { + t.Fatalf("AuditLog: %v", err) + } + if len(log) != 1 || log[0].Action != "approved" || log[0].PerformedBy != "qa-tester" || log[0].PerformedAt.IsZero() { + t.Fatalf("audit-log erwartet genau 1 approved-eintrag mit zeitpunkt und person, habe %+v", log) + } +} + +// AC2 / sicherheitskritische Pruefung 1 des Tickets: eine Zusammenfuehrung +// ohne Freigabe wird von einem Testlauf nachweislich zurueckgewiesen -- der +// Benutzer bleibt als zwei getrennte Konten migrierbar statt faelschlich +// zusammengefuehrt. Simuliert: ein Doppel-Account-Kandidat (archivmail + +// archivdms) bleibt unentschieden/abgelehnt, waehrend dieselben zwei Konten +// -- einzeln, OHNE Merge -- jeweils fuer sich freigegeben und damit +// weiterhin migrierbar bleiben. +func TestUnapprovedMerge_StaysRejectedWhileBothAccountsRemainSeparatelyMigratable(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + nexarchTenantID := insertApprovedTenant(t, ctx, pool, testdb.Slug("map04-ac2"), "Synthetische Test GmbH "+suffix) + + mergeIDs, err := store.InsertCandidates(ctx, []usermatch.Candidate{ + {ArchivmailUserID: mustID(10), ArchivdmsUserID: mustID(20), NexarchTenantID: nexarchTenantID, + Email: "synth.doppel." + suffix + "@example.invalid", Name: "Doppel-Konto " + suffix, PasswordHash: "hash-am"}, + }) + if err != nil { + t.Fatalf("InsertCandidates (merge-kandidat): %v", err) + } + mergeID := mergeIDs[0] + + // Downstream-Filter von COR-02 (internal/migrationuser.MapStore.Approved) + // liest ausschliesslich approved_at IS NOT NULL -- hier direkt + // nachgebildet, um die Zurueckweisung ohne Freigabe nachzuweisen. + assertNotApprovedForDownstream := func(id int64) { + t.Helper() + var count int + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM user_migration_map WHERE id = $1 AND approved_at IS NOT NULL + `, id).Scan(&count); err != nil { + t.Fatalf("downstream-filter pruefen: %v", err) + } + if count != 0 { + t.Fatalf("eintrag %d darf vom downstream-filter (COR-02) nicht erfasst werden, count=%d", id, count) + } + } + assertNotApprovedForDownstream(mergeID) + + if err := store.Reject(ctx, mergeID, "qa-tester", "zusammenfuehrung nicht bestaetigt, konten bleiben getrennt"); err != nil { + t.Fatalf("Reject: %v", err) + } + rejected, err := store.Get(ctx, mergeID) + if err != nil { + t.Fatalf("Get nach Reject: %v", err) + } + if rejected.Status != StatusRejected || rejected.ApprovedAt != nil { + t.Fatalf("abgelehnter merge-kandidat darf nie approved_at erhalten: %+v", rejected) + } + assertNotApprovedForDownstream(mergeID) + + if err := store.Approve(ctx, mergeID, "qa-tester", ""); !errors.Is(err, ErrAlreadyDecided) { + t.Fatalf("erneutes Approve eines abgelehnten merge-kandidaten muss ErrAlreadyDecided liefern, habe %v", err) + } + + // Beide Konten bleiben migrierbar: getrennt, als zwei einseitige + // Kandidaten (keine faelschliche Zusammenfuehrung), jeweils eigenstaendig + // freigebbar. + separateIDs, err := store.InsertCandidates(ctx, []usermatch.Candidate{ + {ArchivmailUserID: mustID(10), NexarchTenantID: nexarchTenantID, + Email: "synth.doppel.am." + suffix + "@example.invalid", Name: "Archivmail-Konto " + suffix, PasswordHash: "hash-am"}, + {ArchivdmsUserID: mustID(20), NexarchTenantID: nexarchTenantID, + Email: "synth.doppel.dms." + suffix + "@example.invalid", Name: "Archivdms-Konto " + suffix, PasswordHash: "hash-dms"}, + }) + if err != nil { + t.Fatalf("InsertCandidates (einseitig): %v", err) + } + if len(separateIDs) != 2 { + t.Fatalf("erwarte 2 einseitige kandidaten, habe %d", len(separateIDs)) + } + if err := store.Approve(ctx, separateIDs[0], "qa-tester", "archivmail-konto eigenstaendig freigegeben"); err != nil { + t.Fatalf("Approve (archivmail-konto): %v", err) + } + if err := store.Approve(ctx, separateIDs[1], "qa-tester", "archivdms-konto eigenstaendig freigegeben"); err != nil { + t.Fatalf("Approve (archivdms-konto): %v", err) + } + + approved, err := store.Approved(ctx) + if err != nil { + t.Fatalf("Approved: %v", err) + } + foundAM, foundDMS := false, false + for _, e := range approved { + if e.ID == mergeID { + t.Fatalf("abgelehnter merge-kandidat darf nicht in Approved() auftauchen: %+v", e) + } + if e.ID == separateIDs[0] { + foundAM = true + if e.ArchivmailUserID == nil || e.ArchivdmsUserID != nil { + t.Fatalf("archivmail-konto muss einseitig (ohne merge) freigegeben sein: %+v", e) + } + } + if e.ID == separateIDs[1] { + foundDMS = true + if e.ArchivdmsUserID == nil || e.ArchivmailUserID != nil { + t.Fatalf("archivdms-konto muss einseitig (ohne merge) freigegeben sein: %+v", e) + } + } + } + if !foundAM || !foundDMS { + t.Fatalf("beide getrennt freigegebenen konten muessen in Approved() erscheinen, habe %+v", approved) + } +} + +// AC3: die freigegebene Map ist die einzige Quelle fuer COR-02 -- +// Approved() liefert ausschliesslich Eintraege mit approved_at gesetzt, +// genau der Filter, den internal/migrationuser.MapStore.Approved +// implementiert. +func TestApproved_LiefertNurFreigegebeneEintraege(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + nexarchTenantID := insertApprovedTenant(t, ctx, pool, testdb.Slug("map04-ac3"), "Synthetische Test GmbH "+suffix) + + ids, err := store.InsertCandidates(ctx, []usermatch.Candidate{ + {ArchivmailUserID: mustID(1), ArchivdmsUserID: mustID(2), NexarchTenantID: nexarchTenantID, + Email: "synth.freigegeben." + suffix + "@example.invalid", Name: "Freigegeben " + suffix, PasswordHash: "hash"}, + {ArchivmailUserID: mustID(3), ArchivdmsUserID: mustID(4), NexarchTenantID: nexarchTenantID, + Email: "synth.pending." + suffix + "@example.invalid", Name: "Pending " + suffix, PasswordHash: "hash"}, + {ArchivmailUserID: mustID(5), ArchivdmsUserID: mustID(6), NexarchTenantID: nexarchTenantID, + Email: "synth.abgelehnt." + suffix + "@example.invalid", Name: "Abgelehnt " + suffix, PasswordHash: "hash"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if err := store.Approve(ctx, ids[0], "qa-tester", ""); err != nil { + t.Fatalf("Approve: %v", err) + } + if err := store.Reject(ctx, ids[2], "qa-tester", ""); err != nil { + t.Fatalf("Reject: %v", err) + } + + approved, err := store.Approved(ctx) + if err != nil { + t.Fatalf("Approved: %v", err) + } + + found := false + for _, e := range approved { + if e.ID == ids[1] || e.ID == ids[2] { + t.Fatalf("Approved() darf pending/rejected eintrag %d nicht liefern", e.ID) + } + if e.ID == ids[0] { + found = true + if e.Status != StatusApproved || e.ApprovedAt == nil { + t.Fatalf("freigegebener eintrag unvollstaendig: %+v", e) + } + } + } + if !found { + t.Fatalf("freigegebener eintrag %d fehlt im ergebnis", ids[0]) + } +} + +// Pruefung 3 des Tickets: Stichprobe von 10 freigegebenen Zuordnungen gegen +// die Quelle (die urspruengliche usermatch.Candidate-Liste) geprueft. +func TestApproved_StichprobeVon10GegenQuellenGeprueft(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + nexarchTenantID := insertApprovedTenant(t, ctx, pool, testdb.Slug("map04-stichprobe"), "Synthetische Test GmbH "+suffix) + + const n = 10 + candidates := make([]usermatch.Candidate, 0, n) + for i := 1; i <= n; i++ { + candidates = append(candidates, usermatch.Candidate{ + ArchivmailUserID: mustID(int64(100 + i)), + ArchivdmsUserID: mustID(int64(200 + i)), + NexarchTenantID: nexarchTenantID, + Email: fmt.Sprintf("synth.stichprobe.%d.%s@example.invalid", i, suffix), + Name: fmt.Sprintf("Synthetischer Nutzer %d %s", i, suffix), + PasswordHash: fmt.Sprintf("hash-%d", i), + }) + } + + ids, err := store.InsertCandidates(ctx, candidates) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if len(ids) != n { + t.Fatalf("erwarte %d angelegte kandidaten, habe %d", n, len(ids)) + } + for _, id := range ids { + if err := store.Approve(ctx, id, "qa-tester", "stichprobe"); err != nil { + t.Fatalf("Approve: %v", err) + } + } + + approved, err := store.Approved(ctx) + if err != nil { + t.Fatalf("Approved: %v", err) + } + byEmail := make(map[string]Entry, len(approved)) + for _, e := range approved { + byEmail[e.Email] = e + } + for _, c := range candidates { + got, ok := byEmail[c.Email] + if !ok { + t.Fatalf("freigegebener kandidat %q fehlt im ergebnis", c.Email) + } + if got.ArchivmailUserID == nil || *got.ArchivmailUserID != *c.ArchivmailUserID || + got.ArchivdmsUserID == nil || *got.ArchivdmsUserID != *c.ArchivdmsUserID || + got.Name != c.Name || got.NexarchTenantID != c.NexarchTenantID { + t.Fatalf("kandidat %q stimmt nicht mit quelle ueberein: habe %+v, quelle %+v", c.Email, got, c) + } + } +} + +// InsertCandidates verwirft Kandidaten ganz ohne Quelle (weder archivmail- +// noch archivdms-ID). +func TestInsertCandidates_DiscardsCandidatesWithoutSource(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + nexarchTenantID := insertApprovedTenant(t, ctx, pool, testdb.Slug("map04-nosource"), "Synthetische Test GmbH "+suffix) + + ids, err := store.InsertCandidates(ctx, []usermatch.Candidate{ + {NexarchTenantID: nexarchTenantID, Email: "ohne.quelle." + suffix + "@example.invalid", Name: "Ohne Quelle"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if len(ids) != 0 { + t.Fatalf("kandidat ohne quelle haette verworfen werden muessen, habe %d angelegt", len(ids)) + } +} diff --git a/migrations/0025_user_migration_map_approval.down.sql b/migrations/0025_user_migration_map_approval.down.sql new file mode 100644 index 00000000..73e0dbbe --- /dev/null +++ b/migrations/0025_user_migration_map_approval.down.sql @@ -0,0 +1,9 @@ +DROP TABLE user_migration_map_audit; + +ALTER TABLE user_migration_map + DROP CONSTRAINT user_migration_map_status_rejected_consistent, + DROP CONSTRAINT user_migration_map_status_approved_consistent, + DROP CONSTRAINT user_migration_map_status_check, + DROP COLUMN rejected_at, + DROP COLUMN rejected_by, + DROP COLUMN status; diff --git a/migrations/0025_user_migration_map_approval.up.sql b/migrations/0025_user_migration_map_approval.up.sql new file mode 100644 index 00000000..28e8cf46 --- /dev/null +++ b/migrations/0025_user_migration_map_approval.up.sql @@ -0,0 +1,26 @@ +-- MAP-04: Freigabe-Workflow fuer user_migration_map (erweitert den +-- COR-02-Stub aus 0020/0023), analog zu 0024 (tenant_migration_map). status +-- ersetzt die reine approved_at-Pruefung um einen expliziten +-- Ablehnungs-Zustand; approved_at/approved_by bleiben bestehen, da COR-02 +-- bereits darauf liest. +ALTER TABLE user_migration_map + ADD COLUMN status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN rejected_by TEXT, + ADD COLUMN rejected_at TIMESTAMPTZ, + ADD CONSTRAINT user_migration_map_status_check + CHECK (status IN ('pending', 'approved', 'rejected')), + ADD CONSTRAINT user_migration_map_status_approved_consistent + CHECK (status != 'approved' OR (approved_at IS NOT NULL AND approved_by IS NOT NULL)), + ADD CONSTRAINT user_migration_map_status_rejected_consistent + CHECK (status != 'rejected' OR (rejected_at IS NOT NULL AND rejected_by IS NOT NULL)); + +CREATE TABLE user_migration_map_audit ( + id BIGSERIAL PRIMARY KEY, + map_id BIGINT NOT NULL REFERENCES user_migration_map(id), + action TEXT NOT NULL CHECK (action IN ('approved', 'rejected')), + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT +); + +CREATE INDEX user_migration_map_audit_map_id_idx ON user_migration_map_audit (map_id);