- 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
270 lines
10 KiB
Go
270 lines
10 KiB
Go
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)
|
|
}
|
|
}
|