mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 17:19:57 +00:00
980 lines
35 KiB
Go
980 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
|
)
|
|
|
|
type grandfatherFloorSupplementalProvider struct {
|
|
settled bool
|
|
readyAt time.Time
|
|
records []unifiedresources.IngestRecord
|
|
}
|
|
|
|
func (p *grandfatherFloorSupplementalProvider) SupplementalRecords(*monitoring.Monitor, string) []unifiedresources.IngestRecord {
|
|
out := make([]unifiedresources.IngestRecord, len(p.records))
|
|
copy(out, p.records)
|
|
return out
|
|
}
|
|
|
|
func (p *grandfatherFloorSupplementalProvider) SnapshotOwnedSources() []unifiedresources.DataSource {
|
|
return []unifiedresources.DataSource{unifiedresources.SourceTrueNAS}
|
|
}
|
|
|
|
func (p *grandfatherFloorSupplementalProvider) SupplementalInventoryReadyAt(*monitoring.Monitor, string) (time.Time, bool) {
|
|
return p.readyAt, p.settled
|
|
}
|
|
|
|
func (p *grandfatherFloorSupplementalProvider) settle(count int) {
|
|
now := time.Now().UTC()
|
|
p.readyAt = now
|
|
p.settled = true
|
|
p.records = buildSupplementalGrandfatherFloorRecords(count, now)
|
|
}
|
|
|
|
func TestGetTenantComponents_AutoExchangesPersistedLegacyJWT(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
|
LicenseID: "lic_migrated",
|
|
Tier: "pro",
|
|
State: "active",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
Email: "user@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate grant jwt: %v", err)
|
|
}
|
|
pkglicensing.SetPublicKey(grantPublicKey)
|
|
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
|
|
|
var exchangeCalled atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/licenses/exchange" {
|
|
t.Fatalf("path = %q, want /v1/licenses/exchange", r.URL.Path)
|
|
}
|
|
exchangeCalled.Add(1)
|
|
|
|
var req pkglicensing.ExchangeLegacyLicenseRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Fatalf("decode exchange request: %v", err)
|
|
}
|
|
if req.LegacyLicenseKey == "" {
|
|
t.Fatal("expected legacy license key in exchange request")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
|
|
License: pkglicensing.ActivateResponseLicense{
|
|
LicenseID: "lic_migrated",
|
|
State: "active",
|
|
Tier: "pro",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
},
|
|
Installation: pkglicensing.ActivateResponseInstallation{
|
|
InstallationID: "inst_migrated",
|
|
InstallationToken: "pit_live_migrated",
|
|
Status: "active",
|
|
},
|
|
Grant: pkglicensing.GrantEnvelope{
|
|
JWT: grantJWT,
|
|
JTI: "grant_migrated",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
if _, err := mtp.GetPersistence("default"); err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
|
|
// Create and persist a legacy test JWT.
|
|
legacyJWT, err := pkglicensing.GenerateLicenseForTesting("user@example.com", pkglicensing.TierPro, 365*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("generate test license: %v", err)
|
|
}
|
|
cp, _ := mtp.GetPersistence("default")
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
if err := persistence.Save(legacyJWT); err != nil {
|
|
t.Fatalf("save legacy JWT: %v", err)
|
|
}
|
|
|
|
// Create handlers.
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
|
|
if !svc.IsActivated() {
|
|
t.Fatal("expected persisted legacy JWT to auto-exchange into activation state")
|
|
}
|
|
if exchangeCalled.Load() != 1 {
|
|
t.Fatalf("expected one exchange call, got %d", exchangeCalled.Load())
|
|
}
|
|
if current := svc.Current(); current == nil || current.Claims.LicenseID != "lic_migrated" {
|
|
t.Fatalf("expected migrated license to be active, got %#v", current)
|
|
}
|
|
if legacyLeft, err := persistence.Load(); err != nil {
|
|
t.Fatalf("load preserved legacy JWT: %v", err)
|
|
} else if legacyLeft != legacyJWT {
|
|
t.Fatalf("expected migrated legacy JWT persistence to be preserved for downgrade, got %q", legacyLeft)
|
|
}
|
|
if activationState, err := persistence.LoadActivationState(); err != nil {
|
|
t.Fatalf("load activation state: %v", err)
|
|
} else if activationState == nil {
|
|
t.Fatal("expected activation state after legacy exchange")
|
|
}
|
|
|
|
handlers.StopAllBackgroundLoops()
|
|
}
|
|
|
|
func TestGetTenantComponents_SkipsExchange_WhenActivationStateExists(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
|
|
|
// Mock server that should NOT be called if activation state exists.
|
|
var exchangeCalled atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1/licenses/exchange" {
|
|
exchangeCalled.Add(1)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
if _, err := mtp.GetPersistence("default"); err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
|
|
// Create both a legacy JWT and activation state on disk.
|
|
// The activation state should take priority and no exchange should happen.
|
|
cp, _ := mtp.GetPersistence("default")
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
|
|
// Save a legacy JWT (shouldn't be used since activation state exists).
|
|
legacyJWT, err := pkglicensing.GenerateLicenseForTesting("user@example.com", pkglicensing.TierPro, 365*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("generate test license: %v", err)
|
|
}
|
|
if err := persistence.Save(legacyJWT); err != nil {
|
|
t.Fatalf("save legacy JWT: %v", err)
|
|
}
|
|
|
|
// Create a grant JWT for the activation state.
|
|
grantClaims := map[string]any{
|
|
"lid": "lic_existing",
|
|
"tier": "pro",
|
|
"st": "active",
|
|
"feat": []string{"relay"},
|
|
"max_monitored_systems": 10,
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
|
}
|
|
grantPayload, _ := json.Marshal(grantClaims)
|
|
grantJWT := "eyJhbGciOiJFZERTQSJ9." + base64RawURLEncode(grantPayload) + "." + base64RawURLEncode([]byte("test-sig"))
|
|
|
|
// Save activation state.
|
|
activationState := &pkglicensing.ActivationState{
|
|
InstallationID: "inst_existing",
|
|
InstallationToken: "pit_live_existing",
|
|
LicenseID: "lic_existing",
|
|
GrantJWT: grantJWT,
|
|
GrantJTI: "grant_existing",
|
|
GrantExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
InstanceFingerprint: "fp-existing",
|
|
LicenseServerURL: server.URL,
|
|
ActivatedAt: time.Now().Unix(),
|
|
LastRefreshedAt: time.Now().Unix(),
|
|
}
|
|
if err := persistence.SaveActivationState(activationState); err != nil {
|
|
t.Fatalf("save activation state: %v", err)
|
|
}
|
|
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
|
|
// Should have restored from activation state, NOT called exchange.
|
|
// Give a brief moment for any potential background goroutine (shouldn't exist).
|
|
time.Sleep(100 * time.Millisecond)
|
|
if exchangeCalled.Load() != 0 {
|
|
t.Error("exchange should NOT be called when activation state exists")
|
|
}
|
|
if !svc.IsActivated() {
|
|
t.Error("expected IsActivated=true from restored activation state")
|
|
}
|
|
|
|
handlers.StopAllBackgroundLoops()
|
|
}
|
|
|
|
func TestGetTenantComponents_PersistsCommercialMigrationState_WhenAutoExchangeFails(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/licenses/exchange" {
|
|
t.Fatalf("path = %q, want /v1/licenses/exchange", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"code": "service_unavailable",
|
|
"message": "exchange unavailable",
|
|
"retryable": true,
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
if _, err := mtp.GetPersistence("default"); err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
|
|
legacyJWT, err := pkglicensing.GenerateLicenseForTesting("user@example.com", pkglicensing.TierPro, 365*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("generate test license: %v", err)
|
|
}
|
|
cp, _ := mtp.GetPersistence("default")
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
if err := persistence.Save(legacyJWT); err != nil {
|
|
t.Fatalf("save legacy JWT: %v", err)
|
|
}
|
|
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
if svc.IsActivated() {
|
|
t.Fatal("expected activation to remain unset after failed exchange")
|
|
}
|
|
|
|
store := config.NewFileBillingStore(baseDir)
|
|
state, err := store.GetBillingState("default")
|
|
if err != nil {
|
|
t.Fatalf("GetBillingState: %v", err)
|
|
}
|
|
if state == nil || state.CommercialMigration == nil {
|
|
t.Fatal("expected commercial migration state to be persisted")
|
|
}
|
|
if state.CommercialMigration.State != pkglicensing.CommercialMigrationStatePending {
|
|
t.Fatalf("commercial_migration.state=%q, want %q", state.CommercialMigration.State, pkglicensing.CommercialMigrationStatePending)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleEntitlements(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("entitlements status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
var payload EntitlementPayload
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode entitlements payload: %v", err)
|
|
}
|
|
if payload.CommercialMigration == nil {
|
|
t.Fatal("expected commercial_migration payload")
|
|
}
|
|
if payload.TrialEligible {
|
|
t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible)
|
|
}
|
|
if payload.TrialEligibilityReason != "commercial_migration_pending" {
|
|
t.Fatalf("trial_eligibility_reason=%q, want %q", payload.TrialEligibilityReason, "commercial_migration_pending")
|
|
}
|
|
|
|
handlers.StopAllBackgroundLoops()
|
|
}
|
|
|
|
func TestGetTenantComponents_AutoExchangeGrandfathersObservedMonitoredSystems(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
|
LicenseID: "lic_floor_auto",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_monthly_grandfathered",
|
|
State: "active",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
Email: "floor-auto@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate grant jwt: %v", err)
|
|
}
|
|
pkglicensing.SetPublicKey(grantPublicKey)
|
|
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/licenses/exchange" {
|
|
t.Fatalf("path = %q, want /v1/licenses/exchange", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
|
|
License: pkglicensing.ActivateResponseLicense{
|
|
LicenseID: "lic_floor_auto",
|
|
State: "active",
|
|
Tier: "pro",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
},
|
|
Installation: pkglicensing.ActivateResponseInstallation{
|
|
InstallationID: "inst_floor_auto",
|
|
InstallationToken: "pit_live_floor_auto",
|
|
Status: "active",
|
|
},
|
|
Grant: pkglicensing.GrantEnvelope{
|
|
JWT: grantJWT,
|
|
JTI: "grant_floor_auto",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
cp, err := mtp.GetPersistence("default")
|
|
if err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
|
|
legacyJWT, err := pkglicensing.GenerateLicenseForTesting("floor-auto@example.com", pkglicensing.TierPro, 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("generate test license: %v", err)
|
|
}
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
if err := persistence.Save(legacyJWT); err != nil {
|
|
t.Fatalf("save legacy jwt: %v", err)
|
|
}
|
|
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
handlers.SetMonitors(buildGrandfatherFloorMonitor(23), nil)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
if !svc.IsActivated() {
|
|
t.Fatal("expected persisted legacy JWT to auto-exchange into activation state")
|
|
}
|
|
if got := svc.Status().MaxMonitoredSystems; got != 23 {
|
|
t.Fatalf("status.MaxMonitoredSystems=%d, want 23", got)
|
|
}
|
|
if activationState, err := persistence.LoadActivationState(); err != nil {
|
|
t.Fatalf("load activation state: %v", err)
|
|
} else if activationState == nil {
|
|
t.Fatal("expected activation state after legacy exchange")
|
|
} else {
|
|
if !activationState.Continuity.LegacyMigration {
|
|
t.Fatal("expected legacy migration continuity flag")
|
|
}
|
|
if activationState.Continuity.GrandfatheredMaxMonitoredSystems != 23 {
|
|
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 23", activationState.Continuity.GrandfatheredMaxMonitoredSystems)
|
|
}
|
|
if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
|
|
t.Fatal("expected grandfather capture timestamp")
|
|
}
|
|
}
|
|
|
|
handlers.StopAllBackgroundLoops()
|
|
}
|
|
|
|
func TestGetTenantComponents_BackfillsGrandfatherFloorAfterRestoreWhenMonitorArrivesLate(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
|
LicenseID: "lic_floor_restore",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_monthly_grandfathered",
|
|
State: "active",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
Email: "floor-restore@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate grant jwt: %v", err)
|
|
}
|
|
pkglicensing.SetPublicKey(grantPublicKey)
|
|
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
cp, err := mtp.GetPersistence("default")
|
|
if err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
if err := persistence.SaveActivationState(&pkglicensing.ActivationState{
|
|
InstallationID: "inst_floor_restore",
|
|
InstallationToken: "pit_live_floor_restore",
|
|
LicenseID: "lic_floor_restore",
|
|
GrantJWT: grantJWT,
|
|
GrantJTI: "grant_floor_restore",
|
|
GrantExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
InstanceFingerprint: "fp-floor-restore",
|
|
LicenseServerURL: "https://license.pulserelay.pro",
|
|
ActivatedAt: time.Now().Add(-time.Hour).Unix(),
|
|
LastRefreshedAt: time.Now().Add(-time.Hour).Unix(),
|
|
Continuity: pkglicensing.ActivationContinuity{
|
|
LegacyMigration: true,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save activation state: %v", err)
|
|
}
|
|
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
if got := svc.Status().MaxMonitoredSystems; got != 10 {
|
|
t.Fatalf("initial status.MaxMonitoredSystems=%d, want 10 before floor capture", got)
|
|
}
|
|
|
|
handlers.SetMonitors(buildGrandfatherFloorMonitor(23), nil)
|
|
|
|
deadline := time.Now().Add(8 * time.Second)
|
|
for {
|
|
loaded, err := persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("load activation state: %v", err)
|
|
}
|
|
if loaded != nil &&
|
|
loaded.Continuity.GrandfatheredMaxMonitoredSystems == 23 &&
|
|
loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("expected async grandfather capture after late monitor restore, last activation state=%+v", loaded)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
loaded, err := persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("load activation state: %v", err)
|
|
}
|
|
if loaded == nil {
|
|
t.Fatal("expected activation state")
|
|
}
|
|
if loaded.Continuity.GrandfatheredMaxMonitoredSystems != 23 {
|
|
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 23", loaded.Continuity.GrandfatheredMaxMonitoredSystems)
|
|
}
|
|
if loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
|
|
t.Fatal("expected captured timestamp after late monitor restore")
|
|
}
|
|
if got := svc.Status().MaxMonitoredSystems; got != 23 {
|
|
t.Fatalf("status.MaxMonitoredSystems=%d, want 23 after late monitor capture", got)
|
|
}
|
|
|
|
handlers.StopAllBackgroundLoops()
|
|
}
|
|
|
|
func TestBillingReads_DoNotRestartLegacyGrandfatherReconcileLoop(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
|
LicenseID: "lic_floor_read_only",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_monthly_grandfathered",
|
|
State: "active",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
Email: "floor-read-only@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate grant jwt: %v", err)
|
|
}
|
|
pkglicensing.SetPublicKey(grantPublicKey)
|
|
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
cp, err := mtp.GetPersistence("default")
|
|
if err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
if err := persistence.SaveActivationState(&pkglicensing.ActivationState{
|
|
InstallationID: "inst_floor_read_only",
|
|
InstallationToken: "pit_live_floor_read_only",
|
|
LicenseID: "lic_floor_read_only",
|
|
GrantJWT: grantJWT,
|
|
GrantJTI: "grant_floor_read_only",
|
|
GrantExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
InstanceFingerprint: "fp-floor-read-only",
|
|
LicenseServerURL: "https://license.pulserelay.pro",
|
|
ActivatedAt: time.Now().Add(-time.Hour).Unix(),
|
|
LastRefreshedAt: time.Now().Add(-time.Hour).Unix(),
|
|
Continuity: pkglicensing.ActivationContinuity{
|
|
LegacyMigration: true,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save activation state: %v", err)
|
|
}
|
|
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
t.Cleanup(handlers.StopAllBackgroundLoops)
|
|
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if value, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok {
|
|
if loop, ok := value.(*legacyGrandfatherReconcileLoop); ok && loop.isRunning() {
|
|
break
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("expected restore-owned grandfather reconcile loop to start")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
handlers.stopLegacyGrandfatherReconcileLoop("default")
|
|
if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok {
|
|
t.Fatal("expected grandfather reconcile loop to stop before read-only check")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleLicenseStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("license status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok {
|
|
t.Fatal("license status read restarted grandfather reconcile loop")
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
|
|
rec = httptest.NewRecorder()
|
|
handlers.HandleEntitlements(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("license entitlements=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok {
|
|
t.Fatal("license entitlements read restarted grandfather reconcile loop")
|
|
}
|
|
}
|
|
|
|
func TestActivateLicenseKey_GrandfathersObservedMonitoredSystemsForLegacyMigration(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
|
LicenseID: "lic_floor_manual",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_annual_grandfathered",
|
|
State: "active",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
Email: "floor-manual@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate grant jwt: %v", err)
|
|
}
|
|
pkglicensing.SetPublicKey(grantPublicKey)
|
|
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/licenses/exchange" {
|
|
t.Fatalf("path = %q, want /v1/licenses/exchange", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
|
|
License: pkglicensing.ActivateResponseLicense{
|
|
LicenseID: "lic_floor_manual",
|
|
State: "active",
|
|
Tier: "pro",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
},
|
|
Installation: pkglicensing.ActivateResponseInstallation{
|
|
InstallationID: "inst_floor_manual",
|
|
InstallationToken: "pit_live_floor_manual",
|
|
Status: "active",
|
|
},
|
|
Grant: pkglicensing.GrantEnvelope{
|
|
JWT: grantJWT,
|
|
JTI: "grant_floor_manual",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
|
|
|
handlers := createTestHandler(t)
|
|
handlers.SetMonitors(buildGrandfatherFloorMonitor(23), nil)
|
|
t.Cleanup(handlers.StopAllBackgroundLoops)
|
|
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
legacyJWT, err := pkglicensing.GenerateLicenseForTesting("floor-manual@example.com", pkglicensing.TierPro, 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("generate test license: %v", err)
|
|
}
|
|
|
|
resp, err := handlers.activateLicenseKey(ctx, legacyJWT)
|
|
if err != nil {
|
|
t.Fatalf("activateLicenseKey: %v", err)
|
|
}
|
|
if !resp.Success {
|
|
t.Fatalf("expected success, got %+v", resp)
|
|
}
|
|
|
|
svc := handlers.Service(ctx)
|
|
if svc == nil {
|
|
t.Fatal("expected non-nil service")
|
|
}
|
|
if got := svc.Status().MaxMonitoredSystems; got != 23 {
|
|
t.Fatalf("status.MaxMonitoredSystems=%d, want 23", got)
|
|
}
|
|
}
|
|
|
|
func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySettles(t *testing.T) {
|
|
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
|
|
|
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
|
LicenseID: "lic_floor_supplemental",
|
|
Tier: "pro",
|
|
PlanKey: "v5_pro_monthly_grandfathered",
|
|
State: "active",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
|
Email: "floor-supplemental@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate grant jwt: %v", err)
|
|
}
|
|
pkglicensing.SetPublicKey(grantPublicKey)
|
|
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/licenses/exchange" {
|
|
t.Fatalf("path = %q, want /v1/licenses/exchange", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
|
|
License: pkglicensing.ActivateResponseLicense{
|
|
LicenseID: "lic_floor_supplemental",
|
|
State: "active",
|
|
Tier: "pro",
|
|
Features: []string{"relay"},
|
|
MaxMonitoredSystems: 10,
|
|
},
|
|
Installation: pkglicensing.ActivateResponseInstallation{
|
|
InstallationID: "inst_floor_supplemental",
|
|
InstallationToken: "pit_live_floor_supplemental",
|
|
Status: "active",
|
|
},
|
|
Grant: pkglicensing.GrantEnvelope{
|
|
JWT: grantJWT,
|
|
JTI: "grant_floor_supplemental",
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
|
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
cp, err := mtp.GetPersistence("default")
|
|
if err != nil {
|
|
t.Fatalf("init default persistence: %v", err)
|
|
}
|
|
|
|
legacyJWT, err := pkglicensing.GenerateLicenseForTesting("floor-supplemental@example.com", pkglicensing.TierPro, 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("generate test license: %v", err)
|
|
}
|
|
persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir())
|
|
if err != nil {
|
|
t.Fatalf("new persistence: %v", err)
|
|
}
|
|
if err := persistence.Save(legacyJWT); err != nil {
|
|
t.Fatalf("save legacy jwt: %v", err)
|
|
}
|
|
|
|
provider := &grandfatherFloorSupplementalProvider{}
|
|
monitor := buildSupplementalGrandfatherFloorMonitor(provider)
|
|
|
|
handlers := NewLicenseHandlers(mtp, false)
|
|
handlers.SetMonitors(monitor, nil)
|
|
t.Cleanup(handlers.StopAllBackgroundLoops)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
|
|
readStatus := func() pkglicensing.LicenseStatus {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleLicenseStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("license status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
var status pkglicensing.LicenseStatus
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
|
|
t.Fatalf("decode license status: %v", err)
|
|
}
|
|
return status
|
|
}
|
|
readEntitlements := func() EntitlementPayload {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleEntitlements(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("license entitlements=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
var payload EntitlementPayload
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode entitlements: %v", err)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
status := readStatus()
|
|
if got := status.MaxMonitoredSystems; got != 10 {
|
|
t.Fatalf("initial status.MaxMonitoredSystems=%d, want 10 while supplemental inventory is unsettled", got)
|
|
}
|
|
if status.MonitoredSystemContinuity == nil || !status.MonitoredSystemContinuity.CapturePending {
|
|
t.Fatalf("expected pending continuity in status payload, got %+v", status.MonitoredSystemContinuity)
|
|
}
|
|
if status.MonitoredSystemContinuity.PlanLimit != 10 || status.MonitoredSystemContinuity.EffectiveLimit != 10 {
|
|
t.Fatalf("unexpected pending continuity status payload: %+v", status.MonitoredSystemContinuity)
|
|
}
|
|
|
|
payload := readEntitlements()
|
|
if payload.MonitoredSystemContinuity == nil || !payload.MonitoredSystemContinuity.CapturePending {
|
|
t.Fatalf("expected pending continuity in entitlements payload, got %+v", payload.MonitoredSystemContinuity)
|
|
}
|
|
if len(payload.Limits) != 1 {
|
|
t.Fatalf("expected one monitored-system limit, got %+v", payload.Limits)
|
|
}
|
|
if payload.Limits[0].CurrentAvailable == nil || *payload.Limits[0].CurrentAvailable {
|
|
t.Fatalf("expected unavailable monitored-system usage while supplemental inventory is unsettled, got %+v", payload.Limits[0])
|
|
}
|
|
if payload.Limits[0].CurrentUnavailableReason != "supplemental_inventory_unsettled" {
|
|
t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.Limits[0].CurrentUnavailableReason, "supplemental_inventory_unsettled")
|
|
}
|
|
|
|
activationState, err := persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("load activation state: %v", err)
|
|
}
|
|
if activationState == nil {
|
|
t.Fatal("expected activation state after legacy exchange")
|
|
}
|
|
if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
|
|
t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 before supplemental baseline settles", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt)
|
|
}
|
|
|
|
provider.settle(23)
|
|
status = readStatus()
|
|
if got := status.MaxMonitoredSystems; got != 10 {
|
|
t.Fatalf("stale supplemental store should not capture grandfather floor yet, got %d", got)
|
|
}
|
|
|
|
monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider)
|
|
status = readStatus()
|
|
if got := status.MaxMonitoredSystems; got != 10 {
|
|
t.Fatalf("status read should not capture grandfather floor directly after canonical store rebuild, got %d", got)
|
|
}
|
|
|
|
deadline := time.Now().Add(8 * time.Second)
|
|
for {
|
|
activationState, err = persistence.LoadActivationState()
|
|
if err != nil {
|
|
t.Fatalf("reload activation state: %v", err)
|
|
}
|
|
if activationState != nil &&
|
|
activationState.Continuity.GrandfatheredMaxMonitoredSystems == 23 &&
|
|
activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("expected async grandfather capture after canonical store rebuild, last activation state=%+v", activationState)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
status = readStatus()
|
|
if got := status.MaxMonitoredSystems; got != 23 {
|
|
t.Fatalf("status.MaxMonitoredSystems=%d, want 23 after async grandfather capture", got)
|
|
}
|
|
if status.MonitoredSystemContinuity == nil || status.MonitoredSystemContinuity.CapturePending {
|
|
t.Fatalf("expected settled continuity in status payload after async capture, got %+v", status.MonitoredSystemContinuity)
|
|
}
|
|
if status.MonitoredSystemContinuity.GrandfatheredFloor != 23 || status.MonitoredSystemContinuity.EffectiveLimit != 23 {
|
|
t.Fatalf("unexpected settled continuity status payload: %+v", status.MonitoredSystemContinuity)
|
|
}
|
|
|
|
payload = readEntitlements()
|
|
if payload.MonitoredSystemContinuity == nil || payload.MonitoredSystemContinuity.CapturePending {
|
|
t.Fatalf("expected settled continuity in entitlements payload after async capture, got %+v", payload.MonitoredSystemContinuity)
|
|
}
|
|
if len(payload.Limits) != 1 || payload.Limits[0].Current != 23 {
|
|
t.Fatalf("expected settled monitored-system usage in entitlements payload, got %+v", payload.Limits)
|
|
}
|
|
if payload.Limits[0].CurrentAvailable == nil || !*payload.Limits[0].CurrentAvailable {
|
|
t.Fatalf("expected available monitored-system usage after async capture, got %+v", payload.Limits[0])
|
|
}
|
|
}
|
|
|
|
func buildGrandfatherFloorMonitor(count int) *monitoring.Monitor {
|
|
now := time.Now().UTC()
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
records := make([]unifiedresources.IngestRecord, 0, count)
|
|
for i := 0; i < count; i++ {
|
|
id := fmt.Sprintf("host-%02d", i+1)
|
|
hostname := fmt.Sprintf("legacy-%02d.lab.local", i+1)
|
|
machineID := fmt.Sprintf("machine-%02d", i+1)
|
|
records = append(records, unifiedresources.IngestRecord{
|
|
SourceID: id,
|
|
Resource: unifiedresources.Resource{
|
|
ID: id,
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: hostname,
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
Agent: &unifiedresources.AgentData{
|
|
AgentID: "agent-" + id,
|
|
Hostname: hostname,
|
|
MachineID: machineID,
|
|
},
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
MachineID: machineID,
|
|
Hostnames: []string{hostname},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
registry.IngestRecords(unifiedresources.SourceAgent, records)
|
|
|
|
monitor := &monitoring.Monitor{}
|
|
monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(registry))
|
|
return monitor
|
|
}
|
|
|
|
func buildSupplementalGrandfatherFloorMonitor(provider *grandfatherFloorSupplementalProvider) *monitoring.Monitor {
|
|
monitor := &monitoring.Monitor{}
|
|
monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(nil))
|
|
monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider)
|
|
return monitor
|
|
}
|
|
|
|
func buildSupplementalGrandfatherFloorRecords(
|
|
count int,
|
|
now time.Time,
|
|
) []unifiedresources.IngestRecord {
|
|
records := make([]unifiedresources.IngestRecord, 0, count)
|
|
for i := 0; i < count; i++ {
|
|
id := fmt.Sprintf("truenas-%02d", i+1)
|
|
hostname := fmt.Sprintf("truenas-%02d.lab.local", i+1)
|
|
records = append(records, unifiedresources.IngestRecord{
|
|
SourceID: "system:" + id,
|
|
Resource: unifiedresources.Resource{
|
|
ID: id,
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: hostname,
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
Hostnames: []string{hostname},
|
|
},
|
|
TrueNAS: &unifiedresources.TrueNASData{
|
|
Hostname: hostname,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return records
|
|
}
|
|
|
|
// base64RawURLEncode is a helper for tests.
|
|
func base64RawURLEncode(data []byte) string {
|
|
// Use the same encoding as JWT: base64 URL-safe without padding.
|
|
const encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
result := make([]byte, 0, (len(data)*4+2)/3)
|
|
for i := 0; i < len(data); i += 3 {
|
|
var b0, b1, b2 byte
|
|
b0 = data[i]
|
|
if i+1 < len(data) {
|
|
b1 = data[i+1]
|
|
}
|
|
if i+2 < len(data) {
|
|
b2 = data[i+2]
|
|
}
|
|
result = append(result, encodeURL[b0>>2])
|
|
result = append(result, encodeURL[((b0&0x03)<<4)|(b1>>4)])
|
|
if i+1 < len(data) {
|
|
result = append(result, encodeURL[((b1&0x0f)<<2)|(b2>>6)])
|
|
}
|
|
if i+2 < len(data) {
|
|
result = append(result, encodeURL[b2&0x3f])
|
|
}
|
|
}
|
|
return string(result)
|
|
}
|