Pulse/internal/license/license_test.go
rcourtman d3eb6a7148 test: improve test coverage for AI, license, config, and monitoring packages
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%)
2025-12-19 22:49:30 +00:00

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")
}
}