LIC-04: backend-api fuer lizenz-modul-verwaltungsoberflaeche (flag.List, adminapi-paket)
This commit is contained in:
@@ -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:<name>" 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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user