mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
765 lines
22 KiB
Go
765 lines
22 KiB
Go
package licensing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGrantRefreshLoop_StartStop(t *testing.T) {
|
|
svc := NewService()
|
|
|
|
// Start without any activation state — should be a no-op internally but not panic.
|
|
svc.StartGrantRefresh(context.Background())
|
|
svc.StopGrantRefresh()
|
|
|
|
// Start and stop multiple times should be safe.
|
|
svc.StartGrantRefresh(context.Background())
|
|
svc.StartGrantRefresh(context.Background()) // Duplicate start is a no-op.
|
|
svc.StopGrantRefresh()
|
|
svc.StopGrantRefresh() // Duplicate stop is a no-op.
|
|
}
|
|
|
|
func TestGrantRefreshLoop_RefreshesGrant(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
// Set up a mock license server that serves a new grant on refresh.
|
|
var refreshCount atomic.Int32
|
|
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_refreshed",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
refreshCount.Add(1)
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{
|
|
Grant: GrantEnvelope{
|
|
JWT: newGrantJWT,
|
|
JTI: "grant_new",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Build the initial grant.
|
|
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_initial",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.mu.Lock()
|
|
svc.activationState = &ActivationState{
|
|
InstallationID: "inst_test",
|
|
InstallationToken: "pit_live_test",
|
|
LicenseID: "lic_initial",
|
|
GrantJWT: initialGrantJWT,
|
|
GrantJTI: "grant_old",
|
|
InstanceFingerprint: "fp-test",
|
|
}
|
|
svc.mu.Unlock()
|
|
|
|
// Restore the license from the initial grant so refreshGrantOnce has something to update.
|
|
if err := svc.RestoreActivation(svc.activationState); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
// Perform a single manual refresh.
|
|
if err := svc.refreshGrantOnce(context.Background()); err != nil {
|
|
t.Fatalf("refreshGrantOnce: %v", err)
|
|
}
|
|
|
|
if refreshCount.Load() != 1 {
|
|
t.Errorf("refresh count = %d, want 1", refreshCount.Load())
|
|
}
|
|
|
|
// Verify the license was updated.
|
|
lic := svc.Current()
|
|
if lic == nil {
|
|
t.Fatal("expected current license after refresh")
|
|
}
|
|
if lic.Claims.LicenseID != "lic_refreshed" {
|
|
t.Errorf("LicenseID = %q, want lic_refreshed", lic.Claims.LicenseID)
|
|
}
|
|
|
|
// Verify activation state was updated.
|
|
state := svc.GetActivationState()
|
|
if state == nil {
|
|
t.Fatal("expected activation state after refresh")
|
|
}
|
|
if state.GrantJTI != "grant_new" {
|
|
t.Errorf("GrantJTI = %q, want grant_new", state.GrantJTI)
|
|
}
|
|
}
|
|
|
|
func TestGrantRefreshLoop_401ClearsActivation(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
// This test exercises the full runGrantRefreshLoop 401 branch, which calls
|
|
// clearActivationState to revert to free tier and exit the loop.
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"code": "token_revoked",
|
|
"message": "Installation revoked",
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_revoked",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
tmpDir, err := os.MkdirTemp("", "pulse-refresh-401-*")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
p, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("create persistence: %v", err)
|
|
}
|
|
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetPersistence(p)
|
|
var callbackState atomic.Pointer[ActivationState]
|
|
var callbackCalled atomic.Bool
|
|
svc.SetActivationStateChangeCallback(func(state *ActivationState) {
|
|
callbackCalled.Store(true)
|
|
callbackState.Store(state)
|
|
})
|
|
|
|
state := &ActivationState{
|
|
InstallationID: "inst_revoked",
|
|
InstallationToken: "pit_live_revoked",
|
|
LicenseID: "lic_revoked",
|
|
GrantJWT: initialGrantJWT,
|
|
GrantJTI: "grant_old",
|
|
InstanceFingerprint: "fp-test",
|
|
}
|
|
if err := p.SaveActivationState(state); err != nil {
|
|
t.Fatalf("save state: %v", err)
|
|
}
|
|
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.mu.Unlock()
|
|
if err := svc.RestoreActivation(state); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
// Set a very short refresh interval so the loop fires immediately.
|
|
svc.SetRefreshHints(RefreshHints{IntervalSeconds: 60, JitterPercent: 0.01})
|
|
// Override the loop's interval directly for instant firing.
|
|
svc.mu.RLock()
|
|
loop := svc.grantRefresh
|
|
svc.mu.RUnlock()
|
|
loop.mu.Lock()
|
|
loop.refreshInterval = time.Millisecond
|
|
loop.jitterPercent = 0
|
|
loop.mu.Unlock()
|
|
|
|
// Start the refresh loop — it should hit 401 and self-exit after clearing state.
|
|
svc.StartGrantRefresh(context.Background())
|
|
|
|
// Wait for the loop to exit (it should exit quickly after the 401).
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
svc.StopGrantRefresh()
|
|
t.Fatal("timed out waiting for refresh loop to exit on 401")
|
|
default:
|
|
}
|
|
// Check if activation was cleared.
|
|
if svc.Current() == nil && !svc.IsActivated() {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
// Verify the license and activation state were cleared.
|
|
if svc.Current() != nil {
|
|
t.Error("expected nil license after 401 revocation")
|
|
}
|
|
if svc.IsActivated() {
|
|
t.Error("expected activation cleared after 401 revocation")
|
|
}
|
|
if !callbackCalled.Load() {
|
|
t.Fatal("expected activation-state callback after 401 revocation")
|
|
}
|
|
if state := callbackState.Load(); state != nil {
|
|
t.Fatalf("expected nil activation-state callback after 401 revocation, got %+v", state)
|
|
}
|
|
|
|
// Verify the persisted state was also cleared.
|
|
if p.ActivationStateExists() {
|
|
t.Error("expected persisted activation state to be cleared after 401")
|
|
}
|
|
}
|
|
|
|
func TestRefreshGrantOnce_NoClient(t *testing.T) {
|
|
svc := NewService()
|
|
svc.mu.Lock()
|
|
svc.activationState = &ActivationState{InstallationID: "inst_test"}
|
|
svc.mu.Unlock()
|
|
|
|
err := svc.refreshGrantOnce(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error when no client")
|
|
}
|
|
}
|
|
|
|
func TestRefreshGrantOnce_NoState(t *testing.T) {
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient("http://localhost"))
|
|
|
|
err := svc.refreshGrantOnce(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error when no state")
|
|
}
|
|
}
|
|
|
|
func TestNextRefreshInterval(t *testing.T) {
|
|
svc := NewService()
|
|
|
|
t.Run("no loop initialized returns default", func(t *testing.T) {
|
|
interval := svc.nextRefreshInterval(0)
|
|
// With no loop, returns exactly defaultRefreshInterval.
|
|
if interval != defaultRefreshInterval {
|
|
t.Errorf("interval = %v, want %v (defaultRefreshInterval)", interval, defaultRefreshInterval)
|
|
}
|
|
})
|
|
|
|
// Initialize the loop.
|
|
svc.StartGrantRefresh(context.Background())
|
|
defer svc.StopGrantRefresh()
|
|
|
|
t.Run("zero failures returns near default interval", func(t *testing.T) {
|
|
interval := svc.nextRefreshInterval(0)
|
|
// Should be within ±20% of 6h (default).
|
|
low := time.Duration(float64(defaultRefreshInterval) * 0.8)
|
|
high := time.Duration(float64(defaultRefreshInterval) * 1.2)
|
|
if interval < low || interval > high {
|
|
t.Errorf("interval = %v, want between %v and %v", interval, low, high)
|
|
}
|
|
})
|
|
|
|
t.Run("first failure returns min backoff", func(t *testing.T) {
|
|
interval := svc.nextRefreshInterval(1)
|
|
if interval != minRefreshBackoff {
|
|
t.Errorf("interval = %v, want %v", interval, minRefreshBackoff)
|
|
}
|
|
})
|
|
|
|
t.Run("exponential backoff", func(t *testing.T) {
|
|
interval := svc.nextRefreshInterval(3)
|
|
// 30s * 2^2 = 120s = 2m
|
|
want := minRefreshBackoff * 4
|
|
if interval != want {
|
|
t.Errorf("interval = %v, want %v", interval, want)
|
|
}
|
|
})
|
|
|
|
t.Run("capped at max backoff", func(t *testing.T) {
|
|
interval := svc.nextRefreshInterval(20)
|
|
if interval > maxRefreshBackoff {
|
|
t.Errorf("interval = %v, want <= %v", interval, maxRefreshBackoff)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSetRefreshHints(t *testing.T) {
|
|
svc := NewService()
|
|
|
|
t.Run("valid hints", func(t *testing.T) {
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: 3600, // 1h
|
|
JitterPercent: 0.1,
|
|
})
|
|
|
|
// Verify by checking the refresh interval via nextRefreshInterval.
|
|
interval := svc.nextRefreshInterval(0)
|
|
low := time.Hour - time.Hour/10
|
|
high := time.Hour + time.Hour/10
|
|
if interval < low || interval > high {
|
|
t.Errorf("interval = %v, want between %v and %v", interval, low, high)
|
|
}
|
|
})
|
|
|
|
t.Run("interval clamped to minimum 1m", func(t *testing.T) {
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: 5, // Too short
|
|
JitterPercent: 0.1,
|
|
})
|
|
|
|
interval := svc.nextRefreshInterval(0)
|
|
if interval < time.Minute-time.Duration(float64(time.Minute)*0.1) {
|
|
t.Errorf("interval = %v, should be clamped to at least ~1m", interval)
|
|
}
|
|
})
|
|
|
|
t.Run("interval clamped to maximum 24h", func(t *testing.T) {
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: 100000, // Way too long
|
|
JitterPercent: 0.1,
|
|
})
|
|
|
|
interval := svc.nextRefreshInterval(0)
|
|
max := 24*time.Hour + 12*time.Hour
|
|
if interval > max {
|
|
t.Errorf("interval = %v, should be clamped to at most ~24h", interval)
|
|
}
|
|
})
|
|
|
|
t.Run("jitter > 0.5 rejected", func(t *testing.T) {
|
|
// Set known good jitter first.
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: 3600,
|
|
JitterPercent: 0.1,
|
|
})
|
|
|
|
// Try to set jitter > 0.5 — should be rejected.
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: 3600,
|
|
JitterPercent: 0.8,
|
|
})
|
|
|
|
// Verify the old jitter (0.1) is still in effect by sampling intervals.
|
|
// With 0.1 jitter, interval should be within ±10% of 1h.
|
|
// With 0.8 jitter it would be within ±80%, so values outside ±50% prove rejection.
|
|
for i := 0; i < 50; i++ {
|
|
interval := svc.nextRefreshInterval(0)
|
|
low := time.Hour / 2 // 30m — would be exceeded if 0.8 jitter were accepted
|
|
high := time.Hour * 2 // 2h
|
|
if interval < low || interval > high {
|
|
t.Errorf("iteration %d: interval = %v, jitter 0.8 should have been rejected", i, interval)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("negative interval ignored", func(t *testing.T) {
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: 3600,
|
|
JitterPercent: 0.1,
|
|
})
|
|
// Now try negative.
|
|
svc.SetRefreshHints(RefreshHints{
|
|
IntervalSeconds: -100,
|
|
JitterPercent: 0.1,
|
|
})
|
|
// Interval should still be ~3600s.
|
|
interval := svc.nextRefreshInterval(0)
|
|
if interval < 50*time.Minute || interval > 70*time.Minute {
|
|
t.Errorf("interval = %v, negative hint should not change it", interval)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRefreshGrantOnce_PersistsState(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_persisted",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{
|
|
Grant: GrantEnvelope{
|
|
JWT: newGrantJWT,
|
|
JTI: "grant_persisted",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "pulse-refresh-persist-*")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
p, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("create persistence: %v", err)
|
|
}
|
|
|
|
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_initial",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetPersistence(p)
|
|
|
|
state := &ActivationState{
|
|
InstallationID: "inst_persist",
|
|
InstallationToken: "pit_live_persist",
|
|
LicenseID: "lic_initial",
|
|
GrantJWT: initialGrantJWT,
|
|
GrantJTI: "grant_old",
|
|
InstanceFingerprint: "fp-persist",
|
|
}
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.mu.Unlock()
|
|
if err := svc.RestoreActivation(state); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
// Refresh should persist the updated state.
|
|
if err := svc.refreshGrantOnce(context.Background()); err != nil {
|
|
t.Fatalf("refreshGrantOnce: %v", err)
|
|
}
|
|
|
|
// Load from disk and verify.
|
|
loaded, err := p.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("LoadActivationState: %v", err)
|
|
}
|
|
if loaded == nil {
|
|
t.Fatal("expected persisted state")
|
|
}
|
|
if loaded.GrantJTI != "grant_persisted" {
|
|
t.Errorf("GrantJTI = %q, want grant_persisted", loaded.GrantJTI)
|
|
}
|
|
if loaded.LastRefreshedAt == 0 {
|
|
t.Error("LastRefreshedAt should be set after refresh")
|
|
}
|
|
}
|
|
|
|
func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_refreshed_floor",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_monthly_grandfathered",
|
|
State: "active",
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{
|
|
Grant: GrantEnvelope{
|
|
JWT: newGrantJWT,
|
|
JTI: "grant_refreshed_floor",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "pulse-refresh-floor-*")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
p, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("create persistence: %v", err)
|
|
}
|
|
|
|
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_initial_floor",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_monthly_grandfathered",
|
|
State: "active",
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetPersistence(p)
|
|
|
|
state := &ActivationState{
|
|
InstallationID: "inst_refresh_floor",
|
|
InstallationToken: "pit_live_refresh_floor",
|
|
LicenseID: "lic_initial_floor",
|
|
GrantJWT: initialGrantJWT,
|
|
GrantJTI: "grant_old_floor",
|
|
InstanceFingerprint: "fp-refresh-floor",
|
|
Continuity: ActivationContinuity{
|
|
LegacyMigration: true,
|
|
GrandfatheredMaxMonitoredSystems: 23,
|
|
GrandfatheredMonitoredSystemsCapturedAt: time.Now().Unix(),
|
|
},
|
|
}
|
|
if err := svc.RestoreActivation(state); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
if err := svc.refreshGrantOnce(context.Background()); err != nil {
|
|
t.Fatalf("refreshGrantOnce: %v", err)
|
|
}
|
|
|
|
status := svc.Status()
|
|
if status.MaxMonitoredSystems != 23 {
|
|
t.Fatalf("status.MaxMonitoredSystems=%d, want 23", status.MaxMonitoredSystems)
|
|
}
|
|
|
|
loaded, err := p.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("LoadActivationState: %v", err)
|
|
}
|
|
if loaded == nil {
|
|
t.Fatal("expected persisted activation state")
|
|
}
|
|
if loaded.Continuity.GrandfatheredMaxMonitoredSystems != 23 {
|
|
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 23", loaded.Continuity.GrandfatheredMaxMonitoredSystems)
|
|
}
|
|
}
|
|
|
|
func TestRefreshGrantOnce_MissingCloudPlanFailsClosed(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_cloud_unknown_plan",
|
|
Tier: "cloud",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{
|
|
Grant: GrantEnvelope{
|
|
JWT: newGrantJWT,
|
|
JTI: "grant_cloud_unknown",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_initial",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
state := &ActivationState{
|
|
InstallationID: "inst_cloud_unknown",
|
|
InstallationToken: "pit_live_cloud_unknown",
|
|
LicenseID: "lic_initial",
|
|
GrantJWT: initialGrantJWT,
|
|
GrantJTI: "grant_old",
|
|
InstanceFingerprint: "fp-cloud-unknown",
|
|
}
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.mu.Unlock()
|
|
if err := svc.RestoreActivation(state); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
if err := svc.refreshGrantOnce(context.Background()); err != nil {
|
|
t.Fatalf("refreshGrantOnce: %v", err)
|
|
}
|
|
|
|
lic := svc.Current()
|
|
if lic == nil {
|
|
t.Fatal("expected current license after refresh")
|
|
}
|
|
if got := lic.Claims.EntitlementPlanVersion(); got != "" {
|
|
t.Fatalf("EntitlementPlanVersion()=%q, want empty", got)
|
|
}
|
|
if got := lic.Claims.EffectiveLimits()["max_monitored_systems"]; got != int64(UnknownPlanDefaultMonitoredSystemLimit) {
|
|
t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, UnknownPlanDefaultMonitoredSystemLimit)
|
|
}
|
|
|
|
status := svc.Status()
|
|
if status.PlanVersion != "" {
|
|
t.Fatalf("status.PlanVersion=%q, want empty", status.PlanVersion)
|
|
}
|
|
if status.MaxMonitoredSystems != UnknownPlanDefaultMonitoredSystemLimit {
|
|
t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, UnknownPlanDefaultMonitoredSystemLimit)
|
|
}
|
|
}
|
|
|
|
func TestRefreshGrantOnce_CallsLicenseChangeCallback(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_callback",
|
|
Tier: "relay",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{
|
|
Grant: GrantEnvelope{
|
|
JWT: newGrantJWT,
|
|
JTI: "grant_cb",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
initialJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_initial",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
var callbackLicense *License
|
|
svc := NewService()
|
|
svc.SetLicenseChangeCallback(func(lic *License) {
|
|
callbackLicense = lic
|
|
})
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
|
|
state := &ActivationState{
|
|
InstallationID: "inst_cb",
|
|
InstallationToken: "pit_live_cb",
|
|
LicenseID: "lic_initial",
|
|
GrantJWT: initialJWT,
|
|
GrantJTI: "grant_old",
|
|
InstanceFingerprint: "fp-cb",
|
|
}
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.mu.Unlock()
|
|
if err := svc.RestoreActivation(state); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
if err := svc.refreshGrantOnce(context.Background()); err != nil {
|
|
t.Fatalf("refreshGrantOnce: %v", err)
|
|
}
|
|
|
|
if callbackLicense == nil {
|
|
t.Fatal("license change callback was not invoked")
|
|
}
|
|
if callbackLicense.Claims.LicenseID != "lic_callback" {
|
|
t.Errorf("callback license ID = %q, want lic_callback", callbackLicense.Claims.LicenseID)
|
|
}
|
|
}
|
|
|
|
func TestRefreshGrantOnce_CallsActivationStateChangeCallback(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
|
|
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_activation_callback",
|
|
Tier: "relay",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{
|
|
Grant: GrantEnvelope{
|
|
JWT: newGrantJWT,
|
|
JTI: "grant_activation_callback",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
initialJWT := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_initial_activation_callback",
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
|
|
var callbackState *ActivationState
|
|
svc := NewService()
|
|
svc.SetActivationStateChangeCallback(func(state *ActivationState) {
|
|
callbackState = state
|
|
})
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
|
|
state := &ActivationState{
|
|
InstallationID: "inst_activation_callback",
|
|
InstallationToken: "pit_live_activation_callback",
|
|
LicenseID: "lic_initial_activation_callback",
|
|
GrantJWT: initialJWT,
|
|
GrantJTI: "grant_old_activation_callback",
|
|
InstanceFingerprint: "fp-activation-callback",
|
|
}
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.mu.Unlock()
|
|
if err := svc.RestoreActivation(state); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
|
|
callbackState = nil
|
|
if err := svc.refreshGrantOnce(context.Background()); err != nil {
|
|
t.Fatalf("refreshGrantOnce: %v", err)
|
|
}
|
|
|
|
if callbackState == nil {
|
|
t.Fatal("activation-state callback was not invoked")
|
|
}
|
|
if callbackState.GrantJTI != "grant_activation_callback" {
|
|
t.Fatalf("GrantJTI = %q, want grant_activation_callback", callbackState.GrantJTI)
|
|
}
|
|
}
|
|
|
|
func TestGenerateFingerprint(t *testing.T) {
|
|
fp1, err := generateFingerprint()
|
|
if err != nil {
|
|
t.Fatalf("generateFingerprint: %v", err)
|
|
}
|
|
|
|
// Should look like a UUID v4.
|
|
if len(fp1) != 36 { // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
t.Errorf("fingerprint length = %d, want 36", len(fp1))
|
|
}
|
|
|
|
// Two fingerprints should be different.
|
|
fp2, err := generateFingerprint()
|
|
if err != nil {
|
|
t.Fatalf("generateFingerprint: %v", err)
|
|
}
|
|
if fp1 == fp2 {
|
|
t.Error("two fingerprints should be different")
|
|
}
|
|
}
|