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) } }