Add hosted MSP signup front door to the cloud control plane

Adds public self-serve signup for the hosted MSP offering alongside the
existing individual Cloud signup page. New handlers serve /cloud/msp/signup,
/cloud/msp/signup/complete, and /api/public/msp/signup, gated behind the same
PublicCloudSignupEnabled flag. Per-tier Stripe prices
(CP_MSP_STARTER_PRICE_ID, CP_MSP_GROWTH_PRICE_ID, CP_MSP_SCALE_PRICE_ID) are
validated against the canonical msp_starter/growth/scale plan versions; the
page renders an explicit "not open yet" notice when no MSP price is
configured. Checkout sessions carry account_kind=msp metadata so the
provisioner seeds an isolated operator workspace. The cloud and MSP JSON
signup endpoints now share one checkout skeleton parameterized per path.
This commit is contained in:
rcourtman
2026-05-28 22:38:15 +01:00
parent 256b1f2fc7
commit 87604edb21
8 changed files with 995 additions and 16 deletions
@@ -98,6 +98,7 @@ cloud-specific enforcement rules.
86. `internal/cloudcp/handoff/handler.go`, `internal/cloudcp/handoff/handoff.go`
87. `internal/cloudcp/stripe/grace_enforcer.go`, `internal/cloudcp/stripe/helpers.go`, `internal/cloudcp/stripe/reconciler.go`, `internal/cloudcp/stripe/webhook.go`
88. `internal/hosted/hosted_metrics.go`, `internal/hosted/reaper.go`
89. `internal/cloudcp/public_msp_signup_handlers.go`
## Shared Boundaries
@@ -230,6 +231,16 @@ or other self-hosted uncapped continuity plans.
tenant-runtime capacity/log retention configuration, or checkout gating
through `internal/cloudcp/config.go` and
`internal/cloudcp/public_cloud_signup_handlers.go`
Public MSP self-serve signup is the operator-account front door for the same
boundary and lives in `internal/cloudcp/public_msp_signup_handlers.go`. Its
per-tier price configuration (`CP_MSP_STARTER_PRICE_ID`,
`CP_MSP_GROWTH_PRICE_ID`, `CP_MSP_SCALE_PRICE_ID`) is validated in
`internal/cloudcp/config.go` against the canonical `msp_starter`,
`msp_growth`, and `msp_scale` plan versions, and the MSP signup routes stay
gated behind the same `PublicCloudSignupEnabled` flag as the individual
cloud signup front door. Checkout metadata produced by the MSP front door
must mark `account_kind=msp` so provisioning seeds an operator workspace
rather than an individual tenant.
6. Add or change the hosted account portal API, Pulse Account access/auth/session handling, task-first browser shell, maintained portal frontend/bundle, or account-scoped workspace/access/billing handoff through `internal/cloudcp/account/audit.go`, `internal/cloudcp/account/handlers.go`, `internal/cloudcp/auth/handlers.go`, `internal/cloudcp/auth/session.go`, `internal/cloudcp/portal/`, and `internal/cloudcp/routes.go`
That same customer-entry boundary owns the canonical hosted Cloud handoff:
public Cloud entry, secure checkout return, and returning-customer sign-in
@@ -686,6 +697,19 @@ boundary: hosted Cloud/MSP checkout events may provision tenants, while
self-hosted landing purchases are acknowledged and ignored rather than
creating hosted containers or Stripe account mappings.
The hosted MSP offering now has a public self-serve front door alongside the
individual cloud signup page. `internal/cloudcp/public_msp_signup_handlers.go`
serves `/cloud/msp/signup`, `/cloud/msp/signup/complete`, and
`/api/public/msp/signup`, all gated behind the same `PublicCloudSignupEnabled`
flag as `/cloud/signup`, so the MSP front door stays dark until an operator
explicitly enables public signup. Each MSP tier (starter, growth, scale) is
served only when its Stripe price is configured (`CP_MSP_STARTER_PRICE_ID`,
`CP_MSP_GROWTH_PRICE_ID`, `CP_MSP_SCALE_PRICE_ID`); the signup page renders an
explicit "not open for self-serve signup yet" notice when no MSP tier price is
configured rather than offering an unbacked checkout. Checkout sessions started
from this front door carry `account_kind=msp` and `signup_source=public_msp_signup`
metadata so the provisioner seeds an isolated operator workspace.
Cloud paid readiness is materially behind architecture work. The main concern is
contract coherence between pricing, entitlements, and runtime enforcement.
Pulse Account portal workspace copy is part of that same readiness contract:
+15
View File
@@ -55,6 +55,9 @@ type CPConfig struct {
TrialSignupPriceID string // Cloud Starter (default tier) price ID
CloudPowerPriceID string // Cloud Power tier price ID (optional)
CloudMaxPriceID string // Cloud Max tier price ID (optional)
CloudMSPStarterPriceID string // MSP Starter tier price ID (optional)
CloudMSPGrowthPriceID string // MSP Growth tier price ID (optional)
CloudMSPScalePriceID string // MSP Scale tier price ID (optional)
LicenseServerURL string
LicenseAdminToken string
TrialActivationPrivateKey string
@@ -183,6 +186,9 @@ func LoadConfig() (*CPConfig, error) {
TrialSignupPriceID: strings.TrimSpace(os.Getenv("CP_TRIAL_SIGNUP_PRICE_ID")),
CloudPowerPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_POWER_PRICE_ID")),
CloudMaxPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_MAX_PRICE_ID")),
CloudMSPStarterPriceID: strings.TrimSpace(os.Getenv("CP_MSP_STARTER_PRICE_ID")),
CloudMSPGrowthPriceID: strings.TrimSpace(os.Getenv("CP_MSP_GROWTH_PRICE_ID")),
CloudMSPScalePriceID: strings.TrimSpace(os.Getenv("CP_MSP_SCALE_PRICE_ID")),
LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"),
LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")),
TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")),
@@ -323,6 +329,15 @@ func (c *CPConfig) validate() error {
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil {
return err
}
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_STARTER_PRICE_ID", c.CloudMSPStarterPriceID, "msp_starter"); err != nil {
return err
}
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_GROWTH_PRICE_ID", c.CloudMSPGrowthPriceID, "msp_growth"); err != nil {
return err
}
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_SCALE_PRICE_ID", c.CloudMSPScalePriceID, "msp_scale"); 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")
}
+42
View File
@@ -386,6 +386,48 @@ func TestLoadConfig_AcceptsCanonicalCloudSignupPriceIDs(t *testing.T) {
}
}
func TestLoadConfig_AcceptsCanonicalMSPSignupPriceIDs(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
t.Setenv("CP_ENV", "production")
t.Setenv("STRIPE_API_KEY", "sk_live_123")
t.Setenv("CP_MSP_STARTER_PRICE_ID", "price_1T5kgTBrHBocJIGHjOs15LI2")
t.Setenv("CP_MSP_GROWTH_PRICE_ID", "price_1T5kgVBrHBocJIGHulNsCTb1")
t.Setenv("CP_MSP_SCALE_PRICE_ID", "price_1T5kgWBrHBocJIGHo40iFeRd")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.CloudMSPStarterPriceID != "price_1T5kgTBrHBocJIGHjOs15LI2" {
t.Fatalf("CloudMSPStarterPriceID=%q want canonical msp_starter price", cfg.CloudMSPStarterPriceID)
}
if cfg.CloudMSPGrowthPriceID != "price_1T5kgVBrHBocJIGHulNsCTb1" {
t.Fatalf("CloudMSPGrowthPriceID=%q want canonical msp_growth price", cfg.CloudMSPGrowthPriceID)
}
if cfg.CloudMSPScalePriceID != "price_1T5kgWBrHBocJIGHo40iFeRd" {
t.Fatalf("CloudMSPScalePriceID=%q want canonical msp_scale price", cfg.CloudMSPScalePriceID)
}
}
func TestLoadConfig_RejectsNonMSPStarterPriceIDInProductionCatalog(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
t.Setenv("CP_ENV", "production")
t.Setenv("STRIPE_API_KEY", "sk_live_123")
// A canonical cloud_power price is a valid Stripe price but maps to the
// wrong plan version for the MSP Starter slot, so config must fail closed.
t.Setenv("CP_MSP_STARTER_PRICE_ID", "price_1T5kg2BrHBocJIGHmkoF0zXY")
_, err := LoadConfig()
if err == nil {
t.Fatal("expected error for non-msp_starter CP_MSP_STARTER_PRICE_ID")
}
if !strings.Contains(err.Error(), "CP_MSP_STARTER_PRICE_ID must map to the canonical msp_starter Stripe price") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadConfig_AllowsMissingTrialSignupPriceWhenPublicCloudSignupDisabled(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
@@ -377,6 +377,34 @@ func (h *PublicCloudSignupHandlers) HandleSignupComplete(w http.ResponseWriter,
}
func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http.Request) {
h.servePublicSignupCheckout(w, r,
"Invalid plan tier. Must be one of: starter, power, max",
"public cloud signup API checkout creation failed",
fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", publicCloudTrialDays),
func(tierRaw string) (bool, bool, func(email, orgName string) (string, error)) {
tier, ok := parseCloudTier(tierRaw)
if !ok {
return false, false, nil
}
_, available := h.priceIDForTier(tier)
return true, available, func(email, orgName string) (string, error) {
return h.createCheckout(email, orgName, tier)
}
},
)
}
// servePublicSignupCheckout runs the shared method/decode/validate/checkout
// skeleton for the public Cloud and MSP signup JSON endpoints. resolve supplies
// the per-path tier parsing, price availability, and checkout creation.
func (h *PublicCloudSignupHandlers) servePublicSignupCheckout(
w http.ResponseWriter,
r *http.Request,
invalidTierMessage string,
checkoutLogMsg string,
successMessage string,
resolve func(tierRaw string) (tierValid bool, available bool, create func(email, orgName string) (string, error)),
) {
if r.Method != http.MethodPost {
writePublicSignupError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
@@ -389,37 +417,37 @@ func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r
return
}
req.Email = strings.TrimSpace(req.Email)
req.OrgName = strings.TrimSpace(req.OrgName)
email := strings.TrimSpace(req.Email)
orgName := strings.TrimSpace(req.OrgName)
tier, tierOK := parseCloudTier(req.Tier)
if !tierOK {
writePublicSignupError(w, http.StatusBadRequest, "invalid_tier", "Invalid plan tier. Must be one of: starter, power, max")
tierValid, available, create := resolve(req.Tier)
if !tierValid {
writePublicSignupError(w, http.StatusBadRequest, "invalid_tier", invalidTierMessage)
return
}
if !isValidCloudSignupEmail(req.Email) {
if !isValidCloudSignupEmail(email) {
writePublicSignupError(w, http.StatusBadRequest, "invalid_email", "Invalid email format")
return
}
if !isValidCloudSignupOrgName(req.OrgName) {
if !isValidCloudSignupOrgName(orgName) {
writePublicSignupError(w, http.StatusBadRequest, "invalid_org_name", "Invalid organization name")
return
}
if _, avail := h.priceIDForTier(tier); !avail {
if !available {
writePublicSignupError(w, http.StatusBadRequest, "tier_unavailable", "The selected plan tier is not currently available")
return
}
checkoutURL, err := h.createCheckout(req.Email, req.OrgName, tier)
checkoutURL, err := create(email, orgName)
if err != nil {
log.Warn().Err(err).Str("email", req.Email).Msg("public cloud signup API checkout creation failed")
log.Warn().Err(err).Str("email", email).Msg(checkoutLogMsg)
writePublicSignupError(w, http.StatusBadGateway, "checkout_failed", "Unable to create checkout session")
return
}
writePublicSignupJSON(w, http.StatusCreated, map[string]any{
"checkout_url": checkoutURL,
"message": fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", publicCloudTrialDays),
"message": successMessage,
})
}
@@ -501,9 +529,6 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
if h.cfg == nil {
return "", fmt.Errorf("control plane config is missing")
}
if strings.TrimSpace(h.cfg.StripeAPIKey) == "" {
return "", fmt.Errorf("stripe api key not configured")
}
priceID, ok := h.priceIDForTier(tier)
if !ok || priceID == "" {
return "", fmt.Errorf("price id not configured for tier %q", tier)
@@ -512,7 +537,6 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
return "", err
}
stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey)
successURL := buildCPURL(h.cfg.BaseURL, canonicalPublicCloudSignupPath+"/complete", nil)
cancelURL := buildCPURL(h.cfg.BaseURL, canonicalPublicCloudSignupPath, url.Values{
"cancelled": {"1"},
@@ -520,6 +544,25 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
"org_name": {orgName},
"tier": {string(tier)},
})
return h.createTrialCheckoutSession(email, priceID, successURL, cancelURL, h.buildCheckoutMetadata(priceID, orgName))
}
// createTrialCheckoutSession builds a subscription-mode Stripe Checkout session
// for a single recurring price with the standard public-signup trial, and
// returns the redirect URL. Shared by the individual Cloud and MSP signup paths;
// callers own price resolution, success/cancel URLs, and metadata.
func (h *PublicCloudSignupHandlers) createTrialCheckoutSession(email, priceID, successURL, cancelURL string, metadata map[string]string) (string, error) {
if h.cfg == nil {
return "", fmt.Errorf("control plane config is missing")
}
if strings.TrimSpace(h.cfg.StripeAPIKey) == "" {
return "", fmt.Errorf("stripe api key not configured")
}
if strings.TrimSpace(priceID) == "" {
return "", fmt.Errorf("price id not configured")
}
stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey)
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
SuccessURL: stripe.String(successURL),
@@ -535,7 +578,7 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
TrialPeriodDays: stripe.Int64(publicCloudTrialDays),
},
Metadata: h.buildCheckoutMetadata(priceID, orgName),
Metadata: metadata,
}
session, err := h.createCheckoutSession(params)
if err != nil {
@@ -0,0 +1,399 @@
package cloudcp
import (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/cpsec"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rs/zerolog/log"
)
const canonicalPublicMSPSignupPath = "/cloud/msp/signup"
// mspTier represents a hosted MSP plan tier for public signup. The three tiers
// mirror the canonical MSP plan-version ladder in pkg/licensing (msp_starter /
// msp_growth / msp_scale), each capped at a different number of client
// workspaces.
type mspTier string
const (
mspTierStarter mspTier = "starter"
mspTierGrowth mspTier = "growth"
mspTierScale mspTier = "scale"
)
var validMSPTiers = map[mspTier]bool{
mspTierStarter: true,
mspTierGrowth: true,
mspTierScale: true,
}
// parseMSPTier normalizes a tier string from user input. Returns mspTierStarter
// if the input is empty (default). Returns ("", false) if the input is a
// non-empty but unrecognized tier.
func parseMSPTier(raw string) (mspTier, bool) {
t := mspTier(strings.ToLower(strings.TrimSpace(raw)))
if t == "" {
return mspTierStarter, true
}
if validMSPTiers[t] {
return t, true
}
return "", false
}
func expectedPlanVersionForMSPTier(tier mspTier) string {
switch tier {
case mspTierStarter:
return "msp_starter"
case mspTierGrowth:
return "msp_growth"
case mspTierScale:
return "msp_scale"
default:
return ""
}
}
// priceIDForMSPTier returns the configured Stripe price ID for the given MSP
// tier. Returns ("", false) if the tier's price ID is not configured. An MSP
// tier with no configured price ID is treated as not offered, which is how the
// front door stays inert until Richard sets the price IDs in CP env.
func (h *PublicCloudSignupHandlers) priceIDForMSPTier(tier mspTier) (string, bool) {
if h.cfg == nil {
return "", false
}
switch tier {
case mspTierStarter:
id := strings.TrimSpace(h.cfg.CloudMSPStarterPriceID)
return id, id != ""
case mspTierGrowth:
id := strings.TrimSpace(h.cfg.CloudMSPGrowthPriceID)
return id, id != ""
case mspTierScale:
id := strings.TrimSpace(h.cfg.CloudMSPScalePriceID)
return id, id != ""
default:
return "", false
}
}
func (h *PublicCloudSignupHandlers) hasMSPTier(tier mspTier) bool {
_, ok := h.priceIDForMSPTier(tier)
return ok
}
// defaultMSPTier returns the lowest configured MSP tier, preferring
// starter → growth → scale. The bool is false when no MSP tier is configured.
func (h *PublicCloudSignupHandlers) defaultMSPTier() (mspTier, bool) {
for _, t := range []mspTier{mspTierStarter, mspTierGrowth, mspTierScale} {
if h.hasMSPTier(t) {
return t, true
}
}
return "", false
}
func validatePublicMSPSignupPriceID(tier mspTier, priceID string) error {
wantPlanVersion := expectedPlanVersionForMSPTier(tier)
if wantPlanVersion == "" {
return fmt.Errorf("unsupported msp tier %q", tier)
}
if err := validateCloudStripePriceID("production", "", "public msp signup price", priceID, wantPlanVersion); err != nil {
return err
}
return nil
}
var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-page").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Start Pulse Cloud for MSPs</title>
<style nonce="{{.Nonce}}">
:root { color-scheme: light; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(140deg, #f8fafc, #e2e8f0); color: #0f172a; }
.wrap { max-width: 760px; margin: 36px auto; padding: 0 16px; }
.card { background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 8px 30px rgba(15,23,42,.08); padding: 24px; }
h1 { margin: 0 0 8px; font-size: 30px; }
p { margin: 0 0 16px; line-height: 1.5; color: #334155; }
.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 14px; }
.note { background: #eff6ff; color: #1e3a8a; border: 1px solid #bfdbfe; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 14px; }
label { display: block; margin: 12px 0 6px; font-size: 14px; font-weight: 600; color: #0f172a; }
input { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; font-size: 15px; }
.cta { margin-top: 16px; border: 0; border-radius: 10px; background: #1d4ed8; color: #fff; font-size: 16px; font-weight: 600; padding: 12px 16px; width: 100%; cursor: pointer; }
.cta:hover { background: #1e40af; }
.fine { font-size: 12px; color: #64748b; margin-top: 12px; }
.tier-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.tier-option { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 400; cursor: pointer; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px; }
.tier-option:has(input:checked) { border-color: #1d4ed8; background: #eff6ff; }
ol { margin: 0; padding-left: 20px; color: #334155; }
li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Start your {{.TrialDays}}-day Pulse Cloud for MSPs trial</h1>
<p>Run Pulse for multiple clients from one hosted operator account. Each client gets an isolated workspace; you manage them all from the MSP portal. Stripe checkout securely collects a payment method, but the subscription starts with a {{.TrialDays}}-day trial and no upfront charge.</p>
{{if .ErrorMessage}}<div class="error">{{.ErrorMessage}}</div>{{end}}
{{if .Cancelled}}<div class="note">Checkout was cancelled. You can start again below.</div>{{end}}
{{if .Available}}
<form method="POST" action="{{.FormAction}}">
{{/* Tier labels show monthly pricing and client-workspace caps for
orientation. Stripe checkout displays the actual price from the
configured price ID. */}}
{{if .ShowTierChoice}}
<label>Plan</label>
<div class="tier-group">
{{if .HasStarter}}<label class="tier-option"><input type="radio" name="tier" value="starter" {{if eq .Tier "starter"}}checked{{end}}> <strong>Starter</strong> — up to 10 client workspaces, $149/mo after trial</label>{{end}}
{{if .HasGrowth}}<label class="tier-option"><input type="radio" name="tier" value="growth" {{if eq .Tier "growth"}}checked{{end}}> <strong>Growth</strong> — up to 25 client workspaces, $249/mo after trial</label>{{end}}
{{if .HasScale}}<label class="tier-option"><input type="radio" name="tier" value="scale" {{if eq .Tier "scale"}}checked{{end}}> <strong>Scale</strong> — up to 50 client workspaces, $399/mo after trial</label>{{end}}
</div>
{{else}}
<input type="hidden" name="tier" value="{{.Tier}}">
{{end}}
<label for="email">Work Email</label>
<input id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" required>
<label for="org_name">Company Name</label>
<input id="org_name" name="org_name" type="text" value="{{.OrgName}}" autocomplete="organization" required>
<button class="cta" type="submit">Continue To Secure Checkout</button>
</form>
<p class="fine">After checkout, we will email a Pulse Account sign-in link so you can open your MSP portal.</p>
<ol>
<li>Stripe starts your {{.TrialDays}}-day trial securely.</li>
<li>Pulse Cloud provisions your MSP operator account.</li>
<li>The email link opens Pulse Account, where you add client workspaces and continue setup.</li>
</ol>
{{else}}
<div class="note">Pulse Cloud for MSPs is not open for self-serve signup yet. Email support@pulserelay.pro and we will get you set up.</div>
{{end}}
</div>
</div>
</body>
</html>
`))
var publicMSPSignupCompleteTemplate = template.Must(template.New("public-msp-signup-complete").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pulse Cloud for MSPs Checkout Complete</title>
<style nonce="{{.Nonce}}">
:root { color-scheme: light; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; }
.wrap { max-width: 680px; margin: 48px auto; padding: 0 16px; }
.card { background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 8px 30px rgba(15,23,42,.08); padding: 24px; }
h1 { margin: 0 0 8px; font-size: 28px; }
p { margin: 0 0 14px; line-height: 1.5; color: #334155; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Trial checkout complete</h1>
<p>Your {{.TrialDays}}-day Pulse Cloud for MSPs trial checkout completed. Pulse Cloud is provisioning your MSP operator account.</p>
<p>Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can open the MSP portal, add client workspaces, and continue setup.</p>
</div>
</div>
</body>
</html>
`))
type publicMSPSignupPageData struct {
Email string
OrgName string
Tier string // selected tier slug ("starter", "growth", "scale")
FormAction string
ErrorMessage string
Cancelled bool
Nonce string
Available bool // true if at least one MSP tier price is configured
ShowTierChoice bool // true if more than one MSP tier is configured
HasStarter bool
HasGrowth bool
HasScale bool
TrialDays int
}
// newMSPSignupPageData seeds page data from the currently configured MSP tiers
// so every render (initial and error) reflects the same availability state.
func (h *PublicCloudSignupHandlers) newMSPSignupPageData() publicMSPSignupPageData {
hasStarter := h.hasMSPTier(mspTierStarter)
hasGrowth := h.hasMSPTier(mspTierGrowth)
hasScale := h.hasMSPTier(mspTierScale)
count := 0
for _, present := range []bool{hasStarter, hasGrowth, hasScale} {
if present {
count++
}
}
data := publicMSPSignupPageData{
FormAction: canonicalPublicMSPSignupPath,
HasStarter: hasStarter,
HasGrowth: hasGrowth,
HasScale: hasScale,
Available: count > 0,
ShowTierChoice: count > 1,
TrialDays: publicCloudTrialDays,
}
if def, ok := h.defaultMSPTier(); ok {
data.Tier = string(def)
}
return data
}
func (h *PublicCloudSignupHandlers) renderMSPSignupPage(w http.ResponseWriter, r *http.Request, status int, data publicMSPSignupPageData) {
data.Nonce = cpsec.NonceFromContext(r.Context())
if data.TrialDays <= 0 {
data.TrialDays = publicCloudTrialDays
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := publicMSPSignupPageTemplate.Execute(w, data); err != nil {
log.Error().Err(err).Msg("public msp signup page render failed")
}
}
func (h *PublicCloudSignupHandlers) HandleMSPSignupPage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data := h.newMSPSignupPageData()
data.Email = strings.TrimSpace(r.URL.Query().Get("email"))
data.OrgName = strings.TrimSpace(r.URL.Query().Get("org_name"))
data.Cancelled = strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("cancelled")), "1")
if tier, ok := parseMSPTier(strings.TrimSpace(r.URL.Query().Get("tier"))); ok && h.hasMSPTier(tier) {
data.Tier = string(tier)
}
h.renderMSPSignupPage(w, r, http.StatusOK, data)
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form body", http.StatusBadRequest)
return
}
email := strings.TrimSpace(r.FormValue("email"))
orgName := strings.TrimSpace(r.FormValue("org_name"))
tierStr := strings.TrimSpace(r.FormValue("tier"))
renderErr := func(status int, msg string) {
data := h.newMSPSignupPageData()
data.Email = email
data.OrgName = orgName
if t, ok := parseMSPTier(tierStr); ok && h.hasMSPTier(t) {
data.Tier = string(t)
}
data.ErrorMessage = msg
h.renderMSPSignupPage(w, r, status, data)
}
tier, tierOK := parseMSPTier(tierStr)
if !tierOK {
renderErr(http.StatusBadRequest, "Invalid plan tier selected.")
return
}
if !isValidCloudSignupEmail(email) {
renderErr(http.StatusBadRequest, "A valid email address is required.")
return
}
if !isValidCloudSignupOrgName(orgName) {
renderErr(http.StatusBadRequest, "Company name must be 3-64 characters and cannot contain slashes.")
return
}
if _, avail := h.priceIDForMSPTier(tier); !avail {
renderErr(http.StatusBadRequest, "The selected plan tier is not currently available.")
return
}
checkoutURL, err := h.createMSPCheckout(email, orgName, tier)
if err != nil {
log.Warn().Err(err).Str("email", email).Str("tier", string(tier)).Msg("public msp signup checkout creation failed")
renderErr(http.StatusBadGateway, "Unable to create checkout session. Please try again.")
return
}
http.Redirect(w, r, checkoutURL, http.StatusSeeOther)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *PublicCloudSignupHandlers) HandleMSPSignupComplete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := publicMSPSignupCompleteTemplate.Execute(w, publicCloudSignupCompleteData{
Nonce: cpsec.NonceFromContext(r.Context()),
TrialDays: publicCloudTrialDays,
}); err != nil {
log.Error().Err(err).Msg("public msp signup complete page render failed")
}
}
func (h *PublicCloudSignupHandlers) HandleMSPPublicSignup(w http.ResponseWriter, r *http.Request) {
h.servePublicSignupCheckout(w, r,
"Invalid plan tier. Must be one of: starter, growth, scale",
"public msp signup API checkout creation failed",
fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud for MSPs trial and provision your operator account.", publicCloudTrialDays),
func(tierRaw string) (bool, bool, func(email, orgName string) (string, error)) {
tier, ok := parseMSPTier(tierRaw)
if !ok {
return false, false, nil
}
_, available := h.priceIDForMSPTier(tier)
return true, available, func(email, orgName string) (string, error) {
return h.createMSPCheckout(email, orgName, tier)
}
},
)
}
func (h *PublicCloudSignupHandlers) createMSPCheckout(email, orgName string, tier mspTier) (string, error) {
if h.cfg == nil {
return "", fmt.Errorf("control plane config is missing")
}
priceID, ok := h.priceIDForMSPTier(tier)
if !ok || priceID == "" {
return "", fmt.Errorf("price id not configured for msp tier %q", tier)
}
if err := validatePublicMSPSignupPriceID(tier, priceID); err != nil {
return "", err
}
successURL := buildCPURL(h.cfg.BaseURL, canonicalPublicMSPSignupPath+"/complete", nil)
cancelURL := buildCPURL(h.cfg.BaseURL, canonicalPublicMSPSignupPath, url.Values{
"cancelled": {"1"},
"email": {email},
"org_name": {orgName},
"tier": {string(tier)},
})
return h.createTrialCheckoutSession(email, priceID, successURL, cancelURL, h.buildMSPCheckoutMetadata(priceID, orgName))
}
func (h *PublicCloudSignupHandlers) buildMSPCheckoutMetadata(priceID, orgName string) map[string]string {
meta := map[string]string{
"account_kind": string(registry.AccountKindMSP),
"account_display_name": orgName,
"display_name": orgName,
"signup_source": "public_msp_signup",
}
// Only accept msp_* plan versions on the MSP signup path. This is the
// mirror of the individual path's cloud_* guard: it prevents granting
// MSP-level workspace limits from a misconfigured non-MSP price.
if plan, ok := pkglicensing.PlanVersionForPriceID(priceID); ok && strings.HasPrefix(plan, "msp_") {
meta["plan_version"] = plan
}
return meta
}
@@ -0,0 +1,400 @@
package cloudcp
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
stripe "github.com/stripe/stripe-go/v82"
)
const (
testMSPStarterPriceID = "price_1T5kgTBrHBocJIGHjOs15LI2"
testMSPGrowthPriceID = "price_1T5kgVBrHBocJIGHulNsCTb1"
testMSPScalePriceID = "price_1T5kgWBrHBocJIGHo40iFeRd"
)
func TestParseMSPTier(t *testing.T) {
tests := []struct {
input string
want mspTier
wantOK bool
}{
{"", mspTierStarter, true},
{"starter", mspTierStarter, true},
{"STARTER", mspTierStarter, true},
{" Growth ", mspTierGrowth, true},
{"growth", mspTierGrowth, true},
{"scale", mspTierScale, true},
{"SCALE", mspTierScale, true},
{"power", "", false},
{"max", "", false},
{"enterprise", "", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, ok := parseMSPTier(tt.input)
if ok != tt.wantOK {
t.Fatalf("parseMSPTier(%q) ok=%v, want %v", tt.input, ok, tt.wantOK)
}
if got != tt.want {
t.Fatalf("parseMSPTier(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestPriceIDForMSPTier(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
CloudMSPStarterPriceID: testMSPStarterPriceID,
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
CloudMSPScalePriceID: testMSPScalePriceID,
}, nil, nil, nil)
tests := []struct {
tier mspTier
want string
}{
{mspTierStarter, testMSPStarterPriceID},
{mspTierGrowth, testMSPGrowthPriceID},
{mspTierScale, testMSPScalePriceID},
}
for _, tt := range tests {
t.Run(string(tt.tier), func(t *testing.T) {
got, ok := h.priceIDForMSPTier(tt.tier)
if !ok || got != tt.want {
t.Fatalf("priceIDForMSPTier(%q) = (%q,%v), want (%q,true)", tt.tier, got, ok, tt.want)
}
})
}
}
func TestMSPSignupPageRendersUnavailableWhenNoTierConfigured(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{}, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup", nil)
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if strings.Contains(body, "<form") {
t.Fatal("expected no signup form when no MSP tier is configured")
}
if !strings.Contains(body, "not open for self-serve signup yet") {
t.Fatalf("expected unavailable notice, got %q", body)
}
}
func TestMSPSignupPageRendersFormWhenTierConfigured(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
CloudMSPStarterPriceID: testMSPStarterPriceID,
}, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup", nil)
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "<form") {
t.Fatal("expected signup form when an MSP tier is configured")
}
if !strings.Contains(body, fmt.Sprintf("Start your %d-day Pulse Cloud for MSPs trial", publicCloudTrialDays)) {
t.Fatal("expected MSP trial heading")
}
if !strings.Contains(body, `action="/cloud/msp/signup"`) {
t.Fatal("expected form to post to the canonical MSP signup path")
}
// Single tier configured → hidden input, no radios.
if strings.Contains(body, `type="radio"`) {
t.Fatal("expected no tier radios when only one MSP tier is configured")
}
if !strings.Contains(body, `type="hidden" name="tier" value="starter"`) {
t.Fatal("expected hidden starter tier input when only starter is configured")
}
}
func TestMSPSignupPageShowsTierRadiosWhenMultipleConfigured(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
CloudMSPStarterPriceID: testMSPStarterPriceID,
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
CloudMSPScalePriceID: testMSPScalePriceID,
}, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup?tier=growth", nil)
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
body := rec.Body.String()
for _, v := range []string{`value="starter"`, `value="growth"`, `value="scale"`} {
if !strings.Contains(body, v) {
t.Fatalf("expected radio option %s", v)
}
}
if !strings.Contains(body, `value="growth" checked`) {
t.Fatal("expected requested growth tier to be pre-checked")
}
}
func TestMSPSignupPostValidRedirectsToStripeWithMSPMetadata(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testMSPStarterPriceID,
}, nil, nil, nil)
var meta map[string]string
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
meta = params.Metadata
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_msp"}, nil
}
form := url.Values{"email": {"owner@example.com"}, "org_name": {"Quesys MSP"}}
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusSeeOther, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "https://checkout.stripe.com/c/pay/cs_msp" {
t.Fatalf("location=%q, want stripe URL", loc)
}
if meta == nil {
t.Fatal("expected checkout metadata")
}
if got := meta["account_kind"]; got != "msp" {
t.Fatalf("account_kind=%q, want %q", got, "msp")
}
if got := meta["plan_version"]; got != "msp_starter" {
t.Fatalf("plan_version=%q, want %q", got, "msp_starter")
}
if got := meta["signup_source"]; got != "public_msp_signup" {
t.Fatalf("signup_source=%q, want %q", got, "public_msp_signup")
}
if got := meta["account_display_name"]; got != "Quesys MSP" {
t.Fatalf("account_display_name=%q, want %q", got, "Quesys MSP")
}
}
func TestMSPSignupPostTierSelectionPicksPrice(t *testing.T) {
cfg := &CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testMSPStarterPriceID,
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
CloudMSPScalePriceID: testMSPScalePriceID,
}
tests := []struct {
tier string
wantPriceID string
}{
{"", testMSPStarterPriceID},
{"starter", testMSPStarterPriceID},
{"growth", testMSPGrowthPriceID},
{"scale", testMSPScalePriceID},
}
for _, tt := range tests {
t.Run("tier="+tt.tier, func(t *testing.T) {
h := NewPublicCloudSignupHandlers(cfg, nil, nil, nil)
var priceID string
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
if len(params.LineItems) > 0 && params.LineItems[0].Price != nil {
priceID = *params.LineItems[0].Price
}
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/test"}, nil
}
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}, "tier": {tt.tier}}
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusSeeOther, rec.Body.String())
}
if priceID != tt.wantPriceID {
t.Fatalf("price_id=%q, want %q", priceID, tt.wantPriceID)
}
})
}
}
func TestMSPSignupPostUnconfiguredTierReturns400(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testMSPStarterPriceID,
// growth/scale intentionally unconfigured
}, nil, nil, nil)
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}, "tier": {"scale"}}
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "not currently available") {
t.Fatalf("expected tier-unavailable message, got %q", rec.Body.String())
}
}
func TestMSPSignupRejectsCloudPriceMisconfiguration(t *testing.T) {
// MSP starter price slot misconfigured with a cloud_starter price ID.
// Must fail closed before calling Stripe — the MSP path only accepts
// msp_* plan versions.
h := NewPublicCloudSignupHandlers(&CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testCloudStarterPriceID, // cloud_starter, not msp_*
}, nil, nil, nil)
stripeCalled := false
h.createCheckoutSession = func(_ *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
stripeCalled = true
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/test"}, nil
}
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}}
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadGateway)
}
if stripeCalled {
t.Fatal("expected cloud-price misconfiguration to fail closed before calling Stripe")
}
}
func TestMSPSignupAPICreatesCheckout(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testMSPStarterPriceID,
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
}, nil, nil, nil)
var priceID string
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
if len(params.LineItems) > 0 && params.LineItems[0].Price != nil {
priceID = *params.LineItems[0].Price
}
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_api"}, nil
}
req := httptest.NewRequest(http.MethodPost, "/api/public/msp/signup", strings.NewReader(`{"email":"o@example.com","org_name":"Quesys MSP","tier":"growth"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleMSPPublicSignup(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
}
if priceID != testMSPGrowthPriceID {
t.Fatalf("price_id=%q, want %q", priceID, testMSPGrowthPriceID)
}
var payload map[string]any
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode: %v", err)
}
if got := strings.TrimSpace(asString(payload["checkout_url"])); got != "https://checkout.stripe.com/c/pay/cs_api" {
t.Fatalf("checkout_url=%q, want stripe URL", got)
}
}
func TestMSPSignupAPIUnconfiguredTierReturns400(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testMSPStarterPriceID,
}, nil, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/public/msp/signup", strings.NewReader(`{"email":"o@example.com","org_name":"Quesys MSP","tier":"scale"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleMSPPublicSignup(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
}
var payload map[string]any
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode: %v", err)
}
if got := asString(payload["code"]); got != "tier_unavailable" {
t.Fatalf("code=%q, want %q", got, "tier_unavailable")
}
}
func TestMSPSignupCompleteRendersHandoff(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{}, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup/complete", nil)
rec := httptest.NewRecorder()
h.HandleMSPSignupComplete(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "Trial checkout complete") {
t.Fatal("expected completion heading")
}
if !strings.Contains(body, "MSP portal") {
t.Fatal("expected MSP portal handoff copy")
}
}
func TestMSPSignupCancelURLPreservesTier(t *testing.T) {
h := NewPublicCloudSignupHandlers(&CPConfig{
BaseURL: "https://cloud.example.com",
StripeAPIKey: "sk_test_123",
CloudMSPStarterPriceID: testMSPStarterPriceID,
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
}, nil, nil, nil)
var cancelURL string
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
if params.CancelURL != nil {
cancelURL = *params.CancelURL
}
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/test"}, nil
}
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}, "tier": {"growth"}}
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleMSPSignupPage(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusSeeOther)
}
parsed, err := url.Parse(cancelURL)
if err != nil {
t.Fatalf("parse cancel URL: %v", err)
}
if got := parsed.Query().Get("tier"); got != "growth" {
t.Fatalf("cancel URL tier=%q, want %q (URL: %s)", got, "growth", cancelURL)
}
if !strings.HasPrefix(parsed.Path, "/cloud/msp/signup") {
t.Fatalf("cancel URL path=%q, want MSP signup path", parsed.Path)
}
}
+7
View File
@@ -151,6 +151,13 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
mux.Handle("/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupComplete)))
mux.Handle("/cloud/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupComplete)))
mux.Handle("/api/public/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandlePublicSignup)))
// Pulse Cloud for MSPs self-serve signup. Registered under the same
// public-signup gate; stays inert (renders an unavailable state) until
// an MSP tier price ID is configured in CP env.
mux.Handle("/cloud/msp/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleMSPSignupPage)))
mux.Handle("/cloud/msp/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleMSPSignupComplete)))
mux.Handle("/api/public/msp/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleMSPPublicSignup)))
}
// Admin API (key-authenticated)
+49
View File
@@ -239,6 +239,52 @@ func TestRegisterRoutes_PublicCloudSignupRoutes(t *testing.T) {
}
}
func TestRegisterRoutes_PublicMSPSignupRoutes(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
mux := http.NewServeMux()
RegisterRoutes(mux, &Deps{
Config: &CPConfig{
DataDir: dir,
AdminKey: "test-admin-key",
BaseURL: "https://cloud.example.com",
StripeWebhookSecret: "whsec_test",
PublicCloudSignupEnabled: true,
},
Registry: reg,
Version: "test",
})
mspPageReq := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup", nil)
mspPageRec := httptest.NewRecorder()
mux.ServeHTTP(mspPageRec, mspPageReq)
if mspPageRec.Code != http.StatusOK {
t.Fatalf("GET /cloud/msp/signup status=%d, want %d", mspPageRec.Code, http.StatusOK)
}
if !strings.Contains(mspPageRec.Body.String(), "Pulse Cloud for MSPs") {
t.Fatalf("expected public MSP signup page body")
}
mspCompleteReq := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup/complete", nil)
mspCompleteRec := httptest.NewRecorder()
mux.ServeHTTP(mspCompleteRec, mspCompleteReq)
if mspCompleteRec.Code != http.StatusOK {
t.Fatalf("GET /cloud/msp/signup/complete status=%d, want %d", mspCompleteRec.Code, http.StatusOK)
}
mspAPIGetReq := httptest.NewRequest(http.MethodGet, "/api/public/msp/signup", nil)
mspAPIGetRec := httptest.NewRecorder()
mux.ServeHTTP(mspAPIGetRec, mspAPIGetReq)
if mspAPIGetRec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /api/public/msp/signup status=%d, want %d", mspAPIGetRec.Code, http.StatusMethodNotAllowed)
}
}
func TestRegisterRoutes_PublicCloudSignupRoutesDisabledByDefault(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
@@ -267,6 +313,9 @@ func TestRegisterRoutes_PublicCloudSignupRoutesDisabledByDefault(t *testing.T) {
{method: http.MethodGet, path: "/cloud/signup"},
{method: http.MethodGet, path: "/signup/complete"},
{method: http.MethodPost, path: "/api/public/signup"},
{method: http.MethodGet, path: "/cloud/msp/signup"},
{method: http.MethodGet, path: "/cloud/msp/signup/complete"},
{method: http.MethodPost, path: "/api/public/msp/signup"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()