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`, tenantID) _, _ = 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) } }