- 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
229 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|