QA-06 (Connect): Schnittstellen-Vertragstests #319

Closed
patrick wants to merge 1 commits from feature/qa-06-schnittstellen-vertragstests-connect into feature/api-03-03-externe-webhook-abonnements-nutzt-core-api-07
pull from: feature/qa-06-schnittstellen-vertragstests-connect
4 changed files with 467 additions and 0 deletions

No files matched your search

@@ -0,0 +1,25 @@
name: Connect-Schnittstellen-Vertragstests
on:
push:
paths:
- "connect/internal/contracttest/**"
- "connect/internal/webhooksub/**"
- "connect/internal/webhookclient/**"
pull_request:
paths:
- "connect/internal/contracttest/**"
- "connect/internal/webhooksub/**"
- "connect/internal/webhookclient/**"
jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Vertragstests ausfuehren
working-directory: connect
run: go test ./internal/contracttest/... -v
+87
View File
@@ -0,0 +1,87 @@
# QA-06 – Prüfprotokoll: Schnittstellen-Vertragstests (Webhook-Abonnements, API-03)
Datum: 2026-10-16. Host: Entwicklungshost 192.168.1.204, Wegwerf-Postgres (Rolle/DB `qa06_connect_test`, zusätzlich isoliertes Schema pro Testlauf über `connect/internal/testdb`). Frischer Klon von `gitea.perlbach24.de/scripte/nexarch.git` nach `/root/qa-06-connect-work-1792157392`, Branch `feature/qa-06-schnittstellen-vertragstests`.
## Abhängigkeit API-03 (PR #316, noch nicht in main gemergt)
QA-06 prüft das Webhook-Abonnement-Format aus API-03. API-03 existiert nur auf dem Branch `feature/api-03-03-externe-webhook-abonnements-nutzt-core-api-07` (PR #316), nicht in `main`. Vorgehen: `git fetch origin`, dann `feature/qa-06-schnittstellen-vertragstests` direkt von `origin/feature/api-03-03-externe-webhook-abonnements-nutzt-core-api-07` abgezweigt (nicht von `main`) — `git merge-base origin/main origin/feature/api-03-...` bestätigte, dass dieser Branch exakt auf dem aktuellen `main`-Stand (`dac7fff5`) aufsetzt und nichts anderes zusätzlich enthält. Damit steht das tatsächlich ausgelieferte Webhook-Abonnement-Format (nicht nur eine Annahme davon) zur Verfügung. Der API-03-Branch wurde ausschließlich als Testgrundlage genutzt, nicht verändert (außer für einen einzigen, unten dokumentierten und wieder rückgängig gemachten Breaking-Change-Versuch) und nicht nach `main` gemerget.
## Ausgangslage: was "das tatsächlich ausgelieferte Format" hier bedeutet
Recherche im Code (nicht nur im Tickettext) ergab, dokumentiert bereits in `connect/docs/API-03-PRUEFPROTOKOLL.md`: Core API-07 (`internal/webhook`) implementiert Registrierung, Zustellung (Retry/Backoff) und HMAC-Signatur als reines Go-Paket — **es existiert kein HTTP-Handler auf Core-Seite**, der diese Funktionen für ein physisch getrenntes Modul wie Connect erreichbar macht (bestätigt per `grep` über den gesamten Checkout: `webhook.Store`/`webhook.Dispatcher` sind an keiner Stelle an einen `net/http`-Mux angeschlossen). Das ist eine bereits von API-03 selbst benannte, nicht in ihrem Scope behobene Core-seitige Lücke.
Für den Vertragskorpus bedeutet das zwei unterschiedlich belastbare Teile:
1. **Zustellungs-Transportvertrag (Core → Connect), zu 100 % real beidseitig vorhanden:** `internal/webhook/dispatcher.go` (`attemptOne`) sendet tatsächlich per HTTP POST, `Content-Type: application/json`, Header `X-Nexarch-Signature-256` = hex-kodiertes HMAC-SHA256 des Payloads. `connect/internal/webhooksub/delivery.go` (`handleDelivery`) ist der tatsächliche, produktionsnahe Empfänger dieses Formats. Beide Seiten existieren als echter Code im selben Checkout — hier konnte ein echter Ende-zu-Ende-Vertragstest gebaut werden (Vertrag 4 unten), inklusive eines real ausgeführten Breaking-Change-Beweises (siehe unten).
2. **Abonnement-Verwaltungsvertrag (Connect → Core, `POST`/`DELETE /webhooks/subscriptions`):** nur Connect-seitig real vorhanden (`connect/internal/webhookclient`). Ohne echten Core-HTTP-Endpunkt bleibt dieser Teil zwangsläufig ein von Connect dokumentierter, noch unbestätigter Vertrag ("Pact" im Sinne von Consumer-Driven Contracts: Connect als Konsument veröffentlicht die Erwartung, Core muss sie beim Bau des HTTP-Wrappers erfüllen). Die Tests prüfen hier die ECHTEN Bytes, die Connects echter `webhookclient.Client` sendet/dekodiert — nicht irgendeine freihändige Annahme — gegen einen festen, hier dokumentierten Feld-Vertragskorpus (Vertrag 1–3 unten). Dieser Teil kann erst nach Bau des Core-seitigen HTTP-Wrappers (core-kanban, außerhalb dieser Kachel) gegen eine echte Core-Instanz abschließend bestätigt werden — hier bereits explizit als offenes Risiko vermerkt, keine stille Vollständigkeitsbehauptung.
## Vertragskorpus (`connect/internal/contracttest`)
| # | Vertrag | Richtung | Pflichtfelder / Format |
|---|---|---|---|
| 1 | Subscribe-Request | Connect → Core | `tenant_slug`, `event_type`, `target_url`, `secret` (JSON-Body) |
| 2 | Subscribe-Response | Core → Connect | `id`, `event_type`, `target_url`, `created_at` (JSON-Body) |
| 3 | Unsubscribe-Request | Connect → Core | `DELETE /webhooks/subscriptions/{id}?tenant_slug=...` |
| 4 | Zustellung | Core → Connect | `POST`, `Content-Type: application/json`, Header `X-Nexarch-Signature-256` = hex(HMAC-SHA256(secret, body)) |
Neues Paket `connect/internal/contracttest` (gleiches Muster wie Core `internal/contracttest` / Archive `archive/internal/contracttest`): `RequireJSONFields` prüft Pflichtfelder einer JSON-Antwort/-Anfrage. 8 Tests in `contracttest_test.go`.
## Prüfungen vor der Abnahme
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Testlauf gegen aktuellen Stand grün | **Bestanden.** Alle 8 neuen Vertragstests grün, siehe Testlauf unten. Zusätzlich gesamtes `connect`-Modul (`go test ./... -p 1`, TEST_TENANT_DSN gesetzt): alle Pakete `ok`, keine Regression. |
| 2 | Simulierte Breaking Change erkannt | **Bestanden, real geprüft (nicht nur per Fixture).** Siehe "Realer Breaking-Change-Beweis" unten: Produktionscode (`connect/internal/webhooksub/delivery.go`, `SignatureHeader`-Konstante) real auf einen falschen Wert geändert, Vertragstests liefen erneut → 2 von 8 Tests schlugen exakt wie erwartet fehl, Rest blieb unberührt. Zusätzlich 2 Tests, die eine Breaking Change direkt über handgefertigte, bewusst gebrochene JSON-Fixtures nachweisen (`RequireJSONFields` erkennt umbenanntes/entferntes Feld zuverlässig) — gleiches Muster wie Core-QA-07/Archive-QA-06. |
| 3 | CI-Integration nachgewiesen | **Teilweise bestanden, siehe Einschränkung.** `.gitea/workflows/connect-contract-tests.yml` angelegt (identisches Muster wie `contract-tests.yml`/`archive-contract-tests.yml`, triggert auf `connect/internal/contracttest`, `webhooksub`, `webhookclient`, führt `go test ./internal/contracttest/... -v` im `connect`-Arbeitsverzeichnis aus). Die Gitea-Instanz hat einen echten, aktiven Actions-Runner (bestätigt über `GET /api/v1/repos/scripte/nexarch/actions/tasks` — echte, abgeschlossene Workflow-Läufe anderer Branches vorhanden). Da `git push` dieses Branches erwartungsgemäß fehlschlägt (siehe unten), konnte dieser konkrete Workflow-Lauf NICHT live auf der Gitea-Instanz ausgelöst werden. Ersatzweise lokal exakt den Workflow-Befehl ausgeführt (`cd connect && go test ./internal/contracttest/... -v`) — grün, siehe Testlauf. Die beiden Postgres-abhängigen Ende-zu-Ende-Tests (Vertrag 4) würden in der CI mangels `TEST_TENANT_DSN`/Postgres-Service übersprungen (`t.Skip`) — identische, bereits akzeptierte Einschränkung wie bei `contract-tests.yml`/`archive-contract-tests.yml` (kein Postgres-Service dort konfiguriert), keine Neuregression durch diese Kachel. |
## Realer Breaking-Change-Beweis (nicht nur Fixture-Simulation)
Zusätzlich zu den beiden Fixture-basierten "Detects Breaking Change"-Tests wurde der Mechanismus gegen eine ECHTE Codeänderung bewiesen:
```
sed -i 's/X-Nexarch-Signature-256/X-Nexarch-Signature-999/' connect/internal/webhooksub/delivery.go
go test ./internal/contracttest/... -v
FAIL TestContract_DeliverySignatureHeader_MatchesDocumentedName
FAIL TestContract_DeliveryEndToEnd_DocumentedSignature_Succeeds (bekam 401 statt 200)
PASS (restliche 6 Tests unverändert gruen, u.a. TestContract_DeliveryEndToEnd_DetectsBreakingChange_RenamedSignatureHeader)
git checkout -- connect/internal/webhooksub/delivery.go # Aenderung vollstaendig zurueckgenommen
go test ./... -p 1 # wieder 100% gruen, keine Regression
```
## Testlauf (real, Host 204, gegen echte Postgres-Testdatenbank)
```
gofmt -l internal/contracttest/ → keine Ausgabe (sauber)
go vet ./internal/contracttest/... → keine Ausgabe (sauber)
go build ./... → keine Ausgabe (sauber)
TEST_TENANT_DSN=postgresql://qa06_connect_test:***@localhost:5432/qa06_connect_test?sslmode=disable \
go test ./internal/contracttest/... -v
PASS TestContract_SubscribeRequest_SentFieldsMatchDocumentedCorpus
PASS TestContract_SubscribeRequest_DetectsBreakingChange_RenamedField
PASS TestContract_SubscribeResponse_RealStructMarshalsDocumentedFields
PASS TestContract_SubscribeResponse_DetectsBreakingChange_RemovedField
PASS TestContract_UnsubscribeRequest_MethodPathAndQueryMatchDocumentedCorpus
PASS TestContract_DeliverySignatureHeader_MatchesDocumentedName
PASS TestContract_DeliveryEndToEnd_DocumentedSignature_Succeeds
PASS TestContract_DeliveryEndToEnd_DetectsBreakingChange_RenamedSignatureHeader
ok connect/internal/contracttest 0.33s–0.37s (mehrere Laeufe)
go test ./... -p 1 (gesamtes connect-Modul, TEST_TENANT_DSN gesetzt)
→ alle Pakete ok, kein FAIL, keine Regression gegenüber API-03-Ausgangsstand
```
## Nicht Bestandteil dieser Kachel
- Core-seitiger HTTP-Wrapper für API-07 (`Store.Subscribe`/`Unsubscribe` über echten Endpunkt) — bereits von API-03 als Core-seitige Lücke benannt, gehört ins core-kanban-Board. Solange dieser fehlt, bleibt der Abonnement-Verwaltungsvertrag (Vertrag 1–3) ein von Connect veröffentlichter Pact statt eines beidseitig bestätigten Vertrags — oben transparent als Einschränkung dokumentiert, nicht stillschweigend als vollständig geprüft behauptet.
- Keine Änderung an API-03s Produktionscode (außer dem oben dokumentierten, vollständig zurückgenommenen Breaking-Change-Test).
- Kein Merge, kein Deploy.
## Durchgeführte Schritte (Zusammenfassung)
1. Frischer Klon nach `/root/qa-06-connect-work-1792157392`, `git fetch origin`, Branch von `origin/feature/api-03-03-externe-webhook-abonnements-nutzt-core-api-07` abgezweigt.
2. Baseline-Testlauf (`go test ./internal/webhooksub/... ./internal/webhookclient/... -v -p 1`) gegen frische Wegwerf-DB (`qa06_connect_test`) bestätigt: API-03-Ausgangsstand bereits grün.
3. `connect/internal/contracttest` angelegt (8 Vertragstests, Vertrag 1–4 oben).
4. Realer Breaking-Change-Beweis durchgeführt und zurückgenommen (siehe oben).
5. `.gitea/workflows/connect-contract-tests.yml` angelegt.
6. Gesamtes `connect`-Modul erneut getestet: grün, keine Regression.
@@ -0,0 +1,34 @@
// Package contracttest implementiert Connect QA-06: Vertragstests für das
// Webhook-Abonnement-Format aus API-03 (nutzt Core API-07). Anders als bei
// Core/Archive-Vertragstests sind hier EXTERNE Drittsysteme (Connect-
// Connectoren und — über Core — externe Zielsysteme) die Konsumenten eines
// Formats, dessen Gegenstück teils in einem physisch getrennten Go-Modul
// liegt (Core) und daher nicht importiert werden kann. Jeder Test prüft die
// TATSÄCHLICHE Ausgabe/Annahme echter Connect-Komponenten gegen den
// dokumentierten Vertragskorpus — eine entfernte, umbenannte oder
// andersartig kodierte Pflichteigenschaft lässt den jeweiligen Test
// fehlschlagen, BEVOR sie ein externes Drittsystem bricht
// (Akzeptanzkriterium 2). Gleiches Muster wie Core QA-07
// (internal/contracttest) und Archive QA-06 (archive/internal/contracttest).
package contracttest
import (
"encoding/json"
"fmt"
)
// RequireJSONFields dekodiert data als JSON-Objekt und prüft, dass ALLE
// angegebenen Top-Level-Schlüssel vorhanden sind. Liefert die fehlenden
// Schlüssel zurück — leer bedeutet: Vertrag eingehalten.
func RequireJSONFields(data []byte, required []string) (missing []string, err error) {
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("contracttest: antwort ist kein json-objekt: %w", err)
}
for _, field := range required {
if _, ok := decoded[field]; !ok {
missing = append(missing, field)
}
}
return missing, nil
}
@@ -0,0 +1,321 @@
package contracttest
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/webhookclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/webhooksub"
)
// --- Vertrag 1: Subscribe-Request-Format (Connect -> Core API-07) ---
// Akzeptanzkriterium 1 + Prüfung 1/2.
// documentedSubscribeRequestFields ist der feste Vertragskorpus für den
// Request-Body, den Connects echter webhookclient.Client beim Abonnieren
// an Core API-07 sendet (siehe connect/docs/QA-06-PRUEFPROTOKOLL.md).
var documentedSubscribeRequestFields = []string{"tenant_slug", "event_type", "target_url", "secret"}
// documentedSubscriptionResponseFields ist der feste Vertragskorpus für die
// Core-Antwort, die Connects webhookclient.Subscription dekodiert.
var documentedSubscriptionResponseFields = []string{"id", "event_type", "target_url", "created_at"}
func TestContract_SubscribeRequest_SentFieldsMatchDocumentedCorpus(t *testing.T) {
var capturedBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_, _ = buf.ReadFrom(r.Body)
capturedBody = buf.Bytes()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "core-sub-vertragstest", "event_type": "dms.document.archived",
"target_url": "https://connect.example/d/abc", "created_at": time.Now().UTC(),
})
}))
defer srv.Close()
client := webhookclient.New(srv.URL, "svc-token-vertragstest")
if _, err := client.Subscribe(context.Background(), "acme", "dms.document.archived", "https://connect.example/d/abc", "shh-secret"); err != nil {
t.Fatalf("Subscribe: %v", err)
}
missing, err := RequireJSONFields(capturedBody, documentedSubscribeRequestFields)
if err != nil {
t.Fatalf("gesendeter request-body nicht lesbar: %v", err)
}
if len(missing) != 0 {
t.Fatalf("subscribe-request fehlen vertrags-pflichtfelder: %v", missing)
}
}
// Prüfung 2: absichtlich simulierter Breaking Change (Feld "target_url"
// umbenannt zu "targetUrl") lässt den Vertragstest fehlschlagen.
func TestContract_SubscribeRequest_DetectsBreakingChange_RenamedField(t *testing.T) {
brokenRequest := []byte(`{"tenant_slug":"acme","event_type":"dms.document.archived","targetUrl":"https://connect.example/d/abc","secret":"shh"}`)
missing, err := RequireJSONFields(brokenRequest, documentedSubscribeRequestFields)
if err != nil {
t.Fatalf("prüfung selbst fehlgeschlagen: %v", err)
}
if len(missing) != 1 || missing[0] != "target_url" {
t.Fatalf("erwartet erkanntes umbenanntes feld 'target_url', habe: %v", missing)
}
}
// --- Vertrag 2: Subscribe-Response-Format (Core API-07 -> Connect) ---
// Akzeptanzkriterium 1 + Prüfung 1/2.
// TestContract_SubscribeResponse_RealStructMarshalsDocumentedFields prüft
// NICHT eine Annahme, sondern die TATSÄCHLICHE JSON-Kodierung von Connects
// echtem webhookclient.Subscription-Typ -- bricht, wenn jemand ein
// json-Tag in diesem Typ umbenennt oder entfernt, ohne den Vertrag
// gleichzeitig zu pflegen.
func TestContract_SubscribeResponse_RealStructMarshalsDocumentedFields(t *testing.T) {
sub := webhookclient.Subscription{
ID: "core-sub-1", EventType: "dms.document.archived",
TargetURL: "https://connect.example/d/abc", CreatedAt: time.Now().UTC(),
}
data, err := json.Marshal(sub)
if err != nil {
t.Fatalf("marshal: %v", err)
}
missing, err := RequireJSONFields(data, documentedSubscriptionResponseFields)
if err != nil {
t.Fatalf("kodierte subscription nicht lesbar: %v", err)
}
if len(missing) != 0 {
t.Fatalf("webhookclient.Subscription kodiert nicht mehr alle vertrags-pflichtfelder: %v", missing)
}
}
// Prüfung 2: absichtlich simulierter Breaking Change (Feld "event_type"
// in der Core-Antwort entfernt) lässt den Vertragstest fehlschlagen.
func TestContract_SubscribeResponse_DetectsBreakingChange_RemovedField(t *testing.T) {
brokenResponse := []byte(`{"id":"core-sub-1","target_url":"https://connect.example/d/abc","created_at":"2026-10-16T12:00:00Z"}`) // "event_type" fehlt absichtlich
missing, err := RequireJSONFields(brokenResponse, documentedSubscriptionResponseFields)
if err != nil {
t.Fatalf("prüfung selbst fehlgeschlagen: %v", err)
}
if len(missing) != 1 || missing[0] != "event_type" {
t.Fatalf("erwartet erkanntes fehlendes feld 'event_type', habe: %v", missing)
}
}
// --- Vertrag 3: Unsubscribe-Request-Format (Connect -> Core API-07) ---
// Akzeptanzkriterium 1 + Prüfung 1.
func TestContract_UnsubscribeRequest_MethodPathAndQueryMatchDocumentedCorpus(t *testing.T) {
var gotMethod, gotPath, gotTenantParam string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
gotTenantParam = r.URL.Query().Get("tenant_slug")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
client := webhookclient.New(srv.URL, "svc-token-vertragstest")
if err := client.Unsubscribe(context.Background(), "acme", "core-sub-1"); err != nil {
t.Fatalf("Unsubscribe: %v", err)
}
if gotMethod != http.MethodDelete {
t.Fatalf("methode = %q, dokumentierter vertrag verlangt DELETE", gotMethod)
}
if gotPath != "/webhooks/subscriptions/core-sub-1" {
t.Fatalf("pfad = %q, dokumentierter vertrag verlangt /webhooks/subscriptions/{id}", gotPath)
}
if gotTenantParam != "acme" {
t.Fatalf("query-parameter 'tenant_slug' = %q, dokumentierter vertrag verlangt den mandanten", gotTenantParam)
}
}
// --- Vertrag 4: Zustellungs-Transportvertrag (Core API-07 -> Connect) ---
// Akzeptanzkriterium 1/2 + Prüfung 1/2 — echter End-zu-Ende-Lauf über eine
// echte, isolierte Postgres-Testdatenbank und Connects echten
// Empfangs-Handler (webhooksub.Manager.RegisterRoutes), signiert mit dem
// dokumentierten Algorithmus (HMAC-SHA256, hex-kodiert, Header
// "X-Nexarch-Signature-256" — Core internal/webhook.SignatureHeader, hier
// erneut nachgebildet, weil Connect dieses Core-interne Paket als
// physisch getrenntes Go-Modul nicht importieren kann).
const documentedSignatureHeader = "X-Nexarch-Signature-256"
func sign(secret string, payload []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
func TestContract_DeliverySignatureHeader_MatchesDocumentedName(t *testing.T) {
if webhooksub.SignatureHeader != documentedSignatureHeader {
t.Fatalf("webhooksub.SignatureHeader = %q, dokumentierter vertrag verlangt %q — ein externes zustellendes system, das nach dem alten namen sendet, wird von connect sonst zurueckgewiesen", webhooksub.SignatureHeader, documentedSignatureHeader)
}
}
type fakeKEK struct{ kek []byte }
func (f *fakeKEK) TenantKEK(context.Context, string) ([]byte, error) { return f.kek, nil }
// fakeCoreWebhookAPI reicht fuer Vertrag 4 aus, um ein echtes, von Connect
// generiertes Geheimnis an Core "weiterzugeben" -- dasselbe, ausdruecklich
// erlaubte Stub-Muster wie in connect/internal/webhooksub/common_test.go
// (kein echter Core-HTTP-Endpunkt fuer API-07-Abonnements auf diesem
// Branch vorhanden, siehe QA-06-PRUEFPROTOKOLL.md).
func newFakeCoreForDeliveryContract() (*httptest.Server, func() string) {
var usedSecret string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in struct {
Secret string `json:"secret"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
usedSecret = in.Secret
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "core-sub-vertragstest", "event_type": "dms.document.archived",
"target_url": "unused", "created_at": time.Now().UTC(),
})
}))
return srv, func() string { return usedSecret }
}
// deliveryContractEnv buendelt alles, was Vertrag 4 braucht: einen echten
// Manager gegen eine echte, isolierte Postgres-Testdatenbank, verdrahtet
// mit dem fuer Vertrag 4 ausreichenden Core-Stub.
type deliveryContractEnv struct {
Manager *webhooksub.Manager
FakeCore *httptest.Server
coreSecret func() string
}
func setupDeliveryContractTest(t *testing.T) deliveryContractEnv {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(findMigrationsDir(t))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
fakeCore, coreSecret := newFakeCoreForDeliveryContract()
t.Cleanup(fakeCore.Close)
cryptoSvc := connectcrypto.NewService(&fakeKEK{kek: make([]byte, connectcrypto.KEKSize)})
mgr := webhooksub.NewManager(pool, cryptoSvc, webhookclient.New(fakeCore.URL, "svc-token-test"), "https://connect.example.test/connector-webhooks/deliveries")
return deliveryContractEnv{Manager: mgr, FakeCore: fakeCore, coreSecret: coreSecret}
}
func findMigrationsDir(t *testing.T) string {
t.Helper()
dir, _ := os.Getwd()
for i := 0; i < 6; i++ {
p := dir + "/migrations"
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
return p
}
dir += "/.."
}
t.Fatal("connect/migrations nicht gefunden")
return ""
}
// TestContract_DeliveryEndToEnd_DocumentedSignature_Succeeds beweist den
// Vertrag end-zu-ende: eine mit dem dokumentierten Algorithmus signierte
// Zustellung wird von Connects ECHTEM Handler angenommen und an die
// registrierte Aktion weitergereicht -- echte Postgres-Zeilen, echter
// HTTP-Roundtrip, kein Mock der geprüften Komponente selbst.
func TestContract_DeliveryEndToEnd_DocumentedSignature_Succeeds(t *testing.T) {
env := setupDeliveryContractTest(t)
var gotPayload []byte
env.Manager.RegisterAction("testcrm", "dms.document.archived", func(_ context.Context, payload []byte) error {
gotPayload = append([]byte(nil), payload...)
return nil
})
reg, err := env.Manager.Subscribe(context.Background(), "acme", "testcrm", "dms.document.archived")
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
mux := http.NewServeMux()
env.Manager.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
payload := []byte(`{"document_id":"doc-vertragstest","event":"archived"}`)
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/connector-webhooks/deliveries/"+reg.ID, bytes.NewReader(payload))
req.Header.Set(documentedSignatureHeader, sign(env.coreSecret(), payload))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("zustellung: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("erwartet 200 bei vertragskonformer zustellung, bekam %d", resp.StatusCode)
}
if string(gotPayload) != string(payload) {
t.Fatalf("connector-aktion erhielt abweichenden payload: %q", gotPayload)
}
}
// Prüfung 2: absichtlich simulierter Breaking Change -- die Signatur wird
// unter einem ANDEREN Headernamen gesendet (wie es ein hypothetischer, den
// Vertrag brechender Core-Umbau täte). Connects ECHTER Handler sucht nach
// dem dokumentierten Namen und lehnt die Zustellung deshalb ab -- der
// Vertragstest erkennt den Breaking Change zuverlässig, weil er echten
// Code tatsächlich ausführt statt nur Strings zu vergleichen.
func TestContract_DeliveryEndToEnd_DetectsBreakingChange_RenamedSignatureHeader(t *testing.T) {
env := setupDeliveryContractTest(t)
triggered := false
env.Manager.RegisterAction("testcrm", "dms.document.archived", func(context.Context, []byte) error {
triggered = true
return nil
})
reg, err := env.Manager.Subscribe(context.Background(), "acme", "testcrm", "dms.document.archived")
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
mux := http.NewServeMux()
env.Manager.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
payload := []byte(`{"document_id":"doc-vertragstest"}`)
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/connector-webhooks/deliveries/"+reg.ID, bytes.NewReader(payload))
req.Header.Set("X-Signature", sign(env.coreSecret(), payload)) // falscher, den vertrag brechender headername
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("zustellung: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("erwartet 401 bei vertragsbruch (umbenannter signaturheader), bekam %d -- breaking change wurde NICHT erkannt", resp.StatusCode)
}
if triggered {
t.Fatal("connector-aktion wurde trotz vertragsbruch (falscher headername) ausgeloest")
}
}