Files
cloud/openapi/openapi_test.go
zeekayandhanzo-dev b78f41c4b9
Hanzo CI/CD / cicd (push) Failing after 54s
CI/CD / gate (push) Failing after 54s
CI/CD / containment (push) Successful in 1m2s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
one rule names an operation, and cloud stops keeping a second copy of it
zip v1.26.0 derives an operation id from the ABSOLUTE path with a parameter
written by_<name>, which is the rule cloud's openapi package already used. So
operationID and sanitize here were a SECOND implementation of one rule, kept in
step by nothing but attention. They are deleted; From calls zip.ID.

The proof that they were a true duplicate rather than merely a close one is that
`make describe` regenerates openapi.yaml BYTE-IDENTICAL across this change —
same sha256, 1710 paths, no diff in any of the 119 subsets. A refactor that
claims to remove a copy should be able to show the copy said nothing of its own,
and this one can.

The renames those two rules used to disagree about landed already in 0d602fc9;
nothing about the published document moves here.

The param-vs-literal test goes with the code it tested. It was a unit test of
this package's copy of the rule, and the rule now lives in zip, which grew its
own tests for it in zip a774624 — the collisions the encoding has to survive: a
parameter against a literal of the same name, the hyphen that must not fold into
the separator, the four spellings of one parameter that must reach one name, and
the '_' aliasing it deliberately CANNOT resolve.

Literals pinned in tests move because the RULE says so, not to go green:
  apps/dataroom     10 dotted (v1.dataroom.get_datarooms_id -> get_v1_dataroom_datarooms_by_id)
  apps/ingress       4 (put_v1_ingress_routes_id -> put_v1_ingress_routes_by_id)
  apps/automations   1 (get_v1_automations_runs_id -> get_v1_automations_runs_by_id)
  apps/notify        1 (v1.notify.post_send -> post_v1_notify_send)
  apps/guide         a comment that still described the dotted scheme as current

Suite: 183 ok, 0 build failures. Eleven tests go green — dataroom's MCP tool
list and its agent round trip, automations' and notify's by-name calls,
ingress's tool prose — and no test fails that did not already fail on a pristine
checkout of this base.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:53:03 -07:00

395 lines
15 KiB
Go

