diff --git a/docs/MAP-02-PRUEFPROTOKOLL.md b/docs/MAP-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b8edcf32 --- /dev/null +++ b/docs/MAP-02-PRUEFPROTOKOLL.md @@ -0,0 +1,71 @@ +# MAP-02 — Prüfprotokoll: Tenant-Migration-Map & Freigabe-Workflow + +Datum: 2026-10-14 +Testhost: root@192.168.1.204 (Wegwerf-Postgres-Cluster, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, kein Bezug zu `dms_dev`/`archivdms`). Arbeitsverzeichnis `/root/map02-work-1791989041` (Wegwerf-Checkout, nicht `/root/nexarch-code`). + +## Synthetische Testdaten + +Alle Tests arbeiten ausschließlich mit frei erfundenen Map-Einträgen (Slugs wie `synth_...`, `onesided_...`, Namen wie „Synthetische Test GmbH“, erfundene `archivmail_tenant_id`/`archivdms_tenant_id`). Keine echten archivmail-/archivdms-Kunden-/Mandantendaten, -Schemas oder -Credentials wurden gelesen oder verwendet. + +## Ausgangslage geprüft + +- `COR-01` (`internal/migrationtenant`, `migrations/0019_tenant_migration_map.up/down.sql`) legt den `tenant_migration_map`-Stub an und liest ausschließlich Zeilen mit `approved_at IS NOT NULL`. Dieser Filter bleibt die einzige Schnittstelle, die COR-01 nutzt — MAP-02 erweitert die Tabelle additiv, ohne diese Spalte/Semantik zu verändern. +- `MAP-01` (`internal/tenantmatch`) liefert `Candidate`-Vorschläge (Slug-/Domain-Matching mit Konfliktmarkierung, Override durch Kunden-Zuordnungsliste), nie automatisch aufgelöst. +- Nächste freie Migrationsnummer auf frisch geklontem main geprüft: `0024` (`ls migrations/ | sort | tail -5` zeigte `0023_user_migration_map_ldap.*` als letzten Eintrag im Registry-Migrationsverzeichnis). + +## Umsetzung + +- `migrations/0024_tenant_migration_map_approval.up/down.sql`: erweitert `tenant_migration_map` um `status` (`pending`/`approved`/`rejected`, CHECK-Constraints für Konsistenz mit `approved_at`/`approved_by` bzw. `rejected_at`/`rejected_by`) und legt `tenant_migration_map_audit` an (Protokoll jeder Freigabe-/Ablehnungs-/Korrektur-Entscheidung mit Zeitpunkt, handelnder Person, vorherigen Werten). +- Neues Paket `internal/tenantapproval` (`store.go`): + - `InsertCandidates`: übernimmt MAP-01-Kandidaten als `status='pending'`-Zeilen, überspringt einseitige (keine echte Zusammenführung). + - `Approve`/`Reject`: einzige Wege, einen Eintrag aus `pending` herauszuführen, jeweils mit Pflichtangabe der handelnden Person, schreiben transaktional einen `tenant_migration_map_audit`-Eintrag. + - `Correct`: ändert eine **bereits freigegebene** Zuordnung (Slug/Name/Altsystem-IDs), protokolliert die vorherigen Werte im Audit-Log; vor einer Freigabe nicht möglich (`ErrNotApproved`). + - `Pending`, `Get`, `AuditLog`: lesende Zugriffe für die Freigabe-Prüfung bzw. das Protokoll. +- Keine zweite/parallele Tabelle angelegt — Erweiterung des bestehenden COR-01-Schemas wie im Ticket gefordert. +- Keine Zugangsdaten im Code; Tests nutzen ausschließlich `TEST_ADMIN_DSN` aus der Umgebung. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Jede Zusammenführung erfordert eine explizite, protokollierte menschliche Freigabe | Erfüllt — `TestApprove_RequiresExplicitDecisionAndLogsIt`: neuer Kandidat ist `pending`/unfreigegeben, `Approve` setzt `status='approved'`+`approved_by`+`approved_at` und schreibt genau einen `tenant_migration_map_audit`-Eintrag mit Zeitpunkt und Person | +| 2 | Ohne Freigabe bleibt ein Tenant unbestätigt und wird von nachgelagerten Schritten nicht verarbeitet | Erfüllt — `TestUnapprovedEntry_IsRejectedByDownstreamConsumer`: unfreigegebener und abgelehnter Eintrag erfüllen nie `approved_at IS NOT NULL` (derselbe Filter, den COR-01s `MapStore.Approved` nutzt) | +| 3 | Freigegebene Map ist die einzige Quelle für tenant_id-Umschreibung | Erfüllt — bestehender COR-01-Test `TestMigrateApproved` läuft unverändert gegen das erweiterte Schema weiter (siehe Regressionslauf unten); `tenantapproval` ändert `approved_at`-Semantik nicht | + +## Prüfungen vor der Abnahme + +1. **Zusammenführung ohne Freigabe wird von einem Testlauf nachweislich zurückgewiesen.** + Zielwert: `SELECT count(*) FROM tenant_migration_map WHERE id=? AND approved_at IS NOT NULL` liefert `0` für einen neu angelegten und für einen abgelehnten Eintrag; ein erneutes `Approve` eines bereits abgelehnten Eintrags scheitert. + Ergebnis: erreicht — `TestUnapprovedEntry_IsRejectedByDownstreamConsumer`, PASS. `count=0` vor und nach `Reject`; `Approve` nach `Reject` liefert `ErrAlreadyDecided`. + +2. **Freigabe-Protokoll enthält Zeitpunkt und freigebende Person.** + Zielwert: nach `Approve`/`Reject` existiert genau ein `tenant_migration_map_audit`-Eintrag mit `performed_by` = übergebene Person und `performed_at` ungleich Zero-Value. + Ergebnis: erreicht — `TestApprove_RequiresExplicitDecisionAndLogsIt`, PASS (`log[0].Action="approved"`, `PerformedBy="qa-tester"`, `PerformedAt` gesetzt). + +3. **Nachträgliche Korrektur einer bereits freigegebenen Zuordnung ist möglich und wird protokolliert.** + Zielwert: `Correct` auf einen freigegebenen Eintrag ändert Slug/Name, ursprüngliche Freigabe (`approved_by`) bleibt erhalten, ein zweiter Audit-Eintrag (`action='corrected'`) mit den vorherigen Werten entsteht; `Correct` vor Freigabe wird abgelehnt. + Ergebnis: erreicht — `TestCorrect_UpdatesApprovedEntryAndLogsPreviousValues`, PASS. `Correct` vor `Approve` liefert `ErrNotApproved`; nach `Approve`+`Correct`: neuer Slug/Name übernommen, `approved_by` unverändert, 2 Audit-Einträge (`approved`, `corrected`), letzterer mit `PreviousSlug`/`PreviousName` der alten Werte. + +Zusätzlich (Regression/Build): +``` +go build ./... -> keine Fehler +go vet ./... -> keine Befunde +TEST_ADMIN_DSN=... go test ./internal/tenantapproval/... -v -> PASS (4/4: Approve+Log, Unapproved-Rejection, + Correct+Log, InsertCandidates-Filter einseitig) +TEST_ADMIN_DSN=... go test ./internal/migrationtenant/... -v -> PASS (TestMigrateApproved, unveraendert, + bestaetigt AC3 gegen erweitertes Schema) +TEST_ADMIN_DSN=... go test ./internal/tenantmatch/... -v -> PASS (keine Regression in MAP-01) +Migrationen 0001..0024 in Reihenfolge gegen frische Wegwerf-DB angewendet -> ALL_APPLIED_OK +Migration 0024 zurueckgerollt und erneut angewendet -> ROLLBACK_0024_OK, REAPPLY_0024_OK +``` + +Hinweis: `TEST_ADMIN_DSN=... go test ./internal/tenant/... -v` zeigt mehrere vorbestehende, nicht mit MAP-02 +zusammenhaengende Fehlschlaege ("slug bereits vergeben") durch Testlauf-Kollisionen auf dem geteilten +Entwicklungs-/Testhost 204 — reproduzierbar auch ohne jede MAP-02-Aenderung, da `internal/tenant` von diesem +Ticket nicht beruehrt wird. Nicht Bestandteil dieser Kachel, keine Korrektur vorgenommen. + +## Entscheidungen (dokumentiert, keine Rückfrage nötig) + +- `status`-Spalte zusätzlich zu `approved_at`/`approved_by` eingeführt, um einen expliziten, dauerhaften Ablehnungs-Zustand (`rejected`) von „noch nicht entschieden“ (`pending`) zu unterscheiden, ohne die von COR-01 bereits gelesene `approved_at`-Semantik zu verändern. +- `Correct` ausschließlich für bereits freigegebene Einträge vorgesehen (Ticket: „Korrektur einer bereits freigegebenen Zuordnung“); eine Korrektur vor Freigabe ist über erneutes `Reject`+Neuanlage bzw. künftige Board-Kacheln (Pflege der Kunden-Zuordnungsliste, explizit nicht Teil dieser Kachel) abgedeckt. +- Kein HTTP-Endpunkt/UI gebaut — laut Auftrag reicht eine Freigabe-Funktion/-API analog zum Rest des Migrationsboards (Backend-Logik, kein ausgebautes Frontend für diese Kachel nötig). diff --git a/internal/tenantapproval/store.go b/internal/tenantapproval/store.go new file mode 100644 index 00000000..76d6e694 --- /dev/null +++ b/internal/tenantapproval/store.go @@ -0,0 +1,292 @@ +// Package tenantapproval implementiert MAP-02: den Freigabe-Workflow fuer +// tenant_migration_map-Kandidaten aus MAP-01. Jede Zusammenfuehrung +// erfordert eine explizite, protokollierte menschliche Entscheidung +// (Approve/Reject/Correct), bevor COR-01 sie als Quelle nutzen darf. +package tenantapproval + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" +) + +var ( + ErrNotFound = errors.New("tenant-map-eintrag nicht gefunden") + ErrAlreadyDecided = errors.New("tenant-map-eintrag ist bereits freigegeben oder abgelehnt") + ErrNotApproved = errors.New("tenant-map-eintrag ist nicht freigegeben, keine korrektur moeglich") +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusApproved Status = "approved" + StatusRejected Status = "rejected" +) + +// Entry ist eine Zeile der tenant_migration_map inklusive Freigabe-Status. +type Entry struct { + ID int64 + ArchivmailTenantID *int64 + ArchivdmsTenantID *int64 + NexarchTenantID string + Slug string + Name string + Status Status + ApprovedBy *string + ApprovedAt *time.Time + RejectedBy *string + RejectedAt *time.Time +} + +// AuditEntry protokolliert eine einzelne Freigabe-Entscheidung oder +// nachtraegliche Korrektur. +type AuditEntry struct { + ID int64 + MapID int64 + Action string + PerformedBy string + PerformedAt time.Time + Note string + PreviousSlug string + PreviousName string +} + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// InsertCandidates legt MAP-01-Kandidaten als unbestaetigte (status=pending) +// tenant_migration_map-Zeilen an. Einseitige Kandidaten (nur archivmail ODER +// nur archivdms) werden uebersprungen: eine Zusammenfuehrung setzt per +// Definition beide Quellen voraus, Freigabe-Pflicht gilt nur fuer echte +// Merges. +func (s *Store) InsertCandidates(ctx context.Context, candidates []tenantmatch.Candidate) ([]int64, error) { + var ids []int64 + for _, c := range candidates { + if c.ArchivmailTenantID == nil || c.ArchivdmsTenantID == nil { + continue + } + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO tenant_migration_map + (archivmail_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, status) + VALUES ($1, $2, gen_random_uuid(), $3, $4, 'pending') + RETURNING id + `, c.ArchivmailTenantID, c.ArchivdmsTenantID, c.Slug, c.Name).Scan(&id) + if err != nil { + return nil, fmt.Errorf("kandidat %q anlegen: %w", c.Slug, err) + } + ids = append(ids, id) + } + return ids, nil +} + +// Pending liefert alle noch unentschiedenen Eintraege (Akzeptanzkriterium 2). +func (s *Store) Pending(ctx context.Context) ([]Entry, error) { + return s.list(ctx, `WHERE status = 'pending'`) +} + +// 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_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, + status, approved_by, approved_at, rejected_by, rejected_at + FROM tenant_migration_map + `+where+` + ORDER BY id + `, args...) + if err != nil { + return nil, fmt.Errorf("tenant-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.ArchivmailTenantID, &e.ArchivdmsTenantID, &e.NexarchTenantID, &e.Slug, &e.Name, + &status, &e.ApprovedBy, &e.ApprovedAt, &e.RejectedBy, &e.RejectedAt); err != nil { + return nil, fmt.Errorf("tenant-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 nachgelagerte +// Migrationsschritte 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 tenant_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-01 uebernommen. +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 tenant_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 tenant_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) +} + +// Correction fasst die aenderbaren Felder einer nachtraeglichen Korrektur +// zusammen. Leere Zeiger/Werte lassen das jeweilige Feld unveraendert. +type Correction struct { + ArchivmailTenantID *int64 + ArchivdmsTenantID *int64 + Slug *string + Name *string +} + +// Correct aendert eine BEREITS freigegebene Zuordnung und protokolliert die +// vorherigen Werte im Audit-Log (Pruefung 3). Eine Korrektur ohne +// vorherige Freigabe ist nicht vorgesehen -- dafuer existiert Reject/Approve. +func (s *Store) Correct(ctx context.Context, id int64, correctedBy, note string, c Correction) error { + if correctedBy == "" { + return errors.New("correctedBy darf nicht leer sein") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("transaktion beginnen: %w", err) + } + defer tx.Rollback(ctx) + + var prevAM, prevDMS *int64 + var prevSlug, prevName string + var status string + err = tx.QueryRow(ctx, ` + SELECT archivmail_tenant_id, archivdms_tenant_id, slug, name, status + FROM tenant_migration_map WHERE id = $1 FOR UPDATE + `, id).Scan(&prevAM, &prevDMS, &prevSlug, &prevName, &status) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("eintrag fuer korrektur lesen: %w", err) + } + if status != string(StatusApproved) { + return ErrNotApproved + } + + newAM, newDMS := prevAM, prevDMS + newSlug, newName := prevSlug, prevName + if c.ArchivmailTenantID != nil { + newAM = c.ArchivmailTenantID + } + if c.ArchivdmsTenantID != nil { + newDMS = c.ArchivdmsTenantID + } + if c.Slug != nil { + newSlug = *c.Slug + } + if c.Name != nil { + newName = *c.Name + } + + if _, err := tx.Exec(ctx, ` + UPDATE tenant_migration_map + SET archivmail_tenant_id = $1, archivdms_tenant_id = $2, slug = $3, name = $4 + WHERE id = $5 + `, newAM, newDMS, newSlug, newName, id); err != nil { + return fmt.Errorf("korrektur schreiben: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO tenant_migration_map_audit + (map_id, action, performed_by, note, previous_archivmail_tenant_id, previous_archivdms_tenant_id, previous_slug, previous_name) + VALUES ($1, 'corrected', $2, $3, $4, $5, $6, $7) + `, id, correctedBy, note, prevAM, prevDMS, prevSlug, prevName); err != nil { + return fmt.Errorf("audit-log fuer korrektur: %w", err) + } + + return tx.Commit(ctx) +} + +// AuditLog liefert das Freigabe-/Korrektur-Protokoll eines Eintrags, +// chronologisch (Pruefung 2/3: 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, ''), + coalesce(previous_slug, ''), coalesce(previous_name, '') + FROM tenant_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, &a.PreviousSlug, &a.PreviousName); err != nil { + return nil, fmt.Errorf("audit-log-eintrag lesen: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} diff --git a/internal/tenantapproval/store_test.go b/internal/tenantapproval/store_test.go new file mode 100644 index 00000000..17402165 --- /dev/null +++ b/internal/tenantapproval/store_test.go @@ -0,0 +1,249 @@ +package tenantapproval + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" +) + +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, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'pending', + rejected_by TEXT, + rejected_at TIMESTAMPTZ, + CONSTRAINT tenant_migration_map_source_present + CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL), + CONSTRAINT tenant_migration_map_status_check + CHECK (status IN ('pending', 'approved', 'rejected')), + CONSTRAINT tenant_migration_map_status_approved_consistent + CHECK (status != 'approved' OR (approved_at IS NOT NULL AND approved_by IS NOT NULL)), + CONSTRAINT tenant_migration_map_status_rejected_consistent + CHECK (status != 'rejected' OR (rejected_at IS NOT NULL AND rejected_by IS NOT NULL)) + ); + CREATE TABLE tenant_migration_map_audit ( + id BIGSERIAL PRIMARY KEY, + map_id BIGINT NOT NULL REFERENCES tenant_migration_map(id), + action TEXT NOT NULL CHECK (action IN ('approved', 'rejected', 'corrected')), + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT, + previous_archivmail_tenant_id BIGINT, + previous_archivdms_tenant_id BIGINT, + previous_slug TEXT, + previous_name 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 } + +// AC1: jede 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() + + ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("synth"), Name: "Synthetische Test GmbH " + suffix}, + }) + 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-02"); 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: ohne Freigabe bleibt ein Tenant unbestaetigt und wird von +// nachgelagerten Schritten nicht verarbeitet -- Pruefung 1 des Tickets +// ("Zusammenfuehrung ohne Freigabe wird von einem Testlauf nachweislich +// zurueckgewiesen"). +func TestUnapprovedEntry_IsRejectedByDownstreamConsumer(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + + ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(10), ArchivdmsTenantID: mustID(20), Slug: testdb.Slug("unapp"), Name: "Unfreigegeben " + testdb.Suffix()}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + id := ids[0] + + // Akzeptanzkriterium 3: approved_at IS NOT NULL ist die einzige Quelle, + // die COR-01 (internal/migrationtenant.MapStore.Approved) tatsaechlich + // liest -- derselbe Filter wird hier direkt nachgebildet, um die + // Zurueckweisung ohne Freigabe nachzuweisen. + var count int + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM tenant_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("unfreigegebener eintrag darf vom downstream-filter nicht erfasst werden, count=%d", count) + } + + if err := store.Reject(ctx, id, "qa-tester", "Konflikt nicht aufloesbar"); err != nil { + t.Fatalf("Reject: %v", err) + } + rejected, err := store.Get(ctx, id) + if err != nil { + t.Fatalf("Get nach Reject: %v", err) + } + if rejected.Status != StatusRejected || rejected.ApprovedAt != nil { + t.Fatalf("abgelehnter eintrag darf nie approved_at erhalten: %+v", rejected) + } + + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM tenant_migration_map WHERE id = $1 AND approved_at IS NOT NULL + `, id).Scan(&count); err != nil { + t.Fatalf("downstream-filter nach Reject pruefen: %v", err) + } + if count != 0 { + t.Fatalf("abgelehnter eintrag darf weiterhin nicht vom downstream-filter erfasst werden, count=%d", count) + } + + if err := store.Approve(ctx, id, "qa-tester", ""); !errors.Is(err, ErrAlreadyDecided) { + t.Fatalf("erneutes Approve eines abgelehnten eintrags muss ErrAlreadyDecided liefern, habe %v", err) + } +} + +// AC3 / Pruefung 3: nachtraegliche Korrektur einer bereits freigegebenen +// Zuordnung ist moeglich und wird protokolliert. +func TestCorrect_UpdatesApprovedEntryAndLogsPreviousValues(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + suffix := testdb.Suffix() + oldSlug := testdb.Slug("alt") + newSlug := testdb.Slug("neu") + + ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(100), ArchivdmsTenantID: mustID(200), Slug: oldSlug, Name: "Alte Firma " + suffix}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + id := ids[0] + + // Korrektur ohne vorherige Freigabe ist nicht vorgesehen. + if err := store.Correct(ctx, id, "qa-tester", "", Correction{Slug: &newSlug}); !errors.Is(err, ErrNotApproved) { + t.Fatalf("Correct vor Approve muss ErrNotApproved liefern, habe %v", err) + } + + if err := store.Approve(ctx, id, "qa-tester", ""); err != nil { + t.Fatalf("Approve: %v", err) + } + + newName := "Neue Firma " + suffix + if err := store.Correct(ctx, id, "qa-lead", "Tippfehler in Slug und Name korrigiert", Correction{ + Slug: &newSlug, + Name: &newName, + }); err != nil { + t.Fatalf("Correct: %v", err) + } + + corrected, err := store.Get(ctx, id) + if err != nil { + t.Fatalf("Get nach Correct: %v", err) + } + if corrected.Slug != newSlug || corrected.Name != newName || corrected.Status != StatusApproved { + t.Fatalf("korrektur nicht uebernommen: %+v", corrected) + } + // Freigabe bleibt bestehen, zweite Freigabe ist nicht noetig. + if corrected.ApprovedBy == nil || *corrected.ApprovedBy != "qa-tester" { + t.Fatalf("urspruengliche freigabe darf durch Correct nicht veraendert werden: %+v", corrected) + } + + log, err := store.AuditLog(ctx, id) + if err != nil { + t.Fatalf("AuditLog: %v", err) + } + if len(log) != 2 { + t.Fatalf("erwarte 2 audit-eintraege (approved, corrected), habe %d: %+v", len(log), log) + } + correction := log[1] + if correction.Action != "corrected" || correction.PerformedBy != "qa-lead" || correction.PreviousSlug != oldSlug || correction.PerformedAt.IsZero() { + t.Fatalf("korrektur-audit-eintrag unvollstaendig: %+v", correction) + } +} + +func TestInsertCandidates_SkipsOneSidedCandidates(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + + ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), Slug: testdb.Slug("onesided"), Name: "Nur Archivmail"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if len(ids) != 0 { + t.Fatalf("einseitiger kandidat (kein merge) darf keine map-zeile anlegen, habe %d", len(ids)) + } +} diff --git a/migrations/0024_tenant_migration_map_approval.down.sql b/migrations/0024_tenant_migration_map_approval.down.sql new file mode 100644 index 00000000..2c745e25 --- /dev/null +++ b/migrations/0024_tenant_migration_map_approval.down.sql @@ -0,0 +1,9 @@ +DROP TABLE tenant_migration_map_audit; + +ALTER TABLE tenant_migration_map + DROP CONSTRAINT tenant_migration_map_status_rejected_consistent, + DROP CONSTRAINT tenant_migration_map_status_approved_consistent, + DROP CONSTRAINT tenant_migration_map_status_check, + DROP COLUMN rejected_at, + DROP COLUMN rejected_by, + DROP COLUMN status; diff --git a/migrations/0024_tenant_migration_map_approval.up.sql b/migrations/0024_tenant_migration_map_approval.up.sql new file mode 100644 index 00000000..24a8c0e8 --- /dev/null +++ b/migrations/0024_tenant_migration_map_approval.up.sql @@ -0,0 +1,29 @@ +-- MAP-02: Freigabe-Workflow fuer tenant_migration_map (erweitert den +-- COR-01-Stub aus 0019). status ersetzt die reine approved_at-Pruefung um +-- einen expliziten Ablehnungs-Zustand; approved_at/approved_by bleiben +-- bestehen, da COR-01 bereits darauf liest. +ALTER TABLE tenant_migration_map + ADD COLUMN status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN rejected_by TEXT, + ADD COLUMN rejected_at TIMESTAMPTZ, + ADD CONSTRAINT tenant_migration_map_status_check + CHECK (status IN ('pending', 'approved', 'rejected')), + ADD CONSTRAINT tenant_migration_map_status_approved_consistent + CHECK (status != 'approved' OR (approved_at IS NOT NULL AND approved_by IS NOT NULL)), + ADD CONSTRAINT tenant_migration_map_status_rejected_consistent + CHECK (status != 'rejected' OR (rejected_at IS NOT NULL AND rejected_by IS NOT NULL)); + +CREATE TABLE tenant_migration_map_audit ( + id BIGSERIAL PRIMARY KEY, + map_id BIGINT NOT NULL REFERENCES tenant_migration_map(id), + action TEXT NOT NULL CHECK (action IN ('approved', 'rejected', 'corrected')), + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT, + previous_archivmail_tenant_id BIGINT, + previous_archivdms_tenant_id BIGINT, + previous_slug TEXT, + previous_name TEXT +); + +CREATE INDEX tenant_migration_map_audit_map_id_idx ON tenant_migration_map_audit (map_id);