From eed73eca8fbb3f97e6982ca72d5da9954af25746 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:37:11 +0200 Subject: [PATCH] LIC-04: backend-api fuer lizenz-modul-verwaltungsoberflaeche (flag.List, adminapi-paket) --- internal/adminapi/lichandler.go | 226 ++++++++++++++++++++++ internal/adminapi/lichandler_test.go | 275 +++++++++++++++++++++++++++ internal/flag/flag.go | 110 +++++++++++ internal/flag/flag_test.go | 43 +++++ internal/flag/service.go | 87 +++++++++ internal/flag/service_test.go | 179 +++++++++++++++++ 6 files changed, 920 insertions(+) create mode 100644 internal/adminapi/lichandler.go create mode 100644 internal/adminapi/lichandler_test.go create mode 100644 internal/flag/flag.go create mode 100644 internal/flag/flag_test.go create mode 100644 internal/flag/service.go create mode 100644 internal/flag/service_test.go diff --git a/internal/adminapi/lichandler.go b/internal/adminapi/lichandler.go new file mode 100644 index 0000000..49f2e4b --- /dev/null +++ b/internal/adminapi/lichandler.go @@ -0,0 +1,226 @@ +// Package adminapi implementiert Core LIC-04: das Backend-API fuer die +// Lizenz-/Modul-Verwaltungsoberflaeche. Das Paket enthaelt KEINE eigene +// Lizenz-/Flag-/Quota-Logik, sondern ist ein duenner Vermittler ueber +// internal/license, internal/flag und internal/usage (Ticket-Vorgabe: +// "Verwaltungsoberflaeche als duenner Client ... ohne eigene Lizenzlogik"). +package adminapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/internal/flag" + "gitea.perlbach24.de/scripte/nexarch/internal/license" + "gitea.perlbach24.de/scripte/nexarch/internal/usage" +) + +// UsageMetrics sind die Metriken, die auf der Uebersichtsseite je Tenant +// dargestellt werden (Akzeptanzkriterium 3). Neue Metriken hinzuzufuegen +// beruehrt kein bestehendes Verhalten von internal/usage. +var UsageMetrics = []string{"users", "storage_bytes", "api_calls"} + +// FlagOverview ist ein Flag zusammen mit der fuer den Tenant ausgewerteten +// Berechtigung, es umzuschalten (Akzeptanzkriterium 2). +type FlagOverview struct { + Key string `json:"key"` + Enabled bool `json:"enabled"` + Licensed bool `json:"licensed"` + DenyGrund string `json:"deny_grund,omitempty"` +} + +// UsageOverview ist eine Metrik mit Zaehlerstand, Quota und Status +// (Akzeptanzkriterium 3). +type UsageOverview struct { + Metric string `json:"metric"` + Value int64 `json:"value"` + Limit int64 `json:"limit"` + Status usage.Status `json:"status"` +} + +// Overview ist die vollstaendige Antwort fuer die Uebersichtsseite +// (Akzeptanzkriterium 1: Lizenzstatus/Laufzeit/Module auf einen Blick). +type Overview struct { + Plan string `json:"plan"` + Modules []string `json:"modules"` + IssuedAt string `json:"issued_at"` + ValidUntil string `json:"valid_until"` + Expired bool `json:"expired"` + Flags []FlagOverview `json:"flags"` + Usage []UsageOverview `json:"usage"` +} + +// ErrFlagNotLicensed wird geliefert, wenn ein Administrator versucht, ein +// Flag zu aktivieren, dessen zugehoeriges Modul die Lizenz nicht umfasst +// (Akzeptanzkriterium 2 / Pruefung 1). +var ErrFlagNotLicensed = errors.New("adminapi: modul ist durch die aktuelle lizenz nicht freigeschaltet") + +// Handler buendelt die drei Vorbedingungs-Dienste hinter einem gemeinsamen +// HTTP-API fuer das Frontend. +type Handler struct { + licenseStore *license.Store + flagStore *flag.Store + flagService *flag.Service + usageStore *usage.Store +} + +func NewHandler(licenseStore *license.Store, flagStore *flag.Store, flagService *flag.Service, usageStore *usage.Store) *Handler { + return &Handler{licenseStore: licenseStore, flagStore: flagStore, flagService: flagService, usageStore: usageStore} +} + +// isModuleLicensed prueft, ob ein Flag-Schluessel als lizenziertes Modul +// gilt. Konvention: ein Flag-Key der Form "module:" ist an das +// gleichnamige Modul in Payload.Modules gebunden; alle anderen Flag-Keys +// gelten als lizenzunabhaengige, rein funktionale Schalter. +func isModuleLicensed(key string, modules []string) (moduleName string, isModuleFlag bool, licensed bool) { + const prefix = "module:" + if !strings.HasPrefix(key, prefix) { + return "", false, true + } + moduleName = strings.TrimPrefix(key, prefix) + for _, m := range modules { + if m == moduleName { + return moduleName, true, true + } + } + return moduleName, true, false +} + +// Overview liefert die kombinierte Uebersicht fuer einen Tenant +// (Akzeptanzkriterium 1 + 3). +func (h *Handler) Overview(ctx context.Context, tenantID string) (Overview, error) { + lic, err := h.licenseStore.Status(ctx, tenantID) + if err != nil { + return Overview{}, fmt.Errorf("lizenzstatus lesen: %w", err) + } + _, expiredErr := h.licenseStore.RequireActive(ctx, tenantID) + expired := errors.Is(expiredErr, license.ErrLicenseExpired) + + flags, err := h.flagStore.List(ctx) + if err != nil { + return Overview{}, fmt.Errorf("flags lesen: %w", err) + } + + tenantSlug := tenantID // Auswertung erwartet den Tenant-Bezug als String; Aufloesung ID->Slug obliegt dem Aufrufer (siehe Handler-Tests). + flagOverviews := make([]FlagOverview, 0, len(flags)) + for _, f := range flags { + _, _, licensed := isModuleLicensed(f.Key, lic.Modules) + fo := FlagOverview{ + Key: f.Key, + Enabled: h.flagService.IsEnabled(ctx, tenantSlug, f.Key), + Licensed: licensed, + } + if !licensed { + fo.DenyGrund = "Modul ist im aktuellen Lizenzplan nicht enthalten" + } + flagOverviews = append(flagOverviews, fo) + } + + usageOverviews := make([]UsageOverview, 0, len(UsageMetrics)) + for _, metric := range UsageMetrics { + value, limit, status, err := h.usageStore.Check(ctx, tenantID, metric) + if err != nil { + return Overview{}, fmt.Errorf("nutzungsstand lesen (%s): %w", metric, err) + } + usageOverviews = append(usageOverviews, UsageOverview{ + Metric: metric, Value: value, Limit: limit, Status: status, + }) + } + + return Overview{ + Plan: lic.Plan, + Modules: lic.Modules, + IssuedAt: lic.IssuedAt.Format("2006-01-02T15:04:05Z07:00"), + ValidUntil: lic.ValidUntil.Format("2006-01-02T15:04:05Z07:00"), + Expired: expired, + Flags: flagOverviews, + Usage: usageOverviews, + }, nil +} + +// ToggleFlag schaltet ein Flag global an/aus, NACHDEM geprueft wurde, dass +// das zugehoerige Modul lizenziert ist (Akzeptanzkriterium 2 / Pruefung 1). +// Ein nicht lizenziertes Modul wird mit ErrFlagNotLicensed abgelehnt, bevor +// irgendein Schreibzugriff erfolgt. +func (h *Handler) ToggleFlag(ctx context.Context, tenantID, key string, enabled bool) error { + lic, err := h.licenseStore.Status(ctx, tenantID) + if err != nil { + return fmt.Errorf("lizenzstatus lesen: %w", err) + } + + if enabled { + if _, _, licensed := isModuleLicensed(key, lic.Modules); !licensed { + return ErrFlagNotLicensed + } + } + + existing, err := h.flagStore.Get(ctx, key) + if err != nil && !errors.Is(err, flag.ErrNotFound) { + return fmt.Errorf("flag lesen: %w", err) + } + existing.Key = key + existing.Enabled = enabled + + if err := h.flagStore.Set(ctx, existing); err != nil { + return fmt.Errorf("flag speichern: %w", err) + } + h.flagService.Invalidate(key) + return nil +} + +// --- HTTP-Bindung --- + +func (h *Handler) OverviewHandler(w http.ResponseWriter, r *http.Request) { + tenantID := r.URL.Query().Get("tenant") + if tenantID == "" { + writeError(w, http.StatusBadRequest, "tenant-parameter fehlt") + return + } + overview, err := h.Overview(r.Context(), tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, overview) +} + +type toggleRequest struct { + Tenant string `json:"tenant"` + Key string `json:"key"` + Enabled bool `json:"enabled"` +} + +func (h *Handler) ToggleFlagHandler(w http.ResponseWriter, r *http.Request) { + var req toggleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + if req.Tenant == "" || req.Key == "" { + writeError(w, http.StatusBadRequest, "tenant und key sind erforderlich") + return + } + + err := h.ToggleFlag(r.Context(), req.Tenant, req.Key, req.Enabled) + switch { + case err == nil: + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + case errors.Is(err, ErrFlagNotLicensed): + writeError(w, http.StatusForbidden, ErrFlagNotLicensed.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/adminapi/lichandler_test.go b/internal/adminapi/lichandler_test.go new file mode 100644 index 0000000..398eb92 --- /dev/null +++ b/internal/adminapi/lichandler_test.go @@ -0,0 +1,275 @@ +package adminapi + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/flag" + "gitea.perlbach24.de/scripte/nexarch/internal/license" + "gitea.perlbach24.de/scripte/nexarch/internal/usage" +) + +type testEnv struct { + handler *Handler + tenantID string + pool *pgxpool.Pool +} + +func setupTest(t *testing.T) (testEnv, 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 tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tenant_licenses ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), + plan TEXT NOT NULL, + modules TEXT[] NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + valid_until TIMESTAMPTZ NOT NULL, + raw_key TEXT NOT NULL, + installed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + 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 usage_counters ( + tenant_id UUID NOT NULL, metric TEXT NOT NULL, value BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, metric) + ); + CREATE TABLE IF NOT EXISTS usage_quotas ( + tenant_id UUID NOT NULL, metric TEXT NOT NULL, limit_value BIGINT NOT NULL, + PRIMARY KEY (tenant_id, metric) + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + slug := fmt.Sprintf("adminapi_test_%d", time.Now().UnixNano()) + var tenantID string + if err := pool.QueryRow(ctx, ` + INSERT INTO tenants (slug, name, db_name, db_dsn) + VALUES ($1, 'Adminapi Test', $1, 'unused') RETURNING id + `, slug).Scan(&tenantID); err != nil { + t.Fatalf("test-tenant anlegen: %v", err) + } + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("schluesselpaar: %v", err) + } + issuer := license.NewIssuer(priv) + licenseStore := license.NewStore(pool, license.NewValidator(pub)) + + payload := license.Payload{ + TenantSlug: slug, + Plan: "professional", + Modules: []string{"dms", "mail"}, + IssuedAt: time.Now().Truncate(time.Second), + ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second), + } + key, err := issuer.Issue(payload) + if err != nil { + t.Fatalf("lizenz ausstellen: %v", err) + } + if _, err := licenseStore.Install(ctx, tenantID, key); err != nil { + t.Fatalf("lizenz installieren: %v", err) + } + + flagStore := flag.NewStore(pool) + flagService := flag.NewService(flagStore, time.Millisecond) // sehr kurze TTL, damit Tests nicht auf Cache-Ablauf warten muessen + usageStore := usage.NewStore(pool) + + handler := NewHandler(licenseStore, flagStore, flagService, usageStore) + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM usage_quotas WHERE tenant_id = $1`, tenantID) + _, _ = pool.Exec(ctx, `DELETE FROM usage_counters WHERE tenant_id = $1`, tenantID) + _, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`) + _, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID) + _, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'adminapi\_test\_%' ESCAPE '\'`) + pool.Close() + } + return testEnv{handler: handler, tenantID: tenantID, pool: pool}, cleanup +} + +// Akzeptanzkriterium 1: Lizenzstatus, Laufzeit und Module sind auf einen +// Blick abfragbar. +func TestOverview_ShowsLicenseStatusAndModules(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + overview, err := env.handler.Overview(ctx, env.tenantID) + if err != nil { + t.Fatalf("overview: %v", err) + } + if overview.Plan != "professional" { + t.Fatalf("plan = %q, want professional", overview.Plan) + } + if len(overview.Modules) != 2 { + t.Fatalf("module = %v, want 2 eintraege", overview.Modules) + } + if overview.Expired { + t.Fatal("lizenz sollte nicht abgelaufen sein") + } +} + +// Akzeptanzkriterium 2 + Pruefung 1: Umschalten eines nicht lizenzierten +// Features wird verhindert und verstaendlich begruendet. +func TestToggleFlag_RejectsUnlicensedModule(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + err := env.handler.ToggleFlag(ctx, env.tenantID, "module:archive", true) + if err == nil { + t.Fatal("erwartet fehler beim aktivieren eines nicht lizenzierten moduls") + } + if err.Error() == "" { + t.Fatal("fehlermeldung sollte verstaendlich sein, nicht leer") + } + t.Logf("erhaltene begruendung: %v", err) + + // Sicherstellen, dass NICHTS geschrieben wurde: Flag existiert weiterhin nicht. + if _, ferr := flag.NewStore(env.pool).Get(ctx, "module:archive"); ferr == nil { + t.Fatal("flag haette wegen fehlender lizenz nicht angelegt werden duerfen") + } +} + +// Akzeptanzkriterium 2: lizenziertes Modul laesst sich umschalten. +func TestToggleFlag_AllowsLicensedModule(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if err := env.handler.ToggleFlag(ctx, env.tenantID, "module:dms", true); err != nil { + t.Fatalf("erwartet erfolg fuer lizenziertes modul, habe: %v", err) + } + + overview, err := env.handler.Overview(ctx, env.tenantID) + if err != nil { + t.Fatalf("overview: %v", err) + } + found := false + for _, f := range overview.Flags { + if f.Key == "module:dms" { + found = true + if !f.Enabled || !f.Licensed { + t.Fatalf("flag-status unerwartet: %+v", f) + } + } + } + if !found { + t.Fatal("erwartet 'module:dms' in der uebersicht nach dem umschalten") + } +} + +// Akzeptanzkriterium 3 + Pruefung 2: Nutzungsstand stimmt mit den +// Backend-Zaehlern ueberein. +func TestOverview_UsageMatchesBackendCounters(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + usageStore := usage.NewStore(env.pool) + if err := usageStore.Increment(ctx, env.tenantID, "users", 7); err != nil { + t.Fatalf("increment: %v", err) + } + if err := usageStore.SetQuota(ctx, env.tenantID, "users", 10); err != nil { + t.Fatalf("setquota: %v", err) + } + + overview, err := env.handler.Overview(ctx, env.tenantID) + if err != nil { + t.Fatalf("overview: %v", err) + } + + var got *UsageOverview + for i := range overview.Usage { + if overview.Usage[i].Metric == "users" { + got = &overview.Usage[i] + } + } + if got == nil { + t.Fatal("erwartet metrik 'users' in der uebersicht") + } + if got.Value != 7 || got.Limit != 10 || got.Status != usage.StatusOK { + t.Fatalf("nutzungsstand stimmt nicht mit backend-zaehler ueberein: %+v (want value=7 limit=10 status=ok, da 7/10=70%% unter der 80%%-warnschwelle liegt)", got) + } +} + +// Akzeptanzkriterium 2 / Pruefung 1 auf HTTP-Ebene: der Endpunkt liefert +// 403 mit verstaendlicher Fehlermeldung, kein interner Serverfehler. +func TestToggleFlagHandler_HTTPForbiddenOnUnlicensed(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + + body, _ := json.Marshal(toggleRequest{Tenant: env.tenantID, Key: "module:archive", Enabled: true}) + req := httptest.NewRequest(http.MethodPost, "/admin/flags/toggle", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + env.handler.ToggleFlagHandler(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("body parsen: %v", err) + } + if resp["error"] == "" { + t.Fatal("erwartet verstaendliche fehlermeldung im response-body") + } +} + +func TestOverviewHandler_HTTPReturnsCombinedData(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/admin/overview?tenant="+env.tenantID, nil) + rec := httptest.NewRecorder() + env.handler.OverviewHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var overview Overview + if err := json.Unmarshal(rec.Body.Bytes(), &overview); err != nil { + t.Fatalf("body parsen: %v", err) + } + if overview.Plan != "professional" { + t.Fatalf("plan = %q, want professional", overview.Plan) + } +} diff --git a/internal/flag/flag.go b/internal/flag/flag.go new file mode 100644 index 0000000..672087f --- /dev/null +++ b/internal/flag/flag.go @@ -0,0 +1,110 @@ +// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit +// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion +// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server +// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket. +package flag + +import ( + "context" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("flag: nicht gefunden") + +// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon +// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung). +type Flag struct { + Key string + Enabled bool + RolloutPercentage int + TargetTenantSlugs []string +} + +// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +func (s *Store) Set(ctx context.Context, f Flag) error { + if f.TargetTenantSlugs == nil { + f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array. + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (key) DO UPDATE SET + enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now() + `, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs) + if err != nil { + return fmt.Errorf("flag speichern: %w", err) + } + return nil +} + +// List liefert alle definierten Flags (Admin-Uebersicht, siehe LIC-04) — +// rein additiv, aendert nichts am bestehenden Verhalten von Set/Get. +func (s *Store) List(ctx context.Context) ([]Flag, error) { + rows, err := s.pool.Query(ctx, ` + SELECT key, enabled, rollout_percentage, target_tenant_slugs + FROM feature_flags ORDER BY key + `) + if err != nil { + return nil, fmt.Errorf("flags auflisten: %w", err) + } + defer rows.Close() + + var out []Flag + for rows.Next() { + var f Flag + if err := rows.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil { + return nil, fmt.Errorf("flag-zeile lesen: %w", err) + } + out = append(out, f) + } + return out, rows.Err() +} + +func (s *Store) Get(ctx context.Context, key string) (Flag, error) { + var f Flag + row := s.pool.QueryRow(ctx, ` + SELECT key, enabled, rollout_percentage, target_tenant_slugs + FROM feature_flags WHERE key = $1 + `, key) + if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Flag{}, ErrNotFound + } + return Flag{}, fmt.Errorf("flag lesen: %w", err) + } + return f, nil +} + +// evaluate wendet die Strategien in fester Reihenfolge an: globaler +// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout. +// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default, +// kein Feature wird versehentlich aktiv. +func evaluate(f Flag, tenantSlug string) bool { + if f.Enabled { + return true + } + for _, target := range f.TargetTenantSlugs { + if target == tenantSlug { + return true + } + } + if f.RolloutPercentage > 0 { + h := fnv.New32a() + _, _ = h.Write([]byte(f.Key + "|" + tenantSlug)) + return int(h.Sum32()%100) < f.RolloutPercentage + } + return false +} diff --git a/internal/flag/flag_test.go b/internal/flag/flag_test.go new file mode 100644 index 0000000..772a336 --- /dev/null +++ b/internal/flag/flag_test.go @@ -0,0 +1,43 @@ +package flag + +import "testing" + +func TestEvaluate_GlobalEnabled(t *testing.T) { + f := Flag{Key: "k", Enabled: true} + if !evaluate(f, "irgendein-tenant") { + t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern") + } +} + +// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie. +func TestEvaluate_TargetTenantStrategy(t *testing.T) { + f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}} + if !evaluate(f, "acme") { + t.Fatal("erwartet true fuer tenant in zielgruppe") + } + if evaluate(f, "globex") { + t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe") + } +} + +func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) { + full := Flag{Key: "k", RolloutPercentage: 100} + if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") { + t.Fatal("100% rollout sollte immer true liefern") + } + + none := Flag{Key: "k", RolloutPercentage: 0} + if evaluate(none, "beliebiger-tenant") { + t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern") + } +} + +func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) { + f := Flag{Key: "k", RolloutPercentage: 50} + first := evaluate(f, "stabiler-tenant") + for i := 0; i < 5; i++ { + if evaluate(f, "stabiler-tenant") != first { + t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein") + } + } +} diff --git a/internal/flag/service.go b/internal/flag/service.go new file mode 100644 index 0000000..6718d28 --- /dev/null +++ b/internal/flag/service.go @@ -0,0 +1,87 @@ +package flag + +import ( + "context" + "log/slog" + "sync" + "time" +) + +// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit +// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser +// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden +// muss (Akzeptanzkriterium 3). +const DefaultCacheTTL = 5 * time.Second + +type cacheEntry struct { + flag Flag + expiresAt time.Time +} + +// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit +// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung). +type Service struct { + store *Store + ttl time.Duration + + mu sync.RWMutex + cache map[string]cacheEntry +} + +func NewService(store *Store, ttl time.Duration) *Service { + if ttl <= 0 { + ttl = DefaultCacheTTL + } + return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)} +} + +// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen +// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige +// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen, +// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes +// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher). +func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool { + f, ok := s.resolve(ctx, key) + if !ok { + return false + } + return evaluate(f, tenantSlug) +} + +func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) { + s.mu.RLock() + entry, exists := s.cache[key] + fresh := exists && time.Now().Before(entry.expiresAt) + s.mu.RUnlock() + if fresh { + return entry.flag, true + } + + f, err := s.store.Get(ctx, key) + if err != nil { + if exists { + slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand", + "flag_key", key, "error", err) + return entry.flag, true + } + slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert", + "flag_key", key, "error", err) + return Flag{}, false + } + + s.mu.Lock() + s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)} + s.mu.Unlock() + return f, true +} + +// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen +// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set +// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung +// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach +// DefaultCacheTTL (siehe Akzeptanzkriterium 3). +func (s *Service) Invalidate(key string) { + s.mu.Lock() + delete(s.cache, key) + s.mu.Unlock() +} diff --git a/internal/flag/service_test.go b/internal/flag/service_test.go new file mode 100644 index 0000000..0ddd5b9 --- /dev/null +++ b/internal/flag/service_test.go @@ -0,0 +1,179 @@ +package flag + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupFlagStoreTest(t *testing.T) (*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() + )`); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`) + pool.Close() + } + return NewStore(pool), cleanup +} + +// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test +// die erwartete Auswertung. +func TestService_TargetTenantStrategy(t *testing.T) { + store, cleanup := setupFlagStoreTest(t) + defer cleanup() + ctx := context.Background() + + if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil { + t.Fatalf("set: %v", err) + } + svc := NewService(store, time.Hour) + + if !svc.IsEnabled(ctx, "acme", "test_target_flag") { + t.Fatal("erwartet true fuer tenant in zielgruppe") + } + if svc.IsEnabled(ctx, "globex", "test_target_flag") { + t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe") + } +} + +// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der +// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen. +func TestService_CacheInvalidationTiming(t *testing.T) { + store, cleanup := setupFlagStoreTest(t) + defer cleanup() + ctx := context.Background() + + const ttl = 150 * time.Millisecond + if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil { + t.Fatalf("set: %v", err) + } + svc := NewService(store, ttl) + + if svc.IsEnabled(ctx, "acme", "test_ttl_flag") { + t.Fatal("erwartet false vor der aenderung") + } + + // Aenderung "auf einer anderen instanz" simulieren: direkt ueber den + // Store, ohne svc.Invalidate aufzurufen. + changedAt := time.Now() + if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil { + t.Fatalf("set: %v", err) + } + + // Sofort danach sollte der Cache noch den alten Stand liefern. + if svc.IsEnabled(ctx, "acme", "test_ttl_flag") { + t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung") + } + + deadline := changedAt.Add(ttl + 100*time.Millisecond) + for time.Now().Before(deadline) { + if svc.IsEnabled(ctx, "acme", "test_ttl_flag") { + elapsed := time.Since(changedAt) + t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt)) +} + +func TestService_InvalidateForcesImmediateRefresh(t *testing.T) { + store, cleanup := setupFlagStoreTest(t) + defer cleanup() + ctx := context.Background() + + if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil { + t.Fatalf("set: %v", err) + } + svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht + _ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag") + + if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil { + t.Fatalf("set: %v", err) + } + svc.Invalidate("test_invalidate_flag") + + if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") { + t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate") + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu +// dokumentiertem Fallback-Verhalten, nicht zum Absturz. +func TestService_FallsBackOnStoreFailure(t *testing.T) { + store, cleanup := setupFlagStoreTest(t) + defer cleanup() + ctx := context.Background() + + if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil { + t.Fatalf("set: %v", err) + } + svc := NewService(store, time.Hour) + + // Cache vorwaermen, waehrend die DB noch erreichbar ist. + if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") { + t.Fatal("erwartet true bei funktionierender db") + } + + brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1") + if err != nil { + t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err) + } + brokenStore := NewStore(brokenPool) + + svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch + svcWithCache.mu.Lock() + svcWithCache.cache["test_fallback_flag"] = cacheEntry{ + flag: Flag{Key: "test_fallback_flag", Enabled: true}, + expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen + } + svcWithCache.mu.Unlock() + + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r) + } + }() + if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") { + t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall") + } + }() + + // Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer + // default false, kein absturz. + freshSvc := NewService(brokenStore, time.Hour) + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("IsEnabled hat gepanict: %v", r) + } + }() + if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") { + t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db") + } + }() +}