package license import ( "context" "crypto/ed25519" "errors" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func setupStoreTest(t *testing.T) (*Store, *Issuer, string, 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() ); `); err != nil { t.Fatalf("schema: %v", err) } var tenantID string if err := pool.QueryRow(ctx, ` INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused') RETURNING id `).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 := NewIssuer(priv) store := NewStore(pool, NewValidator(pub)) cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID) _, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID) pool.Close() } return store, issuer, tenantID, cleanup } // Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar. func TestStore_InstallAndStatus(t *testing.T) { store, issuer, tenantID, cleanup := setupStoreTest(t) defer cleanup() ctx := context.Background() payload := Payload{ TenantSlug: "lic_test_tenant", Plan: "enterprise", Modules: []string{"dms", "mail", "archive"}, 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("issue: %v", err) } if _, err := store.Install(ctx, tenantID, key); err != nil { t.Fatalf("install: %v", err) } status, err := store.Status(ctx, tenantID) if err != nil { t.Fatalf("status: %v", err) } if status.Plan != "enterprise" || len(status.Modules) != 3 { t.Fatalf("status unerwartet: %+v", status) } } func TestStore_InstallRejectsInvalidSignature(t *testing.T) { store, _, tenantID, cleanup := setupStoreTest(t) defer cleanup() ctx := context.Background() _, otherPriv, _ := ed25519.GenerateKey(nil) foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)}) if err != nil { t.Fatalf("issue: %v", err) } if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) { t.Fatalf("erwartet ErrInvalidSignature, habe %v", err) } } // Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem // eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz. func TestStore_RequireActive_DetectsExpiry(t *testing.T) { store, issuer, tenantID, cleanup := setupStoreTest(t) defer cleanup() ctx := context.Background() expired := Payload{ TenantSlug: "lic_test_tenant", Plan: "pro", Modules: []string{"dms"}, IssuedAt: time.Now().Add(-48 * time.Hour), ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen } key, err := issuer.Issue(expired) if err != nil { t.Fatalf("issue: %v", err) } // Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz // muss funktionieren (Ablauf wird erst bei Nutzung bewertet). if _, err := store.Install(ctx, tenantID, key); err != nil { t.Fatalf("install sollte trotz ablauf funktionieren: %v", err) } func() { defer func() { if r := recover(); r != nil { t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r) } }() if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) { t.Fatalf("erwartet ErrLicenseExpired, habe %v", err) } }() // Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive). status, err := store.Status(ctx, tenantID) if err != nil { t.Fatalf("status sollte trotz ablauf funktionieren: %v", err) } if status.Plan != "pro" { t.Fatalf("status unerwartet: %+v", status) } } func TestStore_RequireActive_NoLicense(t *testing.T) { store, _, tenantID, cleanup := setupStoreTest(t) defer cleanup() ctx := context.Background() if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) { t.Fatalf("erwartet ErrNoLicense, habe %v", err) } }