mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 20:52:51 +00:00
97f894b009
Relay left public checkout on 2026-09-29: it only ever connected the Pulse Mobile app, which retires on 31 March 2027, and existing Relay subscribers now carry Pro entitlements. The app still sold it. Every Community install saw a "Get alerts on your phone ... Available with Relay and Pro plans" upgrade panel on Alerts destinations, the plan screen offered a Relay card with "Remote web access via Relay", and the settings section was called Remote Access although Relay never reached the web UI. The Alerts push panel now renders only on instances that have the relay feature, with no upsell. The settings section, nav, header and locale catalogs say Pulse Mobile and carry the retirement date. Community sees only the Pro comparison card and Relay-tier licenses see none; gated mobile features name Pro as their minimum plan. The relay feature is labelled "Pulse Relay (Mobile Connection)" in the catalog, plan copy no longer claims remote web access, the backend pairing diagnostics point at Settings > Pulse Mobile, and the user docs, including PRIVACY.md, state that Relay does not provide remote web UI access.
927 lines
28 KiB
Go
927 lines
28 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 and Explains the Root Cause"},
|
|
{FeatureAIAutoFix, "Patrol Applies Safe Fixes and Verifies the Result"},
|
|
{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 (Mobile Connection)"},
|
|
{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 (Mobile Connection)" {
|
|
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_solo", 3},
|
|
{"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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The provider MSP ladder must rise from the evaluation through every paid
|
|
// tier: the evaluation stays below the cheapest paid plan so needing one more
|
|
// client is what converts, and Solo is that first paid step.
|
|
func TestProviderMSPWorkspaceLadderRisesFromTheEvaluation(t *testing.T) {
|
|
ladder := []string{PlanVersionMSPEval, "msp_solo", "msp_starter", "msp_growth", "msp_scale"}
|
|
previous := 0
|
|
for _, plan := range ladder {
|
|
limit, known := WorkspaceLimitForPlan(plan)
|
|
if !known {
|
|
t.Fatalf("%s has no workspace limit", plan)
|
|
}
|
|
if limit <= previous {
|
|
t.Fatalf("%s allows %d workspaces, not more than the step below it (%d)", plan, limit, previous)
|
|
}
|
|
previous = limit
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The unlicensed evaluation cap is a commercial boundary, not a default that
|
|
// happens to be small. If it ever reaches the cheapest paid tier there is
|
|
// nothing left to sell, which is exactly the state this replaced: the
|
|
// unlicensed control plane used to run on the full 5-client Starter allowance.
|
|
func TestMSPEvalCapStaysBelowCheapestPaidTier(t *testing.T) {
|
|
evalLimit, known := WorkspaceLimitForPlan(PlanVersionMSPEval)
|
|
if !known {
|
|
t.Fatalf("WorkspaceLimitForPlan(%q) unknown; an unrecognized plan fails closed to %d and breaks provider preflight", PlanVersionMSPEval, UnknownPlanDefaultWorkspaceLimit)
|
|
}
|
|
if evalLimit != 2 {
|
|
t.Fatalf("eval workspace limit = %d, want 2", evalLimit)
|
|
}
|
|
|
|
for _, paid := range []string{"msp_starter", "msp_growth", "msp_scale"} {
|
|
paidLimit, paidKnown := WorkspaceLimitForPlan(paid)
|
|
if !paidKnown {
|
|
t.Fatalf("paid plan %q has no workspace limit", paid)
|
|
}
|
|
if evalLimit >= paidLimit {
|
|
t.Fatalf("eval limit %d is not below paid plan %q limit %d", evalLimit, paid, paidLimit)
|
|
}
|
|
}
|
|
}
|