TAR-02: Vier-Augen-Löschworkflow-Migration #275
No files matched your search
@@ -0,0 +1,189 @@
|
||||
// Package deletionrequestmigration implementiert TAR-02: Übernahme
|
||||
// offener Löschanfragen aus archivdms' document_delete_requests in
|
||||
// CMP-06 (archive/internal/deletionapproval) als aktive Vier-Augen-
|
||||
// Vorgänge. Bereits vollzogene Löschungen aus derselben Quelltabelle
|
||||
// werden NICHT als aktiver Vorgang übernommen (das Objekt existiert
|
||||
// nicht mehr), sondern ausschließlich als destruction_log-Eintrag
|
||||
// (RET-03) — append-only, DB-Trigger-geschützt.
|
||||
package deletionrequestmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionapproval"
|
||||
)
|
||||
|
||||
// SourceDeleteRequest ist eine Zeile aus archivdms'
|
||||
// document_delete_requests. Executed==true bedeutet: die Löschung wurde
|
||||
// in archivdms bereits vollzogen, das Dokument existiert dort nicht mehr.
|
||||
type SourceDeleteRequest struct {
|
||||
ObjectType string
|
||||
ObjectReference string
|
||||
RequestedBy string
|
||||
RequestedAt time.Time
|
||||
Executed bool
|
||||
ExecutedBy string
|
||||
ExecutedAt time.Time
|
||||
}
|
||||
|
||||
// Source liefert die offenen und bereits vollzogenen Löschanfragen aus
|
||||
// archivdms (SQL-Export, wie bei DOK-01/dms/internal/migrationdocument).
|
||||
type Source interface {
|
||||
DeleteRequests(ctx context.Context) ([]SourceDeleteRequest, error)
|
||||
}
|
||||
|
||||
type Migrator struct {
|
||||
pool *pgxpool.Pool
|
||||
source Source
|
||||
}
|
||||
|
||||
func NewMigrator(pool *pgxpool.Pool, source Source) *Migrator {
|
||||
return &Migrator{pool: pool, source: source}
|
||||
}
|
||||
|
||||
// Result fasst den Ausgang eines Migrationslaufs zusammen.
|
||||
type Result struct {
|
||||
OpenRequestsMigrated int
|
||||
ExecutedAudited int
|
||||
}
|
||||
|
||||
// MigrateAll übernimmt jede offene Löschanfrage als aktiven CMP-06-
|
||||
// Vorgang (Akzeptanzkriterium 1) und jede bereits vollzogene Löschung
|
||||
// ausschließlich als destruction_log-Audit-Eintrag (Akzeptanzkriterium
|
||||
// 2). Idempotent: ein erneuter Lauf gegen dieselbe Quelle erzeugt keine
|
||||
// Duplikate.
|
||||
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
|
||||
var res Result
|
||||
|
||||
reqs, err := m.source.DeleteRequests(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("deletionrequestmigration: loeschanfragen aus quelle lesen: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range reqs {
|
||||
if r.Executed {
|
||||
done, err := m.auditExecuted(ctx, r)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if done {
|
||||
res.ExecutedAudited++
|
||||
}
|
||||
continue
|
||||
}
|
||||
done, err := m.migrateOpen(ctx, r)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if done {
|
||||
res.OpenRequestsMigrated++
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) resolveRetentionObjectID(ctx context.Context, objectType, objectReference string) (string, error) {
|
||||
var id string
|
||||
err := m.pool.QueryRow(ctx, `
|
||||
INSERT INTO retention_objects (object_type, object_reference)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (object_type, object_reference) DO UPDATE SET object_type = EXCLUDED.object_type
|
||||
RETURNING id
|
||||
`, objectType, objectReference).Scan(&id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deletionrequestmigration: retention-objekt %s/%s aufloesen: %w", objectType, objectReference, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// migrateOpen legt die offene Löschanfrage als CMP-06-Vorgang an, mit
|
||||
// einem frischen Bestätigungs-Token (das archivdms-Original-Token ist
|
||||
// in dieser Quelle nicht vorhanden/anwendbar) und dem ursprünglichen
|
||||
// requested_by/requested_at 1:1 übernommen. Ist für dieses Objekt
|
||||
// bereits eine Löschanfrage migriert, wird sie nicht doppelt angelegt.
|
||||
func (m *Migrator) migrateOpen(ctx context.Context, r SourceDeleteRequest) (bool, error) {
|
||||
retentionObjectID, err := m.resolveRetentionObjectID(ctx, r.ObjectType, r.ObjectReference)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err = m.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM deletion_requests WHERE retention_object_id = $1 AND requested_at = $2 AND requested_by = $3)
|
||||
`, retentionObjectID, r.RequestedAt, r.RequestedBy).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: bestand der loeschanfrage pruefen: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: token erzeugen: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
|
||||
_, err = m.pool.Exec(ctx, `
|
||||
INSERT INTO deletion_requests (retention_object_id, requested_by, requested_at, confirmation_token_hash, token_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, retentionObjectID, r.RequestedBy, r.RequestedAt, sum[:], time.Now().UTC().Add(deletionapproval.TokenTTL))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: loeschanfrage fuer %s/%s anlegen: %w", r.ObjectType, r.ObjectReference, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// auditExecuted registriert für eine bereits vollzogene Löschung
|
||||
// ausschließlich einen destruction_log-Eintrag (append-only) — KEINE
|
||||
// Zeile in deletion_requests, da kein aktiver Vorgang mehr existiert.
|
||||
// Ist bereits ein Audit-Eintrag für dieses Objekt vorhanden, wird nicht
|
||||
// erneut geschrieben.
|
||||
func (m *Migrator) auditExecuted(ctx context.Context, r SourceDeleteRequest) (bool, error) {
|
||||
retentionObjectID, err := m.resolveRetentionObjectID(ctx, r.ObjectType, r.ObjectReference)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err = m.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM destruction_log WHERE retention_object_id = $1)
|
||||
`, retentionObjectID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: bestand des audit-eintrags pruefen: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
tx, err := m.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE retention_objects SET status = 'deleted' WHERE id = $1
|
||||
`, retentionObjectID); err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: objekt %s/%s als vernichtet markieren: %w", r.ObjectType, r.ObjectReference, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO destruction_log (retention_object_id, object_type, object_reference, destroyed_at, destroyed_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, retentionObjectID, r.ObjectType, r.ObjectReference, r.ExecutedAt, r.ExecutedBy); err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: audit-eintrag fuer %s/%s anlegen: %w", r.ObjectType, r.ObjectReference, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, fmt.Errorf("deletionrequestmigration: audit-eintrag committen: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package deletionrequestmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
retain_until TIMESTAMPTZ,
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
requested_by TEXT NOT NULL,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
confirmation_token_hash BYTEA NOT NULL,
|
||||
token_expires_at TIMESTAMPTZ NOT NULL,
|
||||
confirmed_by TEXT,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
executed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS destruction_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT,
|
||||
object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
destroyed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
destroyed_by TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE destruction_log, deletion_requests, retention_objects CASCADE`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
type fixtureSource struct {
|
||||
requests []SourceDeleteRequest
|
||||
}
|
||||
|
||||
func (s *fixtureSource) DeleteRequests(ctx context.Context) ([]SourceDeleteRequest, error) {
|
||||
return s.requests, nil
|
||||
}
|
||||
|
||||
func objectID(t *testing.T, pool *pgxpool.Pool, objectType, objectReference string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2
|
||||
`, objectType, objectReference).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("objekt-id lesen: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestMigrateAll_OpenRequestsBecomeActiveWorkflowEntries ist Pruefung 1
|
||||
// (Akzeptanzkriterium 1): Zeilenzahl offener Loeschanfragen nach
|
||||
// Migration deckungsgleich mit Quelle, jede als aktiver CMP-06-Vorgang
|
||||
// (kein executed_at) vorhanden.
|
||||
func TestMigrateAll_OpenRequestsBecomeActiveWorkflowEntries(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
|
||||
const sampleSize = 30
|
||||
var reqs []SourceDeleteRequest
|
||||
for i := 0; i < sampleSize; i++ {
|
||||
reqs = append(reqs, SourceDeleteRequest{
|
||||
ObjectType: "dms_document",
|
||||
ObjectReference: fmt.Sprintf("tar02-open-%03d", i),
|
||||
RequestedBy: "sachbearbeiter-a",
|
||||
RequestedAt: time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, i),
|
||||
})
|
||||
}
|
||||
|
||||
m := NewMigrator(pool, &fixtureSource{requests: reqs})
|
||||
res, err := m.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("migrateall: %v", err)
|
||||
}
|
||||
if res.OpenRequestsMigrated != sampleSize || res.ExecutedAudited != 0 {
|
||||
t.Fatalf("result = %+v, want %d offene anfragen, 0 audit-eintraege", res, sampleSize)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM deletion_requests WHERE executed_at IS NULL
|
||||
`).Scan(&count); err != nil {
|
||||
t.Fatalf("zeilenzahl lesen: %v", err)
|
||||
}
|
||||
if count != sampleSize {
|
||||
t.Fatalf("aktive vorgaenge nach migration = %d, quelle hatte %d", count, sampleSize)
|
||||
}
|
||||
|
||||
// erneuter Lauf gegen dieselbe Quelle darf keine Duplikate erzeugen.
|
||||
res2, err := m.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("zweiter migrateall-lauf: %v", err)
|
||||
}
|
||||
if res2.OpenRequestsMigrated != 0 {
|
||||
t.Fatalf("zweiter lauf hat %d anfragen erneut migriert, erwartet 0 (idempotenz)", res2.OpenRequestsMigrated)
|
||||
}
|
||||
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM deletion_requests`).Scan(&count); err != nil {
|
||||
t.Fatalf("zeilenzahl nach zweitem lauf lesen: %v", err)
|
||||
}
|
||||
if count != sampleSize {
|
||||
t.Fatalf("zeilenzahl nach zweitem lauf = %d, erwartet unveraendert %d", count, sampleSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_ExecutedDeletionsOnlyAsAuditEntry ist Pruefung 2
|
||||
// (Akzeptanzkriterium 2): bereits vollzogene Loeschungen erscheinen
|
||||
// ausschliesslich als destruction_log-Eintrag, keine Zeile in
|
||||
// deletion_requests, kein retention_objects-Status != 'deleted'.
|
||||
func TestMigrateAll_ExecutedDeletionsOnlyAsAuditEntry(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
|
||||
executedAt := time.Date(2024, time.June, 10, 9, 30, 0, 0, time.UTC)
|
||||
reqs := []SourceDeleteRequest{
|
||||
{
|
||||
ObjectType: "dms_document",
|
||||
ObjectReference: "tar02-executed-001",
|
||||
RequestedBy: "sachbearbeiter-b",
|
||||
RequestedAt: executedAt.Add(-48 * time.Hour),
|
||||
Executed: true,
|
||||
ExecutedBy: "sachbearbeiter-c",
|
||||
ExecutedAt: executedAt,
|
||||
},
|
||||
}
|
||||
|
||||
m := NewMigrator(pool, &fixtureSource{requests: reqs})
|
||||
res, err := m.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("migrateall: %v", err)
|
||||
}
|
||||
if res.ExecutedAudited != 1 || res.OpenRequestsMigrated != 0 {
|
||||
t.Fatalf("result = %+v, want 1 audit-eintrag, 0 offene anfragen", res)
|
||||
}
|
||||
|
||||
id := objectID(t, pool, "dms_document", "tar02-executed-001")
|
||||
|
||||
var deletionRequestCount int
|
||||
if err := pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM deletion_requests WHERE retention_object_id = $1
|
||||
`, id).Scan(&deletionRequestCount); err != nil {
|
||||
t.Fatalf("deletion_requests zaehlen: %v", err)
|
||||
}
|
||||
if deletionRequestCount != 0 {
|
||||
t.Fatalf("vollzogene loeschung hat %d deletion_requests-zeilen, erwartet 0 (kein objekt-datensatz)", deletionRequestCount)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := pool.QueryRow(context.Background(), `SELECT status FROM retention_objects WHERE id = $1`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("status lesen: %v", err)
|
||||
}
|
||||
if status != "deleted" {
|
||||
t.Fatalf("status = %q, want %q", status, "deleted")
|
||||
}
|
||||
|
||||
var gotExecutedBy string
|
||||
var gotExecutedAt time.Time
|
||||
if err := pool.QueryRow(context.Background(), `
|
||||
SELECT destroyed_by, destroyed_at FROM destruction_log WHERE retention_object_id = $1
|
||||
`, id).Scan(&gotExecutedBy, &gotExecutedAt); err != nil {
|
||||
t.Fatalf("audit-eintrag lesen: %v", err)
|
||||
}
|
||||
if gotExecutedBy != "sachbearbeiter-c" || !gotExecutedAt.Equal(executedAt) {
|
||||
t.Fatalf("audit-eintrag = (%q, %v), quelle = (%q, %v)", gotExecutedBy, gotExecutedAt, "sachbearbeiter-c", executedAt)
|
||||
}
|
||||
|
||||
// erneuter Lauf darf keinen zweiten Audit-Eintrag erzeugen.
|
||||
res2, err := m.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("zweiter migrateall-lauf: %v", err)
|
||||
}
|
||||
if res2.ExecutedAudited != 0 {
|
||||
t.Fatalf("zweiter lauf hat %d audit-eintraege erneut geschrieben, erwartet 0 (idempotenz)", res2.ExecutedAudited)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_IndependentObjectsDoNotInterfere beweist mit zwei
|
||||
// unabhaengigen Objekten (eines offen, eines vollzogen), dass beide
|
||||
// Pfade im selben Lauf korrekt getrennt bleiben — echte unabhaengige
|
||||
// Testobjekte statt desselben Datensatzes doppelt (siehe TAR-01-Hinweis
|
||||
// zum methodischen Testfehler).
|
||||
func TestMigrateAll_IndependentObjectsDoNotInterfere(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
|
||||
reqs := []SourceDeleteRequest{
|
||||
{
|
||||
ObjectType: "mail_message",
|
||||
ObjectReference: "tar02-mixed-open",
|
||||
RequestedBy: "sachbearbeiter-d",
|
||||
RequestedAt: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ObjectType: "mail_message",
|
||||
ObjectReference: "tar02-mixed-executed",
|
||||
RequestedBy: "sachbearbeiter-e",
|
||||
RequestedAt: time.Date(2025, time.April, 1, 0, 0, 0, 0, time.UTC),
|
||||
Executed: true,
|
||||
ExecutedBy: "sachbearbeiter-f",
|
||||
ExecutedAt: time.Date(2025, time.April, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
m := NewMigrator(pool, &fixtureSource{requests: reqs})
|
||||
res, err := m.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("migrateall: %v", err)
|
||||
}
|
||||
if res.OpenRequestsMigrated != 1 || res.ExecutedAudited != 1 {
|
||||
t.Fatalf("result = %+v, want 1 offen, 1 audit", res)
|
||||
}
|
||||
|
||||
openID := objectID(t, pool, "mail_message", "tar02-mixed-open")
|
||||
var openStatus string
|
||||
if err := pool.QueryRow(context.Background(), `SELECT status FROM retention_objects WHERE id = $1`, openID).Scan(&openStatus); err != nil {
|
||||
t.Fatalf("status (offen) lesen: %v", err)
|
||||
}
|
||||
if openStatus != "active" {
|
||||
t.Fatalf("offenes objekt hat status %q, want active", openStatus)
|
||||
}
|
||||
var openExecutedAt *time.Time
|
||||
if err := pool.QueryRow(context.Background(), `
|
||||
SELECT executed_at FROM deletion_requests WHERE retention_object_id = $1
|
||||
`, openID).Scan(&openExecutedAt); err != nil {
|
||||
t.Fatalf("offene anfrage lesen: %v", err)
|
||||
}
|
||||
if openExecutedAt != nil {
|
||||
t.Fatalf("offene anfrage hat executed_at gesetzt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# TAR-02 – Prüfprotokoll: Vier-Augen-Löschworkflow-Migration
|
||||
|
||||
## Umfang
|
||||
|
||||
Migration offener Löschanfragen aus archivdms' `document_delete_requests`
|
||||
nach Archive CMP-06 (`archive/internal/deletionapproval`): aktive
|
||||
Vorgänge werden als Zeile in `deletion_requests` übernommen, bereits
|
||||
vollzogene Löschungen ausschließlich als Audit-Eintrag in
|
||||
`destruction_log` (RET-03, append-only).
|
||||
|
||||
Implementiert in `archive/internal/deletionrequestmigration` nach dem
|
||||
in TAR-01 (`archive/internal/retentionmigration`) etablierten Muster:
|
||||
`Source`-Interface als Adapter auf archivdms (SQL-Export, Vorbild
|
||||
`dms/internal/migrationdocument`), `Migrator.MigrateAll` idempotent.
|
||||
|
||||
## Akzeptanzkriterium 1: Offene Löschanfragen als aktiver Vorgang
|
||||
|
||||
Test: `TestMigrateAll_OpenRequestsBecomeActiveWorkflowEntries`
|
||||
(`archive/internal/deletionrequestmigration/deletionrequestmigration_test.go`)
|
||||
|
||||
- 30 synthetische offene Löschanfragen migriert.
|
||||
- `Result.OpenRequestsMigrated` = 30, `ExecutedAudited` = 0.
|
||||
- Zeilenzahl `deletion_requests WHERE executed_at IS NULL` nach
|
||||
Migration = 30 = Quelle (Prüfung 1).
|
||||
- Zweiter Lauf gegen dieselbe Quelle: 0 zusätzliche Zeilen
|
||||
(Idempotenz/keine Duplikate bei Re-Run).
|
||||
|
||||
Ergebnis: **bestanden**, real gegen Postgres (Host 204, `TEST_TENANT_DSN`,
|
||||
Tenant-Schema).
|
||||
|
||||
## Akzeptanzkriterium 2: Vollzogene Löschungen nur als Audit-Eintrag
|
||||
|
||||
Test: `TestMigrateAll_ExecutedDeletionsOnlyAsAuditEntry`
|
||||
|
||||
- Eine bereits vollzogene Löschung (Quelle: `requested_at`/`executed_at`
|
||||
mit historischen Zeitstempeln, `executed_by` abweichend von
|
||||
`requested_by`) migriert.
|
||||
- Stichprobe (Prüfung 2): `deletion_requests` für das zugehörige
|
||||
`retention_object_id` = 0 Zeilen (kein Objekt-Datensatz/aktiver
|
||||
Vorgang).
|
||||
- `retention_objects.status` = `deleted`.
|
||||
- `destruction_log` enthält genau einen Eintrag mit `destroyed_by`/
|
||||
`destroyed_at` exakt aus der Quelle übernommen (keine Neuberechnung).
|
||||
- Zweiter Lauf: kein zusätzlicher `destruction_log`-Eintrag
|
||||
(Idempotenz).
|
||||
|
||||
Ergebnis: **bestanden**, real gegen Postgres.
|
||||
|
||||
## Unabhängigkeits-Nachweis (methodischer Fehler aus TAR-01 vermieden)
|
||||
|
||||
`TestMigrateAll_IndependentObjectsDoNotInterfere`: zwei ECHTE,
|
||||
voneinander unabhängige Testobjekte (unterschiedliche
|
||||
`object_reference`, eines offen, eines vollzogen) im selben Lauf —
|
||||
belegt, dass der offene und der Audit-Pfad sich nicht gegenseitig
|
||||
beeinflussen. Kein Wiederverwenden desselben Datensatzes über zwei
|
||||
Connection-Pools (der in TAR-01 identifizierte Fehler).
|
||||
|
||||
## GoBD-Kontinuität
|
||||
|
||||
Diese Migration berührt `retain_until` nicht (TAR-01 bereits
|
||||
abgeschlossen, 1:1-Übernahme der Fristen). `deletionrequestmigration`
|
||||
übernimmt ausschließlich die Löschanfrage-Metadaten (`requested_by`,
|
||||
`requested_at`, `executed_by`, `executed_at`) 1:1 aus der Quelle, keine
|
||||
Neuberechnung anhand des Migrationsdatums.
|
||||
|
||||
## Ausführung
|
||||
|
||||
```
|
||||
cd archive
|
||||
set -a && source /etc/nexarch-dev/tenant-dev.env && set +a
|
||||
go test ./internal/deletionrequestmigration/... -v
|
||||
```
|
||||
|
||||
Ergebnis: `PASS`, 3/3 Tests, Laufzeit ~1s, gegen echten
|
||||
Postgres-Testcluster Host 204 (Disposable-Testdaten, synthetisch,
|
||||
`TRUNCATE` nach jedem Test).
|
||||
|
||||
`go build ./...` im Modul `archive` fehlerfrei (keine Regression durch
|
||||
die neue Abhängigkeit auf `deletionapproval.TokenTTL`).
|
||||
|
||||
## Nicht Teil dieses Tickets
|
||||
|
||||
- Kein SQL-Export-Adapter gegen die echte archivdms-Datenbank
|
||||
implementiert (analog zu `dms/internal/migrationdocument.Source`:
|
||||
Schnittstelle definiert, echte Implementierung und Tests mit
|
||||
synthetischen Fixtures, kein Zugriff auf reale archivdms-Daten).
|
||||
- Kein Umbau von CMP-06 oder RET-03 selbst.
|
||||
Reference in new issue
Block a user