package dsgvorequest import ( "context" "os" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionapproval" "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionworkflow" ) 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") } return setupTestWithDSN(t, dsn) } func setupTestWithDSN(t *testing.T, dsn string) *pgxpool.Pool { t.Helper() 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) ); ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT; 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 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 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 dsgvo_decision_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), data_subject_ref TEXT NOT NULL, retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT, object_type TEXT NOT NULL, object_reference TEXT NOT NULL, outcome TEXT NOT NULL CHECK (outcome IN ('deletion_requested', 'rejected', 'already_deleted')), reason TEXT NOT NULL, decided_at TIMESTAMPTZ NOT NULL DEFAULT now(), decided_by TEXT NOT NULL ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE dsgvo_decision_log, deletion_requests, destruction_log, legal_holds, retention_class_assignments, retention_objects CASCADE`) }) return pool } func insertObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ref, subjectRef, status string) string { t.Helper() var id string if err := pool.QueryRow(ctx, ` INSERT INTO retention_objects (object_type, object_reference, status, data_subject_ref) VALUES ('dms_document', $1, $2, $3) RETURNING id `, ref, status, subjectRef).Scan(&id); err != nil { t.Fatal(err) } return id } // TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection // ist die geforderte Pflichtprüfung 1. func TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection(t *testing.T) { pool := setupTest(t) ctx := context.Background() deletableID := insertObject(t, ctx, pool, "mixed-deletable", "person-mixed@example.com", "expired") insertObject(t, ctx, pool, "mixed-active", "person-mixed@example.com", "active") decisions, err := ProcessDeletionRequest(ctx, pool, "person-mixed@example.com", "dsgvo-officer@acme.example") if err != nil { t.Fatalf("processdeletionrequest: %v", err) } if len(decisions) != 2 { t.Fatalf("erwartet 2 entscheidungen, habe %d: %+v", len(decisions), decisions) } var requested, rejected int var token string for _, d := range decisions { switch d.Outcome { case OutcomeDeletionRequested: requested++ token = d.DeletionRequestToken if d.RetentionObjectID != deletableID { t.Fatalf("falsches objekt zur loeschung angestossen: %+v", d) } case OutcomeRejected: rejected++ if d.Reason == "" { t.Fatal("ablehnung ohne begruendung") } } } if requested != 1 || rejected != 1 { t.Fatalf("erwartet genau 1 loeschung + 1 ablehnung, habe requested=%d rejected=%d: %+v", requested, rejected, decisions) } // Vollstaendiger Nachweis: die angestossene Loeschung ueber CMP-06 // (Vier-Augen) bis zum Ende durchfuehren - beweist, dass CMP-07 // tatsaechlich denselben Workflow nutzt, nicht nur eine Anfrage // erzeugt, die ins Leere laeuft. var reqID string if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, deletableID).Scan(&reqID); err != nil { t.Fatal(err) } if err := deletionapproval.ConfirmAndExecute(ctx, pool, reqID, token, "second-person@acme.example"); err != nil { t.Fatalf("confirmandexecute: %v", err) } var status string if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, deletableID).Scan(&status); err != nil { t.Fatal(err) } if status != "deleted" { t.Fatalf("erwartet real geloeschtes objekt nach vier-augen-bestaetigung, status = %q", status) } // Protokoll (Akzeptanzkriterium 4) real vorhanden. var logCount int if err := pool.QueryRow(ctx, `SELECT count(*) FROM dsgvo_decision_log WHERE data_subject_ref = $1`, "person-mixed@example.com").Scan(&logCount); err != nil { t.Fatal(err) } if logCount != 2 { t.Fatalf("erwartet 2 protokollierte entscheidungen, habe %d", logCount) } } // TestProcessDeletionRequest_LegalHoldBlocksEvenExpiredObject ist die // geforderte Pflichtprüfung 2. func TestProcessDeletionRequest_LegalHoldBlocksEvenExpiredObject(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertObject(t, ctx, pool, "hold-expired", "person-hold@example.com", "expired") if err := deletionworkflow.SetLegalHold(ctx, pool, objID, "laufendes verfahren", "legal@acme.example"); err != nil { t.Fatal(err) } decisions, err := ProcessDeletionRequest(ctx, pool, "person-hold@example.com", "dsgvo-officer@acme.example") if err != nil { t.Fatal(err) } if len(decisions) != 1 || decisions[0].Outcome != OutcomeRejected { t.Fatalf("erwartet ablehnung trotz abgelaufener frist (legal hold), habe: %+v", decisions) } 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("objekt haette wegen legal hold nicht angefasst werden duerfen, status = %q", status) } } // TestProcessDeletionRequest_TenantIsolation ist die geforderte // Pflichtprüfung 3. func TestProcessDeletionRequest_TenantIsolation(t *testing.T) { dsnB := os.Getenv("TEST_TENANT_DSN_B") if dsnB == "" { t.Skip("TEST_TENANT_DSN_B nicht gesetzt - Test braucht eine echte zweite, physisch getrennte Tenant-Datenbank") } poolA := setupTest(t) poolB := setupTestWithDSN(t, dsnB) ctx := context.Background() insertObject(t, ctx, poolA, "tenant-a-doc", "shared-person@example.com", "expired") decisionsB, err := ProcessDeletionRequest(ctx, poolB, "shared-person@example.com", "dsgvo-officer@acme.example") if err != nil { t.Fatal(err) } if len(decisionsB) != 0 { t.Fatalf("tenant b darf tenant as objekte nicht sehen/anfassen, habe: %+v", decisionsB) } var untouchedStatus string if err := poolA.QueryRow(ctx, `SELECT status FROM retention_objects WHERE object_reference = 'tenant-a-doc'`).Scan(&untouchedStatus); err != nil { t.Fatal(err) } if untouchedStatus != "expired" { t.Fatalf("tenant as objekt haette unangetastet bleiben muessen, status = %q", untouchedStatus) } }