MAP-04: User-Migration-Map und Freigabe-Workflow #279

Merged
patrick merged 3 commits from feature/map-04-user-migration-map-freigabe-workflow into main 2026-10-14 23:59:27 +02:00
5 changed files with 682 additions and 0 deletions

No files matched your search

+48
View File
@@ -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).
+222
View File
@@ -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()
}
@@ -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))
}
}
@@ -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;
@@ -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);