mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Retire self-hosted volume caps
This commit is contained in:
parent
e8987e0974
commit
a67845ada0
17 changed files with 355 additions and 231 deletions
|
|
@ -454,19 +454,28 @@ Community limit enforcement.
|
|||
value must come from optional extras, hosted convenience, business
|
||||
workflow, support, or similar non-core surfaces rather than using
|
||||
monitored-system volume itself as the primary paid gate.
|
||||
Child-resource volume, including guest capacity, must follow the same
|
||||
self-hosted rule instead of becoming a replacement paid gate for core
|
||||
monitoring.
|
||||
Monitored-system admission-preview copy must follow the same rule: ordinary
|
||||
connection previews may describe count impact and active policy checks, but
|
||||
they must not use capacity-style titles or slash-style quota summaries that
|
||||
imply self-hosted monitoring volume is the product being sold.
|
||||
13. Keep migrated self-hosted Community/free billing state uncapped even when
|
||||
the persisted file still carries legacy v5 commercial limit keys:
|
||||
`pkg/licensing/billing_state_normalization.go` and
|
||||
`pkg/licensing/database_source.go` must scrub stale
|
||||
`max_monitored_systems` and `max_guests` values for community/free
|
||||
billing-state plan labels before runtime-capability, entitlement, or
|
||||
warning-banner payloads are built, while leaving non-community plan labels
|
||||
available for bounded hosted or legacy continuity contracts that still
|
||||
carry explicit monitored-system ceilings.
|
||||
13. Keep self-hosted v6 billing state uncapped even when persisted state still
|
||||
carries legacy v5 commercial volume-limit keys:
|
||||
`pkg/licensing/models.go`,
|
||||
`pkg/licensing/service.go`,
|
||||
`pkg/licensing/entitlement_payload.go`,
|
||||
`pkg/licensing/billing_state_normalization.go`,
|
||||
`pkg/licensing/database_source.go`,
|
||||
`pulse-pro:license-server/v6_store.go`, and
|
||||
`pulse-pro:license-server/v6_schema.go` must scrub stale
|
||||
`max_monitored_systems` and `max_guests` values for self-hosted
|
||||
Community/free, Relay, Pro, Pro+, Pro Annual, lifetime, and eligible
|
||||
grandfathered recurring plan labels before runtime-capability,
|
||||
entitlement, grant, or warning-banner payloads are built, while leaving
|
||||
bounded hosted Cloud/MSP contracts available for top-level hosted
|
||||
monitored-system ceilings.
|
||||
14. Keep self-hosted commercial funnel stage ownership explicit:
|
||||
`pkg/licensing/conversion_events.go`,
|
||||
`pkg/licensing/conversion_store.go`, and
|
||||
|
|
|
|||
|
|
@ -5403,7 +5403,8 @@ func TestContract_SelfHostedCommunityRuntimeCapabilitiesJSONSnapshot(t *testing.
|
|||
func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(t *testing.T) {
|
||||
payload := buildEntitlementPayloadWithUsage(&licenseStatus{
|
||||
Valid: true,
|
||||
Tier: pkglicensing.TierPro,
|
||||
Tier: pkglicensing.TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
|
||||
MaxMonitoredSystems: 15,
|
||||
MonitoredSystemContinuity: &pkglicensing.MonitoredSystemContinuityStatus{
|
||||
|
|
@ -5425,7 +5426,8 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(
|
|||
"limits":[{"key":"max_monitored_systems","limit":15,"current":0,"current_available":false,"current_unavailable_reason":"supplemental_inventory_unsettled","state":"ok"}],
|
||||
"subscription_state":"active",
|
||||
"upgrade_reasons":[],
|
||||
"tier":"pro",
|
||||
"plan_version":"cloud_starter",
|
||||
"tier":"cloud",
|
||||
"hosted_mode":false,
|
||||
"valid":true,
|
||||
"is_lifetime":false,
|
||||
|
|
@ -5514,7 +5516,7 @@ func TestContract_EntitlementUsageSnapshotWaitsForSettledSupplementalInventory(t
|
|||
}
|
||||
}
|
||||
|
||||
func TestContract_LegacyMigrationGrandfatherFloorFallbackJSONSnapshot(t *testing.T) {
|
||||
func TestContract_LegacyMigrationFallbackStaysUncappedJSONSnapshot(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
const expectedClientVersion = "6.0.0-rc.1"
|
||||
|
||||
|
|
@ -5689,15 +5691,13 @@ func TestContract_LegacyMigrationGrandfatherFloorFallbackJSONSnapshot(t *testing
|
|||
"tier":"pro",
|
||||
"plan_version":"legacy_migration_fallback",
|
||||
"max_monitored_systems":0,
|
||||
"valid":true,
|
||||
"monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":0,"capture_pending":false,"captured_at":123}
|
||||
"valid":true
|
||||
},
|
||||
"entitlements":{
|
||||
"tier":"pro",
|
||||
"plan_version":"legacy_migration_fallback",
|
||||
"subscription_state":"active",
|
||||
"limits":[],
|
||||
"monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":0,"capture_pending":false,"captured_at":123},
|
||||
"monitored_system_capacity":{"mode":"unlimited","urgency":"ok","current":23,"limit":0,"current_available":true,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":true}
|
||||
}
|
||||
}`
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
if guestLimit == nil {
|
||||
t.Fatalf("expected max_guests limit")
|
||||
if guestLimit != nil {
|
||||
t.Fatalf("expected self-hosted pro to omit max_guests limit, got %+v", guestLimit)
|
||||
}
|
||||
if agentLimit != nil {
|
||||
t.Fatalf("expected no max_monitored_systems limit for self-hosted pro, got %+v", agentLimit)
|
||||
|
|
@ -122,9 +122,6 @@ func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) {
|
|||
if payload.MonitoredSystemCapacity.Mode != "unlimited" || payload.MonitoredSystemCapacity.Current != 12 {
|
||||
t.Fatalf("expected unlimited monitored-system capacity with current=12, got %+v", payload.MonitoredSystemCapacity)
|
||||
}
|
||||
if guestLimit.Current != 44 {
|
||||
t.Fatalf("expected guest current 44, got %d", guestLimit.Current)
|
||||
}
|
||||
if payload.LegacyConnections.ProxmoxNodes != 2 {
|
||||
t.Fatalf("expected proxmox_nodes 2, got %d", payload.LegacyConnections.ProxmoxNodes)
|
||||
}
|
||||
|
|
@ -355,7 +352,7 @@ func TestBuildEntitlementPayload_PreservesPlanVersionForSelfHostedJWT(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitlementHandler_UsesEvaluatorWhenNoLicense(t *testing.T) {
|
||||
func TestEntitlementHandler_HostedEvaluatorKeepsCloudLimitsWhenNoLicense(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(baseDir)
|
||||
|
||||
|
|
@ -373,7 +370,7 @@ func TestEntitlementHandler_UsesEvaluatorWhenNoLicense(t *testing.T) {
|
|||
Limits: map[string]int64{
|
||||
"max_monitored_systems": 5,
|
||||
},
|
||||
PlanVersion: "pro",
|
||||
PlanVersion: "cloud_starter",
|
||||
SubscriptionState: entitlements.SubStateActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err)
|
||||
|
|
@ -398,8 +395,8 @@ func TestEntitlementHandler_UsesEvaluatorWhenNoLicense(t *testing.T) {
|
|||
if payload.SubscriptionState != string(license.SubStateActive) {
|
||||
t.Fatalf("subscription_state=%q, want %q", payload.SubscriptionState, license.SubStateActive)
|
||||
}
|
||||
if payload.PlanVersion != "pro" {
|
||||
t.Fatalf("plan_version=%q, want %q", payload.PlanVersion, "pro")
|
||||
if payload.PlanVersion != "cloud_starter" {
|
||||
t.Fatalf("plan_version=%q, want %q", payload.PlanVersion, "cloud_starter")
|
||||
}
|
||||
|
||||
contains := func(values []string, key string) bool {
|
||||
|
|
@ -428,8 +425,8 @@ func TestEntitlementHandler_UsesEvaluatorWhenNoLicense(t *testing.T) {
|
|||
if maxMonitoredSystems == nil {
|
||||
t.Fatalf("expected max_monitored_systems limit in payload, got %v", payload.Limits)
|
||||
}
|
||||
if maxMonitoredSystems.Limit != 5 {
|
||||
t.Fatalf("max_monitored_systems.limit=%d, want %d", maxMonitoredSystems.Limit, 5)
|
||||
if maxMonitoredSystems.Limit != 10 {
|
||||
t.Fatalf("max_monitored_systems.limit=%d, want %d", maxMonitoredSystems.Limit, 10)
|
||||
}
|
||||
|
||||
// Parity: every advertised capability must be enforced by HasFeature.
|
||||
|
|
@ -445,6 +442,57 @@ func TestEntitlementHandler_UsesEvaluatorWhenNoLicense(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEntitlementHandler_SelfHostedPaidEvaluatorStateIsUncapped(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(baseDir)
|
||||
|
||||
orgID := "test-self-hosted-paid-entitlements"
|
||||
if _, err := mtp.GetPersistence(orgID); err != nil {
|
||||
t.Fatalf("GetPersistence(%s) failed: %v", orgID, err)
|
||||
}
|
||||
|
||||
store := config.NewFileBillingStore(baseDir)
|
||||
if err := store.SaveBillingState(orgID, &entitlements.BillingState{
|
||||
Capabilities: []string{
|
||||
license.FeatureAIPatrol,
|
||||
license.FeatureAIAutoFix,
|
||||
},
|
||||
Limits: map[string]int64{
|
||||
"max_monitored_systems": 5,
|
||||
"max_guests": 50,
|
||||
},
|
||||
PlanVersion: "pro",
|
||||
SubscriptionState: entitlements.SubStateActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err)
|
||||
}
|
||||
|
||||
h := NewLicenseHandlers(mtp, true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleEntitlements(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var payload EntitlementPayload
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload failed: %v", err)
|
||||
}
|
||||
|
||||
if payload.PlanVersion != "pro" {
|
||||
t.Fatalf("plan_version=%q, want %q", payload.PlanVersion, "pro")
|
||||
}
|
||||
for _, limit := range payload.Limits {
|
||||
if limit.Key == "max_monitored_systems" || limit.Key == "max_guests" {
|
||||
t.Fatalf("expected self-hosted paid evaluator payload to omit volume caps, got %+v", payload.Limits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitlementHandler_GrandfatheredRecurringEvaluatorStateIsUncapped(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(baseDir)
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ func TestGetTenantComponents_PersistsCommercialMigrationState_WhenAutoExchangeFa
|
|||
handlers.StopAllBackgroundLoops()
|
||||
}
|
||||
|
||||
func TestGetTenantComponents_AutoExchangeGrandfathersObservedMonitoredSystems(t *testing.T) {
|
||||
func TestGetTenantComponents_AutoExchangeLeavesSelfHostedMonitoringUncapped(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
|
||||
grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
||||
|
|
@ -411,18 +411,18 @@ func TestGetTenantComponents_AutoExchangeGrandfathersObservedMonitoredSystems(t
|
|||
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.GrandfatheredMaxMonitoredSystems != 0 {
|
||||
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMaxMonitoredSystems)
|
||||
}
|
||||
if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
|
||||
t.Fatal("expected grandfather capture timestamp")
|
||||
if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
|
||||
t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt)
|
||||
}
|
||||
}
|
||||
|
||||
handlers.StopAllBackgroundLoops()
|
||||
}
|
||||
|
||||
func TestGetTenantComponents_BackfillsGrandfatherFloorAfterRestoreWhenMonitorArrivesLate(t *testing.T) {
|
||||
func TestGetTenantComponents_DoesNotBackfillGrandfatherFloorForUncappedSelfHostedRestore(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
|
||||
grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
||||
|
|
@ -481,23 +481,7 @@ func TestGetTenantComponents_BackfillsGrandfatherFloorAfterRestoreWhenMonitorArr
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
loaded, err := persistence.LoadActivationState()
|
||||
if err != nil {
|
||||
|
|
@ -506,14 +490,17 @@ func TestGetTenantComponents_BackfillsGrandfatherFloorAfterRestoreWhenMonitorArr
|
|||
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.GrandfatheredMaxMonitoredSystems != 0 {
|
||||
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 0 for uncapped self-hosted restore", loaded.Continuity.GrandfatheredMaxMonitoredSystems)
|
||||
}
|
||||
if loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
|
||||
t.Fatal("expected captured timestamp after late monitor restore")
|
||||
if loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
|
||||
t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted restore", loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt)
|
||||
}
|
||||
if got := svc.Status().MaxMonitoredSystems; got != 0 {
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want 0 after late monitor capture for uncapped self-hosted continuity", got)
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want 0 for uncapped self-hosted restore", got)
|
||||
}
|
||||
if svc.Status().MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no monitored-system continuity for uncapped self-hosted restore, got %+v", svc.Status().MonitoredSystemContinuity)
|
||||
}
|
||||
|
||||
handlers.StopAllBackgroundLoops()
|
||||
|
|
@ -576,22 +563,9 @@ func TestBillingReads_DoNotRestartLegacyGrandfatherReconcileLoop(t *testing.T) {
|
|||
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")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok {
|
||||
t.Fatal("expected grandfather reconcile loop to stop before read-only check")
|
||||
t.Fatal("uncapped self-hosted restore started grandfather reconcile loop")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx)
|
||||
|
|
@ -617,7 +591,7 @@ func TestBillingReads_DoNotRestartLegacyGrandfatherReconcileLoop(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestActivateLicenseKey_GrandfathersObservedMonitoredSystemsForLegacyMigration(t *testing.T) {
|
||||
func TestActivateLicenseKey_KeepsLegacySelfHostedMigrationUncapped(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
|
||||
grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
||||
|
|
@ -692,7 +666,7 @@ func TestActivateLicenseKey_GrandfathersObservedMonitoredSystemsForLegacyMigrati
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySettles(t *testing.T) {
|
||||
func TestGetTenantComponents_DoesNotCaptureGrandfatherFloorWhenSupplementalInventorySettlesForUncappedSelfHosted(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
|
||||
grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
|
||||
|
|
@ -798,16 +772,13 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet
|
|||
if got := status.MaxMonitoredSystems; got != 0 {
|
||||
t.Fatalf("initial status.MaxMonitoredSystems=%d, want 0 while supplemental inventory is unsettled for uncapped self-hosted continuity", 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 != 0 {
|
||||
t.Fatalf("unexpected pending continuity status payload: %+v", status.MonitoredSystemContinuity)
|
||||
if status.MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no continuity in status payload for uncapped self-hosted migration, got %+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 payload.MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no continuity in entitlements payload for uncapped self-hosted migration, got %+v", payload.MonitoredSystemContinuity)
|
||||
}
|
||||
if len(payload.Limits) != 0 {
|
||||
t.Fatalf("expected no enforced monitored-system limit for uncapped continuity, got %+v", payload.Limits)
|
||||
|
|
@ -836,7 +807,7 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet
|
|||
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)
|
||||
t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt)
|
||||
}
|
||||
|
||||
provider.settle(23)
|
||||
|
|
@ -851,37 +822,32 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet
|
|||
t.Fatalf("status read should not reintroduce a monitored-system cap 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)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
activationState, err = persistence.LoadActivationState()
|
||||
if err != nil {
|
||||
t.Fatalf("reload activation state: %v", err)
|
||||
}
|
||||
if activationState == nil {
|
||||
t.Fatal("expected activation state after canonical store rebuild")
|
||||
}
|
||||
if activationState.Continuity.GrandfatheredMaxMonitoredSystems != 0 {
|
||||
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMaxMonitoredSystems)
|
||||
}
|
||||
if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
|
||||
t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt)
|
||||
}
|
||||
|
||||
status = readStatus()
|
||||
if got := status.MaxMonitoredSystems; got != 0 {
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want 0 after async grandfather capture for uncapped self-hosted continuity", got)
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want 0 after canonical store rebuild for uncapped self-hosted continuity", 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 != 0 {
|
||||
t.Fatalf("unexpected settled continuity status payload: %+v", status.MonitoredSystemContinuity)
|
||||
if status.MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no continuity in status payload after canonical store rebuild, got %+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 payload.MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no continuity in entitlements payload after canonical store rebuild, got %+v", payload.MonitoredSystemContinuity)
|
||||
}
|
||||
if len(payload.Limits) != 0 {
|
||||
t.Fatalf("expected no enforced monitored-system limit in entitlements payload after async capture, got %+v", payload.Limits)
|
||||
|
|
@ -890,7 +856,7 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet
|
|||
t.Fatal("expected monitored-system capacity payload after async capture")
|
||||
}
|
||||
if payload.MonitoredSystemCapacity.Mode != "unlimited" || payload.MonitoredSystemCapacity.Current != 23 {
|
||||
t.Fatalf("expected unlimited monitored-system capacity with current=23 after async capture, got %+v", payload.MonitoredSystemCapacity)
|
||||
t.Fatalf("expected unlimited monitored-system capacity with current=23 after canonical store rebuild, got %+v", payload.MonitoredSystemCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package licensing
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
|
@ -100,12 +99,7 @@ func grantClaimsUseUncappedCoreMonitoring(gc *GrantClaims) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
switch Tier(strings.TrimSpace(gc.Tier)) {
|
||||
case TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return IsSelfHostedCoreMonitoringUncappedTier(Tier(gc.Tier))
|
||||
}
|
||||
|
||||
// GrantClaims are the claims parsed from a relay grant JWT payload.
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ func TestNormalizeBillingState_GrandfatheredRecurringPlanStripsLegacyCommercialC
|
|||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBillingState_PreservesNonCloudPlanLimits(t *testing.T) {
|
||||
func TestNormalizeBillingState_PreservesUnknownNonCloudPlanLimits(t *testing.T) {
|
||||
state := &BillingState{
|
||||
PlanVersion: "pro-v2",
|
||||
Limits: map[string]int64{
|
||||
|
|
@ -349,7 +349,39 @@ func TestNormalizeBillingState_PreservesNonCloudPlanLimits(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBillingState_SelfHostedCommunityPlanStripsLegacyCommercialCaps(t *testing.T) {
|
||||
func TestNormalizeBillingState_SelfHostedPlansStripLegacyCommercialCaps(t *testing.T) {
|
||||
plans := []string{" community ", "free", "relay", "pro", "pro_plus", "pro_annual", "lifetime", "v5_lifetime_grandfathered"}
|
||||
for _, plan := range plans {
|
||||
t.Run(plan, func(t *testing.T) {
|
||||
state := &BillingState{
|
||||
PlanVersion: plan,
|
||||
Limits: map[string]int64{
|
||||
"max_monitored_systems": 1,
|
||||
"max_guests": 5,
|
||||
"max_nodes": 99,
|
||||
"max_reports": 7,
|
||||
},
|
||||
SubscriptionState: SubStateActive,
|
||||
}
|
||||
|
||||
normalized := NormalizeBillingState(state)
|
||||
if _, ok := normalized.Limits["max_monitored_systems"]; ok {
|
||||
t.Fatalf("expected max_monitored_systems to be scrubbed, got %v", normalized.Limits)
|
||||
}
|
||||
if _, ok := normalized.Limits["max_guests"]; ok {
|
||||
t.Fatalf("expected max_guests to be scrubbed, got %v", normalized.Limits)
|
||||
}
|
||||
if _, ok := normalized.Limits["max_nodes"]; ok {
|
||||
t.Fatalf("expected max_nodes to be removed during normalization, got %v", normalized.Limits)
|
||||
}
|
||||
if got := normalized.Limits["max_reports"]; got != 7 {
|
||||
t.Fatalf("limits[max_reports]=%d, want %d", got, 7)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBillingState_SelfHostedCommunityPlanCanonicalizesLegacyCommercialCaps(t *testing.T) {
|
||||
state := &BillingState{
|
||||
PlanVersion: " community ",
|
||||
Limits: map[string]int64{
|
||||
|
|
|
|||
|
|
@ -147,29 +147,34 @@ func TestDatabaseSourceGrandfatheredRecurringPlanStripsCappedLimits(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestDatabaseSourceSelfHostedCommunityPlanStripsLegacyCommercialCaps(t *testing.T) {
|
||||
store := &mockBillingStore{
|
||||
state: &BillingState{
|
||||
PlanVersion: "community",
|
||||
Limits: map[string]int64{
|
||||
"max_monitored_systems": 1,
|
||||
"max_guests": 5,
|
||||
"max_reports": 7,
|
||||
},
|
||||
SubscriptionState: SubStateActive,
|
||||
},
|
||||
}
|
||||
func TestDatabaseSourceSelfHostedPlansStripLegacyCommercialCaps(t *testing.T) {
|
||||
plans := []string{"community", "free", "relay", "pro", "pro_plus", "pro_annual", "lifetime", "v5_lifetime_grandfathered"}
|
||||
for _, plan := range plans {
|
||||
t.Run(plan, func(t *testing.T) {
|
||||
store := &mockBillingStore{
|
||||
state: &BillingState{
|
||||
PlanVersion: plan,
|
||||
Limits: map[string]int64{
|
||||
"max_monitored_systems": 1,
|
||||
"max_guests": 5,
|
||||
"max_reports": 7,
|
||||
},
|
||||
SubscriptionState: SubStateActive,
|
||||
},
|
||||
}
|
||||
|
||||
source := NewDatabaseSource(store, "org-1", time.Hour)
|
||||
source := NewDatabaseSource(store, "org-1", time.Hour)
|
||||
|
||||
if got := source.PlanVersion(); got != "community" {
|
||||
t.Fatalf("expected plan_version %q, got %q", "community", got)
|
||||
}
|
||||
if got := source.SubscriptionState(); got != SubStateActive {
|
||||
t.Fatalf("expected subscription_state %q, got %q", SubStateActive, got)
|
||||
}
|
||||
if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_reports": 7}) {
|
||||
t.Fatalf("expected community plan to scrub legacy commercial caps, got limits %v", got)
|
||||
if got := source.PlanVersion(); got != plan {
|
||||
t.Fatalf("expected plan_version %q, got %q", plan, got)
|
||||
}
|
||||
if got := source.SubscriptionState(); got != SubStateActive {
|
||||
t.Fatalf("expected subscription_state %q, got %q", SubStateActive, got)
|
||||
}
|
||||
if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_reports": 7}) {
|
||||
t.Fatalf("expected self-hosted plan to scrub legacy commercial caps, got limits %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -394,6 +394,16 @@ func CommercialPosturePayloadFromEntitlementPayload(
|
|||
return sanitized
|
||||
}
|
||||
|
||||
func licenseStatusUsesUncappedCoreMonitoring(status *LicenseStatus) bool {
|
||||
if status == nil {
|
||||
return false
|
||||
}
|
||||
if status.PlanVersion != "" {
|
||||
return IsSelfHostedCoreMonitoringUncappedPlanVersion(status.PlanVersion)
|
||||
}
|
||||
return IsSelfHostedCoreMonitoringUncappedTier(status.Tier)
|
||||
}
|
||||
|
||||
// BuildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage.
|
||||
func BuildEntitlementPayloadWithUsage(
|
||||
status *LicenseStatus,
|
||||
|
|
@ -434,14 +444,22 @@ func BuildEntitlementPayloadWithUsage(
|
|||
LegacyConnections: usage.LegacyConnections,
|
||||
HasMigrationGap: false,
|
||||
}
|
||||
uncappedCoreMonitoring := licenseStatusUsesUncappedCoreMonitoring(status)
|
||||
if status.MonitoredSystemContinuity != nil {
|
||||
continuity := *status.MonitoredSystemContinuity
|
||||
payload.MonitoredSystemContinuity = &continuity
|
||||
}
|
||||
monitoredSystemLimit := int64(status.MaxMonitoredSystems)
|
||||
capacityContinuity := status.MonitoredSystemContinuity
|
||||
if uncappedCoreMonitoring {
|
||||
monitoredSystemLimit = 0
|
||||
payload.MonitoredSystemContinuity = nil
|
||||
capacityContinuity = nil
|
||||
}
|
||||
payload.MonitoredSystemCapacity = buildMonitoredSystemCapacityStatus(
|
||||
int64(status.MaxMonitoredSystems),
|
||||
monitoredSystemLimit,
|
||||
usage,
|
||||
status.MonitoredSystemContinuity,
|
||||
capacityContinuity,
|
||||
)
|
||||
|
||||
if payload.Capabilities == nil {
|
||||
|
|
@ -470,7 +488,7 @@ func BuildEntitlementPayloadWithUsage(
|
|||
}
|
||||
|
||||
// Build limits.
|
||||
if status.MaxMonitoredSystems > 0 {
|
||||
if status.MaxMonitoredSystems > 0 && !uncappedCoreMonitoring {
|
||||
currentSystems := usage.monitoredSystemCount()
|
||||
limit := LimitStatus{
|
||||
Key: MaxMonitoredSystemsLicenseGateKey,
|
||||
|
|
@ -484,7 +502,7 @@ func BuildEntitlementPayloadWithUsage(
|
|||
}
|
||||
payload.Limits = append(payload.Limits, limit)
|
||||
}
|
||||
if status.MaxGuests > 0 {
|
||||
if status.MaxGuests > 0 && !uncappedCoreMonitoring {
|
||||
payload.Limits = append(payload.Limits, LimitStatus{
|
||||
Key: "max_guests",
|
||||
Limit: int64(status.MaxGuests),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ func containsString(values []string, want string) bool {
|
|||
func TestBuildEntitlementPayload_ActiveLicense(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 50,
|
||||
}
|
||||
|
|
@ -102,7 +103,8 @@ func TestBuildEntitlementPayload_FreeTier(t *testing.T) {
|
|||
func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 50,
|
||||
MaxGuests: 100,
|
||||
|
|
@ -199,7 +201,8 @@ func TestBuildEntitlementPayload_TrialState(t *testing.T) {
|
|||
func TestBuildEntitlementPayloadWithUsage_RequiresCanonicalMonitoredSystemAvailability(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 50,
|
||||
}
|
||||
|
|
@ -227,7 +230,8 @@ func TestBuildEntitlementPayloadWithUsage_RequiresCanonicalMonitoredSystemAvaila
|
|||
func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 50,
|
||||
}
|
||||
|
|
@ -261,7 +265,8 @@ func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *tes
|
|||
func TestBuildEntitlementPayloadWithUsage_CopiesMonitoredSystemContinuity(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 23,
|
||||
MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{
|
||||
|
|
@ -433,8 +438,9 @@ func TestBuildEntitlementPayload_NilStatus_MaxHistoryDays(t *testing.T) {
|
|||
func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierFree,
|
||||
Features: append([]string(nil), TierFeatures[TierFree]...),
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 5,
|
||||
}
|
||||
|
||||
|
|
@ -447,14 +453,14 @@ func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) {
|
|||
},
|
||||
}, nil)
|
||||
|
||||
if payload.Tier != string(TierFree) {
|
||||
t.Fatalf("expected tier=%q, got %q", TierFree, payload.Tier)
|
||||
if payload.Tier != string(TierCloud) {
|
||||
t.Fatalf("expected tier=%q, got %q", TierCloud, payload.Tier)
|
||||
}
|
||||
if payload.SubscriptionState != string(SubStateActive) {
|
||||
t.Fatalf("expected subscription_state=%q, got %q", SubStateActive, payload.SubscriptionState)
|
||||
}
|
||||
if len(payload.UpgradeReasons) == 0 {
|
||||
t.Fatal("expected commercial posture to preserve upgrade reasons")
|
||||
if len(payload.UpgradeReasons) != 0 {
|
||||
t.Fatalf("expected hosted paid posture to omit upgrade reasons, got %v", payload.UpgradeReasons)
|
||||
}
|
||||
if payload.LegacyConnections.ProxmoxNodes != 2 || payload.LegacyConnections.DockerHosts != 1 {
|
||||
t.Fatalf("expected legacy connection counts to be preserved, got %+v", payload.LegacyConnections)
|
||||
|
|
@ -479,7 +485,8 @@ func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) {
|
|||
func TestBuildEntitlementPayloadWithUsage_MarksLegacyContinuityCapturePendingOverage(t *testing.T) {
|
||||
payload := BuildEntitlementPayloadWithUsage(&LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 10,
|
||||
MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{
|
||||
|
|
@ -510,8 +517,8 @@ func TestCommercialPosturePayloadFromEntitlementPayload_StripsBillingIdentityFie
|
|||
expiresAt := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
payload := BuildEntitlementPayloadWithUsage(&LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
PlanVersion: "v5_lifetime_grandfathered",
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Email: "owner@example.com",
|
||||
ExpiresAt: &expiresAt,
|
||||
IsLifetime: false,
|
||||
|
|
@ -528,8 +535,8 @@ func TestCommercialPosturePayloadFromEntitlementPayload_StripsBillingIdentityFie
|
|||
|
||||
posture := CommercialPosturePayloadFromEntitlementPayload(payload)
|
||||
|
||||
if posture.Tier != string(TierPro) {
|
||||
t.Fatalf("expected tier=%q, got %q", TierPro, posture.Tier)
|
||||
if posture.Tier != string(TierCloud) {
|
||||
t.Fatalf("expected tier=%q, got %q", TierCloud, posture.Tier)
|
||||
}
|
||||
if posture.SubscriptionState != string(SubStateActive) {
|
||||
t.Fatalf("expected subscription_state=%q, got %q", SubStateActive, posture.SubscriptionState)
|
||||
|
|
@ -567,7 +574,8 @@ func TestCommercialPosturePayloadFromEntitlementPayload_StripsBillingIdentityFie
|
|||
func TestBuildRuntimeCapabilitiesPayloadWithUsage_CurrentValues(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierRelay,
|
||||
Tier: TierCloud,
|
||||
PlanVersion: "cloud_starter",
|
||||
Features: append([]string(nil), TierFeatures[TierRelay]...),
|
||||
MaxMonitoredSystems: 12,
|
||||
}
|
||||
|
|
@ -580,8 +588,8 @@ func TestBuildRuntimeCapabilitiesPayloadWithUsage_CurrentValues(t *testing.T) {
|
|||
if !reflect.DeepEqual(payload.Capabilities, status.Features) {
|
||||
t.Fatalf("expected capabilities to match status features")
|
||||
}
|
||||
if payload.MaxHistoryDays != TierHistoryDays[TierRelay] {
|
||||
t.Fatalf("expected relay max history days %d, got %d", TierHistoryDays[TierRelay], payload.MaxHistoryDays)
|
||||
if payload.MaxHistoryDays != TierHistoryDays[TierCloud] {
|
||||
t.Fatalf("expected cloud max history days %d, got %d", TierHistoryDays[TierCloud], payload.MaxHistoryDays)
|
||||
}
|
||||
if len(payload.Limits) != 1 {
|
||||
t.Fatalf("expected one runtime limit, got %d", len(payload.Limits))
|
||||
|
|
|
|||
|
|
@ -162,10 +162,7 @@ func IsGrandfatheredRecurringV5PlanVersion(planVersion string) bool {
|
|||
}
|
||||
|
||||
// IsSelfHostedCommunityPlanVersion reports whether a persisted billing-state
|
||||
// plan version denotes the uncapped self-hosted Community/free posture. This
|
||||
// is narrower than tier-based self-hosted licensing: billing-state plan labels
|
||||
// like "pro" can still carry explicit continuity limits in hosted and legacy
|
||||
// migration paths, so only community/free variants are safe to scrub here.
|
||||
// plan version denotes the uncapped self-hosted Community/free posture.
|
||||
func IsSelfHostedCommunityPlanVersion(planVersion string) bool {
|
||||
switch strings.ToLower(CanonicalizePlanVersion(planVersion)) {
|
||||
case "community", string(TierFree):
|
||||
|
|
@ -175,9 +172,32 @@ func IsSelfHostedCommunityPlanVersion(planVersion string) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// IsSelfHostedCoreMonitoringUncappedTier reports whether a tier is part of the
|
||||
// self-hosted v6 contract where core monitoring volume is not monetized.
|
||||
func IsSelfHostedCoreMonitoringUncappedTier(tier Tier) bool {
|
||||
switch Tier(strings.ToLower(strings.TrimSpace(string(tier)))) {
|
||||
case TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion string) bool {
|
||||
return IsSelfHostedCommunityPlanVersion(planVersion) ||
|
||||
IsGrandfatheredRecurringV5PlanVersion(planVersion)
|
||||
normalized := strings.ToLower(CanonicalizePlanVersion(planVersion))
|
||||
switch normalized {
|
||||
case "community",
|
||||
string(TierFree),
|
||||
string(TierRelay),
|
||||
string(TierPro),
|
||||
string(TierProPlus),
|
||||
string(TierProAnnual),
|
||||
string(TierLifetime),
|
||||
"v5_lifetime_grandfathered":
|
||||
return true
|
||||
default:
|
||||
return IsGrandfatheredRecurringV5PlanVersion(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func stripLegacyCommercialCaps(limits map[string]int64) {
|
||||
|
|
|
|||
|
|
@ -695,6 +695,12 @@ func TestIsSelfHostedCoreMonitoringUncappedPlanVersion(t *testing.T) {
|
|||
}{
|
||||
{plan: "community", want: true},
|
||||
{plan: "free", want: true},
|
||||
{plan: "relay", want: true},
|
||||
{plan: "pro", want: true},
|
||||
{plan: "pro_plus", want: true},
|
||||
{plan: "pro_annual", want: true},
|
||||
{plan: "lifetime", want: true},
|
||||
{plan: "v5_lifetime_grandfathered", want: true},
|
||||
{plan: "v5_pro_monthly_grandfathered", want: true},
|
||||
{plan: "v5_pro_annual_grandfathered", want: true},
|
||||
{plan: "cloud_starter", want: false},
|
||||
|
|
@ -712,6 +718,33 @@ func TestIsSelfHostedCoreMonitoringUncappedPlanVersion(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsSelfHostedCoreMonitoringUncappedTier(t *testing.T) {
|
||||
tests := []struct {
|
||||
tier Tier
|
||||
want bool
|
||||
}{
|
||||
{tier: TierFree, want: true},
|
||||
{tier: TierRelay, want: true},
|
||||
{tier: TierPro, want: true},
|
||||
{tier: TierProPlus, want: true},
|
||||
{tier: TierProAnnual, want: true},
|
||||
{tier: TierLifetime, want: true},
|
||||
{tier: TierCloud, want: false},
|
||||
{tier: TierMSP, want: false},
|
||||
{tier: TierEnterprise, want: false},
|
||||
{tier: Tier(" pro "), want: true},
|
||||
{tier: Tier("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.tier), func(t *testing.T) {
|
||||
if got := IsSelfHostedCoreMonitoringUncappedTier(tt.tier); got != tt.want {
|
||||
t.Fatalf("IsSelfHostedCoreMonitoringUncappedTier(%q) = %v, want %v", tt.tier, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPriceIDToPlanVersion_AllMapToKnownPlans ensures every plan version in the
|
||||
// price→plan map is recognized by LimitsForCloudPlan (fail-closed safety net).
|
||||
func TestPriceIDToPlanVersion_AllMapToKnownPlans(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,28 @@ func TestDatabaseSourceCanonicalBoundaryContract(t *testing.T) {
|
|||
t.Fatalf("Limits() = %v, want no grandfathered recurring caps", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("self_hosted_paid_plans_stay_uncapped", func(t *testing.T) {
|
||||
store := &mockBillingStore{
|
||||
state: &BillingState{
|
||||
PlanVersion: "pro",
|
||||
Limits: map[string]int64{
|
||||
"max_monitored_systems": 10,
|
||||
"max_guests": 50,
|
||||
},
|
||||
SubscriptionState: SubStateActive,
|
||||
},
|
||||
}
|
||||
|
||||
source := NewDatabaseSource(store, "org-1", time.Hour)
|
||||
|
||||
if got := source.PlanVersion(); got != "pro" {
|
||||
t.Fatalf("PlanVersion() = %q, want %q", got, "pro")
|
||||
}
|
||||
if got := source.Limits(); len(got) != 0 {
|
||||
t.Fatalf("Limits() = %v, want no self-hosted paid volume caps", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// grantContractJSONTags lists the JSON field names that MUST exist with
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func (c Claims) shouldScrubLegacyCommercialCaps() bool {
|
|||
if c.CoreMonitoringUncapped {
|
||||
return true
|
||||
}
|
||||
if c.Tier == TierLifetime {
|
||||
if IsSelfHostedCoreMonitoringUncappedTier(c.Tier) {
|
||||
return true
|
||||
}
|
||||
if IsSelfHostedCoreMonitoringUncappedPlanVersion(c.PlanVersion) {
|
||||
|
|
|
|||
|
|
@ -140,13 +140,22 @@ func TestClaims_EffectiveLimits(t *testing.T) {
|
|||
expected: map[string]int64{},
|
||||
},
|
||||
{
|
||||
name: "generic_self_hosted_claims_preserve_explicit_limits_without_uncapped_marker",
|
||||
name: "self_hosted_pro_claims_strip_legacy_volume_caps",
|
||||
claims: Claims{
|
||||
Tier: TierPro,
|
||||
Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100},
|
||||
MaxMonitoredSystems: 15,
|
||||
MaxGuests: 100,
|
||||
},
|
||||
expected: map[string]int64{},
|
||||
},
|
||||
{
|
||||
name: "generic_non_self_hosted_claims_preserve_explicit_limits_without_uncapped_marker",
|
||||
claims: Claims{
|
||||
Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100},
|
||||
MaxMonitoredSystems: 15,
|
||||
MaxGuests: 100,
|
||||
},
|
||||
expected: map[string]int64{"max_monitored_systems": 15, "max_guests": 100},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -464,6 +464,10 @@ func (s *Service) CaptureLegacyMonitoredSystemGrandfatherFloor(count int) error
|
|||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if s.legacyMigrationUsesUncappedCoreMonitoringLocked() {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
currentLimit := 0
|
||||
if s.license != nil {
|
||||
|
|
@ -514,7 +518,7 @@ func (s *Service) needsLegacyMonitoredSystemCaptureLocked() bool {
|
|||
if s == nil || s.activationState == nil {
|
||||
return false
|
||||
}
|
||||
if s.legacyMigrationUsesUncappedRecurringPlanLocked() {
|
||||
if s.legacyMigrationUsesUncappedCoreMonitoringLocked() {
|
||||
return false
|
||||
}
|
||||
return normalizeActivationContinuity(s.activationState.Continuity).needsLegacyMonitoredSystemCapture()
|
||||
|
|
@ -540,7 +544,7 @@ func (s *Service) monitoredSystemContinuityStatusLocked() *MonitoredSystemContin
|
|||
if !continuity.LegacyMigration {
|
||||
return nil
|
||||
}
|
||||
if s.legacyMigrationUsesUncappedRecurringPlanLocked() {
|
||||
if s.legacyMigrationUsesUncappedCoreMonitoringLocked() {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -977,18 +981,19 @@ func monitoredSystemLimitFromClaims(claims Claims) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) legacyMigrationUsesUncappedRecurringPlanLocked() bool {
|
||||
func (s *Service) legacyMigrationUsesUncappedCoreMonitoringLocked() bool {
|
||||
if s == nil || s.activationState == nil {
|
||||
return false
|
||||
}
|
||||
if s.license != nil && IsGrandfatheredRecurringV5PlanVersion(s.license.Claims.PlanVersion) {
|
||||
if s.license != nil && s.license.Claims.shouldScrubLegacyCommercialCaps() {
|
||||
return true
|
||||
}
|
||||
gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT)
|
||||
if err != nil || gc == nil {
|
||||
return false
|
||||
}
|
||||
return IsGrandfatheredRecurringV5PlanVersion(gc.PlanKey)
|
||||
return IsSelfHostedCoreMonitoringUncappedTier(Tier(gc.Tier)) ||
|
||||
IsSelfHostedCoreMonitoringUncappedPlanVersion(gc.PlanKey)
|
||||
}
|
||||
|
||||
func remainingDaysCeil(expiresAtUnix, nowUnix int64) int {
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ func TestServiceRestoreActivation_CallsActivationStateChangeCallback(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesStatus(t *testing.T) {
|
||||
func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorSkipsUncappedSelfHosted(t *testing.T) {
|
||||
setupTestPublicKey(t)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "pulse-service-floor-*")
|
||||
|
|
@ -432,20 +432,17 @@ func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesSt
|
|||
if err := svc.CaptureLegacyMonitoredSystemGrandfatherFloor(23); err != nil {
|
||||
t.Fatalf("CaptureLegacyMonitoredSystemGrandfatherFloor: %v", err)
|
||||
}
|
||||
if callbackState == nil {
|
||||
t.Fatal("expected activation-state callback after monitored-system capture")
|
||||
}
|
||||
if callbackState.Continuity.GrandfatheredMaxMonitoredSystems != 23 {
|
||||
t.Fatalf("callback GrandfatheredMaxMonitoredSystems=%d, want 23", callbackState.Continuity.GrandfatheredMaxMonitoredSystems)
|
||||
}
|
||||
if callbackState.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
|
||||
t.Fatal("expected callback capture timestamp")
|
||||
if callbackState != nil {
|
||||
t.Fatalf("expected no activation-state callback for uncapped self-hosted capture, got %+v", callbackState)
|
||||
}
|
||||
|
||||
status := svc.Status()
|
||||
if status.MaxMonitoredSystems != 0 {
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want 0 (uncapped self-hosted)", status.MaxMonitoredSystems)
|
||||
}
|
||||
if status.MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no monitored-system continuity banner for uncapped self-hosted migration, got %+v", status.MonitoredSystemContinuity)
|
||||
}
|
||||
|
||||
current := svc.Current()
|
||||
if current == nil {
|
||||
|
|
@ -454,23 +451,9 @@ func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesSt
|
|||
if got, ok := current.Claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok {
|
||||
t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d present, want absent (uncapped self-hosted)", got)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
|
||||
t.Fatal("expected persisted capture timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatus_ExposesMonitoredSystemContinuityForFallbackMigrations(t *testing.T) {
|
||||
func TestServiceStatus_HidesMonitoredSystemContinuityForSelfHostedFallbackMigrations(t *testing.T) {
|
||||
setupTestPublicKey(t)
|
||||
|
||||
grantJWT := makeTestGrantJWT(t, &GrantClaims{
|
||||
|
|
@ -484,24 +467,16 @@ func TestServiceStatus_ExposesMonitoredSystemContinuityForFallbackMigrations(t *
|
|||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
continuity ActivationContinuity
|
||||
wantPlanLimit int
|
||||
wantEffectiveLimit int
|
||||
wantFloor int
|
||||
wantCapturePending bool
|
||||
wantMaxSystems int
|
||||
name string
|
||||
continuity ActivationContinuity
|
||||
wantMaxSystems int
|
||||
}{
|
||||
{
|
||||
name: "pending capture",
|
||||
continuity: ActivationContinuity{
|
||||
LegacyMigration: true,
|
||||
},
|
||||
wantPlanLimit: 10,
|
||||
wantEffectiveLimit: 0,
|
||||
wantFloor: 0,
|
||||
wantCapturePending: true,
|
||||
wantMaxSystems: 0,
|
||||
wantMaxSystems: 0,
|
||||
},
|
||||
{
|
||||
name: "captured floor",
|
||||
|
|
@ -510,11 +485,7 @@ func TestServiceStatus_ExposesMonitoredSystemContinuityForFallbackMigrations(t *
|
|||
GrandfatheredMaxMonitoredSystems: 23,
|
||||
GrandfatheredMonitoredSystemsCapturedAt: 123,
|
||||
},
|
||||
wantPlanLimit: 10,
|
||||
wantEffectiveLimit: 0,
|
||||
wantFloor: 23,
|
||||
wantCapturePending: false,
|
||||
wantMaxSystems: 0,
|
||||
wantMaxSystems: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -537,20 +508,8 @@ func TestServiceStatus_ExposesMonitoredSystemContinuityForFallbackMigrations(t *
|
|||
if status.MaxMonitoredSystems != tt.wantMaxSystems {
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, tt.wantMaxSystems)
|
||||
}
|
||||
if status.MonitoredSystemContinuity == nil {
|
||||
t.Fatal("expected monitored-system continuity in status")
|
||||
}
|
||||
if status.MonitoredSystemContinuity.PlanLimit != tt.wantPlanLimit {
|
||||
t.Fatalf("PlanLimit=%d, want %d", status.MonitoredSystemContinuity.PlanLimit, tt.wantPlanLimit)
|
||||
}
|
||||
if status.MonitoredSystemContinuity.EffectiveLimit != tt.wantEffectiveLimit {
|
||||
t.Fatalf("EffectiveLimit=%d, want %d", status.MonitoredSystemContinuity.EffectiveLimit, tt.wantEffectiveLimit)
|
||||
}
|
||||
if status.MonitoredSystemContinuity.GrandfatheredFloor != tt.wantFloor {
|
||||
t.Fatalf("GrandfatheredFloor=%d, want %d", status.MonitoredSystemContinuity.GrandfatheredFloor, tt.wantFloor)
|
||||
}
|
||||
if status.MonitoredSystemContinuity.CapturePending != tt.wantCapturePending {
|
||||
t.Fatalf("CapturePending=%v, want %v", status.MonitoredSystemContinuity.CapturePending, tt.wantCapturePending)
|
||||
if status.MonitoredSystemContinuity != nil {
|
||||
t.Fatalf("expected no monitored-system continuity banner for uncapped self-hosted migration, got %+v", status.MonitoredSystemContinuity)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ test.describe.serial("V6 license activation flow", () => {
|
|||
billing_model: "manual",
|
||||
duration_days: 1,
|
||||
max_monitored_systems: 0,
|
||||
max_guests: 5,
|
||||
max_guests: 0,
|
||||
max_installations: 3,
|
||||
send_email: false,
|
||||
},
|
||||
|
|
@ -247,13 +247,9 @@ test.describe.serial("V6 license activation flow", () => {
|
|||
);
|
||||
expect(agentLimit).toBeUndefined();
|
||||
|
||||
// Check max_guests limit.
|
||||
// Self-hosted v6 plans do not gate child resources like guests either.
|
||||
const guestLimit = ent.limits?.find((l) => l.key === "max_guests");
|
||||
expect(
|
||||
guestLimit,
|
||||
"max_guests limit not found in entitlements",
|
||||
).toBeTruthy();
|
||||
expect(guestLimit!.limit).toBe(5);
|
||||
expect(guestLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps activated installs on BYOK or local AI setup without quickstart", async ({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue