Files
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

120 lines
4.5 KiB
Go

package catalog
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/util/bit"
"github.com/hanzoai/commerce/util/permission"
"github.com/hanzoai/commerce/util/test/ae"
)
// probeGate drives the REAL chain AdminRoute mounts — TokenRequired(permission.Admin)
// — to a sentinel that reports whether the named gate admitted the caller. The
// service-token branch is exercised the way production reaches it, a real Bearer
// against a real env, never by stamping the marker by hand: the marker is
// middleware's own private state and a test that writes it directly would keep
// passing after the real branch stopped setting it.
func probeGate(t *testing.T, gate func(*zip.Ctx) bool, seed func(*zip.Ctx), bearer string) (int, bool) {
t.Helper()
admitted := false
app := zip.New(zip.Config{DisableStartupMessage: true})
if seed != nil {
app.Use(zip.H(func(c *zip.Ctx) error { seed(c); return c.Next() }))
}
app.Use(middleware.TokenRequired(permission.Admin))
app.Raw(http.MethodPost, "/x", func(c *zip.Ctx) error {
if !gate(c) {
return nil
}
admitted = true
return c.NoContent(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/x", nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
return resp.StatusCode, admitted
}
func iamIdentity(claims *auth.IAMClaims) func(*zip.Ctx) {
return func(c *zip.Ctx) {
c.Locals("iam_authenticated", true)
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
c.Locals("iam_claims", claims)
}
}
// TestSyncGate_AdmitsThePlatformApp is the regression. The sync endpoints were
// gated on SuperAdmin CLAIMS, which the internal service token does not carry,
// so the scheduled run this catalog depends on could only ever 403 — and the
// model rows were in fact empty in production. A sync a human has to start is a
// catalog that goes stale.
func TestSyncGate_AdmitsThePlatformApp(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
app := &auth.IAMClaims{Owner: "admin"}
app.Subject = "app_catalog_sync"
status, admitted := probeGate(t, requirePlatform, iamIdentity(app), "")
if status != http.StatusOK || !admitted {
t.Fatalf("platform app on the sync gate: status=%d admitted=%v, want 200 & admitted", status, admitted)
}
}
// TestSyncGate_RefusesAnOrgAdmin holds the boundary the widening must not cross:
// an org owner carries org-level IsAdmin and clears TokenRequired(Admin), but the
// platform catalog is cross-tenant data and is not theirs to sync.
func TestSyncGate_RefusesAnOrgAdmin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
orgAdmin := &auth.IAMClaims{Owner: "acme", IsAdmin: true}
status, admitted := probeGate(t, requirePlatform, iamIdentity(orgAdmin), "")
if status != http.StatusForbidden || admitted {
t.Fatalf("org admin on the sync gate: status=%d admitted=%v, want 403 & refused", status, admitted)
}
}
// TestEditGate_RefusesAnOpaqueBearer is the other half of the widening, and the
// one that keeps it honest: a bearer that is not a validated identity lands nowhere,
// on the sync endpoint or here. Editing an entry — which is how a retail price is
// set — takes an admin-org identity like the sync does, and the difference between
// a person and the platform's application is IAM's to draw, not a secret's.
// nothing more. Editing an entry — which is how a retail price is set — stays a
// named human in the admin org, so possession of a service credential can never
// change what a customer is charged.
func TestEditGate_RefusesAnOpaqueBearer(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
status, admitted := probeGate(t, requireSuperAdmin, nil, "not-an-identity")
if status == http.StatusOK || admitted {
t.Fatalf("opaque bearer on the entry-edit gate: status=%d admitted=%v, want refused", status, admitted)
}
}
// TestSyncGate_AdmitsASuperAdmin: a platform admin keeps the access they had, so
// the change is purely additive to who may run a sync.
func TestSyncGate_AdmitsASuperAdmin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
superAdmin := &auth.IAMClaims{Owner: "admin", IsAdmin: true}
status, admitted := probeGate(t, requirePlatform, iamIdentity(superAdmin), "")
if status != http.StatusOK || !admitted {
t.Fatalf("super admin on the sync gate: status=%d admitted=%v, want 200 & admitted", status, admitted)
}
}