MAP-04: User-Migration-Map und Freigabe-Workflow #279
No files matched your search
@@ -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).
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user