Retire self-hosted volume caps

This commit is contained in:
rcourtman 2026-04-28 20:36:37 +01:00
parent e8987e0974
commit a67845ada0
17 changed files with 355 additions and 231 deletions

View file

@ -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

View file

@ -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}
}
}`

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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{

View file

@ -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)
}
})
}
}

View file

@ -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),

View file

@ -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))

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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) {

View file

@ -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},
},
{

View file

@ -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 {

View file

@ -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)
}
})
}

View file

@ -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 ({