mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
6be2af1c19
Two mandatory round-trips stood between an interested MSP and their first screen, and neither was technical. setup.sh required four image digests shipped as literal <pin> placeholders, so the only way to get them was to ask. All four images are publicly readable, so there was never anything to hand out. setup.sh now resolves each blank pin to an immutable digest from its published tag via buildx imagetools and writes it back to .env; hand-set values are left alone. setup.sh then died outright without a licence file, so nobody could start the stack, create a workspace, or see the portal until a human minted a licence for them. The control plane already ran unlicensed via ProviderMSPPlanSourceEnvFallback; only the installer refused. A licence path that is set but missing is still a hard failure, since that is a misconfiguration rather than a choice. Unlicensed now means evaluation rather than the cheapest paid tier. The env fallback defaulted to msp_starter, handing every unlicensed deployment the full 5-client Starter allowance and leaving no boundary between evaluating and buying. Adds msp_eval at 2 workspaces: same capabilities, smaller cap, not purchasable, not on the public ladder. An isolation guarantee is the one claim a provider cannot evaluate from a screenshot, and both MSP leads this year went quiet at exactly this step. Contracts: cloud-paid records the unlicensed plan rule and the strictly-below-paid invariant; deployment-installability records credential-free, correspondence-free installability. Verification: TestMSPEvalCapStaysBelowCheapestPaidTier, TestCanonicalizePlanVersion_MSPEval, TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. The last was negative-tested by reintroducing a <pin> placeholder and confirming it fails. ensure_image_pins exercised against the live registries. licensing, cloudcp, control-plane and installtests all green.
908 lines
28 KiB
Go
908 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 (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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|