package moduleregistry import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/flag" ) func setupTest(t *testing.T) (*Registry, *flag.Store, 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() ); CREATE TABLE IF NOT EXISTS module_credentials ( module_name TEXT PRIMARY KEY REFERENCES modules(name), client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL, issued_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } flagStore := flag.NewStore(pool) // Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an // Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate // zuverlaessig sehen. flagService := flag.NewService(flagStore, 10*time.Millisecond) registry := NewRegistry(pool, flagService) cleanup := func() { pool.Close() } return registry, flagStore, cleanup } func uniqueModuleName(t *testing.T) string { return fmt.Sprintf("dms_%d", time.Now().UnixNano()) } // Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen. func TestRegister_RejectsMissingFields(t *testing.T) { registry, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) { t.Fatalf("erwartet ErrMissingName, habe %v", err) } if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) { t.Fatalf("erwartet ErrMissingVersion, habe %v", err) } } func TestRegister_AndGet(t *testing.T) { registry, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() name := uniqueModuleName(t) m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"}) if err != nil { t.Fatalf("register: %v", err) } if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 { t.Fatalf("unerwartet: %+v", m) } got, err := registry.Get(ctx, name) if err != nil { t.Fatalf("get: %v", err) } if got.Version != "1.2.0" { t.Fatalf("get version = %q", got.Version) } } // Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach // Aktivierung/Deaktivierung eines Moduls. func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) { registry, flagStore, cleanup := setupTest(t) defer cleanup() ctx := context.Background() name := uniqueModuleName(t) flagKey := name + "_enabled" if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil { t.Fatalf("register: %v", err) } active, err := registry.IsActive(ctx, "acme", name) if err != nil { t.Fatalf("is active (vor flag): %v", err) } if active { t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist") } if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil { t.Fatalf("flag setzen: %v", err) } time.Sleep(20 * time.Millisecond) // TTL abwarten active, err = registry.IsActive(ctx, "acme", name) if err != nil { t.Fatalf("is active (nach flag an): %v", err) } if !active { t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde") } if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil { t.Fatalf("flag zuruecksetzen: %v", err) } time.Sleep(20 * time.Millisecond) // TTL abwarten active, err = registry.IsActive(ctx, "acme", name) if err != nil { t.Fatalf("is active (nach flag aus): %v", err) } if active { t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde") } } func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) { registry, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() active, err := registry.IsActive(ctx, "acme", "nie-registriert") if err != nil { t.Fatalf("is active: %v", err) } if active { t.Fatal("unregistriertes modul darf nie aktiv sein") } } // Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird // zentral abgewiesen, BEVOR die Modul-Logik erreicht wird. func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) { registry, flagStore, cleanup := setupTest(t) defer cleanup() ctx := context.Background() name := uniqueModuleName(t) flagKey := name + "_enabled" if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil { t.Fatalf("register: %v", err) } handlerReached := false handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) { handlerReached = true w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil) rec := httptest.NewRecorder() handler(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } if handlerReached { t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen") } if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil { t.Fatalf("flag setzen: %v", err) } req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil) rec2 := httptest.NewRecorder() handler(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code) } if !handlerReached { t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen") } } // Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential. func TestProvisionAndAuthenticate(t *testing.T) { registry, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() name := uniqueModuleName(t) if _, err := registry.Register(ctx, name, "1.0", nil); err != nil { t.Fatalf("register: %v", err) } clientID, secret, err := registry.Provision(ctx, name) if err != nil { t.Fatalf("provision: %v", err) } if clientID == "" || secret == "" { t.Fatal("erwartet nicht-leere client-id/secret") } moduleName, ok, err := registry.Authenticate(ctx, clientID, secret) if err != nil { t.Fatalf("authenticate (korrekt): %v", err) } if !ok || moduleName != name { t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName) } _, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret") if err != nil { t.Fatalf("authenticate (falsch): %v", err) } if ok { t.Fatal("erwartet fehlschlag bei falschem secret") } _, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret) if err != nil { t.Fatalf("authenticate (unbekannt): %v", err) } if ok { t.Fatal("erwartet fehlschlag bei unbekannter client-id") } } func TestProvision_RequiresRegisteredModule(t *testing.T) { registry, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) { t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err) } } func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) { registry, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() name := uniqueModuleName(t) if _, err := registry.Register(ctx, name, "1.0", nil); err != nil { t.Fatalf("register: %v", err) } clientID, secret, err := registry.Provision(ctx, name) if err != nil { t.Fatalf("provision: %v", err) } handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) // Fehlendes Credential. req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil) rec := httptest.NewRecorder() handler(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("ohne credential: status = %d, want 401", rec.Code) } // Falsches Secret. req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil) req2.Header.Set("X-Client-Id", clientID) req2.Header.Set("X-Client-Secret", "falsch") rec2 := httptest.NewRecorder() handler(rec2, req2) if rec2.Code != http.StatusUnauthorized { t.Fatalf("falsches secret: status = %d, want 401", rec2.Code) } // Gueltiges Credential. req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil) req3.Header.Set("X-Client-Id", clientID) req3.Header.Set("X-Client-Secret", secret) rec3 := httptest.NewRecorder() handler(rec3, req3) if rec3.Code != http.StatusOK { t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code) } }