mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
IsSuperAdmin folded the case of the home org and fell back to the owner claim, the org of the application that minted the token, and the money bits were granted on X-User-Owner == admin. Cloud writes X-User-Owner for an admin-org application and an admin-org person's API key as well as for a person, so either could mint money and edit the catalog here while being nobody in the rest of the same process. Every gate now reads X-User-IsAdmin, which the edge strips and writes from authz.Claims.Sudo alone; the standalone EdgeAuth decides it with that predicate over the verified token's signed membership set. X-User-IsAdmin no longer doubles as the org-admin bit, which is X-User-IsOrgAdmin. No machine is SuperAdmin. Global admin is SuperAdmin wherever it named this predicate.
290 lines
12 KiB
Go
290 lines
12 KiB
Go
package metrics
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/zap-proto/zip"
|
||
|
||
"github.com/hanzoai/commerce/api/billing"
|
||
"github.com/hanzoai/commerce/auth"
|
||
"github.com/hanzoai/commerce/middleware"
|
||
"github.com/hanzoai/commerce/models/plan"
|
||
"github.com/hanzoai/commerce/models/subscription"
|
||
"github.com/hanzoai/commerce/models/transaction"
|
||
"github.com/hanzoai/commerce/models/types/currency"
|
||
"github.com/hanzoai/commerce/types"
|
||
"github.com/hanzoai/commerce/util/bit"
|
||
"github.com/hanzoai/commerce/util/permission"
|
||
)
|
||
|
||
// testApp backs the synthetic request contexts the gate tests run against.
|
||
var testApp = zip.New(zip.Config{DisableStartupMessage: true})
|
||
|
||
// gateCtx builds a zip test context carrying optional IAM claims and/or a
|
||
// permissions bit-field, mirroring what the auth middleware would have set.
|
||
func gateCtx(claims *auth.IAMClaims, perms *bit.Field) *zip.Ctx {
|
||
c := testApp.TestCtx(http.MethodGet, "/v1/commerce/metrics/saas")
|
||
if claims != nil {
|
||
c.Locals("iam_claims", claims)
|
||
}
|
||
if perms != nil {
|
||
c.Locals("permissions", *perms)
|
||
}
|
||
return c
|
||
}
|
||
|
||
func iamUser(subject, owner string, isAdmin bool) *auth.IAMClaims {
|
||
cl := &auth.IAMClaims{Owner: owner, IsAdmin: isAdmin}
|
||
cl.Subject = subject
|
||
return cl
|
||
}
|
||
|
||
func adminPerms() *bit.Field {
|
||
f := bit.Field(permission.Admin | permission.Live)
|
||
return &f
|
||
}
|
||
|
||
// TestGetSaaS_RefusesNonPlatformAdmin is the CRITICAL authz proof: the SaaS
|
||
// god-view is cross-tenant business data, so anyone who is not a PLATFORM
|
||
// SuperAdmin (or the trusted internal service token) must get 403 with NO data
|
||
// leaked — before the handler ever touches the datastore. The headline attack is
|
||
// the org-level admin: the gateway mints permission.Admin from a tenant's own
|
||
// IsAdmin, so the Admin bit is present, but the caller carries an IAM Subject and
|
||
// its home org is NOT "admin" — it is not a SuperAdmin and must be refused.
|
||
func TestGetSaaS_RefusesNonPlatformAdmin(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
claims *auth.IAMClaims
|
||
perms *bit.Field
|
||
}{
|
||
{"org admin tenant (Admin bit + IAM subject)", iamUser("u2", "acme", true), adminPerms()},
|
||
{"plain tenant (no admin)", iamUser("u3", "acme", false), nil},
|
||
{"iam user, Admin claim but no perms bit", iamUser("u4", "acme", true), nil},
|
||
{"no auth context at all", nil, nil},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
c := gateCtx(tc.claims, tc.perms)
|
||
_ = GetSaaS(c)
|
||
status := c.Fiber().Response().StatusCode()
|
||
body := string(c.Fiber().Response().Body())
|
||
if status != 403 {
|
||
t.Fatalf("%s got %d, want 403 (must never read cross-org SaaS metrics; body=%s)", tc.name, status, body)
|
||
}
|
||
if strings.Contains(body, "mrrCents") || strings.Contains(body, "\"revenue\"") {
|
||
t.Fatalf("403 body leaked metrics: %s", body)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestGate_AdmitsPlatformPrincipals proves the accept side of the shared gate: a
|
||
// SuperAdmin — the edge's verdict — is admitted. Nobody else is: not an org admin,
|
||
// not an admin-org caller without the verdict, and not an Admin bit with no IAM
|
||
// subject (the shared service token that bit once stood for is gone).
|
||
func TestGate_AdmitsPlatformPrincipals(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
claims *auth.IAMClaims
|
||
perms *bit.Field
|
||
want bool
|
||
}{
|
||
{"superadmin", &auth.IAMClaims{Owner: "admin", Sudo: true}, adminPerms(), true},
|
||
{"admin org, no verdict — REJECT", iamUser("u1", "admin", false), adminPerms(), false},
|
||
{"Admin bit, no IAM user — REJECT", &auth.IAMClaims{}, adminPerms(), false},
|
||
{"Admin bit, nil claims — REJECT", nil, adminPerms(), false},
|
||
{"org admin tenant — REJECT", iamUser("u2", "acme", true), adminPerms(), false},
|
||
{"plain tenant — REJECT", iamUser("u3", "acme", false), nil, false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
c := gateCtx(tc.claims, tc.perms)
|
||
if got := middleware.MayReadPlatform(c); got != tc.want {
|
||
t.Fatalf("MayReadPlatform = %v, want %v", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// mkSub builds an in-memory subscription for the pure-fold aggregation test (no
|
||
// datastore). CreatedAt is on the embedded mixin base model; canceled subs also set
|
||
// the Canceled flag + CanceledAt.
|
||
func mkSub(slug string, priceCents int64, interval types.Interval, status subscription.Status, qty int, created, canceled time.Time, provider string) *subscription.Subscription {
|
||
s := &subscription.Subscription{PlanId: slug, Status: status, Quantity: qty, ProviderType: provider}
|
||
s.Plan = plan.Plan{Slug: slug, Name: slug, Price: currency.Cents(priceCents), Interval: interval}
|
||
s.CreatedAt = created
|
||
if !canceled.IsZero() {
|
||
s.Canceled = true
|
||
s.CanceledAt = canceled
|
||
}
|
||
return s
|
||
}
|
||
|
||
func mkTxn(cents int64, created time.Time, model string) *transaction.Transaction {
|
||
tr := &transaction.Transaction{Amount: currency.Cents(cents), Test: false, Tags: "api-usage"}
|
||
tr.CreatedAt = created
|
||
if model != "" {
|
||
tr.Metadata = types.Map{"model": model}
|
||
} else {
|
||
tr.Metadata = types.Map{}
|
||
}
|
||
return tr
|
||
}
|
||
|
||
// TestRollupAggregation exercises the money math end to end (pure fold, no
|
||
// datastore): MRR/ARR normalization, active/trial/paying counts, new/churn inside
|
||
// the window, bundle-child exclusion, category bucketing, billed usage totals, and
|
||
// the top-customers projection. Every figure is asserted against a hand-computed
|
||
// expectation so a regression in the fold is caught.
|
||
func TestRollupAggregation(t *testing.T) {
|
||
now := time.Now().UTC()
|
||
a := newAcc(now, windowStart(now, "30d"))
|
||
|
||
acmeMonthly := mkSub("plan-a", 2000, types.Monthly, subscription.Active, 1, now.AddDate(0, 0, -10), time.Time{}, "internal")
|
||
acmeAnnual := mkSub("plan-b", 12000, types.Yearly, subscription.Active, 1, now.AddDate(0, 0, -100), time.Time{}, "internal")
|
||
acmeBundle := mkSub("world-x", 0, types.Monthly, subscription.Active, 1, now.AddDate(0, 0, -10), time.Time{}, "bundle") // skipped
|
||
betaTrial := mkSub("plan-a", 2000, types.Monthly, subscription.Trialing, 1, now.AddDate(0, 0, -2), time.Time{}, "internal")
|
||
gammaCancel := mkSub("plan-c", 5000, types.Monthly, subscription.Canceled, 1, now.AddDate(0, 0, -40), now.AddDate(0, 0, -5), "internal")
|
||
|
||
foldSubs(a, "acme", []*subscription.Subscription{acmeMonthly, acmeAnnual, acmeBundle}, false)
|
||
foldSubs(a, "beta", []*subscription.Subscription{betaTrial}, false)
|
||
foldSubs(a, "gamma", []*subscription.Subscription{gammaCancel}, false)
|
||
|
||
foldTxns(a, "acme", []*transaction.Transaction{
|
||
mkTxn(100, now.AddDate(0, 0, -1), "gpt-4o"),
|
||
mkTxn(100, now.AddDate(0, 0, -3), "gpt-4o"),
|
||
}, false)
|
||
foldTxns(a, "beta", []*transaction.Transaction{
|
||
mkTxn(50, now.AddDate(0, 0, -1), "claude-3-5"),
|
||
mkTxn(30, now.AddDate(0, 0, -1), ""), // untagged
|
||
mkTxn(999, now.AddDate(0, 0, -60), "gpt-4o"), // outside 30d window → excluded
|
||
}, false)
|
||
a.orgSeen["acme"], a.orgSeen["beta"], a.orgSeen["gamma"] = true, true, true
|
||
|
||
s := a.snapshot(options{window: "30d", limit: 20})
|
||
|
||
// Revenue: MRR = 2000 (acme monthly) + 1000 (acme annual /12); trial + canceled excluded.
|
||
if s.Revenue.MRRCents != 3000 {
|
||
t.Errorf("MRR = %d, want 3000", s.Revenue.MRRCents)
|
||
}
|
||
if s.Revenue.ARRCents != 36000 {
|
||
t.Errorf("ARR = %d, want 36000", s.Revenue.ARRCents)
|
||
}
|
||
if s.Revenue.ActiveSubscriptions != 2 {
|
||
t.Errorf("active subs = %d, want 2 (bundle child excluded)", s.Revenue.ActiveSubscriptions)
|
||
}
|
||
if s.Revenue.Trials != 1 {
|
||
t.Errorf("trials = %d, want 1", s.Revenue.Trials)
|
||
}
|
||
if s.Revenue.PayingCustomers != 1 {
|
||
t.Errorf("paying customers = %d, want 1 (only acme)", s.Revenue.PayingCustomers)
|
||
}
|
||
// New: acme monthly (created -10d, active, +2000) and beta trial (created
|
||
// -2d). Both are new SUBSCRIPTIONS, so New is 2 — but the trial is not
|
||
// revenue, so it adds 0 and newMRR is 2000, not 4000. This used to book
|
||
// the trial's full 2000 while MRR above booked none of it, so net-new
|
||
// claimed revenue the run-rate never showed.
|
||
if s.Subs.New != 2 || s.Revenue.NewMRRCents != 2000 {
|
||
t.Errorf("new = %d newMRR = %d, want 2 / 2000", s.Subs.New, s.Revenue.NewMRRCents)
|
||
}
|
||
// Churn: gamma canceled -5d, would-be 5000.
|
||
if s.Subs.Canceled != 1 || s.Revenue.ChurnedMRRCents != 5000 {
|
||
t.Errorf("canceled = %d churnedMRR = %d, want 1 / 5000", s.Subs.Canceled, s.Revenue.ChurnedMRRCents)
|
||
}
|
||
// 2000 new − 5000 churned.
|
||
if s.Revenue.NetNewMRRCents != -3000 {
|
||
t.Errorf("netNewMRR = %d, want -3000", s.Revenue.NetNewMRRCents)
|
||
}
|
||
// Usage: window billed = 100+100+50+30 = 280; requests = 4; one untagged.
|
||
if s.Usage.WindowUsageCents != 280 || s.Usage.Requests != 4 {
|
||
t.Errorf("usage = %d / %d req, want 280 / 4", s.Usage.WindowUsageCents, s.Usage.Requests)
|
||
}
|
||
if !s.Usage.Instrumented || s.Usage.UntaggedRequests != 1 {
|
||
t.Errorf("instrumented=%v untagged=%d, want true / 1", s.Usage.Instrumented, s.Usage.UntaggedRequests)
|
||
}
|
||
// Events: 2 created + 1 canceled, most-recent first (beta -2d, gamma -5d, acme -10d).
|
||
if len(s.Subs.Recent) != 3 {
|
||
t.Fatalf("events = %d, want 3", len(s.Subs.Recent))
|
||
}
|
||
if s.Subs.Recent[0].Org != "beta" || s.Subs.Recent[0].Type != "created" {
|
||
t.Errorf("most recent event = %+v, want beta/created", s.Subs.Recent[0])
|
||
}
|
||
// Upgrades/downgrades are not instrumented → null (honest, never fabricated).
|
||
if s.Subs.Upgrades != nil || s.Subs.Downgrades != nil {
|
||
t.Errorf("upgrades/downgrades must be null (not instrumented), got %v/%v", s.Subs.Upgrades, s.Subs.Downgrades)
|
||
}
|
||
// Customers: acme (mrr 3000 + usage 200) ranks above beta (mrr 0 + usage 80);
|
||
// gamma (canceled, no active mrr, no usage) does not appear.
|
||
if len(s.Customers) != 2 {
|
||
t.Fatalf("customers = %d, want 2 (gamma excluded)", len(s.Customers))
|
||
}
|
||
if s.Customers[0].Org != "acme" || s.Customers[0].MRRCents != 3000 || s.Customers[0].UsageCents != 200 {
|
||
t.Errorf("top customer = %+v, want acme mrr 3000 usage 200", s.Customers[0])
|
||
}
|
||
if s.Customers[1].Org != "beta" || s.Customers[1].Status != string(subscription.Trialing) || s.Customers[1].UsageCents != 80 {
|
||
t.Errorf("second customer = %+v, want beta trialing usage 80", s.Customers[1])
|
||
}
|
||
if s.Orgs != 3 {
|
||
t.Errorf("orgs = %d, want 3", s.Orgs)
|
||
}
|
||
}
|
||
|
||
// TestPlanMeta_FromCatalog proves category/name resolution reads the embedded
|
||
// @hanzo/plans catalog (the Stripe-seed SOT) for a real slug and falls back
|
||
// honestly for an unknown one.
|
||
func TestPlanMeta_FromCatalog(t *testing.T) {
|
||
plans := billing.StaticPlans()
|
||
if len(plans) == 0 {
|
||
t.Fatal("embedded plan catalog is empty — commerce cannot map plan categories")
|
||
}
|
||
p := plans[0]
|
||
cat, name := planMeta(p.Slug, "")
|
||
if p.Category != "" && cat != p.Category {
|
||
t.Errorf("planMeta(%q) category = %q, want %q", p.Slug, cat, p.Category)
|
||
}
|
||
if name == "" {
|
||
t.Errorf("planMeta(%q) name is empty", p.Slug)
|
||
}
|
||
if cat2, name2 := planMeta("__no_such_plan__", "Embedded Name"); cat2 != "uncategorized" || name2 != "Embedded Name" {
|
||
t.Errorf("unknown slug = (%q,%q), want (uncategorized, Embedded Name)", cat2, name2)
|
||
}
|
||
}
|
||
|
||
// TestWindowBounds checks window parsing + inWindow, including the "all" (zero
|
||
// start) and skew-tolerance behaviour.
|
||
func TestWindowBounds(t *testing.T) {
|
||
now := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)
|
||
if got := windowStart(now, "7d"); !got.Equal(now.AddDate(0, 0, -7)) {
|
||
t.Errorf("7d start = %v", got)
|
||
}
|
||
if got := windowStart(now, "mtd"); got.Day() != 1 || got.Month() != 7 {
|
||
t.Errorf("mtd start = %v, want first of month", got)
|
||
}
|
||
if got := windowStart(now, "all"); !got.IsZero() {
|
||
t.Errorf("all start = %v, want zero", got)
|
||
}
|
||
start := windowStart(now, "30d")
|
||
if !inWindow(now.AddDate(0, 0, -5), start, now) {
|
||
t.Error("-5d should be in a 30d window")
|
||
}
|
||
if inWindow(now.AddDate(0, 0, -31), start, now) {
|
||
t.Error("-31d should be outside a 30d window")
|
||
}
|
||
if inWindow(time.Time{}, start, now) {
|
||
t.Error("zero time is never in-window")
|
||
}
|
||
}
|
||
|
||
// TestParseLimit clamps the top-N cap.
|
||
func TestParseLimit(t *testing.T) {
|
||
cases := map[string]int{"": defaultLimit, "0": defaultLimit, "-5": defaultLimit, "abc": defaultLimit, "10": 10, "500": 200}
|
||
for in, want := range cases {
|
||
if got := parseLimit(in); got != want {
|
||
t.Errorf("parseLimit(%q) = %d, want %d", in, got, want)
|
||
}
|
||
}
|
||
}
|