Files
gateway/fixtures_test.go
zeekayandHanzo Dev 8592e5db04 gateway: every gate is a value, so the default build carries no gin
The last three first-party gin files were transports wrapped around decisions,
and the decisions could not be reached without the framework. That braiding had
already cost this repo a silent hole once (the fiber adaptor restoring headers
the strip had deleted); it was still costing two more, quietly.

widget_security.go and the CORS half of routes.go had NO zip transport at all.
Not a weaker one — none. So on the HIP-0106 mount, the boundary the unified
cloud binary runs, an hz_ widget key — a public credential anyone can read out
of a page's source — arrived with no origin allowlist and no rate limit against
the model budget, and a browser preflight got whatever the router does with an
unrouted OPTIONS. Both gates existed, both were tested, and neither ran, because
each was written inside a gin closure the zip edge could not call. That is the
same defect class as the deleted gin bridge, one level up: the decision was in a
place instead of being a value.

Now each gate is a value and each edge is a few lines over it — widgetGate.admit
in widget_security.go and corsPolicy.admit in cors.go, alongside the
authGate.admit that already was one. Mount installs all three natively, in the
order legacy_engine.go runs them. Every gin transport moved to
legacy_transports.go behind the tag: one file, one reason to exist, deleted whole
with legacy_engine.go at Phase C.

What that buys, measured on the binary rather than on a test:

  go list -deps ./cmd/gateway | grep gin-gonic   6 packages -> 0
  package count                                  2161 -> 2112

And what it does NOT buy, stated because the opposite is the easy thing to
claim: gin CANNOT leave go.mod, and gin is still in the SHIPPED binary. The
Makefile sets BUILD_TAGS ?= legacy, the Dockerfile runs `make build`, and all
five live workloads run `run -c gateway.json` — so ghcr.io/hanzoai/gateway is
the legacy build, and its gin comes from the vendored KrakenD/Lura tree
(internal/lura, internal/plugin), which is not ours to rewrite.
`go list -deps -tags legacy` still reports gin, unchanged at 2680 packages.
First-party gin is gone; the vendored legacy tree keeps it until Phase C.

The shipped edge is proven unmoved, not asserted to be: both binaries booted
against configs/hanzo/gateway.json and probed on fourteen requests covering
every gate. Route table 19 endpoints before and 19 after, and every response —
status line, CORS headers, security posture, BODY — diffs clean against
origin/main.

Tests: transport_parity_test.go drives one request through BOTH edges over 25
cases (auth classes, widget origin+rate limit, CORS preflight) and fails on
disagreement rather than on either edge alone; it was mutation-checked, and a
broken zip transport reports TRANSPORTS DISAGREE on 6 of them.
TestMount_GatesAreOnTheChainMountInstalls drives the app gateway.Mount BUILT,
because a gate that passes its own test while the mount never installs it is
exactly what was wrong here. The gin-driven suites are `legacy`-tagged — that
build is the only one where a gin transport exists, and it is the one that
ships, so nothing stopped running: 215 tests pass in the legacy pass and the
untagged pass is green too, both with -race. Shared JWKS fixtures moved to an
untagged fixtures_test.go so the two halves cannot grow two signing identities.

The routing table is the one gate with no zip twin, and routes.go says why
rather than deferring: its compiled proxies are net/http, so reaching them from
fasthttp would drop WebSocket upgrade and streaming, and the api-host
passthrough rests on cloud being another process — false inside the unified
binary, where the same rule would proxy it to itself.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:35:08 -07:00

