CMP-06: vier-augen-freigabe-fuer-loeschungen
- migrations/0010_deletion_requests: speichert nur den Token-Hash, nie das Token selbst - archive/internal/deletionapproval: RequestDeletion (einmaliges Klartext-Token), ConfirmAndExecute (FOR UPDATE-Lock, andere Person als requester, zeitkonstanter Tokenvergleich via subtle.ConstantTimeCompare, ruft danach genau einmal deletionworkflow.Destroy (RET-03) auf) - 4 Tests real bestanden, inkl. echtem Nebenlaeufigkeits-Test (zwei echte Goroutinen, genau 1 Erfolg + 1 ErrAlreadyExecuted, genau ein destruction_log-Eintrag) - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-06-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user