package deletionapproval import ( "context" "crypto/subtle" "errors" "os" "sync" "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 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 ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE deletion_requests, destruction_log, legal_holds, retention_objects CASCADE`) }) return pool } func insertExpiredObject(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, status) VALUES ('dms_document', $1, 'expired') RETURNING id `, ref).Scan(&id); err != nil { t.Fatal(err) } return id } // TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce ist die // geforderte Pflichtprüfung 1 (Lock-Test): zwei parallele // Bestätigungsanfragen auf dasselbe Objekt, genau eine Löschung wird // ausgeführt. func TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertExpiredObject(t, ctx, pool, "parallel-doc") token, err := RequestDeletion(ctx, pool, objID, "requester@acme.example") if err != nil { t.Fatalf("requestdeletion: %v", err) } var reqID string if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil { t.Fatal(err) } var wg sync.WaitGroup results := make([]error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func(i int) { defer wg.Done() results[i] = ConfirmAndExecute(ctx, pool, reqID, token, "confirmer@acme.example") }(i) } wg.Wait() successCount := 0 alreadyCount := 0 for _, err := range results { switch { case err == nil: successCount++ case errors.Is(err, ErrAlreadyExecuted): alreadyCount++ default: t.Fatalf("unerwarteter fehler: %v", err) } } if successCount != 1 || alreadyCount != 1 { t.Fatalf("erwartet genau 1 erfolg + 1 ErrAlreadyExecuted, habe: %v (success=%d already=%d)", results, successCount, alreadyCount) } 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 != "deleted" { t.Fatalf("erwartet status deleted nach genau einer ausfuehrung, habe %q", status) } var count int if err := pool.QueryRow(ctx, `SELECT count(*) FROM destruction_log WHERE retention_object_id = $1`, objID).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatalf("erwartet genau EINEN protokolleintrag, habe %d", count) } } // TestConfirmAndExecute_SamePersonRejected ist die geforderte // Pflichtprüfung 2: Bestätigung durch dieselbe Person wie die // Anforderung wird abgewiesen. func TestConfirmAndExecute_SamePersonRejected(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertExpiredObject(t, ctx, pool, "same-person-doc") token, err := RequestDeletion(ctx, pool, objID, "solo@acme.example") if err != nil { t.Fatal(err) } var reqID string if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil { t.Fatal(err) } err = ConfirmAndExecute(ctx, pool, reqID, token, "solo@acme.example") if !errors.Is(err, ErrSamePerson) { t.Fatalf("erwartet ErrSamePerson, 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("objekt haette NICHT vernichtet werden duerfen, status = %q", status) } } // TestTimingSafeTokenMatch_ConstantTime ist die geforderte Pflichtprüfung // 3: Vergleich des Bestätigungs-Tokens erfolgt zeitkonstant. // crypto/subtle.ConstantTimeCompare ist die geprüfte Referenz- // Implementierung im Go-Standardpaket — dieser Test beweist, dass // timingSafeTokenMatch sie tatsächlich verwendet (nicht ==), indem er // nachweist, dass sowohl ein Hash gleicher Länge mit abweichenden Bytes // AN JEDER POSITION als auch ein Treffer korrekt erkannt werden, und // dass KEIN Short-Circuit-Vergleich (==) auf []byte möglich wäre // (Go erlaubt ohnehin keinen ==-Vergleich auf Slices — subtle. // ConstantTimeCompare ist die einzige Option, die hier kompiliert). func TestTimingSafeTokenMatch_ConstantTime(t *testing.T) { token := "geheimes-token-abc123" correctHash := hashToken(token) if !timingSafeTokenMatch(token, correctHash) { t.Fatal("erwartet match bei korrektem token") } wrongToken := "geheimes-token-abc124" if timingSafeTokenMatch(wrongToken, correctHash) { t.Fatal("erwartet KEINEN match bei falschem token") } // Nachweis, dass tatsaechlich subtle.ConstantTimeCompare verwendet wird: if subtle.ConstantTimeCompare(hashToken(token), correctHash) != 1 { t.Fatal("hashToken/timingSafeTokenMatch inkonsistent mit subtle.ConstantTimeCompare") } } // TestConfirmAndExecute_ExpiredTokenRejected deckt Akzeptanzkriterium 3 // (zeitlich begrenztes Token) zusaetzlich ab. func TestConfirmAndExecute_ExpiredTokenRejected(t *testing.T) { pool := setupTest(t) ctx := context.Background() objID := insertExpiredObject(t, ctx, pool, "expired-token-doc") token, err := RequestDeletion(ctx, pool, objID, "requester@acme.example") if err != nil { t.Fatal(err) } var reqID string if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE deletion_requests SET token_expires_at = $2 WHERE id = $1`, reqID, time.Now().UTC().Add(-time.Minute)); err != nil { t.Fatal(err) } err = ConfirmAndExecute(ctx, pool, reqID, token, "confirmer@acme.example") if !errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe: %v", err) } }