feat(archive): RET-06-API Aufbewahrungsfristen-Konfigurations-Backend
Board-Entscheidung: Backend-API zuerst, echtes Next.js-Frontend als separates Folgeticket - vermeidet Pseudo-Frontend-Protokoll. internal/retentionapi: 4 Endpunkte (anlegen/aendern, deaktivieren, liste, vorschau), Vorschau nutzt dieselbe ListExpiringObjects-Funktion wie RET-02s periodischer Job (keine Doppel-Implementierung). RequireRole ist AUSDRUECKLICH kein RBAC-02-Ersatz, sondern ein dokumentiertes Provisorium (Header-Check) - RBAC-02 ist reiner Core-interner Go-Code ohne HTTP-Schnittstelle fuer andere Module, derselbe Befund wie FDN-03/FDN-09. Provisorium real getestet inkl. Negativfall (403 ohne/mit falscher Rolle). retention_class_rules um active-Flag erweitert (deaktivieren ohne Historienverlust). Real auf 131 deployed und per curl end-to-end verifiziert.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
func requireTestPool(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 retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func authedRequest(t *testing.T, method, url string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("request erstellen: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Admin-Roles", adminRole)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request senden: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestConfigureAndListHandler_RealHTTPRoundTrip: Klasse anlegen, ändern,
|
||||
// über die Liste sichtbar - Akzeptanzkriterium 1.
|
||||
func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool)
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
body, _ := json.Marshal(configureRequest{RetentionClass: "klasse-api", Duration: "5 years"})
|
||||
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes", body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("anlegen: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
listResp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes", nil)
|
||||
defer func() { _ = listResp.Body.Close() }()
|
||||
var rules []retentionengine.ClassRule
|
||||
if err := json.NewDecoder(listResp.Body).Decode(&rules); err != nil {
|
||||
t.Fatalf("liste dekodieren: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].RetentionClass != "klasse-api" || !rules[0].Active {
|
||||
t.Fatalf("unerwartete liste: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeactivateHandler_RealHTTPRoundTrip: Deaktivierung wirkt real.
|
||||
func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deakt", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool)
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes/klasse-deakt/deactivate", nil)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("deaktivieren: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
rules, err := retentionengine.ListClassRules(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listclassrules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Active {
|
||||
t.Fatalf("erwartet deaktivierte klasse, habe %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewHandler_MatchesPeriodicJobResult ist die geforderte
|
||||
// Pflichtpruefung: Vorschauliste stimmt mit dem Ergebnis des
|
||||
// periodischen Jobs ueberein - beide nutzen dieselbe Funktion, real
|
||||
// per HTTP UND direkt verglichen.
|
||||
func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-preview", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var objID string
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'preview-doc') RETURNING id`).Scan(&objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class) VALUES ($1, 'klasse-preview')`, objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool)
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes/preview", nil)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var httpResult []retentionengine.ExpiringObject
|
||||
if err := json.NewDecoder(resp.Body).Decode(&httpResult); err != nil {
|
||||
t.Fatalf("preview-antwort dekodieren: %v", err)
|
||||
}
|
||||
|
||||
directResult, err := retentionengine.ListExpiringObjects(ctx, pool, time.Now().UTC().AddDate(0, 0, 30))
|
||||
if err != nil {
|
||||
t.Fatalf("listexpiringobjects direkt: %v", err)
|
||||
}
|
||||
|
||||
if len(httpResult) != len(directResult) || len(httpResult) != 1 {
|
||||
t.Fatalf("http-vorschau (%d) und periodischer job (%d) stimmen nicht ueberein", len(httpResult), len(directResult))
|
||||
}
|
||||
if httpResult[0].RetentionObjectID != directResult[0].RetentionObjectID {
|
||||
t.Fatalf("http-vorschau und periodischer job liefern unterschiedliche objekte: %+v vs %+v", httpResult[0], directResult[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user