package deletionworkflow import ( "context" "errors" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func setupTest(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, ` 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(), UNIQUE (object_type, object_reference) ); CREATE TABLE IF NOT EXISTS retention_class_assignments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS retention_class_rules ( retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL, active BOOLEAN NOT NULL DEFAULT true ); CREATE TABLE IF NOT EXISTS legal_holds ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, reason TEXT NOT NULL, set_by TEXT NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(), released_at TIMESTAMPTZ, released_by TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active ON legal_holds (retention_object_id) WHERE released_at IS NULL; 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 ); CREATE OR REPLACE FUNCTION destruction_log_immutable() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'destruction_log ist unveraenderlich (RET-03) - % auf id=% nicht erlaubt', TG_OP, OLD.id; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log; CREATE TRIGGER trg_destruction_log_no_update BEFORE UPDATE ON destruction_log FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable(); DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log; CREATE TRIGGER trg_destruction_log_no_delete BEFORE DELETE ON destruction_log FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable(); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE destruction_log, legal_holds, retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`) }) return pool } func insertObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ref string) string { t.Helper() var id string if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', $1) RETURNING id`, ref).Scan(&id); err != nil { t.Fatal(err) } return id } // TestDestroy_ObjectWithActiveHoldResistsDeletion ist die geforderte // Pflichtprüfung 1: Objekt mit aktiver Sperre widersteht einem direkten // Löschversuch. func TestDestroy_ObjectWithActiveHoldResistsDeletion(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertObject(t, ctx, pool, "gesperrt-doc") // Simuliert bereits erfolgte Freigabe (status "expired"), um zu // beweisen, dass die Sperre AUCH DANN noch blockiert, nicht nur vor // der Freigabe. if _, err := pool.Exec(ctx, `UPDATE retention_objects SET status = 'expired' WHERE id = $1`, objID); err != nil { t.Fatal(err) } if err := SetLegalHold(ctx, pool, objID, "laufendes gerichtsverfahren az. 12/34", "admin@acme.example"); err != nil { t.Fatalf("sperre setzen: %v", err) } err := Destroy(ctx, pool, objID, "worker") if !errors.Is(err, ErrOnLegalHold) { t.Fatalf("erwartet ErrOnLegalHold, habe: %v", err) } var status string if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, objID).Scan(&status); err != nil { t.Fatal(err) } if status != "expired" { t.Fatalf("status haette unveraendert bleiben muessen, ist %q", status) } } // TestSetLegalHold_RequiresReason ist Akzeptanzkriterium 2: // Begründungspflicht beim Setzen. func TestSetLegalHold_RequiresReason(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertObject(t, ctx, pool, "ohne-begruendung-doc") if err := SetLegalHold(ctx, pool, objID, "", "admin@acme.example"); !errors.Is(err, ErrReasonRequired) { t.Fatalf("erwartet ErrReasonRequired, habe: %v", err) } } // TestDestructionLog_IsImmutable ist die geforderte Pflichtprüfung 2: // Protokolleintrag nach Vernichtung ist nachträglich nicht änderbar // (DB-Constraint/Trigger, nicht nur Anwendungslogik). func TestDestructionLog_IsImmutable(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertObject(t, ctx, pool, "vernichtet-doc") if _, err := pool.Exec(ctx, `UPDATE retention_objects SET status = 'expired' WHERE id = $1`, objID); err != nil { t.Fatal(err) } if err := Destroy(ctx, pool, objID, "worker"); err != nil { t.Fatalf("destroy: %v", err) } var logID string if err := pool.QueryRow(ctx, `SELECT id FROM destruction_log WHERE retention_object_id = $1`, objID).Scan(&logID); err != nil { t.Fatal(err) } // Direkter UPDATE-Versuch (umgeht die Go-API vollständig) — muss am // Postgres-Trigger scheitern, nicht nur weil das Paket keine // Update-Funktion anbietet. _, err := pool.Exec(ctx, `UPDATE destruction_log SET destroyed_by = 'manipuliert' WHERE id = $1`, logID) if err == nil { t.Fatal("erwartet fehler beim direkten UPDATE auf destruction_log, trigger hat nicht gegriffen") } _, err = pool.Exec(ctx, `DELETE FROM destruction_log WHERE id = $1`, logID) if err == nil { t.Fatal("erwartet fehler beim direkten DELETE auf destruction_log, trigger hat nicht gegriffen") } } // TestReleaseLegalHold_IsItselfLogged ist die geforderte Pflichtprüfung // 3: Aufheben einer Sperre ist selbst protokolliert. func TestReleaseLegalHold_IsItselfLogged(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertObject(t, ctx, pool, "aufgehoben-doc") if err := SetLegalHold(ctx, pool, objID, "vorlaeufige pruefung", "admin@acme.example"); err != nil { t.Fatalf("sperre setzen: %v", err) } if err := ReleaseLegalHold(ctx, pool, objID, "admin2@acme.example"); err != nil { t.Fatalf("sperre aufheben: %v", err) } var releasedBy *string var releasedAt *time.Time if err := pool.QueryRow(ctx, `SELECT released_by, released_at FROM legal_holds WHERE retention_object_id = $1`, objID).Scan(&releasedBy, &releasedAt); err != nil { t.Fatal(err) } if releasedBy == nil || *releasedBy != "admin2@acme.example" || releasedAt == nil { t.Fatalf("aufhebung wurde nicht protokolliert: released_by=%v released_at=%v", releasedBy, releasedAt) } onHold, err := IsOnLegalHold(ctx, pool, objID) if err != nil { t.Fatal(err) } if onHold { t.Fatal("objekt haette nach dem aufheben nicht mehr als gesperrt gelten duerfen") } } // TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected ist // Akzeptanzkriterium 1 (kein Sofortlöschen, nur Statuswechsel) UND // Akzeptanzkriterium 2 (Sperre wirkt auch bei abgelaufener Frist). func TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected(t *testing.T) { pool := setupTest(t) ctx := context.Background() if _, err := pool.Exec(ctx, `INSERT INTO retention_class_rules (retention_class, duration) VALUES ('klasse-kurz', '1 day')`); err != nil { t.Fatal(err) } dueObjID := insertObject(t, ctx, pool, "faellig-doc") heldObjID := insertObject(t, ctx, pool, "faellig-aber-gesperrt-doc") notDueObjID := insertObject(t, ctx, pool, "nicht-faellig-doc") past := time.Now().UTC().Add(-48 * time.Hour) future := time.Now().UTC().Add(-1 * time.Hour) // faellig erst in > 1 tag if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, dueObjID, past); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, heldObjID, past); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, notDueObjID, future); err != nil { t.Fatal(err) } if err := SetLegalHold(ctx, pool, heldObjID, "laufendes verfahren", "admin@acme.example"); err != nil { t.Fatal(err) } released, err := ReleaseExpired(ctx, pool, time.Now().UTC()) if err != nil { t.Fatalf("releaseexpired: %v", err) } if len(released) != 1 || released[0] != dueObjID { t.Fatalf("erwartet genau das faellige, ungesperrte objekt, habe: %v", released) } var dueStatus, heldStatus, notDueStatus string if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, dueObjID).Scan(&dueStatus); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, heldObjID).Scan(&heldStatus); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, notDueObjID).Scan(¬DueStatus); err != nil { t.Fatal(err) } // Akzeptanzkriterium 1: "expired", NICHT "deleted" - keine Sofortloeschung. if dueStatus != "expired" { t.Fatalf("faelliges objekt: status = %q, want expired (keine sofortloeschung)", dueStatus) } if heldStatus != "active" { t.Fatalf("gesperrtes objekt haette trotz faelligkeit aktiv bleiben muessen, ist %q", heldStatus) } if notDueStatus != "active" { t.Fatalf("nicht faelliges objekt haette aktiv bleiben muessen, ist %q", notDueStatus) } } // TestDestroy_RequiresPriorRelease beweist, dass Destroy nicht direkt von // "active" aus aufgerufen werden kann (Workflow-Reihenfolge erzwungen). func TestDestroy_RequiresPriorRelease(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertObject(t, ctx, pool, "noch-aktiv-doc") if err := Destroy(ctx, pool, objID, "worker"); !errors.Is(err, ErrNotReleased) { t.Fatalf("erwartet ErrNotReleased, habe: %v", err) } }