mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
TierBusiness: Pro's feature set, 365-day history retention, uncapped core monitoring like every self-hosted tier, Business display name, and a slot in the min-tier ordering. Differentiation is by max_users limit, retention, and support rather than features (multi-user is already a Pro capability via RBAC; see specs/pricing-segmentation.md binder findings). Dormant until the license server issues business plan versions; no checkout, pricing, or UI surface references it yet. Contract shape recorded as cloud-paid Extension Point 26 and pinned by TestBusinessTierContractShape.
883 lines
26 KiB
Go
883 lines
26 KiB
Go
package licensing
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDeriveCapabilitiesFromTier(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tier Tier
|
|
explicitFeatures []string
|
|
wantLen int
|
|
wantContains []string
|
|
wantNotContains []string
|
|
}{
|
|
{
|
|
name: "free_tier_includes_base_features",
|
|
tier: TierFree,
|
|
explicitFeatures: nil,
|
|
wantLen: len(freeFeatures),
|
|
wantContains: []string{FeatureUpdateAlerts, FeatureSSO, FeatureAdvancedSSO, FeatureAIPatrol},
|
|
wantNotContains: []string{FeatureRelay, FeatureAIAutoFix, FeatureLongTermMetrics},
|
|
},
|
|
{
|
|
name: "relay_tier_includes_remote_access",
|
|
tier: TierRelay,
|
|
explicitFeatures: nil,
|
|
wantLen: len(relayFeatures),
|
|
wantContains: []string{FeatureRelay, FeatureMobileApp, FeaturePushNotifications, FeatureLongTermMetrics},
|
|
wantNotContains: []string{FeatureAIAutoFix, FeatureAIAlerts, FeatureRBAC},
|
|
},
|
|
{
|
|
name: "pro_tier_includes_all_ai_and_compliance",
|
|
tier: TierPro,
|
|
explicitFeatures: nil,
|
|
wantLen: len(proFeatures),
|
|
wantContains: []string{FeatureAIAutoFix, FeatureAIAlerts, FeatureRBAC, FeatureAuditLogging, FeatureRelay},
|
|
wantNotContains: []string{FeatureUnlimited, FeatureMultiTenant},
|
|
},
|
|
{
|
|
name: "pro_plus_same_features_as_pro",
|
|
tier: TierProPlus,
|
|
explicitFeatures: nil,
|
|
wantLen: len(proFeatures),
|
|
wantContains: []string{FeatureAIAutoFix, FeatureRelay, FeatureRBAC},
|
|
},
|
|
{
|
|
name: "pro_annual_legacy_same_as_pro",
|
|
tier: TierProAnnual,
|
|
explicitFeatures: nil,
|
|
wantLen: len(proFeatures),
|
|
wantContains: []string{FeatureAIAutoFix, FeatureRelay},
|
|
},
|
|
{
|
|
name: "lifetime_legacy_same_as_pro",
|
|
tier: TierLifetime,
|
|
explicitFeatures: nil,
|
|
wantLen: len(proFeatures),
|
|
wantContains: []string{FeatureAIAutoFix, FeatureRelay},
|
|
},
|
|
{
|
|
name: "cloud_includes_pro_features",
|
|
tier: TierCloud,
|
|
explicitFeatures: nil,
|
|
wantLen: len(proFeatures),
|
|
wantContains: []string{FeatureAIAutoFix, FeatureRelay, FeatureLongTermMetrics},
|
|
},
|
|
{
|
|
name: "msp_tier_includes_unlimited_and_multi_tenant",
|
|
tier: TierMSP,
|
|
explicitFeatures: nil,
|
|
wantLen: len(mspFeatures),
|
|
wantContains: []string{FeatureUnlimited, FeatureMultiTenant},
|
|
},
|
|
{
|
|
name: "enterprise_tier_includes_white_label",
|
|
tier: TierEnterprise,
|
|
explicitFeatures: nil,
|
|
wantLen: len(enterpriseFeatures),
|
|
wantContains: []string{FeatureMultiTenant, FeatureWhiteLabel, FeatureMultiUser},
|
|
},
|
|
{
|
|
name: "explicit_features_merged",
|
|
tier: TierFree,
|
|
explicitFeatures: []string{"custom_feature"},
|
|
wantContains: []string{"custom_feature", FeatureUpdateAlerts},
|
|
},
|
|
{
|
|
name: "unknown_tier_returns_empty",
|
|
tier: Tier("unknown"),
|
|
explicitFeatures: nil,
|
|
wantLen: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := DeriveCapabilitiesFromTier(tt.tier, tt.explicitFeatures)
|
|
if tt.wantLen > 0 && len(got) != tt.wantLen {
|
|
t.Errorf("DeriveCapabilitiesFromTier() returned %d capabilities, want %d", len(got), tt.wantLen)
|
|
}
|
|
gotSet := make(map[string]bool)
|
|
for _, f := range got {
|
|
gotSet[f] = true
|
|
}
|
|
for _, f := range tt.wantContains {
|
|
if !gotSet[f] {
|
|
t.Errorf("DeriveCapabilitiesFromTier() missing expected feature %q", f)
|
|
}
|
|
}
|
|
for _, f := range tt.wantNotContains {
|
|
if gotSet[f] {
|
|
t.Errorf("DeriveCapabilitiesFromTier() should NOT include feature %q for tier %q", f, tt.tier)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeriveCapabilitiesFromTier_Sorted(t *testing.T) {
|
|
got := DeriveCapabilitiesFromTier(TierPro, nil)
|
|
if !sort.StringsAreSorted(got) {
|
|
t.Errorf("DeriveCapabilitiesFromTier() should return sorted capabilities")
|
|
}
|
|
}
|
|
|
|
func TestPatrolGovernedFixesStayProFeature(t *testing.T) {
|
|
if !TierHasFeature(TierFree, FeatureAIPatrol) {
|
|
t.Fatal("community tier must include watch-only Patrol with BYOK")
|
|
}
|
|
if TierHasFeature(TierFree, FeatureAIAutoFix) {
|
|
t.Fatal("community tier must not include governed Patrol fixes")
|
|
}
|
|
if TierHasFeature(TierRelay, FeatureAIAutoFix) {
|
|
t.Fatal("relay tier must not include governed Patrol fixes")
|
|
}
|
|
if !TierHasFeature(TierPro, FeatureAIAutoFix) {
|
|
t.Fatal("pro tier must include governed Patrol fixes")
|
|
}
|
|
}
|
|
|
|
func TestWhiteLabelBrandingRemainsExplicitEnterpriseEntitlement(t *testing.T) {
|
|
if TierHasFeature(TierFree, FeatureWhiteLabel) {
|
|
t.Fatal("community tier must not include white_label report branding")
|
|
}
|
|
if TierHasFeature(TierMSP, FeatureWhiteLabel) {
|
|
t.Fatal("MSP tier defaults must not imply white_label unless the license grants it")
|
|
}
|
|
if !TierHasFeature(TierEnterprise, FeatureWhiteLabel) {
|
|
t.Fatal("enterprise tier must keep white_label report branding entitlement")
|
|
}
|
|
|
|
got := DeriveCapabilitiesFromTier(TierMSP, []string{FeatureWhiteLabel})
|
|
found := false
|
|
for _, feature := range got {
|
|
if feature == FeatureWhiteLabel {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("explicit white_label grants must be merged into MSP capabilities")
|
|
}
|
|
}
|
|
|
|
func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tier Tier
|
|
wantHistoryDays int
|
|
wantIncludedCapabilities []string
|
|
wantExcludedCapabilities []string
|
|
}{
|
|
{
|
|
name: "community keeps core monitoring free and does not claim paid extras",
|
|
tier: TierFree,
|
|
wantHistoryDays: 7,
|
|
wantIncludedCapabilities: []string{
|
|
FeatureUpdateAlerts,
|
|
FeatureSSO,
|
|
FeatureAdvancedSSO,
|
|
FeatureAIPatrol,
|
|
},
|
|
wantExcludedCapabilities: []string{
|
|
FeatureRelay,
|
|
FeatureMobileApp,
|
|
FeaturePushNotifications,
|
|
FeatureLongTermMetrics,
|
|
FeatureAIAlerts,
|
|
FeatureAIAutoFix,
|
|
FeatureRBAC,
|
|
FeatureAdvancedReporting,
|
|
},
|
|
},
|
|
{
|
|
name: "relay sells remote access mobile handoff push and fourteen day history",
|
|
tier: TierRelay,
|
|
wantHistoryDays: 14,
|
|
wantIncludedCapabilities: []string{
|
|
FeatureUpdateAlerts,
|
|
FeatureSSO,
|
|
FeatureAdvancedSSO,
|
|
FeatureAIPatrol,
|
|
FeatureRelay,
|
|
FeatureMobileApp,
|
|
FeaturePushNotifications,
|
|
FeatureLongTermMetrics,
|
|
},
|
|
wantExcludedCapabilities: []string{
|
|
FeatureAIAlerts,
|
|
FeatureAIAutoFix,
|
|
FeatureRBAC,
|
|
FeatureAdvancedReporting,
|
|
FeatureAgentProfiles,
|
|
},
|
|
},
|
|
{
|
|
name: "pro sells operator extras and preserves relay capabilities",
|
|
tier: TierPro,
|
|
wantHistoryDays: 90,
|
|
wantIncludedCapabilities: []string{
|
|
FeatureRelay,
|
|
FeatureMobileApp,
|
|
FeaturePushNotifications,
|
|
FeatureLongTermMetrics,
|
|
FeatureAIAlerts,
|
|
FeatureAIAutoFix,
|
|
FeatureRBAC,
|
|
FeatureAuditLogging,
|
|
FeatureAdvancedReporting,
|
|
FeatureAgentProfiles,
|
|
},
|
|
wantExcludedCapabilities: []string{
|
|
FeatureMultiTenant,
|
|
FeatureUnlimited,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotHistoryDays, ok := TierHistoryDays[tt.tier]
|
|
if !ok {
|
|
t.Fatalf("TierHistoryDays missing entry for tier %q", tt.tier)
|
|
}
|
|
if gotHistoryDays != tt.wantHistoryDays {
|
|
t.Fatalf(
|
|
"TierHistoryDays[%q] = %d, want %d",
|
|
tt.tier,
|
|
gotHistoryDays,
|
|
tt.wantHistoryDays,
|
|
)
|
|
}
|
|
|
|
capabilities := make(map[string]struct{})
|
|
for _, capability := range TierFeatures[tt.tier] {
|
|
capabilities[capability] = struct{}{}
|
|
}
|
|
for _, capability := range tt.wantIncludedCapabilities {
|
|
if _, ok := capabilities[capability]; !ok {
|
|
t.Fatalf("tier %q missing paid-feature claim capability %q", tt.tier, capability)
|
|
}
|
|
}
|
|
for _, capability := range tt.wantExcludedCapabilities {
|
|
if _, ok := capabilities[capability]; ok {
|
|
t.Fatalf("tier %q unexpectedly includes capability %q", tt.tier, capability)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeriveEntitlements(t *testing.T) {
|
|
caps, limits := DeriveEntitlements(TierPro, []string{"custom"}, 50, 100)
|
|
|
|
if len(caps) == 0 {
|
|
t.Error("DeriveEntitlements() returned no capabilities")
|
|
}
|
|
|
|
if _, ok := limits["max_monitored_systems"]; ok {
|
|
t.Errorf("DeriveEntitlements() exposed retired max_monitored_systems limit: %v", limits)
|
|
}
|
|
if limits["max_guests"] != 100 {
|
|
t.Errorf("max_guests limit = %d, want 100", limits["max_guests"])
|
|
}
|
|
}
|
|
|
|
func TestDeriveEntitlements_ZeroLimitsNotIncluded(t *testing.T) {
|
|
_, limits := DeriveEntitlements(TierPro, nil, 0, 0)
|
|
|
|
if _, ok := limits["max_monitored_systems"]; ok {
|
|
t.Error("max_monitored_systems should not be in limits when 0")
|
|
}
|
|
if _, ok := limits["max_guests"]; ok {
|
|
t.Error("max_guests should not be in limits when 0")
|
|
}
|
|
}
|
|
|
|
func TestTierHasFeature(t *testing.T) {
|
|
tests := []struct {
|
|
tier Tier
|
|
feature string
|
|
want bool
|
|
}{
|
|
{TierPro, FeatureAIAutoFix, true},
|
|
{TierPro, FeatureRBAC, true},
|
|
{TierPro, FeatureRelay, true},
|
|
{TierFree, FeatureAIAutoFix, false},
|
|
{TierFree, FeatureRelay, false},
|
|
{TierFree, FeatureUpdateAlerts, true},
|
|
{TierFree, FeatureAdvancedSSO, true},
|
|
{TierFree, FeatureAIPatrol, true},
|
|
{TierRelay, FeatureRelay, true},
|
|
{TierRelay, FeatureMobileApp, true},
|
|
{TierRelay, FeaturePushNotifications, true},
|
|
{TierRelay, FeatureLongTermMetrics, true},
|
|
{TierRelay, FeatureAIAutoFix, false},
|
|
{TierRelay, FeatureRBAC, false},
|
|
{TierProPlus, FeatureAIAutoFix, true},
|
|
{TierProPlus, FeatureRelay, true},
|
|
{TierEnterprise, FeatureMultiTenant, true},
|
|
{Tier("unknown"), FeatureAIAutoFix, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(string(tt.tier)+"_"+tt.feature, func(t *testing.T) {
|
|
got := TierHasFeature(tt.tier, tt.feature)
|
|
if got != tt.want {
|
|
t.Errorf("TierHasFeature(%q, %q) = %v, want %v", tt.tier, tt.feature, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetTierDisplayName(t *testing.T) {
|
|
tests := []struct {
|
|
tier Tier
|
|
want string
|
|
}{
|
|
{TierFree, "Community"},
|
|
{TierRelay, "Relay"},
|
|
{TierPro, "Pro"},
|
|
{TierProPlus, "Pro+"},
|
|
{TierProAnnual, "Pro (Annual)"},
|
|
{TierLifetime, "Pro (Lifetime)"},
|
|
{TierCloud, "Cloud"},
|
|
{TierMSP, "MSP"},
|
|
{TierEnterprise, "Enterprise"},
|
|
{Tier("unknown"), "Unknown"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(string(tt.tier), func(t *testing.T) {
|
|
got := GetTierDisplayName(tt.tier)
|
|
if got != tt.want {
|
|
t.Errorf("GetTierDisplayName(%q) = %q, want %q", tt.tier, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetFeatureDisplayName(t *testing.T) {
|
|
tests := []struct {
|
|
feature string
|
|
want string
|
|
}{
|
|
{FeatureAIPatrol, "Pulse Patrol (Background Health Checks)"},
|
|
{FeatureAIAlerts, "Patrol Investigates Issues"},
|
|
{FeatureAIAutoFix, "Patrol Handles Safe Fixes"},
|
|
{FeatureKubernetesAI, "Kubernetes AI Analysis (Compatibility)"},
|
|
{FeatureUpdateAlerts, "Update Alerts (Container/Package Updates)"},
|
|
{FeatureRBAC, "Role-Based Access Control (RBAC)"},
|
|
{FeatureMultiUser, "Multi-User Mode"},
|
|
{FeatureWhiteLabel, "White-Label Branding"},
|
|
{FeatureMultiTenant, "Multi-Tenant Mode"},
|
|
{FeatureUnlimited, "Hosted Capacity Policy"},
|
|
{FeatureAgentProfiles, "Centralized Agent Profiles"},
|
|
{FeatureAuditLogging, "Audit Logging"},
|
|
{FeatureSSO, "Core SSO (OIDC/SAML)"},
|
|
{FeatureAdvancedSSO, "Multi-Provider SSO"},
|
|
{FeatureRelay, "Pulse Relay (Remote Access)"},
|
|
{FeatureMobileApp, "Pulse Mobile Pairing"},
|
|
{FeaturePushNotifications, "Push Notifications"},
|
|
{FeatureAdvancedReporting, "PDF/CSV Reporting"},
|
|
{FeatureLongTermMetrics, "Extended Metric History"},
|
|
{"unknown_feature", "unknown_feature"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.feature, func(t *testing.T) {
|
|
got := GetFeatureDisplayName(tt.feature)
|
|
if got != tt.want {
|
|
t.Errorf("GetFeatureDisplayName(%q) = %q, want %q", tt.feature, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsCompatibilityOnlyFeature(t *testing.T) {
|
|
if !IsCompatibilityOnlyFeature(FeatureKubernetesAI) {
|
|
t.Fatalf("expected %q to remain compatibility-only", FeatureKubernetesAI)
|
|
}
|
|
if IsCompatibilityOnlyFeature(FeatureAIAlerts) {
|
|
t.Fatalf("did not expect %q to be compatibility-only", FeatureAIAlerts)
|
|
}
|
|
}
|
|
|
|
func TestSelfHostedFeatureMetadataKeepsCanonicalPlanLabelsAndVisibility(t *testing.T) {
|
|
updateAlerts, ok := GetFeatureMetadata(FeatureUpdateAlerts)
|
|
if !ok {
|
|
t.Fatalf("expected metadata for %q", FeatureUpdateAlerts)
|
|
}
|
|
if updateAlerts.DisplayName != "Update Alerts (Container/Package Updates)" {
|
|
t.Fatalf("DisplayName = %q, want detailed runtime label", updateAlerts.DisplayName)
|
|
}
|
|
if updateAlerts.ComparisonName != "Update Alerts" {
|
|
t.Fatalf("ComparisonName = %q, want customer-facing label", updateAlerts.ComparisonName)
|
|
}
|
|
|
|
relay, ok := GetFeatureMetadata(FeatureRelay)
|
|
if !ok {
|
|
t.Fatalf("expected metadata for %q", FeatureRelay)
|
|
}
|
|
if relay.ComparisonName != "Pulse Relay (Remote Access)" {
|
|
t.Fatalf("ComparisonName = %q, want canonical Relay marketing label", relay.ComparisonName)
|
|
}
|
|
if GetSelfHostedFeatureRole(FeatureRelay, TierRelay) != SelfHostedFeatureRolePrimaryPillar {
|
|
t.Fatalf("expected %q to stay a Relay primary pillar", FeatureRelay)
|
|
}
|
|
|
|
multiTenant, ok := GetFeatureMetadata(FeatureMultiTenant)
|
|
if !ok {
|
|
t.Fatalf("expected metadata for %q", FeatureMultiTenant)
|
|
}
|
|
if multiTenant.DisplayableInPlanUI {
|
|
t.Fatalf("did not expect MSP-only %q to be displayable in self-hosted plan UI", FeatureMultiTenant)
|
|
}
|
|
if GetSelfHostedFeatureRole(FeatureMultiTenant, TierPro) != SelfHostedFeatureRoleHidden {
|
|
t.Fatalf("expected %q to stay hidden from the self-hosted Pro plan role", FeatureMultiTenant)
|
|
}
|
|
|
|
kubernetes, ok := GetFeatureMetadata(FeatureKubernetesAI)
|
|
if !ok {
|
|
t.Fatalf("expected metadata for %q", FeatureKubernetesAI)
|
|
}
|
|
if kubernetes.DisplayableInPlanUI {
|
|
t.Fatalf("did not expect compatibility-only %q to be displayable in plan UI", FeatureKubernetesAI)
|
|
}
|
|
}
|
|
|
|
func TestSelfHostedFeatureMetadataMatchesRuntimeTierFeatures(t *testing.T) {
|
|
for _, tier := range []Tier{TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime} {
|
|
tier := tier
|
|
t.Run(string(tier), func(t *testing.T) {
|
|
got := sortedFeatureSet(TierFeatures[tier])
|
|
want := selfHostedMetadataFeatureKeysForTier(tier)
|
|
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
|
|
t.Fatalf("TierFeatures[%q]=%v, want self-hosted metadata features %v", tier, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func selfHostedMetadataFeatureKeysForTier(tier Tier) []string {
|
|
out := make([]string, 0)
|
|
for _, entry := range AllFeatureMetadata() {
|
|
if GetSelfHostedFeatureRole(entry.Key, tier) == SelfHostedFeatureRoleHidden {
|
|
continue
|
|
}
|
|
out = append(out, entry.Key)
|
|
}
|
|
return sortedFeatureSet(out)
|
|
}
|
|
|
|
func sortedFeatureSet(features []string) []string {
|
|
seen := make(map[string]struct{}, len(features))
|
|
for _, feature := range features {
|
|
feature = strings.TrimSpace(feature)
|
|
if feature == "" {
|
|
continue
|
|
}
|
|
seen[feature] = struct{}{}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for feature := range seen {
|
|
out = append(out, feature)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func TestTierHistoryDays(t *testing.T) {
|
|
tests := []struct {
|
|
tier Tier
|
|
want int
|
|
}{
|
|
{TierFree, 7},
|
|
{TierRelay, 14},
|
|
{TierPro, 90},
|
|
{TierProPlus, 90},
|
|
{TierCloud, 90},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(string(tt.tier), func(t *testing.T) {
|
|
got, ok := TierHistoryDays[tt.tier]
|
|
if !ok {
|
|
t.Fatalf("TierHistoryDays missing entry for tier %q", tt.tier)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("TierHistoryDays[%q] = %d, want %d", tt.tier, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTierFeatureInheritance verifies that higher tiers include all features from lower tiers.
|
|
func TestTierFeatureInheritance(t *testing.T) {
|
|
asSet := func(features []string) map[string]bool {
|
|
s := make(map[string]bool, len(features))
|
|
for _, f := range features {
|
|
s[f] = true
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Relay must include all Free features.
|
|
freeSet := asSet(TierFeatures[TierFree])
|
|
relaySet := asSet(TierFeatures[TierRelay])
|
|
for f := range freeSet {
|
|
if !relaySet[f] {
|
|
t.Errorf("Relay tier missing Free feature %q", f)
|
|
}
|
|
}
|
|
|
|
// Pro must include all Relay features.
|
|
proSet := asSet(TierFeatures[TierPro])
|
|
for f := range relaySet {
|
|
if !proSet[f] {
|
|
t.Errorf("Pro tier missing Relay feature %q", f)
|
|
}
|
|
}
|
|
|
|
// Pro+ is a legacy continuity tier, not a bigger self-hosted monitoring tier.
|
|
proPlusSet := asSet(TierFeatures[TierProPlus])
|
|
if len(proPlusSet) != len(proSet) {
|
|
t.Fatalf("Pro+ feature count = %d, want Pro feature count %d", len(proPlusSet), len(proSet))
|
|
}
|
|
for f := range proSet {
|
|
if !proPlusSet[f] {
|
|
t.Errorf("Pro+ tier missing Pro feature %q", f)
|
|
}
|
|
}
|
|
|
|
// MSP must include all Pro features.
|
|
mspSet := asSet(TierFeatures[TierMSP])
|
|
for f := range proSet {
|
|
if !mspSet[f] {
|
|
t.Errorf("MSP tier missing Pro feature %q", f)
|
|
}
|
|
}
|
|
|
|
// Enterprise must include all MSP features.
|
|
entSet := asSet(TierFeatures[TierEnterprise])
|
|
for f := range mspSet {
|
|
if !entSet[f] {
|
|
t.Errorf("Enterprise tier missing MSP feature %q", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublicTierDefaultsNeverIncludeInternalCapabilities(t *testing.T) {
|
|
for tier, features := range TierFeatures {
|
|
for _, feature := range features {
|
|
if !CapabilityVisibleInPublicPayload(feature) {
|
|
t.Fatalf("TierFeatures[%q] leaked internal capability %q", tier, feature)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilterPublicCapabilitiesStripsInternalOnlyFeatures(t *testing.T) {
|
|
got := FilterPublicCapabilities([]string{
|
|
FeatureRelay,
|
|
FeatureDemoFixtures,
|
|
FeatureAIPatrol,
|
|
})
|
|
want := []string{FeatureRelay, FeatureAIPatrol}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("FilterPublicCapabilities() len=%d, want %d (%v)", len(got), len(want), got)
|
|
}
|
|
for i, feature := range want {
|
|
if got[i] != feature {
|
|
t.Fatalf("FilterPublicCapabilities()[%d]=%q, want %q", i, got[i], feature)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAllTiersHaveHistoryDays ensures every tier in TierFeatures also has a
|
|
// metrics-retention entry. Core monitoring volume limits are retired.
|
|
func TestAllTiersHaveHistoryDays(t *testing.T) {
|
|
for tier := range TierFeatures {
|
|
if _, ok := TierHistoryDays[tier]; !ok {
|
|
t.Errorf("TierHistoryDays missing entry for tier %q", tier)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceLimitForPlan_KnownPlans(t *testing.T) {
|
|
tests := []struct {
|
|
plan string
|
|
wantLimit int
|
|
}{
|
|
{"cloud_starter", 1},
|
|
{"cloud_power", 1},
|
|
{"cloud_max", 1},
|
|
{"cloud_founding", 1},
|
|
{"msp_starter", 5},
|
|
{"msp_hosted_v1", 5},
|
|
{"msp_growth", 15},
|
|
{"msp_scale", 40},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.plan, func(t *testing.T) {
|
|
limit, known := WorkspaceLimitForPlan(tt.plan)
|
|
if !known {
|
|
t.Errorf("WorkspaceLimitForPlan(%q): known = false, want true", tt.plan)
|
|
}
|
|
if limit != tt.wantLimit {
|
|
t.Errorf("WorkspaceLimitForPlan(%q) = %d, want %d", tt.plan, limit, tt.wantLimit)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceLimitForPlan_UnknownPlanFailsClosed(t *testing.T) {
|
|
unknownPlans := []string{"stripe", "", "unknown_plan", "cloud_unknown"}
|
|
|
|
for _, plan := range unknownPlans {
|
|
t.Run(plan, func(t *testing.T) {
|
|
limit, known := WorkspaceLimitForPlan(plan)
|
|
if known {
|
|
t.Errorf("WorkspaceLimitForPlan(%q): known = true, want false", plan)
|
|
}
|
|
if limit != UnknownPlanDefaultWorkspaceLimit {
|
|
t.Errorf("WorkspaceLimitForPlan(%q) = %d, want default %d", plan, limit, UnknownPlanDefaultWorkspaceLimit)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCloudWorkspacePlansHaveWorkspaceLimits ensures hosted workspace plans
|
|
// still carry a workspace limit entry after monitored-system caps were retired.
|
|
func TestCloudWorkspacePlansHaveWorkspaceLimits(t *testing.T) {
|
|
for plan := range CloudPlanWorkspaceLimits {
|
|
if !strings.HasPrefix(plan, "cloud_") && !strings.HasPrefix(plan, "msp_") {
|
|
t.Errorf("CloudPlanWorkspaceLimits includes non-hosted plan %q", plan)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanVersionForPriceID_KnownPrices(t *testing.T) {
|
|
tests := []struct {
|
|
priceID string
|
|
wantPlan string
|
|
}{
|
|
// Cloud Starter (monthly, annual, founding)
|
|
{"price_1T5kflBrHBocJIGHUqPv1dzV", "cloud_starter"},
|
|
{"price_1T5kfmBrHBocJIGHTS3ymKxM", "cloud_starter"},
|
|
{"price_1T5kfnBrHBocJIGHATQJr79D", "cloud_founding"},
|
|
// Cloud Power
|
|
{"price_1T5kg2BrHBocJIGHmkoF0zXY", "cloud_power"},
|
|
{"price_1T5kg3BrHBocJIGH2EtzKofV", "cloud_power"},
|
|
// Cloud Max
|
|
{"price_1T5kg4BrHBocJIGHHa8Ecqho", "cloud_max"},
|
|
{"price_1T5kg5BrHBocJIGH5AIJ4nVc", "cloud_max"},
|
|
// MSP Starter
|
|
{"price_1T5kgTBrHBocJIGHjOs15LI2", "msp_starter"},
|
|
{"price_1T5kgUBrHBocJIGHT6PiOn6x", "msp_starter"},
|
|
// MSP Growth
|
|
{"price_1T5kgVBrHBocJIGHulNsCTb1", "msp_growth"},
|
|
{"price_1T5kgWBrHBocJIGHTuaNjnJ2", "msp_growth"},
|
|
// MSP Scale
|
|
{"price_1T5kgWBrHBocJIGHo40iFeRd", "msp_scale"},
|
|
{"price_1T5kgXBrHBocJIGHWlOgTyGV", "msp_scale"},
|
|
// Grandfathered Pulse Pro recurring renewals (v5)
|
|
{"price_1ShIsdBrHBocJIGH71yQusLG", "v5_pro_monthly_grandfathered"},
|
|
{"price_1ShIsnBrHBocJIGHBKkzsZ3T", "v5_pro_annual_grandfathered"},
|
|
// Grandfathered Pulse Pro recurring renewals (legacy v1)
|
|
{"price_1SgDxvBrHBocJIGHStaGuiAX", "v5_pro_monthly_grandfathered"},
|
|
{"price_1SgDxwBrHBocJIGHTKTsIMLc", "v5_pro_annual_grandfathered"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.priceID, func(t *testing.T) {
|
|
plan, ok := PlanVersionForPriceID(tt.priceID)
|
|
if !ok {
|
|
t.Fatalf("PlanVersionForPriceID(%q): not found", tt.priceID)
|
|
}
|
|
if plan != tt.wantPlan {
|
|
t.Errorf("PlanVersionForPriceID(%q) = %q, want %q", tt.priceID, plan, tt.wantPlan)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanVersionForPriceID_UnknownPrices(t *testing.T) {
|
|
unknowns := []string{"price_unknown", "", "not_a_price"}
|
|
for _, id := range unknowns {
|
|
t.Run(id, func(t *testing.T) {
|
|
_, ok := PlanVersionForPriceID(id)
|
|
if ok {
|
|
t.Errorf("PlanVersionForPriceID(%q): expected not found", id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsGrandfatheredRecurringV5PlanVersion(t *testing.T) {
|
|
tests := []struct {
|
|
plan string
|
|
want bool
|
|
}{
|
|
{plan: "v5_pro_monthly_grandfathered", want: true},
|
|
{plan: "v5_pro_annual_grandfathered", want: true},
|
|
{plan: "price_1ShIsdBrHBocJIGH71yQusLG", want: false},
|
|
{plan: "cloud_starter", want: false},
|
|
{plan: "", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.plan, func(t *testing.T) {
|
|
if got := IsGrandfatheredRecurringV5PlanVersion(tt.plan); got != tt.want {
|
|
t.Fatalf("IsGrandfatheredRecurringV5PlanVersion(%q) = %v, want %v", tt.plan, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsSelfHostedCoreMonitoringUncappedPlanVersion(t *testing.T) {
|
|
tests := []struct {
|
|
plan string
|
|
want bool
|
|
}{
|
|
{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},
|
|
{plan: "msp_starter", want: false},
|
|
{plan: "pro-v2", want: false},
|
|
{plan: "", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.plan, func(t *testing.T) {
|
|
if got := IsSelfHostedCoreMonitoringUncappedPlanVersion(tt.plan); got != tt.want {
|
|
t.Fatalf("IsSelfHostedCoreMonitoringUncappedPlanVersion(%q) = %v, want %v", tt.plan, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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_HostedPlansHaveWorkspaceLimits ensures hosted plans
|
|
// in the price map retain workspace policy after monitored-system caps retire.
|
|
func TestPriceIDToPlanVersion_AllMapToKnownPlans(t *testing.T) {
|
|
for priceID, plan := range PriceIDToPlanVersion {
|
|
t.Run(priceID, func(t *testing.T) {
|
|
if !strings.HasPrefix(plan, "cloud_") && !strings.HasPrefix(plan, "msp_") {
|
|
return
|
|
}
|
|
if _, known := WorkspaceLimitForPlan(plan); !known {
|
|
t.Errorf("PriceIDToPlanVersion[%q] = %q, but WorkspaceLimitForPlan does not recognize it", priceID, plan)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBusinessTierContractShape pins the dormant Business tier's contract:
|
|
// Pro's exact feature set (differentiation is by max_users limit, retention,
|
|
// and support, never by features), 365-day history, uncapped self-hosted core
|
|
// monitoring, recognized plan versions, and the Business display name. See
|
|
// cloud-paid Extension Point 26.
|
|
func TestBusinessTierContractShape(t *testing.T) {
|
|
proSet := TierFeatures[TierPro]
|
|
businessSet := TierFeatures[TierBusiness]
|
|
if len(proSet) != len(businessSet) {
|
|
t.Fatalf("Business features (%d) must equal Pro features (%d)", len(businessSet), len(proSet))
|
|
}
|
|
for i, feature := range proSet {
|
|
if businessSet[i] != feature {
|
|
t.Fatalf("Business feature[%d] = %q, want Pro's %q", i, businessSet[i], feature)
|
|
}
|
|
}
|
|
|
|
if got := TierHistoryDays[TierBusiness]; got != 365 {
|
|
t.Fatalf("TierHistoryDays[TierBusiness] = %d, want 365", got)
|
|
}
|
|
|
|
if !IsSelfHostedCoreMonitoringUncappedTier(TierBusiness) {
|
|
t.Fatal("TierBusiness must be self-hosted core-monitoring uncapped")
|
|
}
|
|
for _, plan := range []string{"business", "business_annual"} {
|
|
if !IsSelfHostedCoreMonitoringUncappedPlanVersion(plan) {
|
|
t.Fatalf("plan version %q must be self-hosted core-monitoring uncapped", plan)
|
|
}
|
|
}
|
|
|
|
if got := GetTierDisplayName(TierBusiness); got != "Business" {
|
|
t.Fatalf("GetTierDisplayName(TierBusiness) = %q, want Business", got)
|
|
}
|
|
}
|