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.
175 lines
5.8 KiB
Go
175 lines
5.8 KiB
Go
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])
|
|
}
|
|
}
|