mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
581 lines
20 KiB
Go
581 lines
20 KiB
Go
package licensing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testInstallationStatusState(version int64) *ActivationState {
|
|
return &ActivationState{
|
|
InstallationID: "inst_status",
|
|
InstallationToken: "pit_live_status",
|
|
LicenseID: "lic_status",
|
|
GrantJTI: "grt_status",
|
|
LicenseVersion: version,
|
|
InstanceFingerprint: "fp-status",
|
|
}
|
|
}
|
|
|
|
func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool, message string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if condition() {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal(message)
|
|
}
|
|
|
|
func TestInstallationStatusPollStartsImmediatelyAndIsIdempotent(t *testing.T) {
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Path != "/v1/grants/status" || r.Method != http.MethodPost {
|
|
t.Fatalf("request=%s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer pit_live_status" {
|
|
t.Fatalf("Authorization=%q", got)
|
|
}
|
|
var req InstallationStatusRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Fatalf("Decode request: %v", err)
|
|
}
|
|
if req.InstallationID != "inst_status" || req.InstanceFingerprint != "fp-status" || req.CurrentLicenseVersion != 3 || req.CurrentGrantJTI != "grt_status" {
|
|
t.Fatalf("unexpected request: %+v", req)
|
|
}
|
|
json.NewEncoder(w).Encode(InstallationStatusResponse{
|
|
LicenseVersion: 3,
|
|
StatusPolicy: StatusHints{IntervalSeconds: 300, JitterPercent: 20, RetryBaseSeconds: 30, RetryMaxSeconds: 600},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetClientVersion("6.0.0")
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(3)
|
|
svc.mu.Unlock()
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
waitForCondition(t, time.Second, func() bool { return calls.Load() == 1 }, "immediate status request did not run")
|
|
svc.StopInstallationStatusPoll()
|
|
svc.StopInstallationStatusPoll()
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("calls=%d want 1", calls.Load())
|
|
}
|
|
|
|
status := svc.Status().Synchronization
|
|
if status == nil || status.Running || !status.Healthy || status.LastAttemptAt == nil || status.LastSuccessAt == nil || status.ConsecutiveFailures != 0 {
|
|
t.Fatalf("unexpected synchronization status: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusPollCanRestart(t *testing.T) {
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
json.NewEncoder(w).Encode(InstallationStatusResponse{LicenseVersion: 1})
|
|
}))
|
|
defer server.Close()
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(1)
|
|
svc.mu.Unlock()
|
|
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
waitForCondition(t, time.Second, func() bool { return calls.Load() == 1 }, "first immediate status request did not run")
|
|
svc.StopInstallationStatusPoll()
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
waitForCondition(t, time.Second, func() bool { return calls.Load() == 2 }, "restart did not perform immediate status request")
|
|
svc.StopInstallationStatusPoll()
|
|
}
|
|
|
|
func TestInstallationStatusStopCancelsInFlightRequest(t *testing.T) {
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
close(entered)
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-release:
|
|
}
|
|
}))
|
|
defer func() {
|
|
close(release)
|
|
server.Close()
|
|
}()
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(1)
|
|
svc.mu.Unlock()
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("status request did not start")
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
svc.StopInstallationStatusPoll()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("stop did not cancel and join in-flight request")
|
|
}
|
|
}
|
|
|
|
func TestCheckInstallationStatusSameVersionDoesNotRefresh(t *testing.T) {
|
|
var statusCalls atomic.Int32
|
|
var refreshCalls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/grants/status":
|
|
statusCalls.Add(1)
|
|
json.NewEncoder(w).Encode(InstallationStatusResponse{LicenseVersion: 4})
|
|
case "/v1/grants/refresh":
|
|
refreshCalls.Add(1)
|
|
t.Fatal("same-version status must not refresh the grant")
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(4)
|
|
svc.mu.Unlock()
|
|
if err := svc.checkInstallationStatusOnce(context.Background()); err != nil {
|
|
t.Fatalf("checkInstallationStatusOnce: %v", err)
|
|
}
|
|
if statusCalls.Load() != 1 || refreshCalls.Load() != 0 {
|
|
t.Fatalf("status=%d refresh=%d", statusCalls.Load(), refreshCalls.Load())
|
|
}
|
|
}
|
|
|
|
func TestCheckInstallationStatusHigherVersionRefreshesAndPersists(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
newGrant := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_status",
|
|
InstallationID: "inst_status",
|
|
LicenseVersion: 2,
|
|
Tier: "relay",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
JTI: "grt_new",
|
|
})
|
|
var refreshCalls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/grants/status":
|
|
json.NewEncoder(w).Encode(InstallationStatusResponse{LicenseVersion: 2, RefreshRequired: true})
|
|
case "/v1/grants/refresh":
|
|
refreshCalls.Add(1)
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{Grant: GrantEnvelope{
|
|
JWT: newGrant, JTI: "grt_new", ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
tmpDir, err := os.MkdirTemp("", "pulse-status-refresh-*")
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
persistence, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewPersistence: %v", err)
|
|
}
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetPersistence(persistence)
|
|
state := testInstallationStatusState(1)
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.license = &License{Claims: Claims{Tier: TierPro}}
|
|
svc.mu.Unlock()
|
|
if err := persistence.SaveActivationState(state); err != nil {
|
|
t.Fatalf("SaveActivationState: %v", err)
|
|
}
|
|
|
|
if err := svc.checkInstallationStatusOnce(context.Background()); err != nil {
|
|
t.Fatalf("checkInstallationStatusOnce: %v", err)
|
|
}
|
|
if refreshCalls.Load() != 1 {
|
|
t.Fatalf("refresh calls=%d want 1", refreshCalls.Load())
|
|
}
|
|
updated := svc.GetActivationState()
|
|
if updated == nil || updated.LicenseVersion != 2 || updated.GrantJTI != "grt_new" {
|
|
t.Fatalf("unexpected activation state: %+v", updated)
|
|
}
|
|
persisted, err := persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("LoadActivationState: %v", err)
|
|
}
|
|
if persisted.LicenseVersion != 2 || persisted.GrantJTI != "grt_new" {
|
|
t.Fatalf("unexpected persisted state: %+v", persisted)
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusExplicitRevocationClearsActivation(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
status int
|
|
code string
|
|
}{
|
|
{name: "token", status: http.StatusUnauthorized, code: "TOKEN_REVOKED"},
|
|
{name: "installation", status: http.StatusUnauthorized, code: "INSTALLATION_REVOKED"},
|
|
{name: "license", status: http.StatusForbidden, code: "LICENSE_REVOKED"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(tc.status)
|
|
json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": tc.code, "message": "revoked", "revoked": true}})
|
|
}))
|
|
defer server.Close()
|
|
tmpDir, err := os.MkdirTemp("", "pulse-status-revoked-*")
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
persistence, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewPersistence: %v", err)
|
|
}
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetPersistence(persistence)
|
|
state := testInstallationStatusState(1)
|
|
if err := persistence.SaveActivationState(state); err != nil {
|
|
t.Fatalf("SaveActivationState: %v", err)
|
|
}
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.license = &License{Claims: Claims{Tier: TierPro}}
|
|
svc.mu.Unlock()
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
waitForCondition(t, 2*time.Second, func() bool { return !svc.IsActivated() }, "revocation did not clear activation")
|
|
svc.StopInstallationStatusPoll()
|
|
if svc.Current() != nil || persistence.ActivationStateExists() {
|
|
t.Fatalf("revoked activation survived: current=%+v persisted=%v", svc.Current(), persistence.ActivationStateExists())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusAmbiguous401RetainsActivation(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": "INVALID_TOKEN", "message": "unknown"}})
|
|
}))
|
|
defer server.Close()
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(1)
|
|
svc.license = &License{Claims: Claims{Tier: TierPro}}
|
|
svc.mu.Unlock()
|
|
err := svc.checkInstallationStatusOnce(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if isRevokedActivationError(err) || !svc.IsActivated() || svc.Current() == nil {
|
|
t.Fatalf("ambiguous 401 must retain activation: err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusSuspensionDropsPaidAccessAndRecoversWithScopedActivation(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
initialGrant := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_status",
|
|
InstallationID: "inst_status",
|
|
LicenseVersion: 1,
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
JTI: "grt_status",
|
|
})
|
|
recoveredGrant := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_status",
|
|
InstallationID: "inst_status",
|
|
LicenseVersion: 2,
|
|
Tier: "relay",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
JTI: "grt_recovered",
|
|
})
|
|
var suspended atomic.Bool
|
|
suspended.Store(true)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/grants/status":
|
|
if suspended.Load() {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
|
"code": "LICENSE_SUSPENDED", "message": "paid access suspended",
|
|
}})
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(InstallationStatusResponse{LicenseVersion: 2, RefreshRequired: true})
|
|
case "/v1/grants/refresh":
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{Grant: GrantEnvelope{
|
|
JWT: recoveredGrant, JTI: "grt_recovered", ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
persistence, err := NewPersistence(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewPersistence: %v", err)
|
|
}
|
|
state := testInstallationStatusState(1)
|
|
state.GrantJWT = initialGrant
|
|
if err := persistence.SaveActivationState(state); err != nil {
|
|
t.Fatalf("SaveActivationState: %v", err)
|
|
}
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.SetPersistence(persistence)
|
|
svc.mu.Lock()
|
|
svc.activationState = state
|
|
svc.license = &License{Claims: Claims{Tier: TierPro}}
|
|
svc.mu.Unlock()
|
|
|
|
svc.StartInstallationStatusPoll(context.Background())
|
|
waitForCondition(t, time.Second, func() bool { return svc.Current() == nil }, "suspension did not remove paid entitlements")
|
|
svc.StopInstallationStatusPoll()
|
|
if !svc.IsActivated() || !persistence.ActivationStateExists() {
|
|
t.Fatal("suspension must preserve scoped activation for recovery")
|
|
}
|
|
persistedSuspension, err := persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("LoadActivationState after suspension: %v", err)
|
|
}
|
|
if persistedSuspension == nil || !persistedSuspension.PaidAccessSuspended {
|
|
t.Fatalf("suspension marker was not persisted: %+v", persistedSuspension)
|
|
}
|
|
restarted := NewService()
|
|
if err := restarted.RestoreActivation(persistedSuspension); err != nil {
|
|
t.Fatalf("RestoreActivation after suspension: %v", err)
|
|
}
|
|
if !restarted.IsActivated() || restarted.Current() != nil {
|
|
t.Fatalf("restart must preserve recovery authority without restoring paid access: activated=%v current=%+v", restarted.IsActivated(), restarted.Current())
|
|
}
|
|
if syncStatus := svc.Status().Synchronization; syncStatus == nil || !syncStatus.Healthy || syncStatus.ConsecutiveFailures != 0 {
|
|
t.Fatalf("authoritative suspension should be synchronized, got %+v", syncStatus)
|
|
}
|
|
|
|
suspended.Store(false)
|
|
if err := svc.checkInstallationStatusOnce(context.Background()); err != nil {
|
|
t.Fatalf("recovery status check: %v", err)
|
|
}
|
|
if current := svc.Current(); current == nil || current.Claims.Tier != TierRelay {
|
|
t.Fatalf("paid entitlements did not recover from signed grant: %+v", current)
|
|
}
|
|
if recovered := svc.GetActivationState(); recovered == nil || recovered.LicenseVersion != 2 || recovered.GrantJTI != "grt_recovered" {
|
|
t.Fatalf("activation did not advance after recovery: %+v", recovered)
|
|
} else if recovered.Continuity.DowngradeRetention == nil {
|
|
t.Fatalf("suspended Pro-to-Relay recovery lost downgrade retention: %+v", recovered.Continuity)
|
|
}
|
|
persistedRecovery, err := persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("LoadActivationState after recovery: %v", err)
|
|
}
|
|
if persistedRecovery == nil || persistedRecovery.PaidAccessSuspended {
|
|
t.Fatalf("signed recovery grant did not clear persisted suspension: %+v", persistedRecovery)
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusHintBoundsAndBackoff(t *testing.T) {
|
|
svc := NewService()
|
|
svc.SetInstallationStatusHints(StatusHints{IntervalSeconds: 1, JitterPercent: 90, RetryBaseSeconds: 1, RetryMaxSeconds: 99999})
|
|
svc.mu.RLock()
|
|
loop := svc.installationStatus
|
|
svc.mu.RUnlock()
|
|
loop.mu.Lock()
|
|
if loop.interval != minInstallationStatusInterval || loop.jitterPercent != defaultInstallationStatusJitter || loop.retryBase != 5*time.Second || loop.retryMax != maxInstallationStatusBackoff {
|
|
t.Fatalf("unexpected bounded hints: interval=%v jitter=%v base=%v max=%v", loop.interval, loop.jitterPercent, loop.retryBase, loop.retryMax)
|
|
}
|
|
loop.consecutiveFailures = 20
|
|
loop.mu.Unlock()
|
|
if delay := loop.nextDelay(); delay != maxInstallationStatusBackoff {
|
|
t.Fatalf("backoff=%v want %v", delay, maxInstallationStatusBackoff)
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusSnapshotMarksStaleSynchronizationUnhealthy(t *testing.T) {
|
|
svc := NewService()
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(1)
|
|
loop := newInstallationStatusPollLoop()
|
|
svc.installationStatus = loop
|
|
loop.mu.Lock()
|
|
loop.running = true
|
|
loop.lastAttemptAt = time.Now().Add(-20 * time.Minute)
|
|
loop.lastSuccessAt = time.Now().Add(-20 * time.Minute)
|
|
loop.nextCheckAt = time.Now().Add(-10 * time.Minute)
|
|
loop.mu.Unlock()
|
|
svc.mu.Unlock()
|
|
|
|
status := svc.Status().Synchronization
|
|
if status == nil || !status.Running || !status.Stale || status.Healthy {
|
|
t.Fatalf("unexpected stale synchronization status: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestInstallationStatusNoActivationExitsCleanly(t *testing.T) {
|
|
svc := NewService()
|
|
if err := svc.checkInstallationStatusOnce(context.Background()); !errors.Is(err, errNoActivationState) {
|
|
t.Fatalf("error=%v want no activation", err)
|
|
}
|
|
}
|
|
|
|
func TestLicenseServerClientCheckInstallationStatus(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/v1/grants/status" {
|
|
t.Fatalf("request=%s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer pit_live_transport" {
|
|
t.Fatalf("Authorization=%q", got)
|
|
}
|
|
var req InstallationStatusRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Fatalf("Decode request: %v", err)
|
|
}
|
|
if req.InstallationID != "inst_transport" || req.CurrentLicenseVersion != 7 {
|
|
t.Fatalf("request=%+v", req)
|
|
}
|
|
json.NewEncoder(w).Encode(InstallationStatusResponse{
|
|
LicenseVersion: 8,
|
|
RefreshRequired: true,
|
|
StatusPolicy: StatusHints{IntervalSeconds: 300},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
client := NewLicenseServerClient(server.URL)
|
|
resp, err := client.CheckInstallationStatus(context.Background(), "pit_live_transport", InstallationStatusRequest{
|
|
InstallationID: "inst_transport",
|
|
CurrentLicenseVersion: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CheckInstallationStatus: %v", err)
|
|
}
|
|
if resp.LicenseVersion != 8 || !resp.RefreshRequired || resp.StatusPolicy.IntervalSeconds != 300 {
|
|
t.Fatalf("response=%+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestRestoreActivationBackfillsSignedLicenseVersion(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
grant := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_restore_version",
|
|
InstallationID: "inst_restore_version",
|
|
LicenseVersion: 9,
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
svc := NewService()
|
|
if err := svc.RestoreActivation(&ActivationState{
|
|
InstallationID: "inst_restore_version",
|
|
InstallationToken: "pit_live_restore",
|
|
LicenseID: "lic_restore_version",
|
|
GrantJWT: grant,
|
|
InstanceFingerprint: "fp-restore",
|
|
}); err != nil {
|
|
t.Fatalf("RestoreActivation: %v", err)
|
|
}
|
|
if state := svc.GetActivationState(); state == nil || state.LicenseVersion != 9 {
|
|
t.Fatalf("activation state=%+v want signed version 9", state)
|
|
}
|
|
}
|
|
|
|
func TestGrantRefreshAttemptsAreSerialized(t *testing.T) {
|
|
setupTestPublicKey(t)
|
|
grant := makeTestGrantJWT(t, &GrantClaims{
|
|
LicenseID: "lic_serial",
|
|
InstallationID: "inst_status",
|
|
LicenseVersion: 2,
|
|
Tier: "pro",
|
|
State: "active",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
})
|
|
entered := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
var calls atomic.Int32
|
|
var active atomic.Int32
|
|
var maxActive atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
current := active.Add(1)
|
|
defer active.Add(-1)
|
|
for {
|
|
maximum := maxActive.Load()
|
|
if current <= maximum || maxActive.CompareAndSwap(maximum, current) {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case entered <- struct{}{}:
|
|
<-release
|
|
default:
|
|
}
|
|
json.NewEncoder(w).Encode(RefreshGrantResponse{Grant: GrantEnvelope{
|
|
JWT: grant, JTI: "grt_serial", ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
}})
|
|
}))
|
|
defer server.Close()
|
|
svc := NewService()
|
|
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
|
svc.mu.Lock()
|
|
svc.activationState = testInstallationStatusState(1)
|
|
svc.license = &License{Claims: Claims{Tier: TierPro}}
|
|
svc.mu.Unlock()
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 2)
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); errs <- svc.refreshGrantOnce(context.Background()) }()
|
|
<-entered
|
|
go func() { defer wg.Done(); errs <- svc.refreshGrantOnce(context.Background()) }()
|
|
time.Sleep(50 * time.Millisecond)
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("concurrent refresh reached server before first completed: calls=%d", calls.Load())
|
|
}
|
|
close(release)
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("refresh error: %v", err)
|
|
}
|
|
}
|
|
if calls.Load() != 2 || maxActive.Load() != 1 {
|
|
t.Fatalf("calls=%d max_active=%d want 2/1", calls.Load(), maxActive.Load())
|
|
}
|
|
}
|