QA-06 (Connect): Schnittstellen-Vertragstests #319
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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user