Scrub stale community billing caps

Refs #1429
This commit is contained in:
rcourtman 2026-04-19 12:17:20 +01:00
parent 9c3d96cab2
commit 9e60f2aec6
8 changed files with 176 additions and 3 deletions

View file

@ -247,6 +247,15 @@ Insights`, rather than reviving generic `AI Patrol` or `AI ... analysis`
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.
9. 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.
## Current State

View file

@ -494,6 +494,59 @@ func TestEntitlementHandler_GrandfatheredRecurringEvaluatorStateIsUncapped(t *te
}
}
func TestHandleRuntimeCapabilities_HostedCommunityEvaluatorStateStripsLegacyCommercialCaps(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
orgID := "test-hosted-community-runtime-capabilities"
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,
},
Limits: map[string]int64{
"max_monitored_systems": 1,
"max_guests": 5,
},
PlanVersion: "community",
SubscriptionState: entitlements.SubStateActive,
}); err != nil {
t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err)
}
h := NewLicenseHandlers(mtp, true)
req := httptest.NewRequest(http.MethodGet, "/api/license/runtime-capabilities", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID))
rec := httptest.NewRecorder()
h.HandleRuntimeCapabilities(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
var payload RuntimeCapabilitiesPayload
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal payload failed: %v", err)
}
for _, limit := range payload.Limits {
if limit.Key == "max_monitored_systems" || limit.Key == "max_guests" {
t.Fatalf("expected runtime capabilities to omit stale commercial caps, got %+v", payload.Limits)
}
}
if payload.MonitoredSystemCapacity == nil {
t.Fatal("expected monitored_system_capacity in runtime capabilities payload")
}
if payload.MonitoredSystemCapacity.Limit != 0 {
t.Fatalf("monitored_system_capacity.limit=%d, want %d", payload.MonitoredSystemCapacity.Limit, 0)
}
}
func TestEntitlementHandler_TrialEligibility_FreshOrgAllowed(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)

View file

@ -48,6 +48,9 @@ func NormalizeBillingState(state *BillingState) *BillingState {
normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration)
normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits)
if IsSelfHostedCommunityPlanVersion(normalized.PlanVersion) {
stripLegacyCommercialCaps(normalized.Limits)
}
// Ensure slices/maps are never nil (JSON marshals as [] / {} instead of null).
if normalized.Capabilities == nil {
@ -78,6 +81,9 @@ func NormalizeBillingState(state *BillingState) *BillingState {
func billingStateStoredMonitoredSystemLimit(planVersion string) (int, bool) {
planVersion = CanonicalizePlanVersion(planVersion)
if IsSelfHostedCommunityPlanVersion(planVersion) {
return 0, false
}
if IsGrandfatheredRecurringV5PlanVersion(planVersion) {
return UnknownPlanDefaultMonitoredSystemLimit, true
}

View file

@ -349,6 +349,36 @@ func TestNormalizeBillingState_PreservesNonCloudPlanLimits(t *testing.T) {
}
}
func TestNormalizeBillingState_SelfHostedCommunityPlanStripsLegacyCommercialCaps(t *testing.T) {
state := &BillingState{
PlanVersion: " community ",
Limits: map[string]int64{
"max_monitored_systems": 1,
"max_guests": 5,
"max_nodes": 99,
"max_reports": 7,
},
SubscriptionState: SubStateActive,
}
normalized := NormalizeBillingState(state)
if normalized.PlanVersion != "community" {
t.Fatalf("plan_version=%q, want %q", normalized.PlanVersion, "community")
}
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_StripsEntitlementsForRevokedSubscriptions(t *testing.T) {
state := &BillingState{
Capabilities: []string{"relay"},

View file

@ -243,9 +243,9 @@ func normalizeDatabaseSourceState(state BillingState) BillingState {
normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration)
normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits)
if IsGrandfatheredRecurringV5PlanVersion(normalized.PlanVersion) {
delete(normalized.Limits, MaxMonitoredSystemsLicenseGateKey)
delete(normalized.Limits, "max_guests")
if IsSelfHostedCommunityPlanVersion(normalized.PlanVersion) ||
IsGrandfatheredRecurringV5PlanVersion(normalized.PlanVersion) {
stripLegacyCommercialCaps(normalized.Limits)
}
switch normalized.SubscriptionState {

View file

@ -148,6 +148,32 @@ 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,
},
}
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)
}
}
func TestDatabaseSourcePreservesMissingPlanVersion(t *testing.T) {
store := &mockBillingStore{
state: &BillingState{

View file

@ -6,6 +6,7 @@ package licensing
import (
"sort"
"strings"
"time"
)
@ -160,6 +161,25 @@ 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.
func IsSelfHostedCommunityPlanVersion(planVersion string) bool {
switch strings.ToLower(CanonicalizePlanVersion(planVersion)) {
case "community", string(TierFree):
return true
default:
return false
}
}
func stripLegacyCommercialCaps(limits map[string]int64) {
delete(limits, MaxMonitoredSystemsLicenseGateKey)
delete(limits, "max_guests")
}
// UnknownPlanDefaultMonitoredSystemLimit is the safe-default monitored-system limit applied when a
// plan version is not recognized. Fail-closed: unknown plans get the smallest
// tier limit rather than unlimited access.

View file

@ -599,6 +599,35 @@ func TestIsGrandfatheredRecurringV5PlanVersion(t *testing.T) {
}
}
func TestIsSelfHostedCommunityPlanVersion(t *testing.T) {
tests := []struct {
plan string
want bool
}{
{plan: "community", want: true},
{plan: "Community", want: true},
{plan: "free", want: true},
{plan: "relay", want: false},
{plan: "pro", want: false},
{plan: "pro_plus", want: false},
{plan: "pro_annual", want: false},
{plan: "lifetime", want: false},
{plan: "v5_lifetime_grandfathered", want: false},
{plan: "cloud_starter", want: false},
{plan: "trial", want: false},
{plan: "pro-v2", want: false},
{plan: "", want: false},
}
for _, tt := range tests {
t.Run(tt.plan, func(t *testing.T) {
if got := IsSelfHostedCommunityPlanVersion(tt.plan); got != tt.want {
t.Fatalf("IsSelfHostedCommunityPlanVersion(%q) = %v, want %v", tt.plan, 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) {