MAP-02: Tenant-Migration-Map & Freigabe-Workflow #266
No files matched your search
@@ -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).
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user