CMP-07: dsgvo-loeschantrag-recht-auf-loeschung
- dpreport.SubjectRecord additiv um RetentionObjectID erweitert (CMP-02, bestehendes Verhalten unveraendert) - migrations/0011_dsgvo_decision_log: vollstaendiges Protokoll jeder Einzelentscheidung - archive/internal/dsgvorequest.ProcessDeletionRequest: ruft ausschliesslich CMP-02 (Suche), RET-03 (Sperrpruefung), CMP-06 (Freigabe) auf - keine zweite Aufbewahrungs-/Freigabelogik (vermeidet den im Ticket dokumentierten archivmail-Fehler) - 3 Tests real bestanden: gemischter Datenbestand (1 Loeschung + 1 Ablehnung, Loeschung vollstaendig bis zur tatsaechlichen Vernichtung durchgefuehrt), Legal Hold blockiert trotz abgelaufener Frist, Mandantentrennung real ueber zwei physisch getrennte Tenant-DBs - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-07-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user