package openapi
import (
"context"
"testing"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
func newApp() *zip.App {
return zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
}
// The product axis must be mechanical — first segment after /v1/, no judgment.
// The "" cases are the honesty boundary: a segment that is not a product name
// gets no tag rather than a fabricated one.
func TestProduct(t *testing.T) {
for _, tc := range []struct{ path, want string }{
{"/v1/kms/orgs/:org/secrets", "kms"},
{"/v1/billing/usage", "billing"},
{"/v1/finance/balance", "finance"}, // clients/billing serves it: product != subsystem
{"/v1/billing", "billing"},
{"/v1/billing/*", "billing"}, // a catch-all still names its product
{"/v1/openapi.json", ""}, // a file, not a product
{"/v1/*", ""}, // a wildcard, not a product
{"/v1/:id", ""}, // a param, not a product
{"/v1/", ""},
{"/health", ""}, // not under /v1
{"/.well-known/jwks", ""}, // not under /v1
{"/tasks/*", ""}, // not under /v1
{"/v2/kms/keys", ""}, // house law: there is no v2
} {
if got := Product(tc.path); got != tc.want {
t.Errorf("Product(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
// fiber :param → OpenAPI {param}, and a fiber wildcard becomes a named
// {wildcardN} because "*1" is not a legal URI-template name.
func TestTranslate(t *testing.T) {
for _, tc := range []struct {
in string
want string
params []string
}{
{"/v1/billing/usage", "/v1/billing/usage", nil},
{"/v1/kms/orgs/:org/secrets", "/v1/kms/orgs/{org}/secrets", []string{"org"}},
{"/v1/kms/orgs/:org/secrets/*", "/v1/kms/orgs/{org}/secrets/{wildcard1}", []string{"org", "wildcard1"}},
{"/v1/a/:x/b/:y", "/v1/a/{x}/b/{y}", []string{"x", "y"}},
{"/v1/*", "/v1/{wildcard1}", []string{"wildcard1"}},
} {
got, params := translate(tc.in)
if got != tc.want {
t.Errorf("translate(%q) path = %q, want %q", tc.in, got, tc.want)
}
if len(params) != len(tc.params) {
t.Fatalf("translate(%q) params = %v, want %v", tc.in, params, tc.params)
}
for i := range params {
if params[i] != tc.params[i] {
t.Errorf("translate(%q) params = %v, want %v", tc.in, params, tc.params)
}
}
}
}
// THE LOAD-BEARING FACT, pinned so a zip/fiber bump that changes it fails here.
//
// It just did, which is this test doing its job rather than failing at it.
//
// The fact USED to be: a duplicate registration and a middleware chain produce
// the SAME observable route — one entry, N chained handlers — so they are
// indistinguishable through the public API, and therefore this generator must
// never read the handler count and "handlers > 1 means collision" is not a
// fleet-wide truth.
//
// From zip v1.24 the first half is no longer constructible: a duplicate
// registration is REFUSED at composition time rather than merged into the
// existing entry. The conclusion is unchanged and now rests on something
// stronger — the generator still must not read the handler count, because a
// count above one can ONLY be a chain now that a collision cannot reach the
// registry at all.
//
// So both halves are pinned: the chain still projects to exactly one operation,
// and the duplicate is still refused. If either moves, the generator's
// assumption about handler counts has to be revisited, which is what this test
// exists to force.
func TestChainYieldsOneOperationAndDuplicateIsRefused(t *testing.T) {
// (a) a legitimate chain: ONE registration, middleware + terminal handler —
// the apps/commerce.go:151 shape. One route entry, two handlers.
chain := newApp()
chain.Get("/v1/bots",
func(c *zip.Ctx) error { return c.Next() },
func(c *zip.Ctx) error { return c.JSON(200, "run") },
)
// zip registers a GET as GET plus an automatic HEAD companion, so ONE
// registration is two route ENTRIES. That is not a collision and is measured
// here rather than assumed: the GET is what carries the chain, and the HEAD is
// fiber's own and is dropped from the projection below.
handlers := -1
for _, r := range chain.Fiber().GetRoutes(true) {
if r.Method == "GET" && r.Path == "/v1/bots" {
handlers = len(r.Handlers)
break
}
}
if handlers < 0 {
t.Fatalf("the chain registered no GET /v1/bots at all: %v", chain.Fiber().GetRoutes(true))
}
if handlers != 2 {
t.Fatalf("the chain should carry 2 chained handlers; got %d — if this "+
"diverged, the handler count became meaningful and this package should revisit it",
handlers)
}
// And it projects to exactly one operation.
doc, err := Spec(chain, Info{Title: "t", Version: "v1"})
if err != nil {
t.Fatalf("chain: Spec: %v", err)
}
item, ok := doc.Paths["/v1/bots"]
if !ok || len(item) != 1 || item["get"] == nil {
t.Fatalf("chain: want exactly one get operation at /v1/bots, got %v", doc.Paths)
}
// (b) a genuine duplicate: two separate registrations of one pattern. zip
// refuses to compose it, so it can never reach this generator. The refusal
// arrives as a panic out of the composition step (Registry, reached here
// through Fiber), which is why this is asserted with a recover rather than an
// error return.
func() {
defer func() {
if recover() == nil {
t.Error("zip composed a duplicate registration of GET /v1/bots — " +
"a collision can reach the registry again, so the generator can no " +
"longer assume a handler count above one is always a chain")
}
}()
dup := newApp()
dup.Get("/v1/bots", func(c *zip.Ctx) error { return c.JSON(200, "machine") })
dup.Get("/v1/bots", func(c *zip.Ctx) error { return c.JSON(200, "run") })
_ = dup.Fiber()
}()
}
// Middleware matches path prefixes and is not an operation — fiber's own
// GetRoutes(true) filter drops it, and Live must use it.
func TestLiveDropsMiddleware(t *testing.T) {
app := newApp()
app.Use(zip.H(func(c *zip.Ctx) error { return c.Next() }))
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return c.JSON(200, "ok") })
live := Live(app)
if len(live) != 1 {
t.Fatalf("Live() = %d routes, want 1 (the Use() middleware must not be an operation): %+v", len(live), live)
}
if live[0].Path != "/v1/kms/health" {
t.Errorf("Live()[0].Path = %q", live[0].Path)
}
}
// CONNECT has no OpenAPI Path Item field, and HEAD cannot be stated stably
// (fiber auto-generates it at startupProcess, not at registration). Live must
// drop both — see the `methods` doc comment.
func TestLiveDropsUnrepresentableMethods(t *testing.T) {
app := newApp()
app.All("/v1/tasks", func(c *zip.Ctx) error { return c.JSON(200, "ok") })
for _, r := range Live(app) {
if r.Method == "CONNECT" || r.Method == "HEAD" {
t.Errorf("Live() emitted %s — it is not representable/stable in the document", r.Method)
}
}
// The representable ones from All() survive.
got := map[string]bool{}
for _, r := range Live(app) {
got[r.Method] = true
}
for _, m := range []string{"GET", "POST", "PUT", "DELETE", "PATCH"} {
if !got[m] {
t.Errorf("Live() dropped %s, which All() registers and OpenAPI can express", m)
}
}
}
// From must reject a duplicate operationId rather than emit a document a
// generator would mis-consume. This is also what keeps (method,path) injective.
//
// The pair below is the residual aliasing the encoding cannot remove: '_' is the
// separator, so a literal '_' in a segment can alias a '/'. The guard is what
// makes ids trustworthy, not the encoding.
func TestFromRejectsDuplicateOperationID(t *testing.T) {
rs := []Route{
{Method: "GET", Path: "/v1/a/b_c"},
{Method: "GET", Path: "/v1/a/b/c"}, // both → get_v1_a_b_c
}
if _, err := From(rs, Info{Title: "t", Version: "v1"}); err == nil {
t.Fatal("From() accepted two routes with the same derived operationId; it must refuse")
}
}
// The pair that found this bug: /v1/pricing-policy and /v1/pricing/policy both
// existed, and folding '-' into '_' collapsed them onto one id, making the whole
// document unemittable. The alias has since been deleted, so the routes below are
// now a constructed case rather than a live one — but hyphenated addresses we do
// not own (git-upload-pack, delete-batch) are permanent, and nothing stops the
// next slash-sibling. Pinned so the encoding never regresses.
func TestOperationIDKeepsHyphenDistinctFromPathSeparator(t *testing.T) {
rs := []Route{
{Method: "GET", Path: "/v1/pricing-policy"},
{Method: "GET", Path: "/v1/pricing/policy"},
}
doc, err := From(rs, Info{Title: "t", Version: "v1"})
if err != nil {
t.Fatalf("From: %v — these are two distinct live routes and must yield two distinct ids", err)
}
got := []string{
doc.Paths["/v1/pricing-policy"]["get"].OperationID,
doc.Paths["/v1/pricing/policy"]["get"].OperationID,
}
if got[0] == got[1] {
t.Fatalf("both routes derived operationId %q", got[0])
}
if got[0] != "get_v1_pricing-policy" || got[1] != "get_v1_pricing_policy" {
t.Errorf("ids = %v, want [get_v1_pricing-policy get_v1_pricing_policy]", got)
}
}
// The document shape: 3.1.0, product tags, path params required, and no
// fabricated responses.
func TestFromShape(t *testing.T) {
rs := []Route{
{Method: "GET", Path: "/v1/kms/orgs/:org/secrets"},
{Method: "POST", Path: "/v1/kms/orgs/:org/secrets"},
{Method: "GET", Path: "/v1/billing/usage"},
}
doc, err := From(rs, Info{Title: "Hanzo Cloud", Version: "v1"}, Server{URL: "https://api.hanzo.ai"})
if err != nil {
t.Fatalf("From: %v", err)
}
if doc.OpenAPI != "3.1.0" {
t.Errorf("openapi = %q, want 3.1.0", doc.OpenAPI)
}
if len(doc.Tags) != 2 || doc.Tags[0].Name != "billing" || doc.Tags[1].Name != "kms" {
t.Errorf("tags = %+v, want sorted [billing kms]", doc.Tags)
}
item, ok := doc.Paths["/v1/kms/orgs/{org}/secrets"]
if !ok {
t.Fatalf("missing translated path; have %v", doc.Paths)
}
if len(item) != 2 || item["get"] == nil || item["post"] == nil {
t.Fatalf("path item should carry get+post, got %v", item)
}
get := item["get"]
if len(get.Tags) != 1 || get.Tags[0] != "kms" {
t.Errorf("tags = %v, want [kms]", get.Tags)
}
if len(get.Parameters) != 1 {
t.Fatalf("parameters = %+v, want 1", get.Parameters)
}
if p := get.Parameters[0]; p.Name != "org" || p.In != "path" || !p.Required || p.Schema["type"] != "string" {
t.Errorf("param = %+v, want {org path required string}", p)
}
if get.OperationID == item["post"].OperationID {
t.Errorf("get and post share operationId %q", get.OperationID)
}
}
// Mount serves the document off the app it is registered on, and the document
// includes its OWN route — proof the lazy build sees the final table.
func TestMountServesLiveSpecIncludingItself(t *testing.T) {
app := newApp()
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return c.JSON(200, "ok") })
Mount(app, Info{Title: "Hanzo Cloud", Version: "v1"})
doc, err := Spec(app, Info{Title: "Hanzo Cloud", Version: "v1"})
if err != nil {
t.Fatalf("Spec: %v", err)
}
if _, ok := doc.Paths[Path]; !ok {
t.Errorf("document omits its own endpoint %s; have %v", Path, doc.Paths)
}
if _, ok := doc.Paths["/v1/kms/health"]; !ok {
t.Errorf("document omits /v1/kms/health")
}
}
// The fold is the whole point of two projections: a typed op keeps the shape the
// router proves (address, product tag) and GAINS everything only its Go types
// know — schema, responses, query parameters — while the raw route beside it is
// untouched. Both are in the one document.
type secretIn struct {
Org string `json:"org"`
Limit int `json:"limit"`
}
type secretOut struct {
Names []string `json:"names"`
}
func TestFoldGivesTypedOpsSchemaAndLeavesRawRoutesAlone(t *testing.T) {
app := newApp()
app.Post("/v1/kms/orgs/:org/secrets", func(c *zip.Ctx) error { return c.JSON(200, "ok") })
zip.Get(app, "/v1/kms/orgs/:org/secrets", func(ctx context.Context, in *secretIn) (*secretOut, error) {
return &secretOut{}, nil
}, zip.WithOperationID("kmsListSecrets"), zip.WithSummary("List secret names"))
doc, err := Spec(app, Info{Title: "Hanzo Cloud", Version: "v1"})
if err != nil {
t.Fatalf("Spec: %v", err)
}
item := doc.Paths["/v1/kms/orgs/{org}/secrets"]
get, post := item["get"], item["post"]
if get == nil || post == nil {
t.Fatalf("path item should carry both the typed get and the raw post, got %v", item)
}
if get.OperationID != "kmsListSecrets" || get.Summary != "List secret names" {
t.Errorf("typed op = %q/%q, want the registry's own identity", get.OperationID, get.Summary)
}
if r, ok := get.Responses.(map[string]any); !ok || r["200"] == nil {
t.Errorf("typed op has no 200 response; the Out type is evidence for one (got %T)", get.Responses)
}
if len(get.Tags) != 1 || get.Tags[0] != "kms" {
t.Errorf("tags = %v, want the router's product tag [kms]", get.Tags)
}
// :org is a path param and `limit` a query param, both derived from the
// pattern and the In type — the router alone could only name the first.
in := map[string]string{}
for _, p := range get.Parameters {
in[p.Name] = p.In
}
if in["org"] != "path" || in["limit"] != "query" {
t.Errorf("parameters = %+v, want org in path and limit in query", get.Parameters)
}
if doc.Components == nil || doc.Components.Schemas["secretOut"] == nil {
t.Errorf("components carry no secretOut schema: %+v", doc.Components)
}
// The raw route keeps exactly the four structural facts and invents nothing.
if post.Responses != nil || post.RequestBody != nil || post.Description != "" {
t.Errorf("raw route gained detail it has no evidence for: %+v", post)
}
}
// A registry entry with no live route means the two readings disagree about a
// path. That is a generator bug, and refusing beats emitting an operation
// nothing serves.
func TestFoldRefusesATypedOpWithNoLiveRoute(t *testing.T) {
doc, err := From(nil, Info{Title: "Hanzo Cloud", Version: "v1"})
if err != nil {
t.Fatalf("From: %v", err)
}
err = Fold(doc, Registry{Ops: map[string]*Operation{
"GET /v1/kms/ghost": {OperationID: "ghost"},
}})
if err == nil {
t.Fatal("Fold accepted an operation the router does not serve")
}
}
// A legacy ADDRESS is a fact only the serving code knows: `/v1/iam/get-users`
// and `/v1/iam/users` are two live routes with no relation the route table can
// see. So the serving code declares it with the `compat` tag, and the fold has to
// carry that declaration out — hanzoai/openapi's merge reads it to keep the old
// spelling out of the published document, and without it the customer surface
// carries two of everything: two SDK methods, two docs entries, two CLI commands.
//
// The product tag stays FIRST, because that is the axis a generator files an
// operation under. `compat` rides second.
func TestFoldCarriesTheCompatDeclarationOutOfTheRegistry(t *testing.T) {
app := newApp()
zip.Get(app, "/v1/kms/keys", func(ctx context.Context, in *secretIn) (*secretOut, error) {
return &secretOut{}, nil
}, zip.WithOperationID("kmsListKeys"), zip.WithSummary("List keys"))
zip.Get(app, "/v1/kms/get-keys", func(ctx context.Context, in *secretIn) (*secretOut, error) {
return &secretOut{}, nil
}, zip.WithOperationID("kmsGetKeys"), zip.WithSummary("List keys (legacy verb)"), zip.WithTags(Compat))
doc, err := Spec(app, Info{Title: "Hanzo Cloud", Version: "v1"})
if err != nil {
t.Fatalf("Spec: %v", err)
}
canonical := doc.Paths["/v1/kms/keys"]["get"]
if len(canonical.Tags) != 1 || canonical.Tags[0] != "kms" {
t.Errorf("canonical tags = %v, want the product tag alone [kms]", canonical.Tags)
}
legacy := doc.Paths["/v1/kms/get-keys"]["get"]
if len(legacy.Tags) != 2 || legacy.Tags[0] != "kms" || legacy.Tags[1] != Compat {
t.Errorf("legacy tags = %v, want [kms compat] — product first, the declaration second", legacy.Tags)
}
}