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.
469 lines
18 KiB
Go
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 SuperAdmin seed call.
|
|
if code, body := callCatalog(t, &auth.IAMClaims{Owner: "admin", Sudo: true}, 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", Sudo: true}
|
|
// 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)
|
|
}
|
|
|
|
// SuperAdmin — allowed 201.
|
|
if code, b := callCatalog(t, &auth.IAMClaims{Owner: "admin", Sudo: true}, http.MethodPost, "/v1/catalog/entries", body, CreateEntry); code != 201 {
|
|
t.Fatalf("SuperAdmin 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", Sudo: true}
|
|
|
|
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", Sudo: true}
|
|
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", Sudo: true}
|
|
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", Sudo: true}
|
|
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", Sudo: true}
|
|
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)
|
|
}
|
|
}
|