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

469 lines
18 KiB
Go

package catalog
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
"github.com/hanzoai/commerce/models/catalogentry"
"github.com/hanzoai/commerce/util/test/ae"
)
// callCatalog drives handler h through a real request whose GetContext resolves
// to the ae SQLite datastore, optionally carrying IAM claims. Returns the
// response status and body bytes.
func callCatalog(t *testing.T, claims *auth.IAMClaims, method, target string, body []byte, h zip.Handler) (int, []byte) {
t.Helper()
app := zip.New(zip.Config{DisableStartupMessage: true})
seed := func(c *zip.Ctx) error {
c.SetContext(context.Background())
if claims != nil {
c.Locals("iam_claims", claims)
}
return c.Next()
}
app.Raw(zip.MethodAll, "/*", seed, h)
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req := httptest.NewRequest(method, target, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("test request: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// callPut drives an update handler mounted at its REAL path pattern, so the
// :slug param resolves. callCatalog's wildcard mount cannot express one.
func callPut(t *testing.T, claims *auth.IAMClaims, pattern, target string, body []byte, h zip.Handler) (int, []byte) {
t.Helper()
app := zip.New(zip.Config{DisableStartupMessage: true})
seed := func(c *zip.Ctx) error {
c.SetContext(context.Background())
if claims != nil {
c.Locals("iam_claims", claims)
}
return c.Next()
}
app.Raw(http.MethodPut, pattern, seed, h)
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req := httptest.NewRequest(http.MethodPut, target, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("test request: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
func TestPublic_ReturnsProjection_NoAuth(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
// Seed the catalog in the system namespace via a global-admin seed call.
if code, body := callCatalog(t, &auth.IAMClaims{Owner: "admin"}, http.MethodPost, "/v1/catalog/seed", nil, SeedCatalog); code != 200 {
t.Fatalf("seed status = %d; body=%s", code, body)
}
// Public GET — no auth at all.
code, body := callCatalog(t, nil, http.MethodGet, "/v1/commerce/catalog?brand=hanzo", nil, Public)
if code != 200 {
t.Fatalf("public catalog status = %d; body=%s", code, body)
}
var cat catalogentry.Catalog
if err := json.Unmarshal(body, &cat); err != nil {
t.Fatalf("projection not JSON: %s", body)
}
// Against the TAXONOMY, not a literal. The hanzo brand scopes to no subset
// (brandCategories["hanzo"] is nil), so a complete projection carries every
// canonical category — and asserting the count as a number meant adding one
// reddened this package for a change it has nothing to do with.
if cat.Brand != "hanzo" || len(cat.Products) == 0 {
t.Fatalf("bad projection: brand=%s cats=%d products=%d", cat.Brand, len(cat.Categories), len(cat.Products))
}
// BY NAME, not by count. The count was `!= 10`, so adding Science reddened
// this package for a change it has nothing to do with — and comparing it to
// the taxonomy'''s own length instead would be worse still: both sides read the
// same list, so the assertion passes for any list at all, including an empty
// one. (Measured: it did.)
//
// What can actually go wrong is a category DISAPPEARING from the public
// projection while the taxonomy still declares it — a rename that misses
// renamedCategories, or a brand scope that drops one — and that is a whole
// section missing from the pricing page. So this names a few the projection
// must carry, and stays quiet when a twelfth is added.
have := map[string]bool{}
for _, c := range cat.Categories {
have[c.Label] = true
}
for _, must := range []string{"AI", "Infrastructure", "Security"} {
if !have[must] {
t.Errorf("the public projection carries no %q category — the taxonomy declares "+
"it, so a whole section is missing from the catalog. Present: %v",
must, cat.Categories)
}
}
}
// TestAdminCatalog_GatesAndCarriesMargin proves the owner=="admin" boundary: an
// org-level admin is refused, the public projection NEVER carries cost/margin, and
// only the admin projection surfaces costCents + a derived marginPct.
func TestAdminCatalog_GatesAndCarriesMargin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
admin := &auth.IAMClaims{Owner: "admin"}
// A priced product with an upstream cost: public price $1.00, cost $0.40 →
// derived margin 60%.
body, _ := json.Marshal(map[string]any{
"slug": "margined", "brand": "hanzo", "name": "Margined", "category": "AI",
"iconKey": "Box", "priceCents": 100, "costCents": 40,
})
if code, b := callCatalog(t, admin, http.MethodPost, "/v1/catalog/entries", body, CreateEntry); code != 201 {
t.Fatalf("create status = %d, want 201; body=%s", code, b)
}
// PUBLIC projection must NEVER leak cost or margin — check the raw bytes.
_, pubBody := callCatalog(t, nil, http.MethodGet, "/v1/commerce/catalog?brand=hanzo", nil, Public)
if bytes.Contains(pubBody, []byte("costCents")) || bytes.Contains(pubBody, []byte("marginPct")) {
t.Fatalf("public projection leaked cost/margin: %s", pubBody)
}
// Admin catalog: org-level admin (NOT owner=="admin") is refused 403.
if code, b := callCatalog(t, &auth.IAMClaims{Owner: "acme", IsAdmin: true}, http.MethodGet, "/v1/commerce/admin/catalog?brand=hanzo", nil, AdminCatalog); code != 403 {
t.Fatalf("org-admin admin-catalog status = %d, want 403; body=%s", code, b)
}
// owner=="admin" gets the margin projection.
code, adminBody := callCatalog(t, admin, http.MethodGet, "/v1/commerce/admin/catalog?brand=hanzo", nil, AdminCatalog)
if code != 200 {
t.Fatalf("admin catalog status = %d, want 200; body=%s", code, adminBody)
}
var ac catalogentry.AdminCatalog
if err := json.Unmarshal(adminBody, &ac); err != nil {
t.Fatalf("admin projection not JSON: %s", adminBody)
}
var found bool
for _, p := range ac.Products {
if p.Slug == "margined" {
found = true
if p.CostCents != 40 || p.MarginPct != 60 {
t.Fatalf("admin item cost/margin = %d/%v, want 40/60", p.CostCents, p.MarginPct)
}
}
}
if !found {
t.Fatal("admin projection missing the margined entry")
}
}
func TestCreateEntry_RequiresSuperAdmin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
body, _ := json.Marshal(map[string]any{
"slug": "myproduct", "brand": "hanzo", "name": "My Product",
"category": "AI", "iconKey": "Box",
})
// Org-level admin (NOT global) — must be rejected 403.
if code, b := callCatalog(t, &auth.IAMClaims{Owner: "acme", IsAdmin: true}, http.MethodPost, "/v1/catalog/entries", body, CreateEntry); code != 403 {
t.Fatalf("org-admin create status = %d, want 403 (platform-admin only); body=%s", code, b)
}
// Global admin — allowed 201.
if code, b := callCatalog(t, &auth.IAMClaims{Owner: "admin"}, http.MethodPost, "/v1/catalog/entries", body, CreateEntry); code != 201 {
t.Fatalf("global-admin create status = %d, want 201; body=%s", code, b)
}
// The created entry is now visible in the public projection.
_, projBody := callCatalog(t, nil, http.MethodGet, "/v1/commerce/catalog?brand=hanzo", nil, Public)
var cat catalogentry.Catalog
_ = json.Unmarshal(projBody, &cat)
found := false
for _, p := range cat.Products {
if p.Slug == "myproduct" {
found = true
}
}
if !found {
t.Fatal("created entry did not appear in the public projection")
}
}
func TestCreateEntry_DuplicateSlugRejected(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
body, _ := json.Marshal(map[string]any{
"slug": "dup", "brand": "hanzo", "name": "Dup", "category": "AI", "iconKey": "Box",
})
admin := &auth.IAMClaims{Owner: "admin"}
if code, _ := callCatalog(t, admin, http.MethodPost, "/v1/catalog/entries", body, CreateEntry); code != 201 {
t.Fatalf("first create = %d, want 201", code)
}
if code, _ := callCatalog(t, admin, http.MethodPost, "/v1/catalog/entries", body, CreateEntry); code != 409 {
t.Fatalf("duplicate create = %d, want 409", code)
}
}
// TestModelCatalog_OneListForEveryCaller is the structural proof against the
// 440-vs-106 split: there is ONE catalog and ONE endpoint, so an anonymous
// visitor and an authenticated customer necessarily see the SAME rows. A model
// a caller cannot use still appears, carrying its price and the honest reason —
// entitlement decides USE, never sight.
func TestModelCatalog_OneListForEveryCaller(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
admin := &auth.IAMClaims{Owner: "admin"}
rows := []map[string]any{
{
"slug": "enso", "name": "Enso", "category": catalogentry.CategoryEnso,
"spec": map[string]any{
"vendor": "Hanzo", "family": catalogentry.FamilyEnso, "serves": "enso",
"modality": "chat", "contextWindow": 1000000, "minTier": "paid", "enabled": true,
},
"rates": []map[string]any{
{"key": "in", "unit": "mtok", "cost": "6.00", "price": "20.00"},
},
"published": true,
},
{
"slug": "llama-4-maverick", "name": "Llama 4 Maverick", "category": catalogentry.CategoryModel,
"spec": map[string]any{
"vendor": "Meta", "family": catalogentry.FamilyThirdParty, "serves": "openrouter",
"modality": "chat", "enabled": false, "unavailable": "upstream withdrawn",
},
"rates": []map[string]any{{"key": "in", "unit": "mtok", "cost": "0.15"}},
"published": true,
},
}
for _, r := range rows {
b, _ := json.Marshal(r)
if code, body := callCatalog(t, admin, http.MethodPost, "/v1/catalog/entries", b, CreateEntry); code != 201 {
t.Fatalf("create %v = %d; body=%s", r["slug"], code, body)
}
}
read := func(claims *auth.IAMClaims) catalogentry.Catalog {
t.Helper()
code, body := callCatalog(t, claims, http.MethodGet, "/v1/commerce/catalog?brand=models", nil, Public)
if code != 200 {
t.Fatalf("models catalog status = %d; body=%s", code, body)
}
var cat catalogentry.Catalog
if err := json.Unmarshal(body, &cat); err != nil {
t.Fatalf("not JSON: %s", body)
}
return cat
}
anon := read(nil)
authed := read(&auth.IAMClaims{Owner: "acme"})
if len(anon.Products) != 2 || len(authed.Products) != len(anon.Products) {
t.Fatalf("anonymous saw %d models, authenticated saw %d — the catalog must not fork on auth",
len(anon.Products), len(authed.Products))
}
for i := range anon.Products {
if anon.Products[i].Slug != authed.Products[i].Slug {
t.Fatalf("row %d differs by caller: %s vs %s", i, anon.Products[i].Slug, authed.Products[i].Slug)
}
}
byID := map[string]catalogentry.Item{}
for _, p := range anon.Products {
byID[p.Slug] = p
}
enso := byID["enso"]
if enso.Spec == nil || enso.Spec.MinTier != "paid" || enso.Spec.Vendor != "Hanzo" {
t.Fatalf("enso spec = %+v, want the entitlement + vendor projected publicly", enso.Spec)
}
if len(enso.Rates) != 1 || enso.Rates[0].Price != "20.00" {
t.Fatalf("enso rates = %+v, want the retail price", enso.Rates)
}
llama := byID["llama-4-maverick"]
if llama.Spec.Enabled || llama.Spec.Unavailable == "" {
t.Fatalf("an unusable model must still be LISTED with an honest reason: %+v", llama.Spec)
}
if llama.Spec.Vendor != "Meta" {
t.Fatalf("vendor = %q, want the plain vendor name %q", llama.Spec.Vendor, "Meta")
}
// Derived retail: cost 0.15 x 1, no markup set.
if len(llama.Rates) != 1 || llama.Rates[0].Price != "0.15" {
t.Fatalf("derived retail = %+v, want 0.15", llama.Rates)
}
// Cost and margin never reach a public reader, model rows included.
blob, _ := json.Marshal(anon)
if bytes.Contains(blob, []byte(`"cost"`)) || bytes.Contains(blob, []byte(`"marginPct"`)) {
t.Fatalf("public model catalog leaked cost/margin: %s", blob)
}
}
// TestModelPrice_EditableFromAdmin proves admin.hanzo.ai can set a model's
// retail price and markup through the SAME CRUD every other catalog entry uses,
// and that the change shows up as a margin on the admin projection.
func TestModelPrice_EditableFromAdmin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
admin := &auth.IAMClaims{Owner: "admin"}
create, _ := json.Marshal(map[string]any{
"slug": "zen5", "name": "Zen 5", "category": catalogentry.CategoryZen,
"spec": map[string]any{"vendor": "Hanzo", "family": catalogentry.FamilyZen, "enabled": true},
"rates": []map[string]any{{"key": "in", "unit": "mtok", "cost": "1.00"}},
"published": true,
})
if code, b := callCatalog(t, admin, http.MethodPost, "/v1/catalog/entries", create, CreateEntry); code != 201 {
t.Fatalf("create = %d; %s", code, b)
}
edit, _ := json.Marshal(map[string]any{
"name": "Zen 5",
"category": catalogentry.CategoryZen,
"published": true,
"markup": "2.5",
"rates": []map[string]any{{"key": "in", "unit": "mtok", "cost": "1.00", "price": "4.00"}},
})
if code, b := callPut(t, admin, "/v1/catalog/entries/*", "/v1/catalog/entries/zen5", edit, UpdateEntry); code != 200 {
t.Fatalf("update = %d; %s", code, b)
}
code, body := callCatalog(t, admin, http.MethodGet, "/v1/commerce/admin/catalog?brand=models", nil, AdminCatalog)
if code != 200 {
t.Fatalf("admin models catalog = %d; %s", code, body)
}
var ac catalogentry.AdminCatalog
if err := json.Unmarshal(body, &ac); err != nil {
t.Fatalf("not JSON: %s", body)
}
if len(ac.Products) != 1 {
t.Fatalf("admin models = %d, want 1", len(ac.Products))
}
p := ac.Products[0]
if p.Markup != "2.5" {
t.Fatalf("markup = %q, want 2.5", p.Markup)
}
if len(p.AdminRates) != 1 || p.AdminRates[0].Cost != "1.00" || p.AdminRates[0].Price != "4.00" {
t.Fatalf("admin rate = %+v, want cost 1.00 / price 4.00", p.AdminRates)
}
if p.AdminRates[0].MarginPct == nil || *p.AdminRates[0].MarginPct != 75 {
t.Fatalf("margin = %v, want 75", p.AdminRates[0].MarginPct)
}
}
// TestSyncModels_GatedAndCostOnly proves the write seam: platform-admin only,
// and a syncer's attempt to state retail is refused at the boundary — it may
// only move cost.
func TestSyncModels_GatedAndCostOnly(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
rows, _ := json.Marshal([]map[string]any{{
"slug": "gpt-5.6", "name": "GPT-5.6",
"spec": map[string]any{"vendor": "OpenAI", "family": catalogentry.FamilyThirdParty, "serves": "openrouter", "enabled": true},
"costs": []map[string]any{{"key": "in", "unit": "mtok", "cost": "1.25", "price": "0.01"}},
}})
// An org-level admin may not sync the platform catalog.
if code, b := callCatalog(t, &auth.IAMClaims{Owner: "acme", IsAdmin: true}, http.MethodPost, "/v1/catalog/models", rows, SyncModels); code != 403 {
t.Fatalf("org-admin sync = %d, want 403; %s", code, b)
}
admin := &auth.IAMClaims{Owner: "admin"}
code, body := callCatalog(t, admin, http.MethodPost, "/v1/catalog/models", rows, SyncModels)
if code != 200 {
t.Fatalf("sync = %d; %s", code, body)
}
var stats catalogentry.SyncStats
if err := json.Unmarshal(body, &stats); err != nil || stats.Created != 1 {
t.Fatalf("stats = %+v err=%v, want created 1", stats, err)
}
_, adminBody := callCatalog(t, admin, http.MethodGet, "/v1/commerce/admin/catalog?brand=models", nil, AdminCatalog)
var ac catalogentry.AdminCatalog
_ = json.Unmarshal(adminBody, &ac)
if len(ac.Products) != 1 {
t.Fatalf("synced models = %d, want 1", len(ac.Products))
}
r := ac.Products[0].AdminRates[0]
if r.Cost != "1.25" {
t.Fatalf("cost = %q, want 1.25", r.Cost)
}
// The syncer's "price" was dropped; with no markup set, retail is the cost.
if r.Price != "1.25" {
t.Fatalf("price = %q, want 1.25 (cost x 1) — a sync must never set retail", r.Price)
}
}
// A product's API ADDRESS travels with a deploy, so this endpoint refuses to store
// one — and refuses out loud.
//
// It used to take the whole entity and answer 200, which meant an admin could set
// apiPath to anything, watch it render back, and lose it at the next restart when
// catalogentry.Correct read the snapshot over the row. A write surface that
// reports durable success for a value it cannot keep is worse than one that says
// no: nothing was wrong until a pod cycled, and then nothing said why.
//
// The read-modify-write the console actually performs still has to work, so the
// refusal is for a CHANGE, not for the field being present.
func TestUpdateEntry_RefusesToMoveTheAddress(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
admin := &auth.IAMClaims{Owner: "admin"}
create, _ := json.Marshal(map[string]any{
"slug": "gateway", "name": "Gateway", "category": "Network",
"apiPath": "/v1/gateway", "apiRoute": "api.hanzo.ai/v1/gateway", "published": true,
})
if code, b := callCatalog(t, admin, http.MethodPost, "/v1/catalog/entries", create, CreateEntry); code != 201 {
t.Fatalf("create = %d; %s", code, b)
}
move, _ := json.Marshal(map[string]any{"name": "Gateway", "category": "Network", "apiPath": "/v1/made-up"})
code, b := callPut(t, admin, "/v1/catalog/entries/*", "/v1/catalog/entries/gateway", move, UpdateEntry)
if code != 409 {
t.Fatalf("moving apiPath = %d, want 409; %s", code, b)
}
if !strings.Contains(string(b), "hanzo-catalog.json") {
t.Errorf("the refusal does not say where the address IS edited: %s", b)
}
// The whole-entity round-trip the console performs: same address back, plus
// the defaulted kind the projection always states. Both must be accepted.
echo, _ := json.Marshal(map[string]any{
"name": "Gateway, renamed", "category": "Network",
"apiPath": "/v1/gateway", "apiRoute": "api.hanzo.ai/v1/gateway",
"kind": catalogentry.KindService, "published": true,
})
if code, b := callPut(t, admin, "/v1/catalog/entries/*", "/v1/catalog/entries/gateway", echo, UpdateEntry); code != 200 {
t.Fatalf("echoing the address back = %d, want 200 — the console reads a row and PUTs it whole; %s", code, b)
}
}