Pulse/internal/cloudcp/stripe/cloud_lifecycle_integration_test.go
2026-03-18 16:06:30 +00:00

733 lines
27 KiB
Go

package stripe
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
// TestCloudLifecycle_CheckoutToBillingToMonitoredSystemLimits exercises the full Cloud
// individual tier lifecycle:
//
// checkout.session.completed with plan_version metadata →
// tenant provisioning with correct plan version →
// billing state written with correct monitored-system limits →
// subscription update propagates new tier limits →
// subscription cancellation revokes capabilities.
//
// This is an integration test that wires together the registry, provisioner,
// and entitlements service to verify that Cloud tier assignment is end-to-end
// correct for Starter (10 monitored systems), Power (30), and Max (75).
func TestCloudLifecycle_CheckoutToBillingToMonitoredSystemLimits(t *testing.T) {
tests := []struct {
name string
planVersion string
wantMonitoredSystems int64
wantCaps []string // subset of expected capabilities
wantSubState pkglicensing.SubscriptionState
}{
{
name: "cloud_starter_via_metadata",
planVersion: "cloud_starter",
wantMonitoredSystems: 10,
wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"},
wantSubState: pkglicensing.SubStateActive,
},
{
name: "cloud_power_via_metadata",
planVersion: "cloud_power",
wantMonitoredSystems: 30,
wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"},
wantSubState: pkglicensing.SubStateActive,
},
{
name: "cloud_max_via_metadata",
planVersion: "cloud_max",
wantMonitoredSystems: 75,
wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"},
wantSubState: pkglicensing.SubStateActive,
},
{
name: "cloud_founding_via_metadata",
planVersion: "cloud_founding",
wantMonitoredSystems: 10, // Founding rate = Starter limits
wantCaps: []string{"ai_autofix", "relay"},
wantSubState: pkglicensing.SubStateActive,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
var chowned []string
provisioner.chownFile = func(path string, uid, gid int) error {
chowned = append(chowned, path)
if uid != hostedTenantRuntimeUID || gid != hostedTenantRuntimeGID {
t.Fatalf("unexpected hosted runtime ownership target uid=%d gid=%d", uid, gid)
}
return nil
}
// Build checkout session with metadata or price-based resolution.
session := CheckoutSession{
Customer: "cus_cloud_" + tc.name,
Subscription: "sub_cloud_" + tc.name,
CustomerEmail: tc.name + "@example.com",
}
if tc.planVersion != "" {
session.Metadata = map[string]string{"plan_version": tc.planVersion}
}
// Execute HandleCheckout — this is the real entry point for
// checkout.session.completed webhook events.
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
// ── Verify tenant registry record ───────────────────────────
tenant, err := reg.GetByStripeCustomerID(session.Customer)
if err != nil {
t.Fatalf("GetByStripeCustomerID: %v", err)
}
if tenant == nil {
t.Fatal("expected tenant to exist after checkout")
}
if tenant.State != registry.TenantStateActive {
t.Fatalf("tenant.State = %q, want %q", tenant.State, registry.TenantStateActive)
}
if tenant.PlanVersion != tc.planVersion {
t.Fatalf("tenant.PlanVersion = %q, want %q", tenant.PlanVersion, tc.planVersion)
}
mtp := config.NewMultiTenantPersistence(provisioner.tenantDataDir(tenant.ID))
org, err := mtp.LoadOrganizationStrict(tenant.ID)
if err != nil {
t.Fatalf("LoadOrganizationStrict(%s): %v", tenant.ID, err)
}
if org.ID != tenant.ID {
t.Fatalf("org.ID = %q, want %q", org.ID, tenant.ID)
}
if org.DisplayName != tenant.ID {
t.Fatalf("org.DisplayName = %q, want %q", org.DisplayName, tenant.ID)
}
if org.OwnerUserID != session.CustomerEmail {
t.Fatalf("org.OwnerUserID = %q, want %q", org.OwnerUserID, session.CustomerEmail)
}
if org.GetMemberRole(session.CustomerEmail) != models.OrgRoleOwner {
t.Fatalf("org role for %q = %q, want %q", session.CustomerEmail, org.GetMemberRole(session.CustomerEmail), models.OrgRoleOwner)
}
if time.Since(org.CreatedAt) > time.Minute {
t.Fatalf("org.CreatedAt looks stale: %s", org.CreatedAt)
}
// ── Verify account creation and Stripe mapping ──────────────
if strings.TrimSpace(tenant.AccountID) == "" {
t.Fatal("tenant.AccountID is empty — account was not created")
}
acct, err := reg.GetAccount(tenant.AccountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if acct == nil {
t.Fatal("expected account to exist for Cloud tenant")
}
if acct.Kind != registry.AccountKindIndividual {
t.Fatalf("account.Kind = %q, want %q", acct.Kind, registry.AccountKindIndividual)
}
sa, err := reg.GetStripeAccount(tenant.AccountID)
if err != nil {
t.Fatalf("GetStripeAccount: %v", err)
}
if sa == nil {
t.Fatal("expected StripeAccount mapping to exist")
}
if sa.StripeCustomerID != session.Customer {
t.Fatalf("StripeAccount.StripeCustomerID = %q, want %q", sa.StripeCustomerID, session.Customer)
}
if sa.StripeSubscriptionID != session.Subscription {
t.Fatalf("StripeAccount.StripeSubscriptionID = %q, want %q", sa.StripeSubscriptionID, session.Subscription)
}
// ── Verify billing state and monitored-system limits ────────
store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID))
bs, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if bs == nil {
t.Fatal("billing state is nil")
}
if bs.SubscriptionState != tc.wantSubState {
t.Fatalf("billing.SubscriptionState = %q, want %q", bs.SubscriptionState, tc.wantSubState)
}
// This is the critical assertion the runtime reads to enforce
// monitored-system caps for Cloud tenants.
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != tc.wantMonitoredSystems {
t.Fatalf(
"billing.Limits[%s] = %d, want %d",
pkglicensing.MaxMonitoredSystemsLicenseGateKey,
bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey],
tc.wantMonitoredSystems,
)
}
// Verify capabilities include Pro-level features.
capSet := make(map[string]struct{}, len(bs.Capabilities))
for _, c := range bs.Capabilities {
capSet[c] = struct{}{}
}
for _, want := range tc.wantCaps {
if _, ok := capSet[want]; !ok {
t.Fatalf("billing.Capabilities missing %q; got %v", want, bs.Capabilities)
}
}
// Verify entitlement lease tokens were written and raw state is
// lease-only (no redundant SubscriptionState/Capabilities in the
// raw file — those are derived at read time from the lease).
raw := loadRawBillingState(t, provisioner.tenantDataDir(tenant.ID))
if strings.TrimSpace(raw.EntitlementJWT) == "" {
t.Fatal("raw billing state missing EntitlementJWT")
}
if strings.TrimSpace(raw.EntitlementRefreshToken) == "" {
t.Fatal("raw billing state missing EntitlementRefreshToken")
}
if strings.TrimSpace(raw.Integrity) == "" {
t.Fatal("raw billing state missing Integrity")
}
if raw.SubscriptionState != "" || len(raw.Capabilities) != 0 {
t.Fatalf("expected raw lease-only state (no SubscriptionState/Capabilities), got sub_state=%q caps=%v", raw.SubscriptionState, raw.Capabilities)
}
wantOwnership := map[string]bool{
filepath.Join(tenantsDir, tenant.ID, "orgs"): true,
filepath.Join(tenantsDir, tenant.ID, "orgs", tenant.ID): true,
filepath.Join(tenantsDir, tenant.ID, "orgs", tenant.ID, "org.json"): true,
filepath.Join(tenantsDir, tenant.ID, "billing.json"): true,
filepath.Join(tenantsDir, tenant.ID, ".cloud_handoff_key"): true,
filepath.Join(tenantsDir, tenant.ID, "secrets", "handoff.key"): true,
}
for _, path := range chowned {
delete(wantOwnership, path)
}
if len(wantOwnership) != 0 {
t.Fatalf("missing hosted runtime ownership paths: %v", wantOwnership)
}
})
}
}
// TestCloudLifecycle_SubscriptionUpdateChangesLimits verifies that when a
// Cloud tenant upgrades (e.g., Starter → Power), the subscription.updated
// webhook correctly updates both the tenant record and billing state with
// the new plan's monitored-system limits.
func TestCloudLifecycle_SubscriptionUpdateChangesLimits(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
// Phase 1: Checkout as Cloud Starter (10 monitored systems).
session := CheckoutSession{
Customer: "cus_upgrade_test",
Subscription: "sub_upgrade_test",
CustomerEmail: "upgrader@example.com",
Metadata: map[string]string{"plan_version": "cloud_starter"},
}
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
tenant, err := reg.GetByStripeCustomerID("cus_upgrade_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant: %v (tenant=%v)", err, tenant)
}
// Verify initial state: Starter = 10 monitored systems.
store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID))
bs, err := store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("initial GetBillingState: %v", err)
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 10 {
t.Fatalf("initial %s = %d, want 10", pkglicensing.MaxMonitoredSystemsLicenseGateKey, bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
}
// Phase 2: Simulate subscription.updated → upgrade to Cloud Power (30 monitored systems).
sub := Subscription{
ID: "sub_upgrade_test",
Customer: "cus_upgrade_test",
Status: "active",
Metadata: map[string]string{"plan_version": "cloud_power"},
}
if err := provisioner.HandleSubscriptionUpdated(context.Background(), sub); err != nil {
t.Fatalf("HandleSubscriptionUpdated (upgrade to power): %v", err)
}
// Verify tenant record updated.
tenant, err = reg.GetByStripeCustomerID("cus_upgrade_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant after upgrade: %v", err)
}
if tenant.PlanVersion != "cloud_power" {
t.Fatalf("tenant.PlanVersion after upgrade = %q, want %q", tenant.PlanVersion, "cloud_power")
}
if tenant.State != registry.TenantStateActive {
t.Fatalf("tenant.State after upgrade = %q, want %q", tenant.State, registry.TenantStateActive)
}
// Verify billing state has new limits.
bs, err = store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState after upgrade: %v", err)
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 30 {
t.Fatalf("max_monitored_systems after upgrade = %d, want 30", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
}
if bs.SubscriptionState != pkglicensing.SubStateActive {
t.Fatalf("SubscriptionState after upgrade = %q, want %q", bs.SubscriptionState, pkglicensing.SubStateActive)
}
// Phase 3: Upgrade again to Cloud Max (75 monitored systems).
sub.Metadata = map[string]string{"plan_version": "cloud_max"}
if err := provisioner.HandleSubscriptionUpdated(context.Background(), sub); err != nil {
t.Fatalf("HandleSubscriptionUpdated (upgrade to max): %v", err)
}
bs, err = store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState after max upgrade: %v", err)
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 75 {
t.Fatalf("max_monitored_systems after max upgrade = %d, want 75", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
}
}
// TestCloudLifecycle_CancellationRevokesCapabilities proves that
// subscription.deleted correctly revokes all capabilities and clears
// entitlement lease tokens for Cloud tenants.
func TestCloudLifecycle_CancellationRevokesCapabilities(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
// Provision a Cloud Starter tenant.
session := CheckoutSession{
Customer: "cus_cancel_test",
Subscription: "sub_cancel_test",
CustomerEmail: "canceller@example.com",
Metadata: map[string]string{"plan_version": "cloud_starter"},
}
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
tenant, err := reg.GetByStripeCustomerID("cus_cancel_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant: %v", err)
}
// Verify active state before cancellation.
store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID))
bs, err := store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState: %v", err)
}
if len(bs.Capabilities) == 0 {
t.Fatal("expected capabilities before cancellation, got empty")
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 10 {
t.Fatalf("max_monitored_systems before cancel = %d, want 10", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
}
// Simulate subscription.deleted.
delSub := Subscription{
ID: "sub_cancel_test",
Customer: "cus_cancel_test",
Status: "canceled",
}
if err := provisioner.HandleSubscriptionDeleted(context.Background(), delSub); err != nil {
t.Fatalf("HandleSubscriptionDeleted: %v", err)
}
// Verify tenant state is canceled.
tenant, err = reg.GetByStripeCustomerID("cus_cancel_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant after cancel: %v", err)
}
if tenant.State != registry.TenantStateCanceled {
t.Fatalf("tenant.State = %q, want %q", tenant.State, registry.TenantStateCanceled)
}
// Verify billing state: capabilities revoked, canceled state.
bs, err = store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState after cancel: %v", err)
}
if bs.SubscriptionState != pkglicensing.SubStateCanceled {
t.Fatalf("SubscriptionState = %q, want %q", bs.SubscriptionState, pkglicensing.SubStateCanceled)
}
if len(bs.Capabilities) != 0 {
t.Fatalf("expected empty capabilities after cancellation, got %v", bs.Capabilities)
}
// Verify entitlement lease tokens were cleared.
raw := loadRawBillingState(t, provisioner.tenantDataDir(tenant.ID))
if raw.EntitlementJWT != "" {
t.Fatalf("expected empty EntitlementJWT after cancel, got %q", raw.EntitlementJWT)
}
if raw.EntitlementRefreshToken != "" {
t.Fatalf("expected empty EntitlementRefreshToken after cancel, got %q", raw.EntitlementRefreshToken)
}
// Verify account-level Stripe state after cancellation (matches MSP test parity).
sa, err := reg.GetStripeAccountByCustomerID("cus_cancel_test")
if err != nil {
t.Fatalf("GetStripeAccountByCustomerID after cancel: %v", err)
}
if sa == nil {
t.Fatal("expected StripeAccount to exist after cancellation")
}
if sa.SubscriptionState != "canceled" {
t.Fatalf("StripeAccount.SubscriptionState = %q, want %q", sa.SubscriptionState, "canceled")
}
if sa.GraceStartedAt != nil {
t.Fatalf("StripeAccount.GraceStartedAt after cancel = %v, want nil", sa.GraceStartedAt)
}
}
// TestCloudLifecycle_GracePeriodPreservesAccess proves that a past_due
// subscription transitions the Cloud tenant to grace state (still active)
// with capabilities preserved, matching the behavior of MSP tenants.
func TestCloudLifecycle_GracePeriodPreservesAccess(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
session := CheckoutSession{
Customer: "cus_grace_test",
Subscription: "sub_grace_test",
CustomerEmail: "grace@example.com",
Metadata: map[string]string{"plan_version": "cloud_power"},
}
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
tenant, err := reg.GetByStripeCustomerID("cus_grace_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant: %v", err)
}
// Simulate past_due → grace period.
sub := Subscription{
ID: "sub_grace_test",
Customer: "cus_grace_test",
Status: "past_due",
Metadata: map[string]string{"plan_version": "cloud_power"},
}
if err := provisioner.HandleSubscriptionUpdated(context.Background(), sub); err != nil {
t.Fatalf("HandleSubscriptionUpdated (past_due): %v", err)
}
// Tenant should remain active during grace.
tenant, err = reg.GetByStripeCustomerID("cus_grace_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant after past_due: %v", err)
}
if tenant.State != registry.TenantStateActive {
t.Fatalf("tenant.State during grace = %q, want %q", tenant.State, registry.TenantStateActive)
}
// Billing state should be grace with capabilities preserved.
store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID))
bs, err := store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState during grace: %v", err)
}
if bs.SubscriptionState != pkglicensing.SubStateGrace {
t.Fatalf("SubscriptionState = %q, want %q", bs.SubscriptionState, pkglicensing.SubStateGrace)
}
if len(bs.Capabilities) == 0 {
t.Fatal("expected capabilities during grace, got empty")
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 30 {
t.Fatalf("max_monitored_systems during grace = %d, want 30 (Cloud Power)", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
}
// Stripe account should have grace window started.
sa, err := reg.GetStripeAccountByCustomerID("cus_grace_test")
if err != nil {
t.Fatalf("GetStripeAccountByCustomerID: %v", err)
}
if sa == nil {
t.Fatal("expected StripeAccount to exist")
}
if sa.GraceStartedAt == nil || *sa.GraceStartedAt <= 0 {
t.Fatalf("StripeAccount.GraceStartedAt = %v, want non-nil positive timestamp", sa.GraceStartedAt)
}
}
// TestCloudLifecycle_PriceIDResolution verifies that when checkout metadata
// does NOT contain plan_version, the system correctly resolves the plan
// from the Stripe price ID using the canonical PriceIDToPlanVersion map.
// This is the fallback path when metadata is missing or checkout was created
// outside the control plane (e.g., directly in Stripe Dashboard).
func TestCloudLifecycle_PriceIDResolution(t *testing.T) {
tests := []struct {
name string
priceID string
wantPlan string
wantMonitoredSystems int64
}{
{"starter_monthly", "price_1T5kflBrHBocJIGHUqPv1dzV", "cloud_starter", 10},
{"starter_annual", "price_1T5kfmBrHBocJIGHTS3ymKxM", "cloud_starter", 10},
{"founding_monthly", "price_1T5kfnBrHBocJIGHATQJr79D", "cloud_founding", 10},
{"power_monthly", "price_1T5kg2BrHBocJIGHmkoF0zXY", "cloud_power", 30},
{"power_annual", "price_1T5kg3BrHBocJIGH2EtzKofV", "cloud_power", 30},
{"max_monthly", "price_1T5kg4BrHBocJIGHHa8Ecqho", "cloud_max", 75},
{"max_annual", "price_1T5kg5BrHBocJIGH5AIJ4nVc", "cloud_max", 75},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
// Checkout with NO metadata — only a subscription with a price item.
// HandleCheckout uses DerivePlanVersion which falls through to
// PriceIDToPlanVersion when metadata is empty.
session := CheckoutSession{
Customer: "cus_price_" + tc.name,
Subscription: "sub_price_" + tc.name,
CustomerEmail: tc.name + "@example.com",
// No Metadata — plan resolution must happen via subscription update.
}
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
// The initial checkout without metadata will have a generic plan version.
// Simulate the subscription.updated event that carries the actual price ID.
sub := Subscription{
ID: "sub_price_" + tc.name,
Customer: "cus_price_" + tc.name,
Status: "active",
}
sub.Items.Data = []struct {
Price struct {
ID string `json:"id"`
Metadata map[string]string `json:"metadata"`
} `json:"price"`
}{
{Price: struct {
ID string `json:"id"`
Metadata map[string]string `json:"metadata"`
}{ID: tc.priceID}},
}
if err := provisioner.HandleSubscriptionUpdated(context.Background(), sub); err != nil {
t.Fatalf("HandleSubscriptionUpdated: %v", err)
}
tenant, err := reg.GetByStripeCustomerID(session.Customer)
if err != nil || tenant == nil {
t.Fatalf("lookup tenant: %v", err)
}
if tenant.PlanVersion != tc.wantPlan {
t.Fatalf("tenant.PlanVersion = %q, want %q", tenant.PlanVersion, tc.wantPlan)
}
// Verify price ID was persisted on the tenant record (used by
// stale-plan preservation logic in HandleSubscriptionUpdated).
if tenant.StripePriceID != tc.priceID {
t.Fatalf("tenant.StripePriceID = %q, want %q", tenant.StripePriceID, tc.priceID)
}
store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID))
bs, err := store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState: %v", err)
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != tc.wantMonitoredSystems {
t.Fatalf(
"max_monitored_systems = %d, want %d",
bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey],
tc.wantMonitoredSystems,
)
}
})
}
}
func TestCloudLifecycle_SubscriptionUpdateCanonicalizesStoredFallbackPlan(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
session := CheckoutSession{
Customer: "cus_legacy_fallback",
Subscription: "sub_legacy_fallback",
CustomerEmail: "legacy@example.com",
Metadata: map[string]string{"plan_version": "cloud-v1"},
}
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
tenant, err := reg.GetByStripeCustomerID(session.Customer)
if err != nil || tenant == nil {
t.Fatalf("lookup tenant: %v", err)
}
tenant.PlanVersion = "cloud_v1"
tenant.StripePriceID = "price_1T5kflBrHBocJIGHUqPv1dzV"
if err := reg.Update(tenant); err != nil {
t.Fatalf("Update tenant: %v", err)
}
sa, err := reg.GetStripeAccountByCustomerID(session.Customer)
if err != nil || sa == nil {
t.Fatalf("GetStripeAccountByCustomerID: %v", err)
}
sa.PlanVersion = "cloud_v1"
if err := reg.UpdateStripeAccount(sa); err != nil {
t.Fatalf("UpdateStripeAccount: %v", err)
}
sub := Subscription{
ID: session.Subscription,
Customer: session.Customer,
Status: "active",
}
sub.Items.Data = []struct {
Price struct {
ID string `json:"id"`
Metadata map[string]string `json:"metadata"`
} `json:"price"`
}{
{Price: struct {
ID string `json:"id"`
Metadata map[string]string `json:"metadata"`
}{ID: "price_1T5kflBrHBocJIGHUqPv1dzV"}},
}
if err := provisioner.HandleSubscriptionUpdated(context.Background(), sub); err != nil {
t.Fatalf("HandleSubscriptionUpdated: %v", err)
}
tenant, err = reg.GetByStripeCustomerID(session.Customer)
if err != nil || tenant == nil {
t.Fatalf("lookup tenant after update: %v", err)
}
if tenant.PlanVersion != "cloud_starter" {
t.Fatalf("tenant.PlanVersion = %q, want %q", tenant.PlanVersion, "cloud_starter")
}
sa, err = reg.GetStripeAccountByCustomerID(session.Customer)
if err != nil || sa == nil {
t.Fatalf("GetStripeAccountByCustomerID after update: %v", err)
}
if sa.PlanVersion != "cloud_starter" {
t.Fatalf("stripe account PlanVersion = %q, want %q", sa.PlanVersion, "cloud_starter")
}
store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID))
bs, err := store.GetBillingState("default")
if err != nil || bs == nil {
t.Fatalf("GetBillingState: %v", err)
}
if bs.PlanVersion != "cloud_starter" {
t.Fatalf("billing.PlanVersion = %q, want %q", bs.PlanVersion, "cloud_starter")
}
if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 10 {
t.Fatalf(
"billing.Limits[%s] = %d, want 10",
pkglicensing.MaxMonitoredSystemsLicenseGateKey,
bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey],
)
}
}
// TestCloudLifecycle_WorkspaceLimitEnforcement verifies that Cloud individual
// accounts are limited to 1 workspace (as per CloudPlanWorkspaceLimits).
// After checkout creates the first workspace, attempting a second via the
// HandleCreateTenant HTTP handler must return 403 Forbidden.
func TestCloudLifecycle_WorkspaceLimitEnforcement(t *testing.T) {
reg := newStripeTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true)
// Provision a Cloud Starter tenant via checkout.
session := CheckoutSession{
Customer: "cus_wslimit_test",
Subscription: "sub_wslimit_test",
CustomerEmail: "wslimit@example.com",
Metadata: map[string]string{"plan_version": "cloud_starter"},
}
if err := provisioner.HandleCheckout(context.Background(), session); err != nil {
t.Fatalf("HandleCheckout: %v", err)
}
tenant, err := reg.GetByStripeCustomerID("cus_wslimit_test")
if err != nil || tenant == nil {
t.Fatalf("lookup tenant: %v", err)
}
// Verify workspace limit from plan version.
limit, known := pkglicensing.WorkspaceLimitForPlan("cloud_starter")
if !known {
t.Fatal("cloud_starter should be a known plan")
}
if limit != 1 {
t.Fatalf("WorkspaceLimitForPlan(cloud_starter) = %d, want 1", limit)
}
// Count active workspaces for this account — should be 1 after checkout.
count, err := reg.CountActiveByAccountID(tenant.AccountID)
if err != nil {
t.Fatalf("CountActiveByAccountID: %v", err)
}
if count != 1 {
t.Fatalf("active workspace count = %d, want 1", count)
}
// Exercise the actual HTTP handler to prove workspace creation is blocked.
tenantMux := newTenantMux(reg, provisioner)
createBody := `{"display_name":"Second Workspace (should fail)"}`
createReq := httptest.NewRequest(http.MethodPost, "/api/accounts/"+tenant.AccountID+"/tenants", bytes.NewBufferString(createBody))
createReq.Header.Set("X-Admin-Key", "secret-key")
createRec := httptest.NewRecorder()
tenantMux.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusForbidden {
t.Fatalf("second workspace creation: status = %d, want %d (body=%q)", createRec.Code, http.StatusForbidden, createRec.Body.String())
}
// Verify the response body mentions the workspace limit.
body := createRec.Body.String()
if !strings.Contains(body, "workspace limit") {
t.Fatalf("expected workspace limit error message, got %q", body)
}
// Confirm only 1 workspace still exists.
countAfter, err := reg.CountActiveByAccountID(tenant.AccountID)
if err != nil {
t.Fatalf("CountActiveByAccountID after blocked creation: %v", err)
}
if countAfter != 1 {
t.Fatalf("workspace count after blocked creation = %d, want 1", countAfter)
}
}