diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index b795684fc..33b45e6b5 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -39,44 +39,46 @@ agreement, and cloud-specific enforcement rules. 17. `pkg/licensing/trial_activation.go` 18. `pkg/licensing/stripe_subscription.go` 19. `pkg/licensing/monitored_system_limit.go` -20. `internal/cloudcp/entitlements/service.go` -21. `internal/cloudcp/registry/registry.go` -22. `internal/cloudcp/account/tenant_handlers.go` +20. `internal/cloudcp/account/tenant_handlers.go` +21. `internal/cloudcp/config.go` +22. `internal/cloudcp/entitlements/service.go` 23. `internal/cloudcp/portal/handlers.go` 24. `internal/cloudcp/portal/page.go` -25. `internal/cloudcp/routes.go` -26. `internal/cloudcp/stripe/provisioner.go` -27. `internal/hosted/provisioner.go` -28. `frontend-modern/src/App.tsx` -29. `frontend-modern/src/AppLayout.tsx` -30. `frontend-modern/src/useAppRuntimeState.ts` -31. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` -32. `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` -33. `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx` -34. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` -35. `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx` -36. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` -37. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx` -38. `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx` -39. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` -40. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` -41. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` -42. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` -43. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` -44. `frontend-modern/src/components/Settings/RelayPairingSection.tsx` -45. `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` -46. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` -47. `frontend-modern/src/components/Settings/useProLicensePanelState.ts` -48. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts` -49. `frontend-modern/src/pages/CloudPricing.tsx` -50. `frontend-modern/src/pages/PricingV6.tsx` -51. `frontend-modern/src/utils/apiClient.ts` -52. `frontend-modern/src/utils/cloudPlans.ts` -53. `frontend-modern/src/utils/commercialBillingModel.ts` -54. `frontend-modern/src/utils/monitoredSystemPresentation.ts` -55. `frontend-modern/src/utils/selfHostedPlans.ts` -56. `frontend-modern/src/utils/licensePresentation.ts` -57. `frontend-modern/src/utils/upgradePresentation.ts` +25. `internal/cloudcp/public_cloud_signup_handlers.go` +26. `internal/cloudcp/registry/registry.go` +27. `internal/cloudcp/routes.go` +28. `internal/cloudcp/stripe/provisioner.go` +29. `internal/hosted/provisioner.go` +30. `frontend-modern/src/App.tsx` +31. `frontend-modern/src/AppLayout.tsx` +32. `frontend-modern/src/useAppRuntimeState.ts` +33. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` +34. `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` +35. `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx` +36. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` +37. `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx` +38. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` +39. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx` +40. `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx` +41. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` +42. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` +43. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` +44. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` +45. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` +46. `frontend-modern/src/components/Settings/RelayPairingSection.tsx` +47. `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` +48. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` +49. `frontend-modern/src/components/Settings/useProLicensePanelState.ts` +50. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts` +51. `frontend-modern/src/pages/CloudPricing.tsx` +52. `frontend-modern/src/pages/PricingV6.tsx` +53. `frontend-modern/src/utils/apiClient.ts` +54. `frontend-modern/src/utils/cloudPlans.ts` +55. `frontend-modern/src/utils/commercialBillingModel.ts` +56. `frontend-modern/src/utils/monitoredSystemPresentation.ts` +57. `frontend-modern/src/utils/selfHostedPlans.ts` +58. `frontend-modern/src/utils/licensePresentation.ts` +59. `frontend-modern/src/utils/upgradePresentation.ts` ## Shared Boundaries @@ -91,22 +93,23 @@ agreement, and cloud-specific enforcement rules. 2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go` 3. Add or change control-plane plan storage through `internal/cloudcp/registry/registry.go` 4. Add or change MSP account-scoped workspace provisioning entry handlers through `internal/cloudcp/account/tenant_handlers.go` -5. Add or change the hosted account portal API, task-first browser shell, maintained portal frontend/bundle, or account-scoped workspace/access/billing handoff through `internal/cloudcp/portal/` and `internal/cloudcp/routes.go` -6. Add or change Stripe provisioning plan resolution through `internal/cloudcp/stripe/provisioner.go` -7. Add or change activation/grant lifecycle or dev-mode capability widening through `pkg/licensing/dev_mode_features.go`, `pkg/licensing/service.go`, `pkg/licensing/grant_refresh.go`, and `pkg/licensing/revocation_poll.go` -8. Add or change license-server transport through `pkg/licensing/license_server_client.go` -9. Add or change encrypted activation persistence through `pkg/licensing/persistence.go` and `pkg/licensing/activation_store.go` -10. Add or change hosted trial token semantics through `pkg/licensing/trial_activation.go` -11. Add or change hosted signup provisioning through `internal/hosted/provisioner.go` -12. Add or change hosted billing-admin presentation through `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`, `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`, and `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` -13. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts` -14. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` -15. Add or change self-hosted Pro activation, trial, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts` -16. Add or change monitored-system ledger presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts` -17. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`, `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`, and `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` -18. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx` -19. Add contract tests where runtime and pricing need to stay aligned -20. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts` +5. Add or change public cloud self-serve signup price configuration or checkout gating through `internal/cloudcp/config.go` and `internal/cloudcp/public_cloud_signup_handlers.go` +6. Add or change the hosted account portal API, task-first browser shell, maintained portal frontend/bundle, or account-scoped workspace/access/billing handoff through `internal/cloudcp/portal/` and `internal/cloudcp/routes.go` +7. Add or change Stripe provisioning plan resolution through `internal/cloudcp/stripe/provisioner.go` +8. Add or change activation/grant lifecycle or dev-mode capability widening through `pkg/licensing/dev_mode_features.go`, `pkg/licensing/service.go`, `pkg/licensing/grant_refresh.go`, and `pkg/licensing/revocation_poll.go` +9. Add or change license-server transport through `pkg/licensing/license_server_client.go` +10. Add or change encrypted activation persistence through `pkg/licensing/persistence.go` and `pkg/licensing/activation_store.go` +11. Add or change hosted trial token semantics through `pkg/licensing/trial_activation.go` +12. Add or change hosted signup provisioning through `internal/hosted/provisioner.go` +13. Add or change hosted billing-admin presentation through `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`, `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`, and `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` +14. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts` +15. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` +16. Add or change self-hosted Pro activation, trial, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts` +17. Add or change monitored-system ledger presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts` +18. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`, `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`, and `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` +19. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx` +20. Add contract tests where runtime and pricing need to stay aligned +21. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts` ## Forbidden Paths @@ -178,6 +181,11 @@ Stripe control-plane fallback paths are also part of the boundary: when subscription or workspace provisioning logic reuses an already stored `plan_version`, it must canonicalize that value before persisting tenant, Stripe-account, or billing-state updates. +Public cloud self-serve signup follows the same rule: `internal/cloudcp/config.go` +and `internal/cloudcp/public_cloud_signup_handlers.go` must accept only +canonical `cloud_*` Stripe prices for public hosted signup tiers and fail +closed when misconfigured so self-hosted or prelaunch-only prices cannot leak +into live public checkout flows. Signed hosted entitlement leases are part of the same boundary: lease signing and verification must canonicalize recognized Cloud plan aliases and reconcile lease `limits.max_monitored_systems` to the authoritative per-plan contract instead of diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 379b233c2..fd5c4f7ac 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1642,7 +1642,9 @@ "frontend-modern/src/utils/upgradePresentation.ts", "internal/api/public_signup_handlers.go", "internal/cloudcp/account/tenant_handlers.go", + "internal/cloudcp/config.go", "internal/cloudcp/entitlements/service.go", + "internal/cloudcp/public_cloud_signup_handlers.go", "internal/cloudcp/registry/registry.go", "internal/cloudcp/routes.go", "internal/cloudcp/stripe/provisioner.go", @@ -1793,6 +1795,21 @@ "internal/cloudcp/stripe/msp_lifecycle_integration_test.go" ] }, + { + "id": "public-cloud-signup-price-selection", + "label": "public cloud signup price proof", + "match_prefixes": [], + "match_files": [ + "internal/cloudcp/config.go", + "internal/cloudcp/public_cloud_signup_handlers.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/cloudcp/config_test.go", + "internal/cloudcp/public_cloud_signup_handlers_test.go" + ] + }, { "id": "pulse-account-portal-surface", "label": "Pulse Account portal surface proof", diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 84b186678..5535a4501 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -224,6 +224,15 @@ func (c *CPConfig) validate() error { if strings.TrimSpace(c.StripeAPIKey) != "" && strings.TrimSpace(c.TrialActivationPrivateKey) == "" { return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when STRIPE_API_KEY is configured") } + if err := validateCloudStripePriceID("CP_TRIAL_SIGNUP_PRICE_ID", c.TrialSignupPriceID, "cloud_starter"); err != nil { + return err + } + if err := validateCloudStripePriceID("CP_CLOUD_POWER_PRICE_ID", c.CloudPowerPriceID, "cloud_power"); err != nil { + return err + } + if err := validateCloudStripePriceID("CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil { + return err + } if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" { return fmt.Errorf("PULSE_LICENSE_SERVER_URL is required when PULSE_LICENSE_ADMIN_TOKEN is configured") } @@ -254,6 +263,22 @@ func (c *CPConfig) validate() error { return nil } +func validateCloudStripePriceID(envName, priceID, wantPlanVersion string) error { + trimmed := strings.TrimSpace(priceID) + if trimmed == "" { + return nil + } + + planVersion, ok := pkglicensing.PlanVersionForPriceID(trimmed) + if !ok { + return fmt.Errorf("%s must map to the canonical %s Stripe price, got unknown price id %q", envName, wantPlanVersion, trimmed) + } + if planVersion != wantPlanVersion { + return fmt.Errorf("%s must map to the canonical %s Stripe price, got %q (%s)", envName, wantPlanVersion, trimmed, planVersion) + } + return nil +} + func normalizeCPEnvironment(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "dev": diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index dd31b9bbd..deabcdb56 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -222,6 +222,38 @@ func TestLoadConfig_EmailProviderRequired(t *testing.T) { } } +func TestLoadConfig_RejectsNonCloudTrialSignupPriceID(t *testing.T) { + setRequiredCPEnv(t) + setTrialSigningEnv(t) + t.Setenv("STRIPE_API_KEY", "sk_test_123") + t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_1T47OVBrHBocJIGHg4sMHMV7") + + _, err := LoadConfig() + if err == nil { + t.Fatal("expected error for non-cloud CP_TRIAL_SIGNUP_PRICE_ID") + } + if !strings.Contains(err.Error(), "CP_TRIAL_SIGNUP_PRICE_ID must map to the canonical cloud_starter Stripe price") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadConfig_AcceptsCanonicalCloudSignupPriceIDs(t *testing.T) { + setRequiredCPEnv(t) + setTrialSigningEnv(t) + t.Setenv("STRIPE_API_KEY", "sk_test_123") + t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_1T5kflBrHBocJIGHUqPv1dzV") + t.Setenv("CP_CLOUD_POWER_PRICE_ID", "price_1T5kg2BrHBocJIGHmkoF0zXY") + t.Setenv("CP_CLOUD_MAX_PRICE_ID", "price_1T5kg4BrHBocJIGHHa8Ecqho") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.TrialSignupPriceID != "price_1T5kflBrHBocJIGHUqPv1dzV" { + t.Fatalf("TrialSignupPriceID=%q want canonical cloud starter price", cfg.TrialSignupPriceID) + } +} + func TestLoadConfig_RequiresTrialSignupPriceWhenStripeEnabled(t *testing.T) { setRequiredCPEnv(t) t.Setenv("CP_REQUIRE_EMAIL_PROVIDER", "false") @@ -242,7 +274,7 @@ func TestLoadConfig_RequiresLiveStripeKeyInProduction(t *testing.T) { t.Setenv("CP_ENV", "production") t.Setenv("CP_REQUIRE_EMAIL_PROVIDER", "false") t.Setenv("STRIPE_API_KEY", "sk_test_123") - t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_123") + t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_1T5kflBrHBocJIGHUqPv1dzV") setTrialSigningEnv(t) _, err := LoadConfig() @@ -259,7 +291,7 @@ func TestLoadConfig_RequiresTestStripeKeyInStaging(t *testing.T) { t.Setenv("CP_ENV", "staging") t.Setenv("CP_REQUIRE_EMAIL_PROVIDER", "false") t.Setenv("STRIPE_API_KEY", "sk_live_123") - t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_123") + t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_1T5kflBrHBocJIGHUqPv1dzV") setTrialSigningEnv(t) _, err := LoadConfig() diff --git a/internal/cloudcp/public_cloud_signup_handlers.go b/internal/cloudcp/public_cloud_signup_handlers.go index e8e92b5d5..a1a6d43ba 100644 --- a/internal/cloudcp/public_cloud_signup_handlers.go +++ b/internal/cloudcp/public_cloud_signup_handlers.go @@ -224,6 +224,30 @@ func (h *PublicCloudSignupHandlers) priceIDForTier(tier cloudTier) (string, bool } } +func expectedPlanVersionForPublicCloudTier(tier cloudTier) string { + switch tier { + case cloudTierStarter: + return "cloud_starter" + case cloudTierPower: + return "cloud_power" + case cloudTierMax: + return "cloud_max" + default: + return "" + } +} + +func validatePublicCloudSignupPriceID(tier cloudTier, priceID string) error { + wantPlanVersion := expectedPlanVersionForPublicCloudTier(tier) + if wantPlanVersion == "" { + return fmt.Errorf("unsupported cloud tier %q", tier) + } + if err := validateCloudStripePriceID("public cloud signup price", priceID, wantPlanVersion); err != nil { + return err + } + return nil +} + func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -460,6 +484,9 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c if !ok || priceID == "" { return "", fmt.Errorf("price id not configured for tier %q", tier) } + if err := validatePublicCloudSignupPriceID(tier, priceID); err != nil { + return "", err + } stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey) successURL := buildCPURL(h.cfg.BaseURL, "/signup/complete", nil) diff --git a/internal/cloudcp/public_cloud_signup_handlers_test.go b/internal/cloudcp/public_cloud_signup_handlers_test.go index d00ff2e72..2095b0a8b 100644 --- a/internal/cloudcp/public_cloud_signup_handlers_test.go +++ b/internal/cloudcp/public_cloud_signup_handlers_test.go @@ -14,6 +14,12 @@ import ( stripe "github.com/stripe/stripe-go/v82" ) +const ( + testCloudStarterPriceID = "price_1T5kflBrHBocJIGHUqPv1dzV" + testCloudPowerPriceID = "price_1T5kg2BrHBocJIGHmkoF0zXY" + testCloudMaxPriceID = "price_1T5kg4BrHBocJIGHHa8Ecqho" +) + type captureMagicLinkGenerator struct { calls int email string @@ -78,7 +84,7 @@ func TestPublicCloudSignupHandleSignupPagePostValidRedirectsToStripe(t *testing. h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_test_123", + TrialSignupPriceID: testCloudStarterPriceID, }, nil, nil, nil) h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { if params == nil { @@ -154,7 +160,7 @@ func TestPublicCloudSignupHandlePublicSignupCreatesCheckout(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_test_123", + TrialSignupPriceID: testCloudStarterPriceID, }, nil, nil, nil) h.createCheckoutSession = func(_ *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_live"}, nil @@ -375,17 +381,16 @@ func TestPublicCloudSignupCheckoutMetadataIncludesPlanVersion(t *testing.T) { } } -func TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup(t *testing.T) { - // MSP price IDs should NOT set plan_version on public individual signup. +func TestPublicCloudSignupRejectsMSPPlanForPublicSignup(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", TrialSignupPriceID: "price_1T5kgTBrHBocJIGHjOs15LI2", // msp_starter }, nil, nil, nil) - var capturedMeta map[string]string + stripeCalled := false h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { - capturedMeta = params.Metadata + stripeCalled = true return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_test"}, nil } @@ -396,24 +401,24 @@ func TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup(t *testi h.HandleSignupPage(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("status=%d, want %d", rec.Code, http.StatusSeeOther) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadGateway) } - if _, exists := capturedMeta["plan_version"]; exists { - t.Fatalf("MSP price should NOT set plan_version on public signup, got %q", capturedMeta["plan_version"]) + if stripeCalled { + t.Fatal("expected MSP price misconfiguration to fail closed before calling Stripe") } } -func TestPublicCloudSignupCheckoutMetadataOmitsPlanVersionForUnknownPrice(t *testing.T) { +func TestPublicCloudSignupRejectsUnknownPriceForPublicSignup(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", TrialSignupPriceID: "price_unknown_test", }, nil, nil, nil) - var capturedMeta map[string]string + stripeCalled := false h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { - capturedMeta = params.Metadata + stripeCalled = true return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_test"}, nil } @@ -427,11 +432,42 @@ func TestPublicCloudSignupCheckoutMetadataOmitsPlanVersionForUnknownPrice(t *tes h.HandleSignupPage(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("status=%d, want %d", rec.Code, http.StatusSeeOther) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadGateway) } - if _, exists := capturedMeta["plan_version"]; exists { - t.Fatalf("metadata should NOT contain plan_version for unknown price, got %q", capturedMeta["plan_version"]) + if stripeCalled { + t.Fatal("expected unknown price misconfiguration to fail closed before calling Stripe") + } +} + +func TestPublicCloudSignupHandleSignupPagePostRejectsNonCloudPriceConfig(t *testing.T) { + h := NewPublicCloudSignupHandlers(&CPConfig{ + BaseURL: "https://cloud.example.com", + StripeAPIKey: "sk_test_123", + TrialSignupPriceID: "price_1T47OVBrHBocJIGHg4sMHMV7", // self-hosted v6 Pro monthly + }, nil, nil, nil) + + stripeCalled := false + h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { + stripeCalled = true + return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_test"}, nil + } + + form := url.Values{ + "email": {"owner@example.com"}, + "org_name": {"Pulse Labs"}, + } + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + h.HandleSignupPage(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadGateway) + } + if stripeCalled { + t.Fatal("expected public cloud signup to fail closed before calling Stripe with a non-cloud price id") } } @@ -468,9 +504,9 @@ func TestParseCloudTier(t *testing.T) { func TestPriceIDForTier(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ - TrialSignupPriceID: "price_starter", - CloudPowerPriceID: "price_power", - CloudMaxPriceID: "price_max", + TrialSignupPriceID: testCloudStarterPriceID, + CloudPowerPriceID: testCloudPowerPriceID, + CloudMaxPriceID: testCloudMaxPriceID, }, nil, nil, nil) tests := []struct { @@ -478,9 +514,9 @@ func TestPriceIDForTier(t *testing.T) { want string wantOK bool }{ - {cloudTierStarter, "price_starter", true}, - {cloudTierPower, "price_power", true}, - {cloudTierMax, "price_max", true}, + {cloudTierStarter, testCloudStarterPriceID, true}, + {cloudTierPower, testCloudPowerPriceID, true}, + {cloudTierMax, testCloudMaxPriceID, true}, } for _, tt := range tests { t.Run(string(tt.tier), func(t *testing.T) { @@ -497,7 +533,7 @@ func TestPriceIDForTier(t *testing.T) { func TestPriceIDForTierUnconfiguredReturnsNotOK(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ - TrialSignupPriceID: "price_starter", + TrialSignupPriceID: testCloudStarterPriceID, // Power and Max not configured }, nil, nil, nil) @@ -513,19 +549,19 @@ func TestPublicCloudSignupTierSelectionFormPost(t *testing.T) { cfg := &CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_starter", - CloudPowerPriceID: "price_power", - CloudMaxPriceID: "price_max", + TrialSignupPriceID: testCloudStarterPriceID, + CloudPowerPriceID: testCloudPowerPriceID, + CloudMaxPriceID: testCloudMaxPriceID, } tests := []struct { tier string wantPriceID string }{ - {"", "price_starter"}, // default - {"starter", "price_starter"}, - {"power", "price_power"}, - {"max", "price_max"}, + {"", testCloudStarterPriceID}, // default + {"starter", testCloudStarterPriceID}, + {"power", testCloudPowerPriceID}, + {"max", testCloudMaxPriceID}, } for _, tt := range tests { @@ -561,7 +597,7 @@ func TestPublicCloudSignupTierSelectionFormPost(t *testing.T) { func TestPublicCloudSignupInvalidTierFormPost(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ - TrialSignupPriceID: "price_starter", + TrialSignupPriceID: testCloudStarterPriceID, }, nil, nil, nil) form := url.Values{ @@ -587,7 +623,7 @@ func TestPublicCloudSignupUnconfiguredTierFormPost(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_starter", + TrialSignupPriceID: testCloudStarterPriceID, // CloudPowerPriceID intentionally empty }, nil, nil, nil) @@ -613,7 +649,7 @@ func TestPublicCloudSignupAPIUnconfiguredTierReturns400(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_starter", + TrialSignupPriceID: testCloudStarterPriceID, // CloudPowerPriceID intentionally empty }, nil, nil, nil) @@ -638,9 +674,9 @@ func TestPublicCloudSignupAPITierSelection(t *testing.T) { cfg := &CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_starter", - CloudPowerPriceID: "price_power", - CloudMaxPriceID: "price_max", + TrialSignupPriceID: testCloudStarterPriceID, + CloudPowerPriceID: testCloudPowerPriceID, + CloudMaxPriceID: testCloudMaxPriceID, } tests := []struct { @@ -649,13 +685,13 @@ func TestPublicCloudSignupAPITierSelection(t *testing.T) { wantPriceID string }{ // Default tier (starter) - {`{"email":"o@example.com","org_name":"Pulse Labs"}`, http.StatusCreated, "price_starter"}, + {`{"email":"o@example.com","org_name":"Pulse Labs"}`, http.StatusCreated, testCloudStarterPriceID}, // Explicit starter - {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"starter"}`, http.StatusCreated, "price_starter"}, + {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"starter"}`, http.StatusCreated, testCloudStarterPriceID}, // Power tier - {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"power"}`, http.StatusCreated, "price_power"}, + {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"power"}`, http.StatusCreated, testCloudPowerPriceID}, // Max tier - {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"max"}`, http.StatusCreated, "price_max"}, + {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"max"}`, http.StatusCreated, testCloudMaxPriceID}, // Invalid tier {`{"email":"o@example.com","org_name":"Pulse Labs","tier":"enterprise"}`, http.StatusBadRequest, ""}, } @@ -690,8 +726,8 @@ func TestPublicCloudSignupTierPreservedInCancelURL(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", StripeAPIKey: "sk_test_123", - TrialSignupPriceID: "price_starter", - CloudPowerPriceID: "price_power", + TrialSignupPriceID: testCloudStarterPriceID, + CloudPowerPriceID: testCloudPowerPriceID, }, nil, nil, nil) var capturedCancelURL string @@ -762,8 +798,8 @@ func TestPublicCloudSignupPowerTierMetadataIncludesPlanVersion(t *testing.T) { func TestPublicCloudSignupGETUnconfiguredTierFallsBackToStarter(t *testing.T) { // Power configured, Max not — ?tier=max should fall back to starter. h := NewPublicCloudSignupHandlers(&CPConfig{ - TrialSignupPriceID: "price_starter", - CloudPowerPriceID: "price_power", + TrialSignupPriceID: testCloudStarterPriceID, + CloudPowerPriceID: testCloudPowerPriceID, // CloudMaxPriceID intentionally empty }, nil, nil, nil) @@ -783,8 +819,8 @@ func TestPublicCloudSignupGETUnconfiguredTierFallsBackToStarter(t *testing.T) { func TestPublicCloudSignupFormShowsTierRadiosWhenConfigured(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ - CloudPowerPriceID: "price_power", - CloudMaxPriceID: "price_max", + CloudPowerPriceID: testCloudPowerPriceID, + CloudMaxPriceID: testCloudMaxPriceID, }, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/signup?tier=power", nil) @@ -808,7 +844,7 @@ func TestPublicCloudSignupFormShowsTierRadiosWhenConfigured(t *testing.T) { func TestPublicCloudSignupFormHidesTierRadiosWhenSingleTier(t *testing.T) { h := NewPublicCloudSignupHandlers(&CPConfig{ - TrialSignupPriceID: "price_starter", + TrialSignupPriceID: testCloudStarterPriceID, // No Power or Max configured }, nil, nil, nil)