Pulse/internal/license/license_test.go

382 lines
10 KiB
Go

package license
import (
"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
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"},
}
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 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
testKey, _ := GenerateLicenseForTesting("test@example.com", TierLifetime, 0)
_, _ = service.Activate(testKey)
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")
}
}