mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
120 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|