Files
commerce/api/billing/subscription_c1_test.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

164 lines
6.9 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package billing
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/util/bit"
"github.com/hanzoai/commerce/util/permission"
"github.com/hanzoai/commerce/util/test/ae"
)
// invokeSub drives a subscription handler with an org + datastore context + a
// caller identity (org-admin vs mint principal), reproducing the production reach.
func invokeSub(org *organization.Organization, ctx context.Context, identity func(*zip.Ctx), h zip.Handler, body string) *http.Response {
req := httptest.NewRequest(http.MethodPost, "/v1/billing/subscriptions", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
return driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
identity(c)
}, "/v1/billing/subscriptions", req, h)
}
// c1OrgAdmin models the adversary: an org owner (org-level isAdmin) who is NOT a
// platform global admin and NOT the platform's application → MayMintMoney false.
func c1OrgAdmin(c *zip.Ctx) {
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
c.Locals("iam_authenticated", true)
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme", IsAdmin: true})
}
// c1MintPrincipal models the platform's own application — an IAM identity under
// the reserved admin org — → MayMintMoney true.
func c1MintPrincipal(c *zip.Ctx) { platformApp(c) }
// TestCreateSubscription_OrgAdminPaidTier_Denied is C1-a: an org admin must NOT
// self-create a PAID-tier ("max-5x": $100/mo, $25/mo included allotment) Active
// subscription with zero payment. Before the fix this returned 201, and the
// forged Active sub then unlocked the "max" allotment mint.
func TestCreateSubscription_OrgAdminPaidTier_Denied(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("acme")
body := `{"userId":"acme/self","planId":"max-5x"}`
w := invokeSub(org, ctx, c1OrgAdmin, CreateBillingSubscription, body)
if w.StatusCode != http.StatusForbidden {
t.Fatalf("org-admin self-subscribe to paid 'max': status=%d body=%s, want 403 (no free paid tier)", w.StatusCode, func() string { b, _ := io.ReadAll(w.Body); return string(b) }())
}
}
// TestCreateSubscription_OrgAdminFreeTier_Allowed — control: a FREE tier
// ("developer": $0, no included allotment) stays self-serve for an org admin.
func TestCreateSubscription_OrgAdminFreeTier_Allowed(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("acme")
body := `{"userId":"acme/self","planId":"developer"}`
w := invokeSub(org, ctx, c1OrgAdmin, CreateBillingSubscription, body)
if w.StatusCode == http.StatusForbidden {
t.Fatalf("org-admin self-subscribe to FREE 'developer': status=403 body=%s, want allowed (free tier is self-serve)", func() string { b, _ := io.ReadAll(w.Body); return string(b) }())
}
}
// TestCreateSubscription_MintPrincipalPaidTier_Allowed — control: cloud-api (the
// internal service token) may still create a paid-tier subscription (e.g. after a
// real payment). The gate narrows WHO, not the legitimate path.
func TestCreateSubscription_MintPrincipalPaidTier_Allowed(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("acme")
body := `{"userId":"acme/self","planId":"max-5x"}`
w := invokeSub(org, ctx, c1MintPrincipal, CreateBillingSubscription, body)
if w.StatusCode == http.StatusForbidden {
t.Fatalf("service-token create paid 'max': status=403 body=%s, want allowed (authorized mint principal)", func() string { b, _ := io.ReadAll(w.Body); return string(b) }())
}
}
// seedSub writes a subscription for user with the given plan slug, status, provider
// type, and (optionally) a linked invoice id.
func seedSub(t *testing.T, db *datastore.Datastore, user, slug string, status subscription.Status, providerType, invoiceID string) {
t.Helper()
s := subscription.New(db)
s.UserId = user
s.Status = status
s.ProviderType = providerType
s.CurrentInvoiceId = invoiceID
s.Plan.Slug = slug
s.PlanId = slug
if err := s.Create(); err != nil {
t.Fatalf("seed subscription: %v", err)
}
}
// TestSubscriptionPlanSlug_PaymentBackedOnly is C1-a's entitlement clamp: a PAID
// tier's plan is returned as the mintable entitlement ONLY when the subscription
// is payment-backed. A self-created internal Active 'max' sub yields "" (so the
// allotment clamps to $0); a Stripe-backed or invoiced one yields "max".
func TestSubscriptionPlanSlug_PaymentBackedOnly(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("acme")
db := datastore.New(org.Namespaced(ctx))
// Forged: internal, Active, paid tier, NO invoice → NOT entitlement-bearing.
seedSub(t, db, "acme/forger", "max", subscription.Active, "internal", "")
if got := subscriptionPlanSlug(db, "acme/forger", false); got != "" {
t.Fatalf("forged internal Active 'max' sub: subscriptionPlanSlug=%q, want \"\" (not payment-backed → no minted allotment)", got)
}
// Real: Stripe-managed Active paid sub → entitlement-bearing.
seedSub(t, db, "acme/payer", "max", subscription.Active, "stripe", "")
if got := subscriptionPlanSlug(db, "acme/payer", false); got != "max" {
t.Fatalf("stripe-backed 'max' sub: subscriptionPlanSlug=%q, want \"max\"", got)
}
// Real: internal but INVOICED (billing cycle collected) → entitlement-bearing.
seedSub(t, db, "acme/invoiced", "max", subscription.Active, "internal", "inv_123")
if got := subscriptionPlanSlug(db, "acme/invoiced", false); got != "max" {
t.Fatalf("invoiced internal 'max' sub: subscriptionPlanSlug=%q, want \"max\"", got)
}
// A FREE tier needs no backing (mints nothing) → returned as-is.
seedSub(t, db, "acme/freeuser", "developer", subscription.Active, "internal", "")
if got := subscriptionPlanSlug(db, "acme/freeuser", false); got != "developer" {
t.Fatalf("free 'developer' sub: subscriptionPlanSlug=%q, want \"developer\"", got)
}
}
// TestPlanForGrant_OrgAdminCannotAnchorOnForgedSub proves the clamp end-to-end:
// an org admin (not a mint principal) with a forged internal Active 'max' sub
// cannot have the grant resolve to 'max', even naming it explicitly.
func TestPlanForGrant_OrgAdminCannotAnchorOnForgedSub(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("acme")
db := datastore.New(org.Namespaced(ctx))
seedSub(t, db, "acme/forger", "max", subscription.Active, "internal", "")
// Build an org-admin request context (MayMintMoney false).
app := zip.New(zip.Config{DisableStartupMessage: true})
c := app.TestCtx("POST", "/")
c1OrgAdmin(c)
if got := planForGrant(c, db, "acme/forger", "max", false); got != "" {
t.Fatalf("org-admin planForGrant with forged 'max' sub + explicit 'max': got %q, want \"\" (clamped — allotment mints $0)", got)
}
}