From 196b48ce09ad59b8eef68e2235194975db3b75db Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 10:34:25 +0200 Subject: [PATCH] QA-07: schnittstellen-vertragstests fuer api-01/api-05/api-02/api-07 + gitea-actions-workflow --- .gitea/workflows/contract-tests.yml | 22 +++ internal/contracttest/contracttest.go | 57 ++++++ internal/contracttest/contracttest_test.go | 193 +++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 .gitea/workflows/contract-tests.yml create mode 100644 internal/contracttest/contracttest.go create mode 100644 internal/contracttest/contracttest_test.go diff --git a/.gitea/workflows/contract-tests.yml b/.gitea/workflows/contract-tests.yml new file mode 100644 index 0000000..fecd52d --- /dev/null +++ b/.gitea/workflows/contract-tests.yml @@ -0,0 +1,22 @@ +name: Core-Schnittstellen-Vertragstests + +on: + push: + paths: + - "internal/contracttest/**" + - "internal/apiserver/**" + - "internal/moduletrust/**" + - "internal/moduleregistry/**" + - "internal/webhook/**" + pull_request: {} + +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 + run: go test ./internal/contracttest/... -v diff --git a/internal/contracttest/contracttest.go b/internal/contracttest/contracttest.go new file mode 100644 index 0000000..f821644 --- /dev/null +++ b/internal/contracttest/contracttest.go @@ -0,0 +1,57 @@ +// Package contracttest implementiert Core QA-07: Vertragstests fuer die +// Core-Schnittstellen, die von den Fachmodulen (DMS/Mail/Archive/Workflow/ +// AI/Connect) konsumiert werden — REST-Fehlerschema (API-01), JWKS-/Rechte- +// Cache-Kontrakt (API-05), Modul-Registry (API-02) und Webhook-Zustellung +// (API-07). Jeder Test prueft die TATSAECHLICHE Antwort einer echten +// Core-Komponente gegen ihr dokumentiertes Schema — eine entfernte oder +// umbenannte Pflichteigenschaft laesst den jeweiligen Test fehlschlagen, +// BEVOR sie ein konsumierendes Modul bricht (Akzeptanzkriterium 2). +package contracttest + +import ( + "encoding/json" + "fmt" +) + +// RequireJSONFields dekodiert data als JSON-Objekt und prueft, dass ALLE +// angegebenen Top-Level-Schluessel vorhanden sind. Liefert die fehlenden +// Schluessel zurueck — 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 +} + +// RequireJSONArrayItemFields prueft, dass data ein JSON-Objekt mit einem +// Array-Feld arrayField ist, dessen ERSTES Element alle itemFields enthaelt +// — Vertrag fuer Listen-Antworten wie JWKS ("keys": [{...}]). +func RequireJSONArrayItemFields(data []byte, arrayField string, itemFields []string) (missing []string, err error) { + var decoded map[string]json.RawMessage + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, fmt.Errorf("contracttest: antwort ist kein json-objekt: %w", err) + } + raw, ok := decoded[arrayField] + if !ok { + return itemFields, nil // das array-feld selbst fehlt bereits -> alles "fehlend" + } + var items []map[string]any + if err := json.Unmarshal(raw, &items); err != nil { + return nil, fmt.Errorf("contracttest: feld %q ist kein array: %w", arrayField, err) + } + if len(items) == 0 { + return nil, fmt.Errorf("contracttest: feld %q ist leer, kann nicht gegen kontrakt geprueft werden", arrayField) + } + for _, field := range itemFields { + if _, ok := items[0][field]; !ok { + missing = append(missing, field) + } + } + return missing, nil +} diff --git a/internal/contracttest/contracttest_test.go b/internal/contracttest/contracttest_test.go new file mode 100644 index 0000000..88ac943 --- /dev/null +++ b/internal/contracttest/contracttest_test.go @@ -0,0 +1,193 @@ +package contracttest + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/apiserver" + "gitea.perlbach24.de/scripte/nexarch/internal/flag" + "gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry" + "gitea.perlbach24.de/scripte/nexarch/internal/moduletrust" + "gitea.perlbach24.de/scripte/nexarch/internal/webhook" +) + +// --- Vertrag 1: API-01 REST-Fehlerschema --- +// Akzeptanzkriterium 1/2 + Pruefung 1/2. + +func TestContract_API01_ErrorEnvelope(t *testing.T) { + rec := httptest.NewRecorder() + apiserver.WriteError(rec, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet") + + if missing, err := RequireJSONFields(rec.Body.Bytes(), []string{"error"}); err != nil { + t.Fatalf("antwort nicht lesbar: %v", err) + } else if len(missing) != 0 { + t.Fatalf("erwartet top-level feld 'error', fehlt: %v", missing) + } + + var decoded map[string]json.RawMessage + _ = json.Unmarshal(rec.Body.Bytes(), &decoded) + if missing, err := RequireJSONFields(decoded["error"], []string{"code", "message"}); err != nil { + t.Fatalf("error-objekt nicht lesbar: %v", err) + } else if len(missing) != 0 { + t.Fatalf("error-objekt fehlen pflichtfelder: %v", missing) + } +} + +// Pruefung 2: absichtlich simulierter Breaking Change (Feld "message" +// entfernt) laesst den Vertragstest fehlschlagen. +func TestContract_API01_DetectsBreakingChange_RemovedField(t *testing.T) { + brokenResponse := []byte(`{"error":{"code":"unauthenticated"}}`) // "message" fehlt absichtlich + var decoded map[string]json.RawMessage + if err := json.Unmarshal(brokenResponse, &decoded); err != nil { + t.Fatalf("fixture nicht lesbar: %v", err) + } + missing, err := RequireJSONFields(decoded["error"], []string{"code", "message"}) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "message" { + t.Fatalf("erwartet erkanntes fehlendes feld 'message', habe: %v", missing) + } +} + +// --- Vertrag 2: API-05 JWKS / Rechte-Cache-Kontrakt --- +// Akzeptanzkriterium 1/2 + Pruefung 1/2. + +func TestContract_API05_JWKS(t *testing.T) { + km, err := moduletrust.NewKeyManager() + if err != nil { + t.Fatalf("keymanager: %v", err) + } + if _, err := km.Rotate(); err != nil { + t.Fatalf("rotate: %v", err) + } + + rec := httptest.NewRecorder() + km.ServeJWKS(rec, httptest.NewRequest(http.MethodGet, "/jwks", nil)) + + missing, err := RequireJSONArrayItemFields(rec.Body.Bytes(), "keys", []string{"kid", "public_key"}) + if err != nil { + t.Fatalf("jwks-antwort verletzt vertrag: %v", err) + } + if len(missing) != 0 { + t.Fatalf("jwks-eintrag fehlen pflichtfelder: %v", missing) + } +} + +// Pruefung 2: simulierter Breaking Change — Feld "public_key" umbenannt +// (z.B. faelschlich zu "publicKey"), Vertragstest erkennt das fehlende +// Originalfeld zuverlaessig. +func TestContract_API05_DetectsBreakingChange_RenamedField(t *testing.T) { + brokenJWKS := []byte(`{"keys":[{"kid":"abc","publicKey":"base64..."}]}`) + missing, err := RequireJSONArrayItemFields(brokenJWKS, "keys", []string{"kid", "public_key"}) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "public_key" { + t.Fatalf("erwartet erkanntes umbenanntes feld 'public_key', habe: %v", missing) + } +} + +// --- Vertrag 3: API-02 Modul-Registry --- +// Akzeptanzkriterium 1/2 + Pruefung 1/2. + +func setupModuleRegistryTest(t *testing.T) (*moduleregistry.Registry, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS feature_flags ( + key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false, + rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS modules ( + name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''), + required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond) + return moduleregistry.NewRegistry(pool, flagService), func() { pool.Close() } +} + +func TestContract_API02_ModuleInfo(t *testing.T) { + registry, cleanup := setupModuleRegistryTest(t) + defer cleanup() + ctx := context.Background() + + m, err := registry.Register(ctx, "contracttest-dms", "1.0.0", []string{"some-flag"}) + if err != nil { + t.Fatalf("register: %v", err) + } + + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + missing, err := RequireJSONFields(data, []string{"Name", "Version", "RequiredFlags"}) + if err != nil { + t.Fatalf("modul-antwort nicht lesbar: %v", err) + } + if len(missing) != 0 { + t.Fatalf("modul-info fehlen pflichtfelder: %v", missing) + } +} + +// Pruefung 2: simulierter Breaking Change — Feld "RequiredFlags" entfernt. +func TestContract_API02_DetectsBreakingChange_RemovedField(t *testing.T) { + brokenModuleJSON := []byte(`{"Name":"dms","Version":"1.0.0"}`) + missing, err := RequireJSONFields(brokenModuleJSON, []string{"Name", "Version", "RequiredFlags"}) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "RequiredFlags" { + t.Fatalf("erwartet erkanntes fehlendes feld 'RequiredFlags', habe: %v", missing) + } +} + +// --- Vertrag 4: API-07 Webhook-Zustellung (HMAC-Signaturheader) --- +// Akzeptanzkriterium 1/2 + Pruefung 1/2. + +func TestContract_API07_WebhookSignatureHeader(t *testing.T) { + if webhook.SignatureHeader != "X-Nexarch-Signature-256" { + t.Fatalf("signaturheader-name = %q, want stabilen vertrag 'X-Nexarch-Signature-256' — konsumierende module lesen genau diesen namen", webhook.SignatureHeader) + } + + secret := "vertragstest-secret" + payload := []byte(`{"event":"test"}`) + signature := webhook.Sign(secret, payload) + + if !webhook.VerifySignature(secret, payload, signature) { + t.Fatal("konsumentenseitige signaturpruefung schlaegt fuer eine korrekte core-signatur fehl") + } +} + +// Pruefung 2: simulierter Breaking Change — eine Zustellung, die den +// Signaturheader unter einem ANDEREN Namen sendet (wie es ein +// hypothetischer, den Vertrag brechender Core-Umbau taete). Ein +// konsumierendes Modul, das nach dem DOKUMENTIERTEN Namen sucht, findet in +// diesem Fall nichts — der Vertragstest erkennt das zuverlaessig. +func TestContract_API07_DetectsBreakingChange_RenamedHeader(t *testing.T) { + brokenHeaders := http.Header{} + brokenHeaders.Set("X-Signature", webhook.Sign("secret", []byte("payload"))) // falscher name, simuliert breaking change + + if got := brokenHeaders.Get(webhook.SignatureHeader); got != "" { + t.Fatalf("erwartet leeren wert unter dem dokumentierten headernamen bei simuliertem breaking change, habe: %q", got) + } +}