diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 65c7d6d42..51328c62e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 097891653..9d4091cdb 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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} } }` diff --git a/internal/api/entitlement_handlers_test.go b/internal/api/entitlement_handlers_test.go index 79b832529..46abdaaf6 100644 --- a/internal/api/entitlement_handlers_test.go +++ b/internal/api/entitlement_handlers_test.go @@ -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) diff --git a/internal/api/licensing_handlers_auto_migrate_test.go b/internal/api/licensing_handlers_auto_migrate_test.go index 3d49b8fc1..65864723a 100644 --- a/internal/api/licensing_handlers_auto_migrate_test.go +++ b/internal/api/licensing_handlers_auto_migrate_test.go @@ -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) } } diff --git a/pkg/licensing/activation_types.go b/pkg/licensing/activation_types.go index 14bf2c139..6337441af 100644 --- a/pkg/licensing/activation_types.go +++ b/pkg/licensing/activation_types.go @@ -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. diff --git a/pkg/licensing/billing_state_normalization_test.go b/pkg/licensing/billing_state_normalization_test.go index ea7c5f187..727558631 100644 --- a/pkg/licensing/billing_state_normalization_test.go +++ b/pkg/licensing/billing_state_normalization_test.go @@ -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{ diff --git a/pkg/licensing/database_source_test.go b/pkg/licensing/database_source_test.go index e74ac572c..c889d272e 100644 --- a/pkg/licensing/database_source_test.go +++ b/pkg/licensing/database_source_test.go @@ -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) + } + }) } } diff --git a/pkg/licensing/entitlement_payload.go b/pkg/licensing/entitlement_payload.go index fc7868618..80f6439d9 100644 --- a/pkg/licensing/entitlement_payload.go +++ b/pkg/licensing/entitlement_payload.go @@ -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), diff --git a/pkg/licensing/entitlement_payload_test.go b/pkg/licensing/entitlement_payload_test.go index aa7453959..d0d054834 100644 --- a/pkg/licensing/entitlement_payload_test.go +++ b/pkg/licensing/entitlement_payload_test.go @@ -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)) diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index 21e8cf007..7afcfbfe3 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -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) { diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 371bb0979..cc2021064 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -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) { diff --git a/pkg/licensing/grant_claims_contract_test.go b/pkg/licensing/grant_claims_contract_test.go index ba9e145f4..447a48803 100644 --- a/pkg/licensing/grant_claims_contract_test.go +++ b/pkg/licensing/grant_claims_contract_test.go @@ -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 diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index f424c6934..ee7f1c03c 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -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) { diff --git a/pkg/licensing/models_test.go b/pkg/licensing/models_test.go index 1a48ed722..a3a335e81 100644 --- a/pkg/licensing/models_test.go +++ b/pkg/licensing/models_test.go @@ -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}, }, { diff --git a/pkg/licensing/service.go b/pkg/licensing/service.go index a752212a7..ad49edbd8 100644 --- a/pkg/licensing/service.go +++ b/pkg/licensing/service.go @@ -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 { diff --git a/pkg/licensing/service_activate_test.go b/pkg/licensing/service_activate_test.go index 04e9484fa..d48b61d75 100644 --- a/pkg/licensing/service_activate_test.go +++ b/pkg/licensing/service_activate_test.go @@ -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) } }) } diff --git a/tests/integration/tests/10-v6-license-activation.spec.ts b/tests/integration/tests/10-v6-license-activation.spec.ts index c81804070..04ec53002 100644 --- a/tests/integration/tests/10-v6-license-activation.spec.ts +++ b/tests/integration/tests/10-v6-license-activation.spec.ts @@ -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 ({