From f5697871e18dad447e3cea673fca1f520f3bf489 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 16 Oct 2026 19:09:05 +0000 Subject: [PATCH] ENG-06: unabhaengige-tenant-validierung-bei-objekttyp-registrierung-durch-dms-mail Co-Authored-By: Claude Sonnet 5 --- dms/cmd/app/api.go | 1 + dms/cmd/worker/main.go | 1 + dms/internal/workflowevent/workflowevent.go | 9 +- .../workflowevent/workflowevent_test.go | 7 +- workflow/cmd/workflow-app/main.go | 2 +- workflow/docs/ENG-06-PRUEFPROTOKOLL.md | 132 ++++++++++++++++++ workflow/internal/moduleadapter/handler.go | 42 +++++- .../internal/moduleadapter/handler_test.go | 132 +++++++++++++++++- 8 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 workflow/docs/ENG-06-PRUEFPROTOKOLL.md diff --git a/dms/cmd/app/api.go b/dms/cmd/app/api.go index e56f945f..11b2a25c 100644 --- a/dms/cmd/app/api.go +++ b/dms/cmd/app/api.go @@ -106,6 +106,7 @@ func workflowEventConfig() workflowevent.Config { return workflowevent.Config{ BaseURL: os.Getenv("NEXARCH_DMS_APP_WORKFLOW_URL"), ServiceToken: os.Getenv("NEXARCH_DMS_APP_WORKFLOW_SERVICE_TOKEN"), + TenantSlug: os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), } } diff --git a/dms/cmd/worker/main.go b/dms/cmd/worker/main.go index 453474cc..7743e0bb 100644 --- a/dms/cmd/worker/main.go +++ b/dms/cmd/worker/main.go @@ -252,6 +252,7 @@ func startJobWorkers(ctx context.Context, pool *pgxpool.Pool, queue *jobqueue.Qu deps.WorkflowEvent = workflowevent.Config{ BaseURL: os.Getenv("NEXARCH_DMS_WORKER_WORKFLOW_URL"), ServiceToken: os.Getenv("NEXARCH_DMS_WORKER_WORKFLOW_SERVICE_TOKEN"), + TenantSlug: tenantSlug, } if deps.WorkflowEvent.Enabled() { // INT-13 Akzeptanzkriterium 1: Objekttyp + Trigger beim Start diff --git a/dms/internal/workflowevent/workflowevent.go b/dms/internal/workflowevent/workflowevent.go index 56618ff4..652a7658 100644 --- a/dms/internal/workflowevent/workflowevent.go +++ b/dms/internal/workflowevent/workflowevent.go @@ -33,10 +33,14 @@ const ( var Triggers = []string{TriggerCreated, TriggerStatusChanged, TriggerDeadlineReached} // Config steuert Ziel und Zugangsdaten. BaseURL leer bedeutet -// abgeschaltet (Standard aus, analog zu FDN-18). +// abgeschaltet (Standard aus, analog zu FDN-18). TenantSlug ist der +// Tenant-Kontext dieser DMS-Instanz (ENG-06: Workflow prueft ihn +// unabhaengig gegen die Core-Tenant-Registry, bevor eine Registrierung +// uebernommen wird). type Config struct { BaseURL string ServiceToken string + TenantSlug string } // Enabled meldet, ob eine Workflow-Anbindung konfiguriert ist. @@ -46,6 +50,7 @@ type registerRequest struct { ModuleName string `json:"module_name"` ObjectType string `json:"object_type"` Triggers []string `json:"triggers"` + TenantSlug string `json:"tenant_slug"` } // Register registriert den Objekttyp "document" mit seinen Auslöser- @@ -55,7 +60,7 @@ func Register(ctx context.Context, cfg Config) error { if !cfg.Enabled() { return nil } - body, err := json.Marshal(registerRequest{ModuleName: ModuleName, ObjectType: ObjectType, Triggers: Triggers}) + body, err := json.Marshal(registerRequest{ModuleName: ModuleName, ObjectType: ObjectType, Triggers: Triggers, TenantSlug: cfg.TenantSlug}) if err != nil { return fmt.Errorf("workflowevent: registrierung serialisieren: %w", err) } diff --git a/dms/internal/workflowevent/workflowevent_test.go b/dms/internal/workflowevent/workflowevent_test.go index 28374462..d2999982 100644 --- a/dms/internal/workflowevent/workflowevent_test.go +++ b/dms/internal/workflowevent/workflowevent_test.go @@ -91,7 +91,7 @@ func TestRegister_SendsObjectTypeAndTriggers(t *testing.T) { srv := fw.server() defer srv.Close() - cfg := Config{BaseURL: srv.URL, ServiceToken: "secret-token"} + cfg := Config{BaseURL: srv.URL, ServiceToken: "secret-token", TenantSlug: "mandant-a"} if err := Register(context.Background(), cfg); err != nil { t.Fatalf("register: %v", err) } @@ -105,6 +105,11 @@ func TestRegister_SendsObjectTypeAndTriggers(t *testing.T) { if len(got.Triggers) != len(Triggers) { t.Fatalf("triggers = %v, want %v", got.Triggers, Triggers) } + // ENG-06: Workflow prueft tenant_slug unabhaengig gegen Core, DMS + // muss ihn also mitschicken, sonst wird die Registrierung abgelehnt. + if got.TenantSlug != "mandant-a" { + t.Fatalf("tenant_slug = %q, want mandant-a", got.TenantSlug) + } } // TestRegister_Disabled prueft: ohne BaseURL passiert nichts (Standard aus). diff --git a/workflow/cmd/workflow-app/main.go b/workflow/cmd/workflow-app/main.go index 143f19c9..1b637622 100644 --- a/workflow/cmd/workflow-app/main.go +++ b/workflow/cmd/workflow-app/main.go @@ -121,7 +121,7 @@ func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlu caseoverviewapi.NewServer(caseoverviewSvc).RegisterRoutes(mux) backchannelapi.NewServer(backchannelSvc).RegisterRoutes(mux) routingruleapi.NewServer(routingruleStore, buildRoutingSuggester()).RegisterRoutes(mux) - moduleadapter.NewServer(pool, serviceToken, tenantSlug).RegisterRoutes(mux) + moduleadapter.NewServer(pool, serviceToken, tenantSlug, tenantVerifier).RegisterRoutes(mux) return mux, nil } diff --git a/workflow/docs/ENG-06-PRUEFPROTOKOLL.md b/workflow/docs/ENG-06-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..30e1ee6c --- /dev/null +++ b/workflow/docs/ENG-06-PRUEFPROTOKOLL.md @@ -0,0 +1,132 @@ +# ENG-06 – Pruefprotokoll: Unabhaengige Tenant-Validierung bei Objekttyp-Registrierung durch DMS/Mail + +Durchgefuehrt am 2026-10-16 gegen echten Postgres auf Testhost +192.168.1.204 (eigene Wegwerf-Schemas ueber `internal/testdb`, kein +Bezug zu archivdms-Daten). + +## Ausgangslage + +ENG-02 (`internal/moduleadapter`, `Register`/`handleRegister`) nahm +Objekttyp- und Trigger-Registrierungen von DMS/Mail bislang ohne jeden +Tenant-Kontext entgegen — `registerRequest` kannte kein `tenant_slug`. +Das ist die gleiche Luecke, die RET-12 fuer Archive bereits geschlossen +hat (`archive/internal/moduleadapter`: `TenantVerifier`-Interface + +Pruefung in `RegisterHandler` gegen Core TEN-01, bevor `Register` +aufgerufen wird) und die VOR-06 fuer Workflows Rueckkanal +(`internal/backchannel`) mit demselben Muster (Abgleich gegen +`ownTenantSlug` UND gegen die Core-Registry) bereits umgesetzt hat. +ENG-09 hatte fuer `/events` (`PublishEvent`) zwar schon einen +`tenant_slug`/`ownTenantSlug`-Abgleich, aber keinen Core-Registry-Aufruf +und keine Pruefung bei der eigentlichen Registrierung — dieses Ticket +schliesst die Luecke fuer `/register`. + +## Umsetzung + +- `workflow/internal/moduleadapter/handler.go`: neues + `TenantVerifier`-Interface (gleiches Muster wie Archive RET-12); + `Server` erhaelt ein `tenants TenantVerifier`-Feld, `NewServer` nimmt + es als vierten Parameter entgegen. Neue Methode `tenantValid` prueft + zweifach, bevor eine Registrierung uebernommen wird: (1) der + mitgelieferte `tenant_slug` muss zur Server-Instanz passen + (`ownTenantSlug`, Modell A — ein Prozess/eine DB je Mandant), (2) + unabhaengig davon bestaetigt `tenants.VerifyTenant` gegen Core TEN-01, + dass der Tenant dort tatsaechlich gefuehrt wird. Ein fehlgeschlagener + Core-Aufruf zaehlt als ungueltig (fail closed). `registerRequest` hat + jetzt ein Pflichtfeld `tenant_slug`; `handleRegister` lehnt bei + ungueltigem Tenant-Kontext mit 403 ab und protokolliert + Modul/Objekttyp/Tenant. `Register`/`Rollback`/`Deregister` (DB-Ebene) + bleiben unveraendert — die Pruefung sitzt an der API-Grenze, analog zu + `backchannel.ForObject` (VOR-06), keine Aenderung an Schema oder + Fachlogik noetig (kleinste Loesung). +- `workflow/cmd/workflow-app/main.go`: `moduleadapter.NewServer(...)` + bekommt den bereits vorhandenen `tenantVerifier` + (`tenantregistryclient.Client`, zuvor schon fuer `backchannel` + verdrahtet) als vierten Parameter mit. +- `dms/internal/workflowevent/workflowevent.go` (INT-13, der reale + Aufrufer): `Config` und `registerRequest` bekommen `TenantSlug`, + `Register` schickt es mit. Ohne diese Ergaenzung haette der neue, + serverseitig *pflichtige* `tenant_slug` den bestehenden, + unveraenderten DMS-Registrierungsablauf aus ENG-02 kaputt gemacht + (Regression gegen Pruefung 3) — DMS kannte das Feld bislang gar + nicht. +- `dms/cmd/worker/main.go` und `dms/cmd/app/api.go`: bestehende, + bereits vorhandene `NEXARCH_DMS_WORKER_TENANT_SLUG` / + `NEXARCH_DMS_APP_TENANT_SLUG` Umgebungsvariablen werden jetzt auch in + die `workflowevent.Config` durchgereicht (kein neuer Zugangsdaten- + Parameter noetig). +- Mail hat noch keinen eigenen Registrierungs-Client im Repo (kein + Pendant zu `dms/internal/workflowevent` vorhanden) — nichts zu + aendern, aber jeder zukuenftige Mail-Adapter muss `tenant_slug` + ebenso mitschicken. + +## Pruefungen + +### Pruefung 1: Registrierung mit gueltigem, existierendem Tenant-Kontext wird uebernommen +Ergebnis: PASS +- `TestHandleRegister_ValidTenantContextAccepted` + (workflow/internal/moduleadapter): `tenant_slug=mandant-a` gegen einen + fuer `mandant-a` konfigurierten Server, Core-Fake bestaetigt + `mandant-a` -> HTTP 200. +- `TestHandleRegisterAndList_EndToEnd` (angepasst, jetzt mit + `tenant_slug`) zeigt zusaetzlich, dass die Registrierung tatsaechlich + in `module_registrations` landet und ueber `/registrations` sichtbar + ist. +- `TestRegister_SendsObjectTypeAndTriggers` (dms/internal/workflowevent, + INT-13-Seite): bestaetigt, dass DMS den `tenant_slug` tatsaechlich im + Request-Body mitschickt (`got.TenantSlug == "mandant-a"`). + +### Pruefung 2: Registrierung mit manipuliertem/fremdem Tenant-Kontext wird abgelehnt +Ergebnis: PASS, drei Varianten abgedeckt: +- `TestHandleRegister_ForeignTenantSlugRejected`: `tenant_slug` weicht + von der Server-Instanz ab (`mandant-fremd` statt `mandant-a`) -> HTTP + 403, Log-Zeile `registrierung abgelehnt, unbekannter tenant-kontext`, + anschliessend `TriggersFor` bestaetigt, dass nichts uebernommen wurde. +- `TestHandleRegister_TenantUnknownToCoreRegistryRejected`: der + `tenant_slug` passt formal zur Server-Instanz (`mandant-a`), aber die + (simulierte) Core-Registry kennt diesen Tenant nicht (mehr) -> HTTP + 403. Dieser Fall beweist, dass die Pruefung nicht nur lokal gegen die + Instanz-Konfiguration geht (das waere kein unabhaengiger Abgleich), + sondern wirklich gegen Core TEN-01. +- `TestHandleRegister_TenantRegistryCallFailsRejected`: Core-Aufruf + schlaegt technisch fehl (simulierter Transportfehler) -> HTTP 403, + fail closed statt stillschweigend akzeptiert. + +### Pruefung 3 (Regression): normaler DMS-/Mail-Registrierungsablauf bleibt funktionsfaehig +Ergebnis: PASS +- Alle bestehenden ENG-02-Tests (`TestRegister_TwoFictionalModulesRegisterIndependently`, + `TestRegister_DuplicateSameTriggersRejected`, + `TestRegister_ChangedTriggersCreateNewVersionAndRollbackRestoresPrevious`, + `TestDeregister_RemovesTriggersCompletely`, + `TestTriggersFor_UnknownObjectTypeReturnsClearError`, + `TestRollback_UnknownVersionReturnsError`) unveraendert gruen — sie + rufen `Register`/`Rollback`/`Deregister` direkt auf Go-Ebene auf, die + DB-Funktionen selbst sind unangetastet. +- Bestehende ENG-09-Tests (`TestPublishEvent_*`) weiterhin gruen, + `/events` ist von diesem Ticket nicht betroffen. +- Kompletter `workflow`-Modultest (`go test ./...`, alle Pakete) + gruen, `go vet ./...` ohne Befund. +- `dms`-Modul: `internal/workflowevent`-Tests gruen (inkl. der beiden + Integrationstests mit echter Jobqueue), `go build ./...` und + `go vet ./...` fuer das komplette Modul fehlerfrei, `cmd/app` und + `cmd/worker` bauen mit der erweiterten `workflowevent.Config`. + +## Testbefehl +``` +TEST_TENANT_DSN='postgresql://:@localhost:5432/?sslmode=disable' \ + go test ./... -p 1 +``` +(ausgefuehrt sowohl im `workflow`- als auch im `dms`-Modul, jeweils +gegen ein frisches Wegwerf-Schema ueber `internal/testdb`) + +## Nicht Bestandteil (laut Ticket / kleinste Loesung) +- `/rollback`, `/deregister`, `/registrations` (GET) bleiben ohne + eigene Tenant-Pruefung — sie wirken auf bereits bestehende, + modul-/objekttyp-identifizierte Registrierungen, nicht auf eine neue + Registrierung mit fremdem Tenant-Claim; AK/Ticket beziehen sich nur + auf die Registrierung selbst. +- `/events` (`PublishEvent`, ENG-09) bleibt wie vor diesem Ticket ohne + Core-Registry-Abgleich (nur lokaler `ownTenantSlug`-Vergleich) — das + ist eine vorbestehende, separate Luecke aus ENG-09 und nicht + Gegenstand von ENG-06 (kein Umbau angrenzender Bereiche). +- Kein eigener Mail-Registrierungs-Client — existiert im Repo noch + nicht, also nichts zu migrieren. diff --git a/workflow/internal/moduleadapter/handler.go b/workflow/internal/moduleadapter/handler.go index 2ad971fa..b8e043d3 100644 --- a/workflow/internal/moduleadapter/handler.go +++ b/workflow/internal/moduleadapter/handler.go @@ -1,8 +1,10 @@ package moduleadapter import ( + "context" "encoding/json" "errors" + "log" "net/http" "time" @@ -11,6 +13,14 @@ import ( "gitea.perlbach24.de/scripte/nexarch/workflow/internal/svcauth" ) +// TenantVerifier prueft einen vom aufrufenden Modul mitgelieferten +// Tenant-Kontext unabhaengig gegen die Core-Tenant-Registry (TEN-01) - +// ENG-06: Workflow vertraut dabei nicht der Angabe von DMS/Mail selbst +// (Muster aus archive/internal/moduleadapter, RET-12). +type TenantVerifier interface { + VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) +} + // Server bindet die Registrierungs-API an einen Postgres-Pool und // verlangt ein Service-Token bei jedem Aufruf (fail closed: ein leeres // oder falsches Token wird immer abgewiesen, Lehre aus RET-14/F-08). @@ -21,10 +31,11 @@ type Server struct { pool *pgxpool.Pool serviceToken string ownTenantSlug string + tenants TenantVerifier } -func NewServer(pool *pgxpool.Pool, serviceToken, ownTenantSlug string) *Server { - return &Server{pool: pool, serviceToken: serviceToken, ownTenantSlug: ownTenantSlug} +func NewServer(pool *pgxpool.Pool, serviceToken, ownTenantSlug string, tenants TenantVerifier) *Server { + return &Server{pool: pool, serviceToken: serviceToken, ownTenantSlug: ownTenantSlug, tenants: tenants} } func (s *Server) RegisterRoutes(mux *http.ServeMux) { @@ -46,10 +57,32 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { } } +// tenantValid prueft den mitgelieferten Tenant-Kontext unabhaengig von +// der Angabe des registrierenden Moduls (ENG-06 Akzeptanzkriterium 1): +// er muss zu dieser Server-Instanz passen UND von der Core-Tenant- +// Registry als echter, gefuehrter Tenant bestaetigt werden. Ein +// fehlgeschlagener Core-Aufruf gilt als ungueltig (fail closed), kein +// stillschweigendes Durchwinken. +func (s *Server) tenantValid(ctx context.Context, tenantSlug string) bool { + if tenantSlug == "" || tenantSlug != s.ownTenantSlug { + return false + } + if s.tenants == nil { + return true + } + ok, err := s.tenants.VerifyTenant(ctx, tenantSlug) + if err != nil { + log.Printf("moduleadapter: tenant-registry-abgleich fehlgeschlagen tenant=%q: %v", tenantSlug, err) + return false + } + return ok +} + type registerRequest struct { ModuleName string `json:"module_name"` ObjectType string `json:"object_type"` Triggers []string `json:"triggers"` + TenantSlug string `json:"tenant_slug"` } func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { @@ -58,6 +91,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") return } + if !s.tenantValid(r.Context(), req.TenantSlug) { + log.Printf("moduleadapter: registrierung abgelehnt, unbekannter tenant-kontext modul=%s objekttyp=%s tenant=%q", req.ModuleName, req.ObjectType, req.TenantSlug) + writeError(w, http.StatusForbidden, "unbekannter tenant-kontext") + return + } reg, err := Register(r.Context(), s.pool, req.ModuleName, req.ObjectType, req.Triggers) if errors.Is(err, ErrDuplicateTriggers) { writeError(w, http.StatusConflict, "registrierung unveraendert, keine neue version noetig") diff --git a/workflow/internal/moduleadapter/handler_test.go b/workflow/internal/moduleadapter/handler_test.go index 859222c2..75fc692c 100644 --- a/workflow/internal/moduleadapter/handler_test.go +++ b/workflow/internal/moduleadapter/handler_test.go @@ -2,17 +2,34 @@ package moduleadapter import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" ) +// fakeVerifier simuliert Core TEN-01 fuer die Handler-Tests, ohne einen +// echten Core-Dienst zu benoetigen (Muster wie +// archive/internal/moduleadapter, RET-12). +type fakeVerifier struct { + validTenants map[string]bool + err error +} + +func (f fakeVerifier) VerifyTenant(_ context.Context, tenantSlug string) (bool, error) { + if f.err != nil { + return false, f.err + } + return f.validTenants[tenantSlug], nil +} + func newTestServer(t *testing.T) (*Server, string) { t.Helper() pool := testPool(t) token := "test-service-token-secret" - return NewServer(pool, token, "mandant-a"), token + verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}} + return NewServer(pool, token, "mandant-a", verifier), token } func TestHandleRegister_RequiresServiceToken(t *testing.T) { @@ -22,7 +39,7 @@ func TestHandleRegister_RequiresServiceToken(t *testing.T) { ts := httptest.NewServer(mux) defer ts.Close() - body, _ := json.Marshal(registerRequest{ModuleName: "dms-fiktiv", ObjectType: "invoice", Triggers: []string{"document.uploaded"}}) + body, _ := json.Marshal(registerRequest{ModuleName: "dms-fiktiv", ObjectType: "invoice", Triggers: []string{"document.uploaded"}, TenantSlug: "mandant-a"}) // Ohne Token: 401. resp, err := http.Post(ts.URL+"/register", "application/json", bytes.NewReader(body)) @@ -68,7 +85,7 @@ func TestHandleRegisterAndList_EndToEnd(t *testing.T) { ts := httptest.NewServer(mux) defer ts.Close() - body, _ := json.Marshal(registerRequest{ModuleName: "mail-fiktiv", ObjectType: "message", Triggers: []string{"mail.received"}}) + body, _ := json.Marshal(registerRequest{ModuleName: "mail-fiktiv", ObjectType: "message", Triggers: []string{"mail.received"}, TenantSlug: "mandant-a"}) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/register", bytes.NewReader(body)) req.Header.Set("X-Service-Token", token) req.Header.Set("Content-Type", "application/json") @@ -96,3 +113,112 @@ func TestHandleRegisterAndList_EndToEnd(t *testing.T) { t.Fatalf("erwarte 1 registrierung fuer mail-fiktiv, habe %+v", regs) } } + +// ENG-06 Pruefung 1: Registrierung mit gueltigem, existierendem +// Tenant-Kontext wird uebernommen. +func TestHandleRegister_ValidTenantContextAccepted(t *testing.T) { + srv, token := newTestServer(t) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + + body, _ := json.Marshal(registerRequest{ModuleName: "dms-fiktiv", ObjectType: "invoice", Triggers: []string{"document.uploaded"}, TenantSlug: "mandant-a"}) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/register", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("registrieren: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwarte 200 fuer gueltigen tenant, habe %d", resp.StatusCode) + } +} + +// ENG-06 Pruefung 2: Registrierung mit abweichendem Tenant-Kontext +// (passt nicht zur Server-Instanz) wird abgelehnt und nicht uebernommen. +func TestHandleRegister_ForeignTenantSlugRejected(t *testing.T) { + srv, token := newTestServer(t) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + + body, _ := json.Marshal(registerRequest{ModuleName: "dms-fiktiv", ObjectType: "invoice", Triggers: []string{"document.uploaded"}, TenantSlug: "mandant-fremd"}) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/register", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("registrieren: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("erwarte 403 fuer fremden tenant, habe %d", resp.StatusCode) + } + + if _, err := TriggersFor(context.Background(), srv.pool, "dms-fiktiv", "invoice"); err == nil { + t.Fatal("erwarte keine uebernommene registrierung fuer fremden tenant") + } +} + +// ENG-06 Pruefung 2 (Variante): die Server-Instanz ist zwar fuer +// "mandant-a" konfiguriert, aber die Core-Registry kennt diesen Tenant +// nicht (mehr) -- die Registrierung darf sich nicht allein auf den +// lokal konfigurierten Tenant-Slug verlassen, sondern muss unabhaengig +// gegen Core pruefen. +func TestHandleRegister_TenantUnknownToCoreRegistryRejected(t *testing.T) { + pool := testPool(t) + token := "test-service-token-secret" + verifier := fakeVerifier{validTenants: map[string]bool{}} + srv := NewServer(pool, token, "mandant-a", verifier) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + + body, _ := json.Marshal(registerRequest{ModuleName: "dms-fiktiv", ObjectType: "invoice", Triggers: []string{"document.uploaded"}, TenantSlug: "mandant-a"}) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/register", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("registrieren: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("erwarte 403 wenn core-registry tenant nicht bestaetigt, habe %d", resp.StatusCode) + } + + if _, err := TriggersFor(context.Background(), pool, "dms-fiktiv", "invoice"); err == nil { + t.Fatal("erwarte keine uebernommene registrierung ohne core-bestaetigung") + } +} + +// ENG-06 Regressionstest: Core-Registry-Aufruf schlaegt technisch fehl +// (Transportfehler) -> fail closed, keine Registrierung. +func TestHandleRegister_TenantRegistryCallFailsRejected(t *testing.T) { + pool := testPool(t) + token := "test-service-token-secret" + verifier := fakeVerifier{err: context.DeadlineExceeded} + srv := NewServer(pool, token, "mandant-a", verifier) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + + body, _ := json.Marshal(registerRequest{ModuleName: "dms-fiktiv", ObjectType: "invoice", Triggers: []string{"document.uploaded"}, TenantSlug: "mandant-a"}) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/register", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("registrieren: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("erwarte 403 bei fehlgeschlagenem core-aufruf (fail closed), habe %d", resp.StatusCode) + } +}