276 lines
8.4 KiB
Go
276 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|