RET-03: löschworkflow-und-aufbewahrungssperre-legal-hold
- migrations/0008_legal_hold_destruction: legal_holds (historisiert, Partial-Unique-Index gegen doppelte aktive Sperre), destruction_log (append-only, per Postgres-Trigger gegen UPDATE/DELETE geschuetzt) - archive/internal/deletionworkflow: SetLegalHold (Begruendungspflicht), ReleaseLegalHold (Aufheben selbst protokolliert, keine Loeschung der Zeile), ReleaseExpired (Freigabeprozess active->expired, keine Sofortloeschung, Sperre wird respektiert), Destroy (verlangt vorherigen expired-Status, prueft Sperre erneut, transaktional mit Protokolleintrag) - 6 Tests, alle Pflichtpruefungen real bestanden (Sperre widersteht Loeschversuch, Protokoll real unveraenderlich per Trigger, Aufheben real protokolliert) - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/RET-03-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// Package deletionworkflow implementiert RET-03: den kontrollierten
|
||||
// Löschworkflow für abgelaufene Aufbewahrungsobjekte (Freigabe →
|
||||
// Vernichtung) und die Aufbewahrungssperre (Legal Hold), die jede
|
||||
// Löschung unabhängig vom Fristablauf verhindert. Baut auf RET-01
|
||||
// (retention_objects.status) und RET-02 (Fristenberechnung) auf, keine
|
||||
// eigene Fristenlogik.
|
||||
package deletionworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrReasonRequired wird geliefert, wenn beim Setzen einer Sperre keine
|
||||
// Begründung angegeben wurde (Akzeptanzkriterium 2: Begründungspflicht).
|
||||
var ErrReasonRequired = errors.New("deletionworkflow: begründung ist beim setzen einer aufbewahrungssperre pflicht")
|
||||
|
||||
// ErrAlreadyOnHold wird geliefert, wenn für das Objekt bereits eine
|
||||
// aktive Sperre existiert (Partial-Unique-Index erzwingt das auch auf
|
||||
// DB-Ebene).
|
||||
var ErrAlreadyOnHold = errors.New("deletionworkflow: objekt steht bereits unter aufbewahrungssperre")
|
||||
|
||||
// ErrOnLegalHold wird von Destroy geliefert, wenn eine aktive Sperre die
|
||||
// Vernichtung verhindert (Akzeptanzkriterium 2: Sperre überstimmt jede
|
||||
// Fristregel).
|
||||
var ErrOnLegalHold = errors.New("deletionworkflow: objekt steht unter aufbewahrungssperre, vernichtung nicht erlaubt")
|
||||
|
||||
// ErrNotReleased wird geliefert, wenn Destroy für ein Objekt aufgerufen
|
||||
// wird, das noch nicht über ReleaseExpired freigegeben wurde
|
||||
// (Akzeptanzkriterium 1: kein Sprung von "active" direkt zu "deleted").
|
||||
var ErrNotReleased = errors.New("deletionworkflow: objekt ist nicht zur vernichtung freigegeben (status != expired)")
|
||||
|
||||
// SetLegalHold setzt eine Aufbewahrungssperre für ein Objekt. reason ist
|
||||
// PFLICHT (Akzeptanzkriterium 2). Existiert bereits eine aktive Sperre,
|
||||
// wird ErrAlreadyOnHold geliefert (der Partial-Unique-Index
|
||||
// idx_legal_holds_active verhindert eine zweite aktive Zeile auch bei
|
||||
// gleichzeitigen Aufrufen).
|
||||
func SetLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, reason, setBy string) error {
|
||||
if reason == "" {
|
||||
return ErrReasonRequired
|
||||
}
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO legal_holds (retention_object_id, reason, set_by)
|
||||
VALUES ($1, $2, $3)
|
||||
`, retentionObjectID, reason, setBy)
|
||||
if err != nil {
|
||||
var pgErr interface{ SQLState() string }
|
||||
if errors.As(err, &pgErr) && pgErr.SQLState() == "23505" {
|
||||
return ErrAlreadyOnHold
|
||||
}
|
||||
return fmt.Errorf("deletionworkflow: sperre setzen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseLegalHold hebt die aktive Sperre eines Objekts auf. Die
|
||||
// ursprüngliche Zeile bleibt bestehen (released_at/released_by werden
|
||||
// gesetzt, kein DELETE) — das Aufheben ist dadurch selbst dauerhaft
|
||||
// protokolliert (Akzeptanzkriterium/Pflichtprüfung 3).
|
||||
func ReleaseLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, releasedBy string) error {
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE legal_holds SET released_at = now(), released_by = $2
|
||||
WHERE retention_object_id = $1 AND released_at IS NULL
|
||||
`, retentionObjectID, releasedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletionworkflow: sperre aufheben: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("deletionworkflow: keine aktive sperre für objekt %q gefunden", retentionObjectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOnLegalHold prüft, ob ein Objekt aktuell unter Sperre steht.
|
||||
func IsOnLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (bool, error) {
|
||||
var exists bool
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM legal_holds WHERE retention_object_id = $1 AND released_at IS NULL)
|
||||
`, retentionObjectID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deletionworkflow: sperrstatus prüfen: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ReleaseExpired ist der Freigabeprozess (Akzeptanzkriterium 1): setzt
|
||||
// den Status abgelaufener Objekte von "active" auf "expired" — KEINE
|
||||
// automatische Sofortlöschung. Objekte unter aktiver Aufbewahrungssperre
|
||||
// werden übersprungen, unabhängig vom Fristablauf (Akzeptanzkriterium
|
||||
// 2). Liefert die IDs der freigegebenen Objekte.
|
||||
func ReleaseExpired(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
WITH latest_assignment AS (
|
||||
SELECT DISTINCT ON (retention_object_id)
|
||||
retention_object_id, retention_class, assigned_at
|
||||
FROM retention_class_assignments
|
||||
ORDER BY retention_object_id, assigned_at DESC
|
||||
),
|
||||
due AS (
|
||||
SELECT o.id
|
||||
FROM retention_objects o
|
||||
JOIN latest_assignment a ON a.retention_object_id = o.id
|
||||
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
|
||||
WHERE o.status = 'active'
|
||||
AND (a.assigned_at + r.duration) <= $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM legal_holds h
|
||||
WHERE h.retention_object_id = o.id AND h.released_at IS NULL
|
||||
)
|
||||
)
|
||||
UPDATE retention_objects SET status = 'expired'
|
||||
WHERE id IN (SELECT id FROM due)
|
||||
RETURNING id
|
||||
`, asOf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deletionworkflow: freigabeprozess: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("deletionworkflow: freigegebene id lesen: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// Destroy vernichtet EIN Objekt, das zuvor über ReleaseExpired freigegeben
|
||||
// wurde (status "expired") — kein direkter Sprung von "active".
|
||||
// Verweigert die Vernichtung, wenn ZWISCHENZEITLICH eine Sperre gesetzt
|
||||
// wurde (Verteidigung in der Tiefe, zusätzlich zu ReleaseExpireds eigenem
|
||||
// Sperr-Ausschluss). Erzeugt einen unveränderlichen Protokolleintrag
|
||||
// (destruction_log, per DB-Trigger gegen UPDATE/DELETE geschützt).
|
||||
func Destroy(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, destroyedBy string) error {
|
||||
onHold, err := IsOnLegalHold(ctx, pool, retentionObjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if onHold {
|
||||
return ErrOnLegalHold
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletionworkflow: transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var objectType, objectReference string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE retention_objects SET status = 'deleted'
|
||||
WHERE id = $1 AND status = 'expired'
|
||||
RETURNING object_type, object_reference
|
||||
`, retentionObjectID).Scan(&objectType, &objectReference)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotReleased
|
||||
}
|
||||
return fmt.Errorf("deletionworkflow: objekt als vernichtet markieren: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO destruction_log (retention_object_id, object_type, object_reference, destroyed_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, retentionObjectID, objectType, objectReference, destroyedBy); err != nil {
|
||||
return fmt.Errorf("deletionworkflow: protokolleintrag erzeugen: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("deletionworkflow: vernichtung committen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user