New test files: - internal/ai/providers/gemini_test.go: Comprehensive Gemini provider tests - internal/api/ai_intelligence_handlers_test.go: AI intelligence endpoint tests - internal/api/ai_patrol_handlers_test.go: AI patrol endpoint tests - internal/api/license_handlers_test.go: License API handler tests - internal/api/security_oidc_response_test.go: OIDC response formatting tests - internal/config/ai_config_test.go: AI configuration function tests - internal/config/persistence_ai_test.go: AI config persistence tests - internal/config/persistence_extended_test.go: Extended persistence tests - internal/license/persistence_test.go: License persistence tests - internal/license/pubkey_test.go: Public key handling tests - internal/monitoring/host_agent_temps_test.go: Temperature processing tests Enhanced existing files: - internal/api/updates_test.go: Added update handler tests - internal/license/license_test.go: Added Service method tests Coverage improvements: - ai/providers: 57.3% -> 73.0% (+15.7%) - license: 78.3% -> 85.9% (+7.6%) - config: 49.7% -> 53.9% (+4.2%) - monitoring: 49.8% -> 50.8% (+1.0%) - api: 28.4% -> 29.8% (+1.4%)
720 lines
20 KiB
Go
720 lines
20 KiB
Go
package license
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// init sets dev mode for tests so license validation works without a real public key
|
|
func init() {
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
}
|
|
|
|
|
|
func TestTierHasFeature(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tier Tier
|
|
feature string
|
|
expected bool
|
|
}{
|
|
{"free has no AI patrol", TierFree, FeatureAIPatrol, false},
|
|
{"pro has AI patrol", TierPro, FeatureAIPatrol, true},
|
|
{"pro has AI alerts", TierPro, FeatureAIAlerts, true},
|
|
{"pro has AI autofix", TierPro, FeatureAIAutoFix, true},
|
|
{"pro has K8s AI", TierPro, FeatureKubernetesAI, true},
|
|
{"pro does not have multi-user", TierPro, FeatureMultiUser, false},
|
|
{"lifetime has AI patrol", TierLifetime, FeatureAIPatrol, true},
|
|
{"msp has unlimited", TierMSP, FeatureUnlimited, true},
|
|
{"msp does not have multi-user yet", TierMSP, FeatureMultiUser, false},
|
|
{"enterprise has multi-user", TierEnterprise, FeatureMultiUser, true},
|
|
{"enterprise has white-label", TierEnterprise, FeatureWhiteLabel, true},
|
|
{"unknown tier has nothing", Tier("unknown"), FeatureAIPatrol, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := TierHasFeature(tt.tier, tt.feature)
|
|
if result != tt.expected {
|
|
t.Errorf("TierHasFeature(%v, %v) = %v, want %v",
|
|
tt.tier, tt.feature, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLicenseHasFeature(t *testing.T) {
|
|
license := &License{
|
|
Claims: Claims{
|
|
Tier: TierPro,
|
|
Features: []string{"custom_feature"},
|
|
},
|
|
}
|
|
|
|
// Should have tier features
|
|
if !license.HasFeature(FeatureAIPatrol) {
|
|
t.Error("Pro license should have AI Patrol")
|
|
}
|
|
|
|
// Should have explicit features
|
|
if !license.HasFeature("custom_feature") {
|
|
t.Error("License should have explicitly granted feature")
|
|
}
|
|
|
|
// Should not have ungranted features
|
|
if license.HasFeature(FeatureMultiUser) {
|
|
t.Error("Pro license should not have multi-user")
|
|
}
|
|
}
|
|
|
|
func TestLicenseExpiration(t *testing.T) {
|
|
t.Run("lifetime license never expires", func(t *testing.T) {
|
|
license := &License{
|
|
Claims: Claims{
|
|
Tier: TierLifetime,
|
|
ExpiresAt: 0,
|
|
},
|
|
}
|
|
if license.IsExpired() {
|
|
t.Error("Lifetime license should not be expired")
|
|
}
|
|
if !license.IsLifetime() {
|
|
t.Error("Should be detected as lifetime")
|
|
}
|
|
if license.DaysRemaining() != -1 {
|
|
t.Error("Lifetime should return -1 days remaining")
|
|
}
|
|
})
|
|
|
|
t.Run("expired license", func(t *testing.T) {
|
|
license := &License{
|
|
Claims: Claims{
|
|
Tier: TierPro,
|
|
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(),
|
|
},
|
|
}
|
|
if !license.IsExpired() {
|
|
t.Error("License should be expired")
|
|
}
|
|
if license.DaysRemaining() != 0 {
|
|
t.Error("Expired license should return 0 days remaining")
|
|
}
|
|
})
|
|
|
|
t.Run("valid license", func(t *testing.T) {
|
|
license := &License{
|
|
Claims: Claims{
|
|
Tier: TierPro,
|
|
ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(),
|
|
},
|
|
}
|
|
if license.IsExpired() {
|
|
t.Error("License should not be expired")
|
|
}
|
|
remaining := license.DaysRemaining()
|
|
if remaining < 29 || remaining > 30 {
|
|
t.Errorf("Expected ~30 days remaining, got %d", remaining)
|
|
}
|
|
})
|
|
|
|
t.Run("grace period license", func(t *testing.T) {
|
|
// Create a license that expired 3 days ago (within 7-day grace period)
|
|
expiredAt := time.Now().Add(-3 * 24 * time.Hour).Unix()
|
|
testKey, _ := GenerateLicenseForTesting("test@example.com", TierPro, 0)
|
|
// Manually create claims with expired time for testing
|
|
claims := Claims{
|
|
LicenseID: "test_grace",
|
|
Email: "grace@example.com",
|
|
Tier: TierPro,
|
|
IssuedAt: time.Now().Add(-33 * 24 * time.Hour).Unix(),
|
|
ExpiresAt: expiredAt,
|
|
}
|
|
|
|
license := &License{
|
|
Raw: testKey,
|
|
Claims: claims,
|
|
}
|
|
|
|
// License is technically expired
|
|
if !license.IsExpired() {
|
|
t.Error("License should be expired")
|
|
}
|
|
|
|
// But with grace period set, it should still work
|
|
gracePeriodEnd := time.Now().Add(4 * 24 * time.Hour)
|
|
license.GracePeriodEnd = &gracePeriodEnd
|
|
|
|
// Service should recognize grace period
|
|
service := NewService()
|
|
service.mu.Lock()
|
|
service.license = license
|
|
service.mu.Unlock()
|
|
|
|
// Should still have features during grace period
|
|
if !service.HasFeature(FeatureAIPatrol) {
|
|
t.Error("Should have feature during grace period")
|
|
}
|
|
if !service.IsValid() {
|
|
t.Error("Should be valid during grace period")
|
|
}
|
|
|
|
// Status should show grace period
|
|
status := service.Status()
|
|
if !status.InGracePeriod {
|
|
t.Error("Status should show in grace period")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServiceFeatureGating(t *testing.T) {
|
|
service := NewService()
|
|
|
|
// No license - should not have features
|
|
if service.HasFeature(FeatureAIPatrol) {
|
|
t.Error("Should not have feature without license")
|
|
}
|
|
if service.IsValid() {
|
|
t.Error("Should not be valid without license")
|
|
}
|
|
|
|
// Activate test license
|
|
SetPublicKey(nil)
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate test license: %v", err)
|
|
}
|
|
|
|
// Clear public key for testing (since test licenses have fake signatures)
|
|
SetPublicKey(nil)
|
|
|
|
license, err := service.Activate(testKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to activate test license: %v", err)
|
|
}
|
|
|
|
if license.Claims.Email != "test@example.com" {
|
|
t.Error("Email mismatch")
|
|
}
|
|
if license.Claims.Tier != TierPro {
|
|
t.Error("Tier mismatch")
|
|
}
|
|
|
|
// Should now have Pro features
|
|
if !service.HasFeature(FeatureAIPatrol) {
|
|
t.Error("Should have AI Patrol with Pro license")
|
|
}
|
|
if !service.IsValid() {
|
|
t.Error("Should be valid with active license")
|
|
}
|
|
|
|
// Require feature should succeed
|
|
if err := service.RequireFeature(FeatureAIPatrol); err != nil {
|
|
t.Errorf("RequireFeature should succeed: %v", err)
|
|
}
|
|
|
|
// Require feature should fail for ungranted feature
|
|
if err := service.RequireFeature(FeatureMultiUser); err == nil {
|
|
t.Error("RequireFeature should fail for multi-user")
|
|
}
|
|
|
|
// Clear license
|
|
service.Clear()
|
|
if service.IsValid() {
|
|
t.Error("Should not be valid after clearing")
|
|
}
|
|
}
|
|
|
|
func TestValidateLicenseMalformed(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
licenseKey string
|
|
}{
|
|
{"empty", ""},
|
|
{"not jwt", "not-a-jwt"},
|
|
{"two parts", "part1.part2"},
|
|
{"bad base64 header", "!!!.part2.part3"},
|
|
{"bad base64 payload", "eyJhbGciOiJFZERTQSJ9.!!!.part3"},
|
|
{"bad base64 signature", "eyJhbGciOiJFZERTQSJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.!!!"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := ValidateLicense(tt.licenseKey)
|
|
if err == nil {
|
|
t.Error("Expected error for malformed license")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateLicense_RequiredFields(t *testing.T) {
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
defer os.Unsetenv("PULSE_LICENSE_DEV_MODE")
|
|
|
|
tests := []struct {
|
|
name string
|
|
claims map[string]interface{}
|
|
}{
|
|
{"missing id", map[string]interface{}{"email": "t@e.c", "tier": "pro"}},
|
|
{"missing email", map[string]interface{}{"lid": "test", "tier": "pro"}},
|
|
{"missing tier", map[string]interface{}{"lid": "test", "email": "t@e.c"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
|
|
payloadBytes, _ := json.Marshal(tt.claims)
|
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
key := header + "." + payload + ".fake-sig"
|
|
|
|
_, err := ValidateLicense(key)
|
|
if err == nil {
|
|
t.Error("Expected error for missing required fields")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateLicense_ExpiredPastGrace(t *testing.T) {
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
defer os.Unsetenv("PULSE_LICENSE_DEV_MODE")
|
|
|
|
claims := Claims{
|
|
LicenseID: "test-expired",
|
|
Email: "t@e.c",
|
|
Tier: TierPro,
|
|
ExpiresAt: time.Now().Add(-10 * 24 * time.Hour).Unix(), // 10 days ago (past 7-day grace)
|
|
}
|
|
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
|
|
payloadBytes, _ := json.Marshal(claims)
|
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
key := header + "." + payload + ".fake-sig"
|
|
|
|
_, err := ValidateLicense(key)
|
|
if err == nil {
|
|
t.Error("Expected error for license past grace period")
|
|
}
|
|
}
|
|
|
|
func TestLicenseStatus(t *testing.T) {
|
|
service := NewService()
|
|
|
|
// Status with no license
|
|
status := service.Status()
|
|
if status.Valid {
|
|
t.Error("Should not be valid")
|
|
}
|
|
if status.Tier != TierFree {
|
|
t.Errorf("Expected free tier, got %v", status.Tier)
|
|
}
|
|
|
|
// Activate license
|
|
SetPublicKey(nil) // Skip signature check for testing
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
testKey, _ := GenerateLicenseForTesting("test@example.com", TierLifetime, 0)
|
|
_, err := service.Activate(testKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to activate test license: %v", err)
|
|
}
|
|
|
|
status = service.Status()
|
|
if !status.Valid {
|
|
t.Error("Should be valid")
|
|
}
|
|
if status.Tier != TierLifetime {
|
|
t.Errorf("Expected lifetime tier, got %v", status.Tier)
|
|
}
|
|
if !status.IsLifetime {
|
|
t.Error("Should be detected as lifetime")
|
|
}
|
|
if status.DaysRemaining != -1 {
|
|
t.Errorf("Expected -1 days remaining, got %d", status.DaysRemaining)
|
|
}
|
|
if len(status.Features) == 0 {
|
|
t.Error("Should have features")
|
|
}
|
|
}
|
|
|
|
func TestGetTierDisplayName(t *testing.T) {
|
|
if GetTierDisplayName(TierPro) != "Pro (Monthly)" {
|
|
t.Error("Wrong display name for Pro")
|
|
}
|
|
if GetTierDisplayName(TierLifetime) != "Pro (Lifetime)" {
|
|
t.Error("Wrong display name for Lifetime")
|
|
}
|
|
}
|
|
|
|
func TestGetFeatureDisplayName(t *testing.T) {
|
|
if GetFeatureDisplayName(FeatureAIPatrol) != "AI Patrol (Background Health Checks)" {
|
|
t.Error("Wrong display name for AI Patrol")
|
|
}
|
|
}
|
|
|
|
func TestPublicKeyRequiredWithoutDevMode(t *testing.T) {
|
|
// This test verifies that without PULSE_LICENSE_DEV_MODE=true,
|
|
// license validation fails when no public key is set.
|
|
// The test itself runs with PULSE_LICENSE_DEV_MODE=true (set in go test env),
|
|
// so we just check that ValidateLicense returns ErrNoPublicKey when appropriate.
|
|
|
|
// Save current public key state
|
|
originalKey := publicKey
|
|
defer SetPublicKey(originalKey)
|
|
|
|
// Clear public key
|
|
SetPublicKey(nil)
|
|
|
|
// Generate a test license
|
|
testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate test license: %v", err)
|
|
}
|
|
|
|
// In dev mode (set via env), this should succeed
|
|
// In production (no dev mode), this would fail with ErrNoPublicKey
|
|
// We test that the license CAN be validated in dev mode
|
|
_, err = ValidateLicense(testKey)
|
|
if err != nil {
|
|
// If running without PULSE_LICENSE_DEV_MODE=true, we expect this error
|
|
if err.Error() != "no public key configured for validation: signature verification required" {
|
|
t.Logf("License validation in dev mode: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatusSetsGracePeriodDynamically(t *testing.T) {
|
|
// Test that Status() dynamically sets GracePeriodEnd when license expires
|
|
// without requiring HasFeature() to be called first
|
|
|
|
service := NewService()
|
|
|
|
// Create a license that expired 3 days ago (within 7-day grace)
|
|
expiredAt := time.Now().Add(-3 * 24 * time.Hour)
|
|
lic := &License{
|
|
Claims: Claims{
|
|
LicenseID: "test_status_grace",
|
|
Email: "test@example.com",
|
|
Tier: TierPro,
|
|
IssuedAt: time.Now().Add(-33 * 24 * time.Hour).Unix(),
|
|
ExpiresAt: expiredAt.Unix(),
|
|
},
|
|
ValidatedAt: time.Now().Add(-33 * 24 * time.Hour),
|
|
// Note: GracePeriodEnd is NOT set - simulating runtime expiration
|
|
}
|
|
|
|
// Manually set the license without grace period
|
|
service.mu.Lock()
|
|
service.license = lic
|
|
service.mu.Unlock()
|
|
|
|
// Verify GracePeriodEnd is nil initially
|
|
if lic.GracePeriodEnd != nil {
|
|
t.Fatal("GracePeriodEnd should be nil initially")
|
|
}
|
|
|
|
// Call Status() - this should set GracePeriodEnd dynamically
|
|
status := service.Status()
|
|
|
|
// Verify Status() set the grace period
|
|
if lic.GracePeriodEnd == nil {
|
|
t.Fatal("Status() should have set GracePeriodEnd")
|
|
}
|
|
|
|
// Status should show as valid during grace period
|
|
if !status.Valid {
|
|
t.Error("Status should be valid during grace period")
|
|
}
|
|
if !status.InGracePeriod {
|
|
t.Error("Status should show in grace period")
|
|
}
|
|
if status.GracePeriodEnd == nil {
|
|
t.Error("Status should include GracePeriodEnd")
|
|
}
|
|
|
|
// Verify HasFeature also works during grace
|
|
if !service.HasFeature(FeatureAIPatrol) {
|
|
t.Error("HasFeature should return true during grace period")
|
|
}
|
|
}
|
|
|
|
func TestServiceCurrent(t *testing.T) {
|
|
service := NewService()
|
|
|
|
// No license - Current() returns nil
|
|
if service.Current() != nil {
|
|
t.Error("Current() should return nil when no license")
|
|
}
|
|
|
|
// Activate license
|
|
SetPublicKey(nil)
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate test license: %v", err)
|
|
}
|
|
|
|
_, err = service.Activate(testKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to activate: %v", err)
|
|
}
|
|
|
|
// Current() should return the license
|
|
lic := service.Current()
|
|
if lic == nil {
|
|
t.Fatal("Current() should return license after activation")
|
|
}
|
|
if lic.Claims.Email != "test@example.com" {
|
|
t.Errorf("Expected email 'test@example.com', got %q", lic.Claims.Email)
|
|
}
|
|
|
|
// Clear and verify Current() returns nil again
|
|
service.Clear()
|
|
if service.Current() != nil {
|
|
t.Error("Current() should return nil after Clear()")
|
|
}
|
|
}
|
|
|
|
func TestServiceGetLicenseState(t *testing.T) {
|
|
t.Run("no license", func(t *testing.T) {
|
|
service := NewService()
|
|
state, lic := service.GetLicenseState()
|
|
if state != LicenseStateNone {
|
|
t.Errorf("Expected state 'none', got %q", state)
|
|
}
|
|
if lic != nil {
|
|
t.Error("Expected nil license")
|
|
}
|
|
})
|
|
|
|
t.Run("active license", func(t *testing.T) {
|
|
service := NewService()
|
|
SetPublicKey(nil)
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
|
|
testKey, _ := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
|
|
_, err := service.Activate(testKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to activate: %v", err)
|
|
}
|
|
|
|
state, lic := service.GetLicenseState()
|
|
if state != LicenseStateActive {
|
|
t.Errorf("Expected state 'active', got %q", state)
|
|
}
|
|
if lic == nil {
|
|
t.Error("Expected license to be returned")
|
|
}
|
|
})
|
|
|
|
t.Run("expired license in grace period", func(t *testing.T) {
|
|
service := NewService()
|
|
|
|
// Create an expired license within grace period (3 days ago)
|
|
expiredAt := time.Now().Add(-3 * 24 * time.Hour)
|
|
lic := &License{
|
|
Claims: Claims{
|
|
LicenseID: "test_expired",
|
|
Email: "test@example.com",
|
|
Tier: TierPro,
|
|
IssuedAt: time.Now().Add(-33 * 24 * time.Hour).Unix(),
|
|
ExpiresAt: expiredAt.Unix(),
|
|
},
|
|
ValidatedAt: time.Now().Add(-33 * 24 * time.Hour),
|
|
}
|
|
|
|
service.mu.Lock()
|
|
service.license = lic
|
|
service.mu.Unlock()
|
|
|
|
state, returnedLic := service.GetLicenseState()
|
|
if state != LicenseStateGracePeriod {
|
|
t.Errorf("Expected state 'grace_period', got %q", state)
|
|
}
|
|
if returnedLic == nil {
|
|
t.Error("Expected license to be returned")
|
|
}
|
|
// Should have set grace period end
|
|
if returnedLic.GracePeriodEnd == nil {
|
|
t.Error("Expected GracePeriodEnd to be set")
|
|
}
|
|
})
|
|
|
|
t.Run("expired license past grace period", func(t *testing.T) {
|
|
service := NewService()
|
|
|
|
// Create an expired license past grace period (10 days ago)
|
|
expiredAt := time.Now().Add(-10 * 24 * time.Hour)
|
|
gracePeriodEnd := expiredAt.Add(7 * 24 * time.Hour) // Grace ended 3 days ago
|
|
lic := &License{
|
|
Claims: Claims{
|
|
LicenseID: "test_expired_past",
|
|
Email: "test@example.com",
|
|
Tier: TierPro,
|
|
IssuedAt: time.Now().Add(-40 * 24 * time.Hour).Unix(),
|
|
ExpiresAt: expiredAt.Unix(),
|
|
},
|
|
ValidatedAt: time.Now().Add(-40 * 24 * time.Hour),
|
|
GracePeriodEnd: &gracePeriodEnd,
|
|
}
|
|
|
|
service.mu.Lock()
|
|
service.license = lic
|
|
service.mu.Unlock()
|
|
|
|
state, returnedLic := service.GetLicenseState()
|
|
if state != LicenseStateExpired {
|
|
t.Errorf("Expected state 'expired', got %q", state)
|
|
}
|
|
if returnedLic == nil {
|
|
t.Error("Expected license to be returned")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServiceGetLicenseStateString(t *testing.T) {
|
|
t.Run("no license", func(t *testing.T) {
|
|
service := NewService()
|
|
stateStr, hasFeatures := service.GetLicenseStateString()
|
|
if stateStr != "none" {
|
|
t.Errorf("Expected state string 'none', got %q", stateStr)
|
|
}
|
|
if hasFeatures {
|
|
t.Error("Expected hasFeatures to be false for no license")
|
|
}
|
|
})
|
|
|
|
t.Run("active license", func(t *testing.T) {
|
|
service := NewService()
|
|
SetPublicKey(nil)
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
|
|
testKey, _ := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
|
|
service.Activate(testKey)
|
|
|
|
stateStr, hasFeatures := service.GetLicenseStateString()
|
|
if stateStr != "active" {
|
|
t.Errorf("Expected state string 'active', got %q", stateStr)
|
|
}
|
|
if !hasFeatures {
|
|
t.Error("Expected hasFeatures to be true for active license")
|
|
}
|
|
})
|
|
|
|
t.Run("grace period", func(t *testing.T) {
|
|
service := NewService()
|
|
|
|
expiredAt := time.Now().Add(-3 * 24 * time.Hour)
|
|
lic := &License{
|
|
Claims: Claims{
|
|
LicenseID: "test_grace",
|
|
Email: "test@example.com",
|
|
Tier: TierPro,
|
|
ExpiresAt: expiredAt.Unix(),
|
|
},
|
|
}
|
|
|
|
service.mu.Lock()
|
|
service.license = lic
|
|
service.mu.Unlock()
|
|
|
|
stateStr, hasFeatures := service.GetLicenseStateString()
|
|
if stateStr != "grace_period" {
|
|
t.Errorf("Expected state string 'grace_period', got %q", stateStr)
|
|
}
|
|
if !hasFeatures {
|
|
t.Error("Expected hasFeatures to be true during grace period")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServiceSetLicenseChangeCallback(t *testing.T) {
|
|
service := NewService()
|
|
|
|
var callbackLicense *License
|
|
callbackCalled := false
|
|
|
|
service.SetLicenseChangeCallback(func(lic *License) {
|
|
callbackCalled = true
|
|
callbackLicense = lic
|
|
})
|
|
|
|
// Activate license - should trigger callback
|
|
SetPublicKey(nil)
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
testKey, _ := GenerateLicenseForTesting("callback@example.com", TierPro, 30*24*time.Hour)
|
|
_, err := service.Activate(testKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to activate: %v", err)
|
|
}
|
|
|
|
if !callbackCalled {
|
|
t.Error("Callback should have been called on Activate")
|
|
}
|
|
if callbackLicense == nil {
|
|
t.Error("Callback should receive the license")
|
|
}
|
|
if callbackLicense != nil && callbackLicense.Claims.Email != "callback@example.com" {
|
|
t.Errorf("Callback received wrong license, email: %q", callbackLicense.Claims.Email)
|
|
}
|
|
|
|
// Reset for Clear test
|
|
callbackCalled = false
|
|
callbackLicense = nil
|
|
|
|
// Clear license - should trigger callback with nil
|
|
service.Clear()
|
|
|
|
if !callbackCalled {
|
|
t.Error("Callback should have been called on Clear")
|
|
}
|
|
if callbackLicense != nil {
|
|
t.Error("Callback should receive nil on Clear")
|
|
}
|
|
}
|
|
|
|
func TestValidateLicense_RealSignature(t *testing.T) {
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
SetPublicKey(pub)
|
|
defer SetPublicKey(nil)
|
|
|
|
os.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
defer os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
|
|
claims := Claims{
|
|
LicenseID: "test-sig",
|
|
Email: "t@e.c",
|
|
Tier: TierPro,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(),
|
|
}
|
|
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
|
|
payloadBytes, _ := json.Marshal(claims)
|
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
|
|
signedData := header + "." + payload
|
|
signature := ed25519.Sign(priv, []byte(signedData))
|
|
sigEncoded := base64.RawURLEncoding.EncodeToString(signature)
|
|
|
|
key := signedData + "." + sigEncoded
|
|
|
|
lic, err := ValidateLicense(key)
|
|
if err != nil {
|
|
t.Fatalf("Failed to validate license with real signature: %v", err)
|
|
}
|
|
if lic.Claims.Email != "t@e.c" {
|
|
t.Error("Email mismatch in validated license")
|
|
}
|
|
|
|
// Test invalid signature
|
|
badKey := signedData + "." + base64.RawURLEncoding.EncodeToString([]byte("invalid-signature-length-must-be-64-bytes-long-12345678901234567890"))
|
|
_, err = ValidateLicense(badKey)
|
|
if err == nil {
|
|
t.Error("Expected error for invalid signature")
|
|
}
|
|
}
|