package retentiondestroy import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" ) 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 users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT 'Test' ); CREATE TABLE IF NOT EXISTS documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), folder_id UUID, title TEXT NOT NULL, current_revision_id UUID, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS file_revisions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, revision_number INT NOT NULL, storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL, mime_type TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (document_id, revision_number) ); CREATE TABLE IF NOT EXISTS processing_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), job_type TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb, idempotency_key TEXT UNIQUE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'dead_letter')), attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, available_at TIMESTAMPTZ NOT NULL DEFAULT now(), locked_at TIMESTAMPTZ, locked_by TEXT, last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE processing_jobs, file_revisions, documents, users CASCADE`) }) return pool } type fakeUsageReporter struct{ reports []int64 } func (f *fakeUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error { f.reports = append(f.reports, delta) return nil } // fakeRET05Server simuliert Archive RET-05/RET-09 (POST /register). func fakeRET05Server(t *testing.T, fail bool) *retentionclient.Client { t.Helper() mux := http.NewServeMux() mux.HandleFunc("POST /register", func(w http.ResponseWriter, r *http.Request) { if fail { http.Error(w, "simulierter fehler", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "ID": "fake-reg-id", "ModuleName": ModuleName, "ObjectType": ObjectTypeDocument, "RetentionClass": "test-klasse", "CallbackURL": "http://test/callback", }) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) return retentionclient.New(server.URL) } // TestRegisterOrRequeue_RealRegistrationAgainstTestEndpoint ist die // geforderte Pflichtpruefung 1: Registrierung real gegen einen // Test-RET-05-Endpunkt durchgefuehrt und verifiziert. func TestRegisterOrRequeue_RealRegistrationAgainstTestEndpoint(t *testing.T) { pool := setupTest(t) client := fakeRET05Server(t, false) queue := jobqueue.NewQueue(pool, time.Minute) queued, err := RegisterOrRequeue(context.Background(), client, queue, RegisterConfig{ ObjectType: ObjectTypeDocument, RetentionClass: "test-klasse", CallbackURL: "http://test/callback", }) if err != nil { t.Fatalf("erwartet erfolgreiche registrierung, habe fehler: %v", err) } if queued { t.Fatal("erfolgreiche registrierung haette NICHT requeued werden duerfen") } } // TestDestroyCallbackHandler_RealDeletionAnd2xx ist die geforderte // Pflichtpruefung 2: Vernichtungs-Rueckruf gegen einen echten Testfall // ausgeloest, DMS-Loeschung nachweislich erfolgt, 2xx-Antwort gesendet. func TestDestroyCallbackHandler_RealDeletionAnd2xx(t *testing.T) { pool := setupTest(t) ctx := context.Background() var userID string if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('destroy-test@acme.example', 'Destroy Test') RETURNING id`).Scan(&userID); err != nil { t.Fatal(err) } var docID string if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('Zu vernichtendes Dokument', $1) RETURNING id`, userID).Scan(&docID); err != nil { t.Fatal(err) } storageDir := t.TempDir() driver := storage.NewLocalDriver(storageDir, []byte("secret"), "https://files.example.test") usage := &fakeUsageReporter{} storageSvc := storage.NewService(driver, usage, "acme") storageKey := "documents/" + docID + "/revisions/rev-1" content := "vertrauliches dokument" if _, err := storageSvc.Put(ctx, storageKey, strings.NewReader(content), int64(len(content)), "text/plain"); err != nil { t.Fatalf("testdatei ablegen: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by) VALUES ($1, 1, $2, 'irrelevant', $3, 'text/plain', $4) `, docID, storageKey, len(content), userID); err != nil { t.Fatal(err) } handler := DestroyCallbackHandler(pool, storageSvc) server := httptest.NewServer(handler) defer server.Close() body, _ := json.Marshal(destroyRequest{ ObjectType: ObjectTypeDocument, ObjectReference: docID, DestroyedAt: time.Now().UTC().Format(time.RFC3339), }) resp, err := http.Post(server.URL, "application/json", strings.NewReader(string(body))) if err != nil { t.Fatalf("post: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { t.Fatalf("status = %d, want 2xx", resp.StatusCode) } // Nachweis: Storage-Objekt WIRKLICH geloescht (nicht nur DB-Flag). if _, err := driver.Get(ctx, storageKey); err == nil { t.Fatal("erwartet geloeschtes storage-objekt, konnte es aber noch lesen") } var deletedAt *time.Time if err := pool.QueryRow(ctx, `SELECT deleted_at FROM documents WHERE id = $1`, docID).Scan(&deletedAt); err != nil { t.Fatal(err) } if deletedAt == nil { t.Fatal("erwartet gesetztes deleted_at nach vernichtung") } if len(usage.reports) != 2 || usage.reports[0] <= 0 || usage.reports[1] >= 0 { t.Fatalf("erwartet eine positive (put) und eine negative (delete) nutzungsmeldung, habe: %v", usage.reports) } } // TestRegisterOrRequeue_FailedRegistrationCreatesRequeueEntry ist die // geforderte Pflichtpruefung 3: simulierter Nicht-2xx-Fehler bei der // Registrierung fuehrt nachweislich zu einem Requeue-Eintrag in der // Jobqueue, kein Absturz. func TestRegisterOrRequeue_FailedRegistrationCreatesRequeueEntry(t *testing.T) { pool := setupTest(t) client := fakeRET05Server(t, true) queue := jobqueue.NewQueue(pool, time.Minute) queued, err := RegisterOrRequeue(context.Background(), client, queue, RegisterConfig{ ObjectType: ObjectTypeDocument, RetentionClass: "test-klasse", CallbackURL: "http://test/callback", }) if err == nil { t.Fatal("erwartet fehler von der fehlgeschlagenen registrierung (fuer protokollierung), habe nil") } if !queued { t.Fatal("erwartet queued=true bei fehlgeschlagener registrierung") } var count int if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM processing_jobs WHERE job_type = $1 AND status = 'pending'`, JobTypeRegister).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatalf("erwartet genau einen requeue-eintrag in der jobqueue, habe %d", count) } } // TestProcessRegisterJob_SucceedsOnRetry beweist, dass ein Requeue-Job // vom Worker erfolgreich nachgeholt werden kann (Requeue ist kein // Sackgassen-Zustand). func TestProcessRegisterJob_SucceedsOnRetry(t *testing.T) { setupTest(t) client := fakeRET05Server(t, false) payload, _ := json.Marshal(RegisterConfig{ObjectType: ObjectTypeDocument, RetentionClass: "test-klasse", CallbackURL: "http://test/callback"}) if err := ProcessRegisterJob(context.Background(), client, payload); err != nil { t.Fatalf("erwartet erfolgreichen retry, habe fehler: %v", err) } }