MAP-03: User-Export und E-Mail-Abgleich innerhalb Tenant #273
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user