mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
6be2af1c19
Two mandatory round-trips stood between an interested MSP and their first screen, and neither was technical. setup.sh required four image digests shipped as literal <pin> placeholders, so the only way to get them was to ask. All four images are publicly readable, so there was never anything to hand out. setup.sh now resolves each blank pin to an immutable digest from its published tag via buildx imagetools and writes it back to .env; hand-set values are left alone. setup.sh then died outright without a licence file, so nobody could start the stack, create a workspace, or see the portal until a human minted a licence for them. The control plane already ran unlicensed via ProviderMSPPlanSourceEnvFallback; only the installer refused. A licence path that is set but missing is still a hard failure, since that is a misconfiguration rather than a choice. Unlicensed now means evaluation rather than the cheapest paid tier. The env fallback defaulted to msp_starter, handing every unlicensed deployment the full 5-client Starter allowance and leaving no boundary between evaluating and buying. Adds msp_eval at 2 workspaces: same capabilities, smaller cap, not purchasable, not on the public ladder. An isolation guarantee is the one claim a provider cannot evaluate from a screenshot, and both MSP leads this year went quiet at exactly this step. Contracts: cloud-paid records the unlicensed plan rule and the strictly-below-paid invariant; deployment-installability records credential-free, correspondence-free installability. Verification: TestMSPEvalCapStaysBelowCheapestPaidTier, TestCanonicalizePlanVersion_MSPEval, TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. The last was negative-tested by reintroducing a <pin> placeholder and confirming it fails. ensure_image_pins exercised against the live registries. licensing, cloudcp, control-plane and installtests all green.
221 lines
6.8 KiB
Go
221 lines
6.8 KiB
Go
package licensing
|
|
|
|
import "testing"
|
|
|
|
func TestMapStripeSubscriptionStatusToState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status string
|
|
want SubscriptionState
|
|
}{
|
|
{name: "active", status: "active", want: SubStateActive},
|
|
{name: "trialing", status: "trialing", want: SubStateTrial},
|
|
{name: "past due", status: "past_due", want: SubStateGrace},
|
|
{name: "unpaid", status: "unpaid", want: SubStateGrace},
|
|
{name: "canceled", status: "canceled", want: SubStateCanceled},
|
|
{name: "paused", status: "paused", want: SubStateSuspended},
|
|
{name: "incomplete", status: "incomplete", want: SubStateExpired},
|
|
{name: "incomplete expired", status: "incomplete_expired", want: SubStateExpired},
|
|
{name: "unknown", status: "unknown", want: SubStateExpired},
|
|
{name: "trim and case", status: " ACTIVE ", want: SubStateActive},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := MapStripeSubscriptionStatusToState(tt.status)
|
|
if got != tt.want {
|
|
t.Fatalf("state=%q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseStoredSubscriptionState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stored string
|
|
want SubscriptionState
|
|
}{
|
|
{name: "active", stored: "active", want: SubStateActive},
|
|
{name: "trial (normalized form, the one that was misparsed)", stored: "trial", want: SubStateTrial},
|
|
{name: "past_due", stored: "past_due", want: SubStateGrace},
|
|
{name: "canceled", stored: "canceled", want: SubStateCanceled},
|
|
{name: "grace tolerated for migration", stored: "grace", want: SubStateGrace},
|
|
{name: "suspended tolerated for migration", stored: "suspended", want: SubStateSuspended},
|
|
{name: "expired tolerated for migration", stored: "expired", want: SubStateExpired},
|
|
{name: "raw stripe trialing must NOT be accepted here", stored: "trialing", want: SubStateExpired},
|
|
{name: "unknown fails closed", stored: "garbage", want: SubStateExpired},
|
|
{name: "blank fails closed", stored: "", want: SubStateExpired},
|
|
{name: "trim and case", stored: " TRIAL ", want: SubStateTrial},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ParseStoredSubscriptionState(tt.stored)
|
|
if got != tt.want {
|
|
t.Fatalf("state=%q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldGrantPaidCapabilities(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
state SubscriptionState
|
|
want bool
|
|
}{
|
|
{name: "active", state: SubStateActive, want: true},
|
|
{name: "trial", state: SubStateTrial, want: true},
|
|
{name: "grace", state: SubStateGrace, want: true},
|
|
{name: "expired", state: SubStateExpired, want: false},
|
|
{name: "suspended", state: SubStateSuspended, want: false},
|
|
{name: "canceled", state: SubStateCanceled, want: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ShouldGrantPaidCapabilities(tt.state); got != tt.want {
|
|
t.Fatalf("paid=%t, want %t", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeriveStripePlanVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
metadata map[string]string
|
|
priceID string
|
|
want string
|
|
}{
|
|
{
|
|
name: "plan version wins",
|
|
metadata: map[string]string{"plan_version": "cloud_v6"},
|
|
priceID: "price_123",
|
|
want: "cloud_v6",
|
|
},
|
|
{
|
|
name: "legacy cloud v1 metadata canonicalizes to starter",
|
|
metadata: map[string]string{"plan_version": "cloud-v1"},
|
|
priceID: "price_123",
|
|
want: "cloud_starter",
|
|
},
|
|
{
|
|
name: "shorthand plan metadata canonicalizes to cloud tier",
|
|
metadata: map[string]string{"plan": "power"},
|
|
priceID: "price_123",
|
|
want: "cloud_power",
|
|
},
|
|
{
|
|
name: "plan fallback",
|
|
metadata: map[string]string{"plan": "cloud_v5"},
|
|
priceID: "price_123",
|
|
want: "cloud_v5",
|
|
},
|
|
{
|
|
name: "unknown price fallback",
|
|
metadata: nil,
|
|
priceID: " price_123 ",
|
|
want: "stripe_price:price_123",
|
|
},
|
|
{
|
|
name: "known price resolves to plan",
|
|
metadata: nil,
|
|
priceID: "price_1T5kflBrHBocJIGHUqPv1dzV",
|
|
want: "cloud_starter",
|
|
},
|
|
{
|
|
name: "metadata price_id resolves to plan",
|
|
metadata: map[string]string{"price_id": "price_1T5kg2BrHBocJIGHmkoF0zXY"},
|
|
priceID: "",
|
|
want: "cloud_power",
|
|
},
|
|
{
|
|
name: "metadata plan_id resolves to plan",
|
|
metadata: map[string]string{"plan_id": "price_1T5kg4BrHBocJIGHHa8Ecqho"},
|
|
priceID: "",
|
|
want: "cloud_max",
|
|
},
|
|
{
|
|
name: "legacy v5 recurring monthly price resolves to grandfathered plan",
|
|
metadata: nil,
|
|
priceID: "price_1ShIsdBrHBocJIGH71yQusLG",
|
|
want: "v5_pro_monthly_grandfathered",
|
|
},
|
|
{
|
|
name: "legacy v5 recurring plan_id resolves to grandfathered plan",
|
|
metadata: map[string]string{"plan_id": "price_1ShIsdBrHBocJIGH71yQusLG"},
|
|
priceID: "",
|
|
want: "v5_pro_monthly_grandfathered",
|
|
},
|
|
{
|
|
name: "legacy v1 recurring annual price resolves to grandfathered plan",
|
|
metadata: nil,
|
|
priceID: "price_1SgDxwBrHBocJIGHTKTsIMLc",
|
|
want: "v5_pro_annual_grandfathered",
|
|
},
|
|
{
|
|
name: "metadata plan_version takes priority over known price",
|
|
metadata: map[string]string{"plan_version": "cloud_power"},
|
|
priceID: "price_1T5kflBrHBocJIGHUqPv1dzV",
|
|
want: "cloud_power",
|
|
},
|
|
{
|
|
name: "default",
|
|
metadata: nil,
|
|
priceID: "",
|
|
want: "stripe",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := DeriveStripePlanVersion(tt.metadata, tt.priceID); got != tt.want {
|
|
t.Fatalf("plan_version=%q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanonicalizePlanVersion(t *testing.T) {
|
|
tests := []struct {
|
|
raw string
|
|
want string
|
|
}{
|
|
{"cloud-v1", "cloud_starter"},
|
|
{" cloud_v1 ", "cloud_starter"},
|
|
{"starter", "cloud_starter"},
|
|
{"msp_hosted_v1", "msp_starter"},
|
|
{"msp-starter", "msp_starter"},
|
|
{"power", "cloud_power"},
|
|
{"max", "cloud_max"},
|
|
{"founding", "cloud_founding"},
|
|
{"msp_growth", "msp_growth"},
|
|
{"custom_plan", "custom_plan"},
|
|
{"", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.raw, func(t *testing.T) {
|
|
if got := CanonicalizePlanVersion(tt.raw); got != tt.want {
|
|
t.Fatalf("CanonicalizePlanVersion(%q) = %q, want %q", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// msp_eval is not a Stripe-purchasable plan, but it flows through the same
|
|
// canonicalizer as every paid plan version, so an unrecognized spelling would
|
|
// fall through to the passthrough default and then fail closed at a
|
|
// 1-workspace limit in provider preflight.
|
|
func TestCanonicalizePlanVersion_MSPEval(t *testing.T) {
|
|
for _, raw := range []string{"msp_eval", "msp-eval", " MSP-Eval "} {
|
|
if got := CanonicalizePlanVersion(raw); got != PlanVersionMSPEval {
|
|
t.Fatalf("CanonicalizePlanVersion(%q) = %q, want %q", raw, got, PlanVersionMSPEval)
|
|
}
|
|
}
|
|
if got := CanonicalizePlanVersion("msp_starter"); got != "msp_starter" {
|
|
t.Fatalf("msp_starter must not be captured by the eval case, got %q", got)
|
|
}
|
|
}
|