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:
patrickandClaude Sonnet 5 committed 2026-09-21 23:04:33 +02:00
commit 884eaeb4b3
8 files changed
+693 -4

No files matched your search

+11 -1
View File
@@ -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)
}
}
+41
View File
@@ -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`).
+102
View File
@@ -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"