diff --git a/cmd/map03-user-match/main.go b/cmd/map03-user-match/main.go new file mode 100644 index 00000000..b49736a6 --- /dev/null +++ b/cmd/map03-user-match/main.go @@ -0,0 +1,75 @@ +// map03-user-match fuehrt MAP-03 aus: Export der users-Tabellen aus +// archivmail und archivdms sowie E-Mail-Abgleich ausschliesslich innerhalb +// bereits per MAP-02 freigegebener Tenants. Ausgabe ist eine +// Kandidatenliste (CSV) fuer die menschliche Freigabe in user_migration_map +// (MAP-04) -- kein automatischer Merge. +package main + +import ( + "context" + "log" + "os" + + "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval" + "gitea.perlbach24.de/scripte/nexarch/internal/usermatch" +) + +func main() { + archivmailDSN := os.Getenv("MAP03_ARCHIVMAIL_DSN") + if archivmailDSN == "" { + log.Fatal("MAP03_ARCHIVMAIL_DSN nicht gesetzt") + } + archivdmsDSN := os.Getenv("MAP03_ARCHIVDMS_DSN") + if archivdmsDSN == "" { + log.Fatal("MAP03_ARCHIVDMS_DSN nicht gesetzt") + } + registryDSN := os.Getenv("NEXARCH_REGISTRY_DSN") + if registryDSN == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + + ctx := context.Background() + + amPool, err := db.Connect(ctx, archivmailDSN) + if err != nil { + log.Fatalf("archivmail-verbindung: %v", err) + } + defer amPool.Close() + + dmsPool, err := db.Connect(ctx, archivdmsDSN) + if err != nil { + log.Fatalf("archivdms-verbindung: %v", err) + } + defer dmsPool.Close() + + registryPool, err := db.Connect(ctx, registryDSN) + if err != nil { + log.Fatalf("registry-verbindung: %v", err) + } + defer registryPool.Close() + + archivmailUsers, err := usermatch.ExportUsers(ctx, amPool, "archivmail") + if err != nil { + log.Fatalf("archivmail-export: %v", err) + } + archivdmsUsers, err := usermatch.ExportUsers(ctx, dmsPool, "archivdms") + if err != nil { + log.Fatalf("archivdms-export: %v", err) + } + + approvalStore := tenantapproval.NewStore(registryPool) + approvedTenants, err := approvalStore.Approved(ctx) + if err != nil { + log.Fatalf("freigegebene tenants laden: %v", err) + } + + candidates := usermatch.Match(archivmailUsers, archivdmsUsers, approvedTenants) + + if err := usermatch.WriteCandidatesCSV(os.Stdout, candidates); err != nil { + log.Fatalf("kandidatenliste schreiben: %v", err) + } + + log.Printf("MAP-03: archivmail=%d archivdms=%d freigegebene_tenants=%d kandidaten=%d", + len(archivmailUsers), len(archivdmsUsers), len(approvedTenants), len(candidates)) +} diff --git a/docs/MAP-03-PRUEFPROTOKOLL.md b/docs/MAP-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c0a0a219 --- /dev/null +++ b/docs/MAP-03-PRUEFPROTOKOLL.md @@ -0,0 +1,98 @@ +# MAP-03 – Prüfprotokoll + +Umgebung: Host 192.168.1.204, Testlauf gegen den echten Postgres-Testcluster +mit `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env` (Wert nie im +Klartext ausgegeben, nur per `set -a && source ... && set +a` im selben +Befehl eingespeist). Alle Testdaten synthetisch (`synth-*`-Präfixe), keine +echten archivmail-/archivdms-Daten. + +## Implementiert + +- `internal/usermatch` (neu): Export der `users`-Tabellen beider Altsysteme + (`ExportUsers`, `CountUsers`) und E-Mail-Abgleich (`Match`) ausschließlich + innerhalb von Tenants, die als `tenantapproval.StatusApproved` vorliegen. + CSV-Ausgabe (`WriteCandidatesCSV`) ohne `password_hash` als dünne + Schnittstelle für die menschliche Freigabe in `user_migration_map` + (MAP-04, nicht Teil dieser Kachel). +- `internal/tenantapproval`: `Store.Approved()` ergänzt (analog zu + `Pending()`), liefert nur `status = 'approved'`-Zeilen — einzige für + MAP-03 nutzbare Quelle. +- `cmd/map03-user-match`: CLI analog zu `cmd/map01-tenant-match`, liest + `MAP03_ARCHIVMAIL_DSN`, `MAP03_ARCHIVDMS_DSN`, `NEXARCH_REGISTRY_DSN`, + schreibt Kandidaten-CSV auf stdout. + +## Prüfungen + +### Prüfung 1 – Export-Vollständigkeit gegen Quelltabellen-Zeilenzahl + +Test: `internal/usermatch/export_test.go::TestExportUsers_Vollstaendigkeit` +(Integrationstest gegen echten Postgres-Testcluster, 3 synthetische Zeilen, +2 Tenants). + +Ergebnis: PASS. `len(ExportUsers(...))` == `CountUsers(...)`, alle Felder +(Email, Name, TenantSourceID, SourceID, PasswordHash) stimmen zeilenweise. + +``` +=== RUN TestExportUsers_Vollstaendigkeit +--- PASS: TestExportUsers_Vollstaendigkeit (0.31s) +``` + +### Prüfung 2 – Stichprobe bekannter Doppel-Accounts korrekt erkannt + +Test: +`internal/usermatch/match_test.go::TestMatch_BekannteDoppelAccountsImFreigegebenenTenantKorrektErkannt` + +Szenario: 1 freigegebener Tenant, je 1 Doppel-Account (gleiche E-Mail in +archivmail und archivdms) plus je 1 einseitiger Benutzer je Seite. + +Ergebnis: PASS. Doppel-Account liefert genau 1 Kandidat mit beiden +User-IDs, Confidence 1.0, konfliktfrei; einseitige Benutzer bleiben +getrennt sichtbar (genau 1 ID je Kandidat). + +### Prüfung 3 – Cross-Tenant-Isolation (sicherheitskritisch, Akzeptanzkriterium 3) + +Tests: +- `internal/usermatch/match_test.go::TestMatch_IdentischeEmailUeberTenantGrenzenBleibtGetrennt` + — 2 unterschiedliche freigegebene Tenants, identische E-Mail-Adresse je + einmal auf archivmail-Seite (Tenant A) und einmal auf archivdms-Seite + (Tenant B). Erwartung: kein Kandidat verknüpft beide Seiten über die + Tenant-Grenze; jede Seite bleibt als eigener einseitiger Kandidat im + jeweils korrekten `nexarch_tenant_id` sichtbar. +- `internal/usermatch/match_test.go::TestMatch_NichtFreigegebenerTenantLiefertKeineKandidaten` + — Akzeptanzkriterium 2: ein `status = 'pending'`-Tenant (nicht + freigegeben) liefert trotz identischer E-Mail auf beiden Seiten **keinen** + Kandidaten. +- `internal/tenantapproval/approved_test.go::TestApproved_LiefertNurFreigegebeneEintraege` + — `Approved()` liefert nachweislich nie pending/rejected Einträge. + +Ergebnis: alle PASS. + +``` +=== RUN TestMatch_IdentischeEmailUeberTenantGrenzenBleibtGetrennt +--- PASS: TestMatch_IdentischeEmailUeberTenantGrenzenBleibtGetrennt (0.00s) +=== RUN TestMatch_NichtFreigegebenerTenantLiefertKeineKandidaten +--- PASS: TestMatch_NichtFreigegebenerTenantLiefertKeineKandidaten (0.00s) +=== RUN TestApproved_LiefertNurFreigegebeneEintraege +--- PASS: TestApproved_LiefertNurFreigegebeneEintraege (0.43s) +``` + +## Gesamtlauf + +``` +cd /root/map03-work-1792007933 +set -a && source /etc/nexarch-dev/core-dev.env && set +a +go test ./internal/usermatch/... ./internal/tenantapproval/... -v +go vet ./... +go build ./... +``` + +Alle Tests PASS, `go vet`/`go build` ohne Fehler für das gesamte Modul. + +## Nicht Bestandteil + +Freigabe der Zusammenführung selbst (Schreiben nach `user_migration_map` +inkl. Approve/Reject-Workflow) ist MAP-04 und wurde hier nicht +implementiert. `usermatch.Candidate` ist als dünne, dem `tenantmatch`-Muster +entsprechende Schnittstelle dafür vorbereitet (Felder 1:1 auf +`user_migration_map`-Spalten gemappt, ohne `nexarch_user_id`/ +`approved_at`). diff --git a/internal/tenantapproval/approved_test.go b/internal/tenantapproval/approved_test.go new file mode 100644 index 00000000..36884513 --- /dev/null +++ b/internal/tenantapproval/approved_test.go @@ -0,0 +1,54 @@ +package tenantapproval + +import ( + "context" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" +) + +// MAP-03 konsumiert ausschliesslich Approved() -- dieser Test stellt sicher, +// dass pending/rejected Eintraege dort nie auftauchen. +func TestApproved_LiefertNurFreigegebeneEintraege(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + + ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("synth-approved"), Name: "Freigegeben " + suffix}, + {ArchivmailTenantID: mustID(3), ArchivdmsTenantID: mustID(4), Slug: testdb.Slug("synth-pending"), Name: "Pending " + suffix}, + {ArchivmailTenantID: mustID(5), ArchivdmsTenantID: mustID(6), Slug: testdb.Slug("synth-rejected"), Name: "Abgelehnt " + suffix}, + }) + 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]) + } +} diff --git a/internal/tenantapproval/store.go b/internal/tenantapproval/store.go index 76d6e694..e26a3538 100644 --- a/internal/tenantapproval/store.go +++ b/internal/tenantapproval/store.go @@ -97,6 +97,13 @@ func (s *Store) Pending(ctx context.Context) ([]Entry, error) { return s.list(ctx, `WHERE status = 'pending'`) } +// Approved liefert ausschliesslich freigegebene Eintraege +// (approved_at gesetzt) -- einzige fuer nachgelagerte Schritte wie MAP-03 +// nutzbare Quelle. +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) diff --git a/internal/usermatch/csv_out.go b/internal/usermatch/csv_out.go new file mode 100644 index 00000000..9472c651 --- /dev/null +++ b/internal/usermatch/csv_out.go @@ -0,0 +1,46 @@ +package usermatch + +import ( + "encoding/csv" + "fmt" + "io" +) + +// WriteCandidatesCSV schreibt die Kandidatenliste im Format +// archivmail_user_id,archivdms_user_id,nexarch_tenant_id,email,name,confidence,source,conflict,conflict_reason +// -- ohne password_hash, der bleibt ausserhalb der menschlich geprueften +// Freigabe-Uebersicht. +func WriteCandidatesCSV(w io.Writer, candidates []Candidate) error { + cw := csv.NewWriter(w) + defer cw.Flush() + + header := []string{"archivmail_user_id", "archivdms_user_id", "nexarch_tenant_id", "email", "name", "confidence", "source", "conflict", "conflict_reason"} + if err := cw.Write(header); err != nil { + return err + } + + for _, c := range candidates { + record := []string{ + idOrEmpty(c.ArchivmailUserID), + idOrEmpty(c.ArchivdmsUserID), + c.NexarchTenantID, + c.Email, + c.Name, + fmt.Sprintf("%.2f", c.Confidence), + c.Source, + fmt.Sprintf("%t", c.Conflict), + c.ConflictReason, + } + if err := cw.Write(record); err != nil { + return err + } + } + return cw.Error() +} + +func idOrEmpty(id *int64) string { + if id == nil { + return "" + } + return fmt.Sprintf("%d", *id) +} diff --git a/internal/usermatch/export.go b/internal/usermatch/export.go new file mode 100644 index 00000000..26793da9 --- /dev/null +++ b/internal/usermatch/export.go @@ -0,0 +1,53 @@ +// Package usermatch implementiert MAP-03: Export der users-Tabellen aus +// archivmail und archivdms sowie den E-Mail-Abgleich innerhalb eines bereits +// per MAP-02/internal/tenantapproval freigegebenen Tenants. +package usermatch + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ExportedUser ist eine Zeile aus der users-Tabelle eines Altsystems. +type ExportedUser struct { + Source string + SourceID int64 + TenantSourceID int64 + Email string + Name string + PasswordHash string +} + +// ExportUsers liest id/tenant_id/email/name/password_hash aus der +// users-Tabelle eines Altsystems. source identifiziert das Herkunftssystem +// ("archivmail" oder "archivdms"). +func ExportUsers(ctx context.Context, pool *pgxpool.Pool, source string) ([]ExportedUser, error) { + rows, err := pool.Query(ctx, `SELECT id, tenant_id, email, name, password_hash FROM users ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("users-export (%s): %w", source, err) + } + defer rows.Close() + + var out []ExportedUser + for rows.Next() { + var e ExportedUser + if err := rows.Scan(&e.SourceID, &e.TenantSourceID, &e.Email, &e.Name, &e.PasswordHash); err != nil { + return nil, fmt.Errorf("users-zeile lesen (%s): %w", source, err) + } + e.Source = source + out = append(out, e) + } + return out, rows.Err() +} + +// CountUsers liefert die Zeilenzahl der Quelltabelle, um die +// Export-Vollstaendigkeit zu pruefen (Pruefung 1 des Tickets). +func CountUsers(ctx context.Context, pool *pgxpool.Pool) (int, error) { + var n int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil { + return 0, fmt.Errorf("users-zeilenzahl: %w", err) + } + return n, nil +} diff --git a/internal/usermatch/export_test.go b/internal/usermatch/export_test.go new file mode 100644 index 00000000..656d2e7e --- /dev/null +++ b/internal/usermatch/export_test.go @@ -0,0 +1,86 @@ +package usermatch + +import ( + "context" + "os" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" +) + +// Integrationstest gegen eine echte Postgres-Instanz mit frei erfundenen +// Benutzer-Zeilen (E-Mails wie "synth-user-a@..."), niemals echte +// archivmail-/archivdms-Kundendaten. Siehe docs/MAP-03-PRUEFPROTOKOLL.md. +// Ohne TEST_ADMIN_DSN uebersprungen. +func TestExportUsers_Vollstaendigkeit(t *testing.T) { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool := testdb.Pool(t, adminDSN) + defer pool.Close() + + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL DEFAULT '' + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + suffix := testdb.Suffix() + rows := []ExportedUser{ + {SourceID: 1, TenantSourceID: 10, Email: "synth-user-a-" + suffix + "@synth.example", Name: "Synthetische Nutzerin A", PasswordHash: "hash-a"}, + {SourceID: 2, TenantSourceID: 10, Email: "synth-user-b-" + suffix + "@synth.example", Name: "Synthetischer Nutzer B", PasswordHash: "hash-b"}, + {SourceID: 3, TenantSourceID: 11, Email: "synth-user-c-" + suffix + "@synth.example", Name: "Synthetischer Nutzer C", PasswordHash: "hash-c"}, + } + for _, r := range rows { + if _, err := pool.Exec(ctx, `INSERT INTO users (id, tenant_id, email, name, password_hash) VALUES ($1,$2,$3,$4,$5)`, + r.SourceID, r.TenantSourceID, r.Email, r.Name, r.PasswordHash); err != nil { + t.Fatalf("insert: %v", err) + } + } + t.Cleanup(func() { + for _, r := range rows { + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE email = $1`, r.Email) + } + }) + + wantCount, err := CountUsers(ctx, pool) + if err != nil { + t.Fatalf("CountUsers: %v", err) + } + + got, err := ExportUsers(ctx, pool, "archivmail") + if err != nil { + t.Fatalf("ExportUsers: %v", err) + } + + // Pruefung 1: Export-Vollstaendigkeit gegen Quelltabellen-Zeilenzahl. + if len(got) != wantCount { + t.Fatalf("export unvollstaendig: habe %d zeilen, quelltabelle hat %d", len(got), wantCount) + } + + byEmail := make(map[string]ExportedUser, len(got)) + for _, g := range got { + if g.Source != "archivmail" { + t.Fatalf("source nicht gesetzt: %+v", g) + } + byEmail[g.Email] = g + } + for _, r := range rows { + g, ok := byEmail[r.Email] + if !ok { + t.Fatalf("zeile %q fehlt im export", r.Email) + } + if g.Name != r.Name || g.TenantSourceID != r.TenantSourceID || g.SourceID != r.SourceID || g.PasswordHash != r.PasswordHash { + t.Fatalf("zeile %q nicht korrekt exportiert: habe %+v, erwarte %+v", r.Email, g, r) + } + } +} diff --git a/internal/usermatch/match.go b/internal/usermatch/match.go new file mode 100644 index 00000000..39ff3852 --- /dev/null +++ b/internal/usermatch/match.go @@ -0,0 +1,143 @@ +package usermatch + +import ( + "fmt" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval" +) + +const ( + ConfidenceEmailMatch = 1.0 + ConfidenceUnmatched = 0.0 +) + +// Candidate ist ein Abgleichsvorschlag im Ausgabeformat von +// user_migration_map (archivmail_user_id, archivdms_user_id, +// nexarch_tenant_id, email, name, password_hash), ergaenzt um Konfidenz und +// Konfliktmarkierung. Match liefert ausschliesslich Kandidaten -- die +// Freigabe (approved_at) ist MAP-04 vorbehalten. +type Candidate struct { + ArchivmailUserID *int64 + ArchivdmsUserID *int64 + NexarchTenantID string + Email string + Name string + PasswordHash string + Confidence float64 + Source string + Conflict bool + ConflictReason string +} + +// Match gleicht archivmail- und archivdms-Benutzer per E-Mail ab, aber +// ausschliesslich innerhalb eines Tenants, der in approvedTenants als +// freigegeben vorliegt (Akzeptanzkriterium 2). Benutzer, deren +// archivmail-/archivdms-Tenant in approvedTenants fehlt, werden +// uebersprungen -- ein nicht freigegebener Tenant liefert keinerlei +// Kandidaten. E-Mail-Gleichheit ueber Tenant-Grenzen hinweg wird nie +// verglichen, da der Abgleich pro Tenant-Paar separat laeuft +// (Akzeptanzkriterium 3). +func Match(archivmailUsers, archivdmsUsers []ExportedUser, approvedTenants []tenantapproval.Entry) []Candidate { + var out []Candidate + + for _, t := range approvedTenants { + if t.Status != tenantapproval.StatusApproved { + continue + } + if t.ArchivmailTenantID == nil || t.ArchivdmsTenantID == nil { + continue + } + + amUsers := filterByTenant(archivmailUsers, *t.ArchivmailTenantID) + dmsUsers := filterByTenant(archivdmsUsers, *t.ArchivdmsTenantID) + + dmsByEmail := make(map[string][]ExportedUser, len(dmsUsers)) + for _, d := range dmsUsers { + key := strings.ToLower(d.Email) + dmsByEmail[key] = append(dmsByEmail[key], d) + } + + consumed := make(map[int64]bool, len(dmsUsers)) + for _, am := range amUsers { + matches := dmsByEmail[strings.ToLower(am.Email)] + if len(matches) == 0 { + out = append(out, oneSidedArchivmail(am, t.NexarchTenantID, "kein archivdms-benutzer mit gleicher e-mail im freigegebenen tenant")) + continue + } + + d := matches[0] + conflict := len(matches) > 1 + reason := "" + if conflict { + reason = fmt.Sprintf("mehrere archivdms-benutzer mit e-mail %q im tenant, erster verwendet", am.Email) + } + out = append(out, candidateFromMatch(am, d, t.NexarchTenantID, conflict, reason)) + consumed[d.SourceID] = true + } + + for _, d := range dmsUsers { + if consumed[d.SourceID] { + continue + } + out = append(out, oneSidedArchivdms(d, t.NexarchTenantID, "kein archivmail-benutzer mit gleicher e-mail im freigegebenen tenant")) + } + } + + return out +} + +func filterByTenant(users []ExportedUser, tenantSourceID int64) []ExportedUser { + var out []ExportedUser + for _, u := range users { + if u.TenantSourceID == tenantSourceID { + out = append(out, u) + } + } + return out +} + +func candidateFromMatch(am, dms ExportedUser, nexarchTenantID string, conflict bool, reason string) Candidate { + amID := am.SourceID + dmsID := dms.SourceID + return Candidate{ + ArchivmailUserID: &amID, + ArchivdmsUserID: &dmsID, + NexarchTenantID: nexarchTenantID, + Email: am.Email, + Name: am.Name, + PasswordHash: am.PasswordHash, + Confidence: ConfidenceEmailMatch, + Source: "email", + Conflict: conflict, + ConflictReason: reason, + } +} + +func oneSidedArchivmail(am ExportedUser, nexarchTenantID, reason string) Candidate { + amID := am.SourceID + return Candidate{ + ArchivmailUserID: &amID, + NexarchTenantID: nexarchTenantID, + Email: am.Email, + Name: am.Name, + PasswordHash: am.PasswordHash, + Confidence: ConfidenceUnmatched, + Source: "unmatched", + ConflictReason: reason, + } +} + +func oneSidedArchivdms(d ExportedUser, nexarchTenantID, reason string) Candidate { + dmsID := d.SourceID + return Candidate{ + ArchivdmsUserID: &dmsID, + NexarchTenantID: nexarchTenantID, + Email: d.Email, + Name: d.Name, + PasswordHash: d.PasswordHash, + Confidence: ConfidenceUnmatched, + Source: "unmatched", + ConflictReason: reason, + } +} diff --git a/internal/usermatch/match_test.go b/internal/usermatch/match_test.go new file mode 100644 index 00000000..97137e58 --- /dev/null +++ b/internal/usermatch/match_test.go @@ -0,0 +1,132 @@ +package usermatch + +import ( + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval" +) + +func approvedEntry(id int64, amTenant, dmsTenant int64, nexarchTenantID string) tenantapproval.Entry { + am := amTenant + dms := dmsTenant + return tenantapproval.Entry{ + ID: id, + ArchivmailTenantID: &am, + ArchivdmsTenantID: &dms, + NexarchTenantID: nexarchTenantID, + Status: tenantapproval.StatusApproved, + } +} + +func findByEmail(t *testing.T, candidates []Candidate, tenantID, email string) []Candidate { + t.Helper() + var out []Candidate + for _, c := range candidates { + if c.NexarchTenantID == tenantID && c.Email == email { + out = append(out, c) + } + } + return out +} + +// Pruefung 2: Stichprobe bekannter Doppel-Accounts (gleiche E-Mail in +// archivmail und archivdms, innerhalb desselben freigegebenen Tenants) +// korrekt erkannt -- synthetische Daten. +func TestMatch_BekannteDoppelAccountsImFreigegebenenTenantKorrektErkannt(t *testing.T) { + entries := []tenantapproval.Entry{approvedEntry(1, 10, 20, "nexarch-tenant-a")} + + archivmail := []ExportedUser{ + {SourceID: 1, TenantSourceID: 10, Email: "doppel@synth.example", Name: "Doppel Account", PasswordHash: "hash-am"}, + {SourceID: 2, TenantSourceID: 10, Email: "nur-mail@synth.example", Name: "Nur Mail"}, + } + archivdms := []ExportedUser{ + {SourceID: 101, TenantSourceID: 20, Email: "doppel@synth.example", Name: "Doppel Account", PasswordHash: "hash-dms"}, + {SourceID: 102, TenantSourceID: 20, Email: "nur-dms@synth.example", Name: "Nur DMS"}, + } + + candidates := Match(archivmail, archivdms, entries) + if len(candidates) != 3 { + t.Fatalf("erwarte 3 kandidaten (1 doppel + 2 einseitig), habe %d: %+v", len(candidates), candidates) + } + + dupe := findByEmail(t, candidates, "nexarch-tenant-a", "doppel@synth.example") + if len(dupe) != 1 { + t.Fatalf("erwarte genau 1 kandidat fuer doppel-account, habe %d", len(dupe)) + } + c := dupe[0] + if c.ArchivmailUserID == nil || *c.ArchivmailUserID != 1 || c.ArchivdmsUserID == nil || *c.ArchivdmsUserID != 101 { + t.Fatalf("doppel-account nicht korrekt verknuepft: %+v", c) + } + if c.Confidence != ConfidenceEmailMatch || c.Conflict { + t.Fatalf("erwarte konfliktfreien email-match, habe %+v", c) + } + + for _, email := range []string{"nur-mail@synth.example", "nur-dms@synth.example"} { + only := findByEmail(t, candidates, "nexarch-tenant-a", email) + if len(only) != 1 || (only[0].ArchivmailUserID != nil && only[0].ArchivdmsUserID != nil) { + t.Fatalf("e-mail %q sollte einseitig sichtbar bleiben, habe %+v", email, only) + } + } +} + +// Akzeptanzkriterium 2: Abgleich erfolgt ausschliesslich innerhalb eines +// bereits freigegebenen Tenants. Benutzer eines nicht freigegebenen +// (pending) Tenants erzeugen ueberhaupt keine Kandidaten. +func TestMatch_NichtFreigegebenerTenantLiefertKeineKandidaten(t *testing.T) { + am := int64(30) + dms := int64(40) + pending := tenantapproval.Entry{ + ID: 2, + ArchivmailTenantID: &am, + ArchivdmsTenantID: &dms, + NexarchTenantID: "nexarch-tenant-pending", + Status: tenantapproval.StatusPending, + } + + archivmail := []ExportedUser{{SourceID: 1, TenantSourceID: 30, Email: "warte@synth.example", Name: "Wartet"}} + archivdms := []ExportedUser{{SourceID: 101, TenantSourceID: 40, Email: "warte@synth.example", Name: "Wartet"}} + + candidates := Match(archivmail, archivdms, []tenantapproval.Entry{pending}) + if len(candidates) != 0 { + t.Fatalf("nicht freigegebener tenant darf keine kandidaten liefern, habe %d: %+v", len(candidates), candidates) + } +} + +// Akzeptanzkriterium 3 / sicherheitskritische Pruefung: zwei unterschiedliche +// freigegebene Tenants mit identischer E-Mail-Adresse bleiben getrennt -- +// kein Kandidat darf archivmail-Benutzer aus Tenant A mit archivdms-Benutzer +// aus Tenant B verknuepfen. +func TestMatch_IdentischeEmailUeberTenantGrenzenBleibtGetrennt(t *testing.T) { + entries := []tenantapproval.Entry{ + approvedEntry(1, 10, 20, "nexarch-tenant-a"), + approvedEntry(2, 11, 21, "nexarch-tenant-b"), + } + + const sharedEmail = "gleiche-adresse@synth.example" + archivmail := []ExportedUser{ + {SourceID: 1, TenantSourceID: 10, Email: sharedEmail, Name: "Tenant A Benutzer"}, + } + archivdms := []ExportedUser{ + {SourceID: 101, TenantSourceID: 21, Email: sharedEmail, Name: "Tenant B Benutzer"}, + } + + candidates := Match(archivmail, archivdms, entries) + if len(candidates) != 2 { + t.Fatalf("erwarte 2 einseitige kandidaten (je tenant einen), habe %d: %+v", len(candidates), candidates) + } + + for _, c := range candidates { + if c.ArchivmailUserID != nil && c.ArchivdmsUserID != nil { + t.Fatalf("kandidat verknuepft faelschlich ueber tenant-grenzen hinweg: %+v", c) + } + } + + tenantA := findByEmail(t, candidates, "nexarch-tenant-a", sharedEmail) + if len(tenantA) != 1 || tenantA[0].ArchivdmsUserID != nil { + t.Fatalf("tenant a sollte nur den archivmail-benutzer einseitig sehen, habe %+v", tenantA) + } + tenantB := findByEmail(t, candidates, "nexarch-tenant-b", sharedEmail) + if len(tenantB) != 1 || tenantB[0].ArchivmailUserID != nil { + t.Fatalf("tenant b sollte nur den archivdms-benutzer einseitig sehen, habe %+v", tenantB) + } +}