Merge feature/ret-13-legal-hold-abfrage-endpunkt in integration/gesamtprojekt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
884eaeb4b3
8 files changed
+693
-4
No files matched your search
@@ -1,7 +1,8 @@
|
||||
// moduleadapter-api ist der Aufrufpunkt fuer RET-09: startet den bereits
|
||||
// fertigen archive/internal/moduleadapter.RegisterHandler (RET-05) als
|
||||
// eigenstaendigen HTTP-Dienst. REINES WIRING — keine Aenderung an
|
||||
// moduleadapter.go/handler.go, keine neue Logik.
|
||||
// moduleadapter.go/handler.go, keine neue Logik. RET-13 haengt die
|
||||
// schreibgeschuetzte Sperrabfrage /legal-hold an denselben Dienst.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/legalholdapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/tenantregistryclient"
|
||||
)
|
||||
@@ -42,6 +44,14 @@ func main() {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/register", moduleadapter.RegisterHandler(pool, verifier))
|
||||
instanceTenant := os.Getenv("NEXARCH_MODULEADAPTER_TENANT_SLUG")
|
||||
if instanceTenant == "" {
|
||||
log.Print("moduleadapter-api: NEXARCH_MODULEADAPTER_TENANT_SLUG nicht gesetzt, /legal-hold antwortet 503")
|
||||
}
|
||||
mux.HandleFunc("/legal-hold", legalholdapi.Handler(pool, verifier, legalholdapi.Config{
|
||||
InstanceTenant: instanceTenant,
|
||||
ServiceToken: os.Getenv("NEXARCH_MODULEADAPTER_SERVICE_TOKEN"),
|
||||
}))
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
log.Printf("moduleadapter-api: listening on %s", addr)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package legalholdapi ist RET-13: die schreibgeschützte HTTP-Abfrage der
|
||||
// Aufbewahrungssperre (RET-03) für Module, die Objekte endgültig löschen.
|
||||
package legalholdapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderTenant = "X-Nexarch-Tenant"
|
||||
HeaderServiceToken = "X-Service-Token"
|
||||
|
||||
maxTenantLen = 128
|
||||
maxParamLen = 512
|
||||
maxReasonLen = 1000
|
||||
)
|
||||
|
||||
// TenantVerifier ist die Tenant-Prüfung gegen die Core-Registry (RET-12).
|
||||
type TenantVerifier interface {
|
||||
VerifyTenant(ctx context.Context, tenantSlug string) (bool, error)
|
||||
}
|
||||
|
||||
// Config bindet die Instanz an genau einen Mandanten: Ihre Datenbank
|
||||
// gehört diesem Mandanten (Modell C). ServiceToken ist optional; leer
|
||||
// bedeutet, dass nur der Tenant-Kontext geprüft wird.
|
||||
type Config struct {
|
||||
InstanceTenant string
|
||||
ServiceToken string
|
||||
}
|
||||
|
||||
type response struct {
|
||||
OnHold bool `json:"on_hold"`
|
||||
Reason string `json:"reason"`
|
||||
Registered bool `json:"registered"`
|
||||
}
|
||||
|
||||
// Handler beantwortet GET /legal-hold?object_type=..&object_reference=..
|
||||
// mit 200 {"on_hold","reason","registered"}. Jeder unklare Fall ist ein
|
||||
// Fehlerstatus, nie ein „nicht gesperrt“.
|
||||
func Handler(pool *pgxpool.Pool, verifier TenantVerifier, cfg Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !validToken(cfg.InstanceTenant, maxTenantLen) {
|
||||
log.Printf("legalholdapi: instanz-tenant nicht konfiguriert, anfrage abgelehnt")
|
||||
http.Error(w, "sperrabfrage nicht konfiguriert", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if cfg.ServiceToken != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(r.Header.Get(HeaderServiceToken)), []byte(cfg.ServiceToken)) != 1 {
|
||||
log.Printf("legalholdapi: anfrage ohne gueltiges service-token abgelehnt")
|
||||
http.Error(w, "nicht autorisiert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tenant := strings.TrimSpace(r.Header.Get(HeaderTenant))
|
||||
if !validToken(tenant, maxTenantLen) {
|
||||
http.Error(w, "header "+HeaderTenant+" fehlt oder ist ungueltig", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if tenant != cfg.InstanceTenant {
|
||||
log.Printf("legalholdapi: anfrage abgelehnt - fremder tenant-kontext %q", tenant)
|
||||
http.Error(w, "tenant-kontext passt nicht zu dieser instanz", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
known, err := verifier.VerifyTenant(r.Context(), tenant)
|
||||
if err != nil {
|
||||
log.Printf("legalholdapi: tenant-verifikation fehlgeschlagen fuer tenant=%q: %v", tenant, err)
|
||||
http.Error(w, "tenant-verifikation nicht moeglich", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !known {
|
||||
log.Printf("legalholdapi: anfrage abgelehnt - unbekannter tenant %q", tenant)
|
||||
http.Error(w, "unbekannter tenant-kontext", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
objectType, objectReference := q.Get("object_type"), q.Get("object_reference")
|
||||
if !validParam(objectType) || !validParam(objectReference) {
|
||||
http.Error(w, "object_type und object_reference sind pflichtparameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := lookup(r.Context(), pool, objectType, objectReference)
|
||||
if err != nil {
|
||||
log.Printf("legalholdapi: datenbankfehler bei sperrabfrage: %v", err)
|
||||
http.Error(w, "sperrstatus nicht pruefbar", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
}
|
||||
|
||||
func lookup(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (response, error) {
|
||||
var res response
|
||||
var reason string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT o.id IS NOT NULL, h.id IS NOT NULL, COALESCE(h.reason, '')
|
||||
FROM (SELECT 1) x
|
||||
LEFT JOIN retention_objects o ON o.object_type = $1 AND o.object_reference = $2
|
||||
LEFT JOIN legal_holds h ON h.retention_object_id = o.id AND h.released_at IS NULL
|
||||
`, objectType, objectReference).Scan(&res.Registered, &res.OnHold, &reason)
|
||||
if err != nil {
|
||||
return response{}, err
|
||||
}
|
||||
res.Reason = truncate(reason, maxReasonLen)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func validToken(s string, max int) bool {
|
||||
if s == "" || len(s) > max {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x21 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validParam(s string) bool {
|
||||
if s == "" || len(s) > maxParamLen || !utf8.ValidString(s) {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package legalholdapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type fakeVerifier struct {
|
||||
valid map[string]bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeVerifier) VerifyTenant(_ context.Context, slug string) (bool, error) {
|
||||
if f.err != nil {
|
||||
return false, f.err
|
||||
}
|
||||
return f.valid[slug], nil
|
||||
}
|
||||
|
||||
const schemaDDL = `
|
||||
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 legal_holds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL, set_by TEXT NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
released_at TIMESTAMPTZ, released_by TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active
|
||||
ON legal_holds (retention_object_id) WHERE released_at IS NULL;
|
||||
`
|
||||
|
||||
func poolFor(t *testing.T, envName string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(envName)
|
||||
if dsn == "" {
|
||||
t.Skip(envName + " nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(ctx, schemaDDL); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
clean := func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE legal_holds, retention_objects CASCADE`)
|
||||
}
|
||||
clean()
|
||||
t.Cleanup(clean)
|
||||
return pool
|
||||
}
|
||||
|
||||
func seedObject(t *testing.T, pool *pgxpool.Pool, objectType, ref string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`INSERT INTO retention_objects (object_type, object_reference) VALUES ($1, $2) RETURNING id`,
|
||||
objectType, ref).Scan(&id); err != nil {
|
||||
t.Fatalf("objekt anlegen: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func seedHold(t *testing.T, pool *pgxpool.Pool, objectID, reason string, released bool) {
|
||||
t.Helper()
|
||||
q := `INSERT INTO legal_holds (retention_object_id, reason, set_by) VALUES ($1, $2, 'tester')`
|
||||
if released {
|
||||
q = `INSERT INTO legal_holds (retention_object_id, reason, set_by, released_at, released_by) VALUES ($1, $2, 'tester', now(), 'tester')`
|
||||
}
|
||||
if _, err := pool.Exec(context.Background(), q, objectID, reason); err != nil {
|
||||
t.Fatalf("sperre anlegen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, tenant, objectType, ref string, header http.Header) (int, string) {
|
||||
t.Helper()
|
||||
q := url.Values{}
|
||||
if objectType != "" {
|
||||
q.Set("object_type", objectType)
|
||||
}
|
||||
if ref != "" {
|
||||
q.Set("object_reference", ref)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/legal-hold?"+q.Encode(), nil)
|
||||
if tenant != "" {
|
||||
req.Header.Set(HeaderTenant, tenant)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header[k] = v
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
func okVerifier() fakeVerifier {
|
||||
return fakeVerifier{valid: map[string]bool{"mandant-a": true, "mandant-b": true}}
|
||||
}
|
||||
|
||||
func decode(t *testing.T, body string) response {
|
||||
t.Helper()
|
||||
var r response
|
||||
if err := json.Unmarshal([]byte(body), &r); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v (%s)", err, body)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Pruefung 1 / AK1: gesperrt, freigegeben, nie gesperrt, unbekannt.
|
||||
func TestHandler_HoldStates(t *testing.T) {
|
||||
pool := poolFor(t, "TEST_TENANT_DSN")
|
||||
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
|
||||
|
||||
held := seedObject(t, pool, "document", "doc-held")
|
||||
seedHold(t, pool, held, "Rechtsstreit Az. 12/26", false)
|
||||
released := seedObject(t, pool, "document", "doc-released")
|
||||
seedHold(t, pool, released, "alt", true)
|
||||
seedObject(t, pool, "document", "doc-never")
|
||||
|
||||
cases := []struct {
|
||||
ref string
|
||||
onHold bool
|
||||
registered bool
|
||||
reason string
|
||||
}{
|
||||
{"doc-held", true, true, "Rechtsstreit Az. 12/26"},
|
||||
{"doc-released", false, true, ""},
|
||||
{"doc-never", false, true, ""},
|
||||
{"doc-unknown", false, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
code, body := get(t, h, "mandant-a", "document", c.ref, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("%s: status = %d (%s), want 200", c.ref, code, body)
|
||||
}
|
||||
got := decode(t, body)
|
||||
if got.OnHold != c.onHold || got.Registered != c.registered || got.Reason != c.reason {
|
||||
t.Errorf("%s: %+v, want on_hold=%v registered=%v reason=%q", c.ref, got, c.onHold, c.registered, c.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sperre wirkt nur auf den passenden object_type.
|
||||
func TestHandler_ObjectTypeIsPartOfIdentity(t *testing.T) {
|
||||
pool := poolFor(t, "TEST_TENANT_DSN")
|
||||
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
|
||||
id := seedObject(t, pool, "document", "gemeinsame-ref")
|
||||
seedHold(t, pool, id, "grund", false)
|
||||
seedObject(t, pool, "message", "gemeinsame-ref")
|
||||
|
||||
_, body := get(t, h, "mandant-a", "message", "gemeinsame-ref", nil)
|
||||
if got := decode(t, body); got.OnHold {
|
||||
t.Errorf("message/gemeinsame-ref faelschlich gesperrt: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 2 / AK2: nie "nicht gesperrt" bei Unklarheit.
|
||||
func TestHandler_FailClosed(t *testing.T) {
|
||||
pool := poolFor(t, "TEST_TENANT_DSN")
|
||||
id := seedObject(t, pool, "document", "doc-held")
|
||||
seedHold(t, pool, id, "grund", false)
|
||||
|
||||
closed, err := pgxpool.New(context.Background(), os.Getenv("TEST_TENANT_DSN"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed.Close()
|
||||
|
||||
tok := func(v string) http.Header { return http.Header{HeaderServiceToken: {v}} }
|
||||
cases := []struct {
|
||||
name string
|
||||
h http.Handler
|
||||
tenant string
|
||||
typ string
|
||||
ref string
|
||||
header http.Header
|
||||
want int
|
||||
}{
|
||||
{"kein tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "", "document", "doc-held", nil, 400},
|
||||
{"ungueltiger tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant a\x7f", "document", "doc-held", nil, 400},
|
||||
{"fremder tenant", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-b", "document", "doc-held", nil, 403},
|
||||
{"unbekannter tenant im register", Handler(pool, fakeVerifier{valid: map[string]bool{}}, Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 403},
|
||||
{"register nicht erreichbar", Handler(pool, fakeVerifier{err: errors.New("down")}, Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503},
|
||||
{"datenbank nicht erreichbar", Handler(closed, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503},
|
||||
{"instanz ohne tenant-konfiguration", Handler(pool, okVerifier(), Config{}), "mandant-a", "document", "doc-held", nil, 503},
|
||||
{"fehlender parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "", nil, 400},
|
||||
{"zu langer parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", strings.Repeat("x", maxParamLen+1), nil, 400},
|
||||
{"service-token fehlt", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}), "mandant-a", "document", "doc-held", nil, 401},
|
||||
{"service-token falsch", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}), "mandant-a", "document", "doc-held", tok("falsch"), 401},
|
||||
}
|
||||
for _, c := range cases {
|
||||
code, body := get(t, c.h, c.tenant, c.typ, c.ref, c.header)
|
||||
if code != c.want {
|
||||
t.Errorf("%s: status = %d, want %d", c.name, code, c.want)
|
||||
}
|
||||
if strings.Contains(body, `"on_hold"`) {
|
||||
t.Errorf("%s: Fehlerantwort enthaelt eine Sperrangabe: %s", c.name, body)
|
||||
}
|
||||
}
|
||||
|
||||
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"})
|
||||
code, body := get(t, h, "mandant-a", "document", "doc-held", tok("geheim"))
|
||||
if code != http.StatusOK || !decode(t, body).OnHold {
|
||||
t.Errorf("richtiges service-token: status = %d, body = %s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_OnlyGET(t *testing.T) {
|
||||
pool := poolFor(t, "TEST_TENANT_DSN")
|
||||
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
req := httptest.NewRequest(m, "/legal-hold?object_type=document&object_reference=x", nil)
|
||||
req.Header.Set(HeaderTenant, "mandant-a")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s: status = %d, want 405", m, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AK3: die Abfrage aendert keine Sperre und keinen Objektstatus.
|
||||
func TestHandler_IsReadOnly(t *testing.T) {
|
||||
pool := poolFor(t, "TEST_TENANT_DSN")
|
||||
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
|
||||
id := seedObject(t, pool, "document", "doc-held")
|
||||
seedHold(t, pool, id, "grund", false)
|
||||
|
||||
snapshot := func() string {
|
||||
var s string
|
||||
if err := pool.QueryRow(context.Background(), `
|
||||
SELECT (SELECT count(*) FROM retention_objects)::text || '|' ||
|
||||
(SELECT count(*) FROM legal_holds)::text || '|' ||
|
||||
(SELECT count(*) FROM legal_holds WHERE released_at IS NULL)::text || '|' ||
|
||||
COALESCE((SELECT string_agg(status, ',') FROM retention_objects), '')
|
||||
`).Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
before := snapshot()
|
||||
for i := 0; i < 5; i++ {
|
||||
get(t, h, "mandant-a", "document", "doc-held", nil)
|
||||
get(t, h, "mandant-a", "document", "doc-unknown", nil)
|
||||
}
|
||||
if after := snapshot(); after != before {
|
||||
t.Errorf("datenbankzustand hat sich geaendert: %q -> %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Mandantentrennung: zwei Instanzen mit je eigener Datenbank.
|
||||
func TestHandler_TenantIsolationWithTwoDatabases(t *testing.T) {
|
||||
poolA := poolFor(t, "TEST_TENANT_DSN")
|
||||
poolB := poolFor(t, "TEST_TENANT_DSN_B")
|
||||
hA := Handler(poolA, okVerifier(), Config{InstanceTenant: "mandant-a"})
|
||||
hB := Handler(poolB, okVerifier(), Config{InstanceTenant: "mandant-b"})
|
||||
|
||||
idA := seedObject(t, poolA, "document", "doc-1")
|
||||
seedHold(t, poolA, idA, "nur in A", false)
|
||||
seedObject(t, poolB, "document", "doc-1")
|
||||
|
||||
if _, body := get(t, hA, "mandant-a", "document", "doc-1", nil); !decode(t, body).OnHold {
|
||||
t.Error("instanz A: doc-1 muss gesperrt sein")
|
||||
}
|
||||
if _, body := get(t, hB, "mandant-b", "document", "doc-1", nil); decode(t, body).OnHold {
|
||||
t.Error("instanz B: doc-1 darf nicht gesperrt sein (Sperre liegt in Datenbank A)")
|
||||
}
|
||||
if code, _ := get(t, hB, "mandant-a", "document", "doc-1", nil); code != http.StatusForbidden {
|
||||
t.Errorf("instanz B mit tenant-header von A: status = %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate_KeepsValidUTF8(t *testing.T) {
|
||||
in := strings.Repeat("ä", 700)
|
||||
out := truncate(in, maxReasonLen)
|
||||
if len(out) > maxReasonLen || strings.ContainsRune(out, '�') {
|
||||
t.Errorf("truncate: laenge %d, ungueltige zeichen enthalten", len(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package retentionclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Vertragstest gegen den ECHTEN Archive-Dienst (moduleadapter-api, RET-05/12/13).
|
||||
// Voraussetzung und Ablauf: scripts/legalhold-contract-test.sh.
|
||||
func archiveClient(t *testing.T) (*Client, string) {
|
||||
t.Helper()
|
||||
base := os.Getenv("TEST_ARCHIVE_URL")
|
||||
tenant := os.Getenv("TEST_ARCHIVE_TENANT")
|
||||
if base == "" || tenant == "" {
|
||||
t.Skip("TEST_ARCHIVE_URL/TEST_ARCHIVE_TENANT nicht gesetzt, Vertragstest uebersprungen")
|
||||
}
|
||||
return New(base, tenant), tenant
|
||||
}
|
||||
|
||||
func TestArchiveContract_RegisterAndLegalHold(t *testing.T) {
|
||||
c, _ := archiveClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.Register(ctx, "dms", "dms_document", "klasse-vertrag", "http://dms.example.test/cb"); err != nil {
|
||||
t.Fatalf("register gegen echtes Archive: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
ref string
|
||||
onHold bool
|
||||
reason string
|
||||
}{
|
||||
{"contract-held", true, "Vertragstest Sperre"},
|
||||
{"contract-released", false, ""},
|
||||
{"contract-free", false, ""},
|
||||
{"contract-unknown", false, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := c.CheckLegalHold(ctx, "document", tc.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.ref, err)
|
||||
}
|
||||
if got.OnHold != tc.onHold || got.Reason != tc.reason {
|
||||
t.Errorf("%s: %+v, want on_hold=%v reason=%q", tc.ref, got, tc.onHold, tc.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveContract_ForeignTenantIsAnError(t *testing.T) {
|
||||
c, tenant := archiveClient(t)
|
||||
foreign := New(c.BaseURL, tenant+"-fremd")
|
||||
_, err := foreign.CheckLegalHold(context.Background(), "document", "contract-held")
|
||||
if err == nil {
|
||||
t.Fatal("fremder Tenant bekam eine Sperrauskunft")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("erwartet Status 403, habe %v", err)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func (c *Client) NewRequest(ctx context.Context, tc modulecall.Context, method,
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
RetentionClass string `json:"retention_class"`
|
||||
@@ -61,7 +62,7 @@ type Registration struct {
|
||||
// (Akzeptanzkriterium 3).
|
||||
func (c *Client) Register(ctx context.Context, moduleName, objectType, retentionClass, callbackURL string) (Registration, error) {
|
||||
body, err := json.Marshal(registerRequest{
|
||||
ModuleName: moduleName, ObjectType: objectType, RetentionClass: retentionClass, CallbackURL: callbackURL,
|
||||
TenantSlug: c.Tenant, ModuleName: moduleName, ObjectType: objectType, RetentionClass: retentionClass, CallbackURL: callbackURL,
|
||||
})
|
||||
if err != nil {
|
||||
return Registration{}, fmt.Errorf("retentionclient: request kodieren: %w", err)
|
||||
@@ -95,8 +96,8 @@ func (c *Client) httpClient() *http.Client {
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// LegalHoldStatus ist die angenommene Antwort der Archive-Sperrabfrage
|
||||
// (Vertrag siehe INT-11-Pruefprotokoll; die Gegenseite existiert noch nicht).
|
||||
// LegalHoldStatus ist die Antwort der Archive-Sperrabfrage (Archive RET-13,
|
||||
// Vertragstest gegen den echten Handler: contract_archive_test.go).
|
||||
type LegalHoldStatus struct {
|
||||
OnHold bool `json:"on_hold"`
|
||||
Reason string `json:"reason"`
|
||||
|
||||
@@ -3,10 +3,12 @@ package retentionclient
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/dms/internal/modulecall"
|
||||
@@ -53,3 +55,19 @@ func TestOnlyOneRequestConstructionSite(t *testing.T) {
|
||||
t.Fatalf("%d stellen bauen requests, erwartet genau 1 (NewRequest mit Tenant-Kontext)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBodyCarriesTenantSlug(t *testing.T) {
|
||||
var body string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
body = string(b)
|
||||
_, _ = w.Write([]byte(`{"ID":"1"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := New(srv.URL, "acme").Register(context.Background(), "dms", "dms_document", "k", "http://cb"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(body, `"tenant_slug":"acme"`) {
|
||||
t.Fatalf("register-body ohne tenant_slug: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# RET-13 – Prüfprotokoll: Legal-Hold-Abfrage-Endpunkt
|
||||
|
||||
Ticket: `archive-kanban/tickets/RET-13.md`. Branch `feature/ret-13-legal-hold-abfrage-endpunkt` (Basis: Archive-Portierung, PR #105).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/internal/legalholdapi`: `GET /legal-hold?object_type=&object_reference=`, Antwort `200 {"on_hold","reason","registered"}`. Die ersten beiden Felder sind der vom DMS angenommene Vertrag (INT-11), `registered` ist eine Zusatzangabe (das DMS-JSON ignoriert sie).
|
||||
- Eingehängt in `archive/cmd/moduleadapter-api` (derselbe Dienst und Port 8095 wie `/register`, dieselbe Basis-URL wie der DMS-`retentionclient`). Kein neuer Port, kein neuer Routenpräfix, `check-port-defaults.sh` und `check-route-prefixes.sh` sind grün.
|
||||
- Reihenfolge der Prüfungen, jede fail-closed: Methode (405) → Instanz konfiguriert (503) → optionales Service-Token (401) → `X-Nexarch-Tenant` gültig (400) → gleich dem Instanz-Tenant (403) → Tenant im Core-Register bekannt (403, Register nicht erreichbar 503, gleiches Muster wie RET-12) → Parameter gültig (400) → Datenbank (Fehler 503). Fehlerantworten enthalten nie eine Sperrangabe.
|
||||
- Mandanten-DB-Routing: Wie bei allen Archive-Diensten gehört die Datenbank der Instanz genau einem Mandanten (`NEXARCH_MODULEADAPTER_TENANT_DSN`). Neu ist `NEXARCH_MODULEADAPTER_TENANT_SLUG`: der Mandant, den diese Instanz bedient. Ein anderer Tenant-Header wird abgelehnt. Ohne die Variable antwortet die Route 503.
|
||||
- `NEXARCH_MODULEADAPTER_SERVICE_TOKEN` (optional): Ist es gesetzt, verlangt Archive `X-Service-Token` (konstante Vergleichszeit). Das ist der Header, den Archive auch gegenüber der Core-Registry nutzt.
|
||||
- Rein lesend: eine Abfrage `SELECT` mit `LEFT JOIN` auf `retention_objects` und die aktive Zeile in `legal_holds`. Ein unbekanntes Objekt liefert `200`, `on_hold:false`, `registered:false`.
|
||||
|
||||
## Gefundener Fehler (behoben)
|
||||
|
||||
Der DMS-`retentionclient.Register` sendete kein `tenant_slug` im Body. Der Archive-`RegisterHandler` verlangt es seit RET-12 als Pflichtfeld und antwortet sonst `400`. Die Registrierung des DMS gegen ein echtes Archive schlug damit im Gesamtprojekt fehl. Behoben in `dms/internal/retentionclient` (`tenant_slug` aus dem Client-Tenant), Test `TestRegisterBodyCarriesTenantSlug`. Der Fehler fiel im Vertragstest auf.
|
||||
|
||||
## Prüfungen (Entwicklungshost 204, frischer Klon des gepushten Branches, Wegwerf-Cluster `17/coredev`)
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
|---|---|
|
||||
| `gofmt -l`, `go vet ./...` (Modul `archive`) | sauber |
|
||||
| `internal/legalholdapi` (echte Datenbank), 7 Tests | PASS: gesperrt/freigegeben/nie gesperrt/unbekannt; Objekttyp gehört zur Identität; 11 Fehlerfälle (kein/ungültiger/fremder Tenant, Tenant unbekannt im Register, Register nicht erreichbar, Datenbank geschlossen, Instanz ohne Tenant-Konfiguration, fehlender/zu langer Parameter, Service-Token fehlt/falsch) liefern 400/401/403/503 und nie eine Sperrangabe; nur GET; Datenbankzustand unverändert; zwei Instanzen mit zwei Datenbanken getrennt |
|
||||
| `go test -p 1 ./...` (Modul `archive`) | alle Pakete ok |
|
||||
| Vertragstest `scripts/legalhold-contract-test.sh` | bestanden (siehe unten) |
|
||||
| `retentionclient`, `retentiondestroy`, `legalhold`, `trash`, `cmd/...` (Modul `dms`) | ok |
|
||||
| Port- und Routenskripte | ok |
|
||||
|
||||
### Vertragstest
|
||||
|
||||
Das Skript legt eine eigene temporäre Datenbank im Wegwerf-Cluster an (und entfernt sie wieder), wendet alle 15 Archive-Migrationen an, startet den echten `moduleadapter-api` (Registry als Loopback-Stub statt Core) und lässt den echten DMS-`retentionclient` dagegen laufen: `Register` (200), `CheckLegalHold` für gesperrt (`true` samt Grund), freigegeben, nie gesperrt und unbekannt (jeweils `false`), fremder Tenant (Fehler mit Status 403). Dazu Prüfungen per `curl`: ohne Tenant-Header 400, fremder Tenant 403, POST 405, Register gestoppt 503 (ohne Sperrangabe). Ergebnis: `VERTRAGSTEST BESTANDEN`.
|
||||
|
||||
Lösung für zwei getrennte Go-Module (`dms`, `archive`): `internal`-Pakete sind modulübergreifend nicht importierbar, deshalb läuft der Test als Skript mit zwei Prozessen über HTTP. Das entspricht dem Betrieb. Der DMS-Test (`contract_archive_test.go`) wird ohne `TEST_ARCHIVE_URL` übersprungen.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Service-Token:** Der DMS-`retentionclient` sendet noch kein `X-Service-Token`. Ist es in Archive gesetzt, antwortet Archive 401 und das DMS löscht nie (fail closed). Bis Core die Dienst-zu-Dienst-Authentifizierung ausliefert, gilt Tenant-Bindung plus Registerprüfung wie bei RET-05/12.
|
||||
- **Unbekanntes Objekt:** Archive antwortet `on_hold:false`, `registered:false`. Ob das DMS `registered:false` strenger behandeln soll (nicht löschen), ist eine DMS-Entscheidung.
|
||||
- **Betrieb:** `NEXARCH_MODULEADAPTER_TENANT_SLUG` muss in `/etc/nexarch/archive-moduleadapter-api.env` gesetzt werden, sonst antwortet `/legal-hold` 503. Die Datei liegt nicht im Repository.
|
||||
- **Nicht umgesetzt (Muster im Archive fehlt):** Ratenbegrenzung (liegt im Core-Gateway) und Audit-Eintrag für Lesezugriffe (nur Log bei Ablehnungen).
|
||||
- Die Tests der neuen Pakete legen ihre Tabellen selbst an (Handschema) und laufen im gemeinsamen Wegwerf-Cluster seriell (`-p 1`).
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# RET-13 Vertragstest: DMS-retentionclient gegen den echten Archive-Dienst
|
||||
# (archive/cmd/moduleadapter-api) mit echter Datenbank und den echten Migrationen.
|
||||
# Voraussetzung: TEST_TENANT_DSN_B zeigt auf den Entwicklungs-Cluster (Port 5433) und
|
||||
# darf Datenbanken anlegen. Das Skript legt eine eigene temporaere Datenbank an und
|
||||
# entfernt sie wieder; andere Datenbanken bleiben unberuehrt.
|
||||
# Nur Loopback, Registry-Stub statt Core.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
: "${TEST_TENANT_DSN_B:?TEST_TENANT_DSN_B nicht gesetzt}"
|
||||
if [[ "$TEST_TENANT_DSN_B" != *:5433/* ]]; then
|
||||
echo "abgebrochen: die Datenbank muss im Wegwerf-Cluster auf Port 5433 liegen" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tenant=acme-contract
|
||||
workdir=$(mktemp -d)
|
||||
pids=()
|
||||
dbname="lh_contract_$$"
|
||||
withdb() { python3 - "$TEST_TENANT_DSN_B" "$1" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
u = urlsplit(sys.argv[1])
|
||||
print(urlunsplit((u.scheme, u.netloc, "/" + sys.argv[2], u.query, u.fragment)))
|
||||
PY
|
||||
}
|
||||
admin_dsn=$(withdb postgres)
|
||||
test_dsn=$(withdb "$dbname")
|
||||
cleanup() {
|
||||
for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done
|
||||
psql "$admin_dsn" -q -c "DROP DATABASE IF EXISTS $dbname WITH (FORCE)" >/dev/null 2>&1 || true
|
||||
rm -rf "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
psql "$admin_dsn" -v ON_ERROR_STOP=1 -q -c "CREATE DATABASE $dbname" >/dev/null
|
||||
sql() { psql "$test_dsn" -v ON_ERROR_STOP=1 -q "$@"; }
|
||||
freeport() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'; }
|
||||
|
||||
sql -c "CREATE EXTENSION IF NOT EXISTS pgcrypto"
|
||||
for f in $(ls "$root"/archive/migrations/*.up.sql | sort); do sql -f "$f" >/dev/null; done
|
||||
sql <<'SQL' >/dev/null
|
||||
INSERT INTO retention_objects (object_type, object_reference) VALUES
|
||||
('document','contract-held'), ('document','contract-released'), ('document','contract-free');
|
||||
INSERT INTO legal_holds (retention_object_id, reason, set_by)
|
||||
SELECT id, 'Vertragstest Sperre', 'tester' FROM retention_objects WHERE object_reference = 'contract-held';
|
||||
INSERT INTO legal_holds (retention_object_id, reason, set_by, released_at, released_by)
|
||||
SELECT id, 'alt', 'tester', now(), 'tester' FROM retention_objects WHERE object_reference = 'contract-released';
|
||||
SQL
|
||||
|
||||
rport=$(freeport)
|
||||
cat > "$workdir/registry.py" <<PY
|
||||
import http.server
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200 if self.path == "/tenants/$tenant" else 404)
|
||||
self.end_headers()
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
http.server.HTTPServer(("127.0.0.1", $rport), H).serve_forever()
|
||||
PY
|
||||
python3 "$workdir/registry.py" & pids+=($!)
|
||||
|
||||
(cd "$root/archive" && go build -o "$workdir/moduleadapter-api" ./cmd/moduleadapter-api)
|
||||
aport=$(freeport)
|
||||
NEXARCH_MODULEADAPTER_TENANT_DSN="$test_dsn" \
|
||||
NEXARCH_MODULEADAPTER_API_LISTEN_ADDR="127.0.0.1:$aport" \
|
||||
NEXARCH_TENANT_REGISTRY_URL="http://127.0.0.1:$rport" \
|
||||
NEXARCH_MODULEADAPTER_TENANT_SLUG="$tenant" \
|
||||
"$workdir/moduleadapter-api" > "$workdir/archive.log" 2>&1 & pids+=($!)
|
||||
|
||||
for _ in $(seq 1 50); do
|
||||
curl -fsS "http://127.0.0.1:$aport/healthz" >/dev/null 2>&1 && break
|
||||
sleep 0.2
|
||||
done
|
||||
curl -fsS "http://127.0.0.1:$aport/healthz" >/dev/null || { cat "$workdir/archive.log"; echo "archive-dienst startet nicht" >&2; exit 1; }
|
||||
|
||||
status() { curl -s -o "$workdir/body" -w '%{http_code}' "$@"; }
|
||||
fail=0
|
||||
check() { # name expected actual
|
||||
if [[ "$2" == "$3" ]]; then echo "PASS $1 ($3)"; else echo "FAIL $1: erwartet $2, erhalten $3"; fail=1; fi
|
||||
}
|
||||
url="http://127.0.0.1:$aport/legal-hold?object_type=document&object_reference=contract-held"
|
||||
check "ohne tenant-header" 400 "$(status "$url")"
|
||||
check "fremder tenant-header" 403 "$(status -H "X-Nexarch-Tenant: anderer-mandant" "$url")"
|
||||
if grep -q on_hold "$workdir/body"; then echo "FAIL Fehlerantwort enthaelt on_hold"; fail=1; fi
|
||||
check "richtiger tenant" 200 "$(status -H "X-Nexarch-Tenant: $tenant" "$url")"
|
||||
grep -q '"on_hold":true' "$workdir/body" || { echo "FAIL on_hold:true fehlt"; fail=1; }
|
||||
check "POST ist nicht erlaubt" 405 "$(status -X POST -H "X-Nexarch-Tenant: $tenant" "$url")"
|
||||
|
||||
(cd "$root/dms" && TEST_ARCHIVE_URL="http://127.0.0.1:$aport" TEST_ARCHIVE_TENANT="$tenant" \
|
||||
go test -count=1 -v -run 'ArchiveContract' ./internal/retentionclient/) || fail=1
|
||||
|
||||
kill "${pids[0]}" 2>/dev/null || true
|
||||
wait "${pids[0]}" 2>/dev/null || true
|
||||
sleep 0.3
|
||||
check "tenant-register nicht erreichbar" 503 "$(status -H "X-Nexarch-Tenant: $tenant" "$url")"
|
||||
if grep -q on_hold "$workdir/body"; then echo "FAIL Registry-Ausfall liefert Sperrangabe"; fail=1; fi
|
||||
|
||||
if [[ $fail -ne 0 ]]; then echo "VERTRAGSTEST FEHLGESCHLAGEN"; tail -20 "$workdir/archive.log"; exit 1; fi
|
||||
echo "VERTRAGSTEST BESTANDEN"
|
||||
Reference in new issue
Block a user