Files
nexarch/dms/internal/retentiondestroy/retentiondestroy_test.go
T
sysops 9843275e9c DOC-16: ret-05-client-aufbewahrungsklasse-registrieren-vernichtungs-rueckruf
- dms/internal/retentionclient: HTTP-Client fuer Archive RET-05/RET-09
- dms/internal/retentiondestroy: RegisterOrRequeue (FDN-04-Requeue bei
  Fehlschlag, kein Absturz/stilles Verwerfen), DestroyCallbackHandler
  (physische Loeschung aller Datei-Revisionen ueber storage.Service,
  markiert Dokument als vernichtet, 2xx erst danach)
- dms/cmd/app: mountet destroy-callback, registriert beim Start
- dms/cmd/worker: verarbeitet Requeue-Jobs (Retry)
- nur dms_document registriert (DOC-01/FDN-02 kennen keinen separaten
  Anhang-Typ, kein Umbau angrenzender Bereiche)
- real getestet: registrierung gegen echten fake-RET-05-Server, echte
  Loeschung inkl. Storage-Byte-Nachweis, echter Requeue-Eintrag bei
  Fehlschlag, erfolgreicher Retry
- live auf 131 gegen echten RET-09-Dienst (Port 8095) verifiziert,
  echter curl-Vernichtungs-Rueckruf mit real geloeschter Datei
- reale Core-API-06-Deploy-Luecke dokumentiert (nicht verschwiegen):
  /internal/resync/usage auf 131 aktuell nicht gemountet

Pruefungen siehe dms/docs/DOC-16-PRUEFPROTOKOLL.md
2026-08-30 09:37:12 +02:00

229 lines
8.5 KiB
Go

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)
}
}