217 lines
7.4 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
package gateway
// fixtures_test.go is the signing identity and the claim shapes every auth test
// in this package mints with — and it is UNTAGGED on purpose.
//
// The gin-driven suites are `legacy`-tagged (that build is what the shipping
// image runs and the only one where a gin transport exists), while the zip
// suites run untagged. A fixture that lived in either would be a fixture the
// other build cannot see, and the two halves would grow two signing identities:
// two ways to mint the one token this edge validates. So the fixtures live here,
// in the intersection, and both halves mint the same way.
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/authz"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// testJWKS holds a test RSA key pair and provides helpers for creating
// signed JWTs and serving a JWKS endpoint.
// testJWKS is a throwaway signing identity plus the key set that publishes it.
// It signs with golang-jwt, the SAME library IAM signs with and the one
// hanzoai/authz verifies with — a test that mints with a different library is
// testing a signer the estate does not run.
type testJWKS struct {
key *rsa.PrivateKey
keyID string
}
func newTestJWKS(t *testing.T) *testJWKS {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate RSA key: %v", err)
}
return &testJWKS{key: key, keyID: "test-key-1"}
}
// publicJWKS renders a key set the edge's reader accepts: kty/kid/use plus the
// modulus and exponent as base64url unsigned integers.
func publicJWKS(kid string, pub *rsa.PublicKey) []byte {
data, _ := json.Marshal(map[string]any{"keys": []map[string]any{{
"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}}})
return data
}
// jwksJSON returns the JWKS as JSON bytes (public key only).
func (tj *testJWKS) jwksJSON(t *testing.T) []byte {
t.Helper()
return publicJWKS(tj.keyID, &tj.key.PublicKey)
}
// serveJWKS starts an httptest.Server that serves the JWKS endpoint.
// The caller must defer server.Close().
func (tj *testJWKS) serveJWKS(t *testing.T) *httptest.Server {
t.Helper()
data := tj.jwksJSON(t)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}))
}
// signToken signs claims under this identity's key, naming the key in the header.
// The kid is not optional: authz.Verify refuses a token that names no key rather
// than trying every key in a set, which is how a reader ends up accepting a
// signature from a key the token never claimed.
// signToken signs either a typed claim set or a bare claim MAP. The map form
// exists so a test can mint a shape no struct would produce — a missing issuer,
// an unexpected claim, a wrong type — which is what a hostile token looks like.
func (tj *testJWKS) signToken(t *testing.T, claims any) string {
t.Helper()
return signAs(t, tj.key, tj.keyID, claimsOf(t, claims))
}
func claimsOf(t *testing.T, c any) jwt.Claims {
t.Helper()
switch v := c.(type) {
case jwt.Claims:
return v
case map[string]any:
return jwt.MapClaims(v)
}
t.Fatalf("cannot sign claims of type %T", c)
return nil
}
// signAs signs claims under an arbitrary key and kid — the shape an attacker's
// token takes when it names a real key it does not hold.
func signAs(t *testing.T, key *rsa.PrivateKey, kid string, claims any) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claimsOf(t, claims))
tok.Header["kid"] = kid
raw, err := tok.SignedString(key)
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
return raw
}
// validClaims returns authz.Claims with valid issuer, audience, subject,
// owner, and expiry for use in tests.
func validClaims(issuer, audience string) authz.Claims {
now := time.Now()
return authz.Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: "alice",
Audience: jwt.ClaimStrings{audience},
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
},
// Owner is the APP's org, which is what IAM stamps there. The MEMBERSHIP SET is
// the subject's own org and the only thing that confers authority — every user
// token IAM signs carries it, home first (store.MemberOrgRefs), so a fixture
// without it is a token that cannot exist.
Owner: "hanzo",
Name: "Alice",
Email: "[email protected]",
Orgs: []authz.Membership{{Org: "hanzo", Role: authz.Member}},
}
}
// iamToken is the claim MAP form — a token minted as data rather than through a
// struct, so a test can produce a shape no struct would (a missing issuer, an
// unexpected claim, a wrong type) which is what a hostile token looks like.
func iamToken(owner string, extra map[string]any) map[string]any {
now := time.Now()
c := map[string]any{
"iss": "https://hanzo.id", "sub": "uuid-alice",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-time.Minute).Unix(), "exp": now.Add(10 * time.Minute).Unix(),
"owner": owner, "email": "[email protected]", "preferred_username": "alice",
}
for k, v := range extra {
c[k] = v
}
return c
}
// memberToken is the shape every user token IAM signs has: an owner AND the
// signed membership set, which is the only thing that confers an org. A fixture
// without `orgs` is a token that cannot exist (see validClaims).
func memberToken() map[string]any {
return iamToken("hanzo", map[string]any{
"orgs": []map[string]any{{"org": "hanzo", "role": "member"}},
})
}
// edgeAuthConfig is the auth config the zip and gin edges are BOTH driven with:
// one JWKS, one issuer, one audience allowlist, no billing, auth REQUIRED (so a
// tokenless request is a refusal rather than an anonymous pass-through).
func edgeAuthConfig(jwksURL string) AuthConfig {
return AuthConfig{
Enabled: true,
JWKSURL: jwksURL,
Issuer: "https://hanzo.id",
Audiences: []string{"https://api.hanzo.ai"},
BillingEnabled: false,
PublicPaths: []string{"/healthz"},
PublicHosts: []string{"hanzo.id"},
RequireAuth: true,
}
}
// edgeResult is one edge's answer, in the shape both edges can report.
type edgeResult struct {
name string
status int
acao string
credentials string
vary string
}
// zipEdge runs the SAME request through the zip handler and reports the answer
// in the same shape, so the two are comparable as values.
func zipEdge(t *testing.T, h zip.Handler, method, path, host string, hdr map[string]string) edgeResult {
t.Helper()
z := zip.New(zip.Config{Logger: luxlog.New("test"), AppName: "gateway"})
z.Use(h)
z.All(path, func(c *zip.Ctx) error { return c.String(http.StatusOK, "") })
req := httptest.NewRequest(method, path, nil)
req.Host = host
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := z.Fiber().Test(req)
if err != nil {
t.Fatalf("zip: %v", err)
}
return edgeResult{
name: "zip",
status: resp.StatusCode,
acao: resp.Header.Get("Access-Control-Allow-Origin"),
credentials: resp.Header.Get("Access-Control-Allow-Credentials"),
vary: resp.Header.Get("Vary"),
}
}