Merge branch 'feature/qa-07-schnittstellen-vertragstests' into feature/qa-05-abnahme-compliance-pruefung-core
# Conflicts: # internal/apiserver/server.go # internal/flag/flag.go
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user