MAP-03: User-Export und E-Mail-Abgleich innerhalb Tenant #273

Merged
patrick merged 2 commits from feature/map-03-user-export-email-abgleich-tenant into main 2026-10-14 22:09:13 +02:00
9 changed files with 694 additions and 0 deletions

No files matched your search

+75
View File
@@ -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))
}
+98
View File
@@ -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`).
+54
View File
@@ -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])
}
}
+7
View File
@@ -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)
+46
View File
@@ -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)
}
+53
View File
@@ -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
}
+86
View File
@@ -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)
}
}
}
+143
View File
@@ -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,
}
}
+132
View File
@@ -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)
}
}