Files
gateway/router_engine_routing_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

194 lines
6.4 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
//go:build legacy
// +build legacy
// gin-driven: this suite drives the LEGACY Lura edge's transports
// (legacy_transports.go), which exist only under this tag. The policies it
// exercises are framework-free values shared with the zip edge, and
// transport_parity_test.go asserts the two edges answer alike.
// Package gateway — AI-endpoint routing regression tests.
//
// These pin the contract that broke prod in the v2.14.x ZAP-relay
// rearchitecture: when the gateway runs as the HTTP edge (the legacy
// legacy `run` path the prod container's CMD drives), api.hanzo.ai's
// /v1/chat/completions and the rest of apiHanzoAIEndpoints MUST be
// proxied straight to cloud over HTTP — NOT 404'd because the only
// live surface was a ZAP relay to backends that don't exist
// (cloud:9090 is cloud's health listener; the base service is absent).
//
// The relay (build_app.go RegisterRelay) is optional: HTTP serving here
// needs no ZAP peer at all.
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// newAPIEdge builds a minimal gin engine wired exactly like the
// production HTTP edge for the cloud passthrough concern: the CORS
// preflight middleware, then hostProxyMiddleware. Auth is intentionally
// left out so the test isolates the ROUTING decision (the auth gate is
// covered by auth_middleware_test.go). The cloud target is pointed
// at a local httptest backend via GATEWAY_CLOUD_API_URL. The engine is
// served from a real httptest.Server (not httptest.NewRecorder) because
// httputil.ReverseProxy requires a CloseNotifier-capable ResponseWriter.
func newAPIEdge(t *testing.T, backend string) *httptest.Server {
t.Helper()
gin.SetMode(gin.TestMode)
t.Setenv("GATEWAY_CLOUD_API_URL", backend)
e := gin.New()
e.Use(corsPreflightMiddleware())
e.Use(hostProxyMiddleware())
// Sentinel: if hostProxyMiddleware does NOT abort (i.e. the request
// fell through unrouted), gin lands here. In prod this is the
// legacy-engine NoRoute → 404 the v2.14.x relay-only binary returned for
// every model call.
e.NoRoute(func(c *gin.Context) {
c.String(http.StatusNotFound, "unrouted")
})
srv := httptest.NewServer(e)
t.Cleanup(srv.Close)
return srv
}
// TestHostProxy_APIEndpoints_RouteToCloudAPI is the core regression guard.
// Every apiHanzoAIEndpoints prefix on api.hanzo.ai must reach cloud
// (HTTP 200 from the stub), never the NoRoute 404 sentinel.
func TestHostProxy_APIEndpoints_RouteToCloudAPI(t *testing.T) {
var gotPaths []string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer backend.Close()
edge := newAPIEdge(t, backend.URL)
// One representative path per declared prefix, plus the canonical
// completions route that cowork.hanzo.ai actually calls.
paths := []string{
"/v1/chat/completions",
"/v1/completions",
"/v1/messages",
"/v1/models",
"/v1/embeddings",
"/v1/images/generations",
"/v1/audio/speech",
"/v1/zap",
"/zap",
}
for _, p := range paths {
t.Run(p, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, edge.URL+p, strings.NewReader(`{}`))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Host = "api.hanzo.ai"
resp, err := edge.Client().Do(req)
if err != nil {
t.Fatalf("%s: request failed: %v", p, err)
}
body := readAll(t, resp)
if resp.StatusCode == http.StatusNotFound {
t.Fatalf("%s returned 404 (unrouted) — must proxy to cloud; body=%q", p, body)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: got status %d, want 200 from cloud stub; body=%q", p, resp.StatusCode, body)
}
if !strings.Contains(body, `"ok":true`) {
t.Fatalf("%s: response %q did not come from cloud stub", p, body)
}
})
}
// The passthrough proxy must forward the path unchanged (cloud
// speaks /v1/* natively — no /api/ prefix, no rewrite).
for i, want := range paths {
if i >= len(gotPaths) {
t.Fatalf("backend received only %d requests, want %d", len(gotPaths), len(paths))
}
if gotPaths[i] != want {
t.Fatalf("backend saw path %q, want %q (passthrough must not rewrite)", gotPaths[i], want)
}
}
}
// TestHostProxy_NonAPIHost_NotProxiedToCloudAPI ensures the cloud
// passthrough is scoped to api.hanzo.ai only — a /v1/* path on some
// other host must not be hijacked to cloud.
func TestHostProxy_NonAPIHost_NotProxiedToCloudAPI(t *testing.T) {
hit := false
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
edge := newAPIEdge(t, backend.URL)
req, err := http.NewRequest(http.MethodPost, edge.URL+"/v1/chat/completions", strings.NewReader(`{}`))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Host = "example.com"
resp, err := edge.Client().Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
_ = readAll(t, resp)
if hit {
t.Fatal("cloud backend was hit for a non-api.hanzo.ai host")
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("non-api host /v1/chat/completions: got %d, want 404 (unrouted sentinel)", resp.StatusCode)
}
}
// readAll drains and closes a response body, returning it as a string.
func readAll(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
// TestCloudAPIURL_Override pins the env-override + safe-fallback contract
// for the cloud target resolver.
func TestCloudAPIURL_Override(t *testing.T) {
t.Run("default when unset", func(t *testing.T) {
t.Setenv("GATEWAY_CLOUD_API_URL", "")
if got := cloudAPIURL().String(); got != defaultCloudAPIURL {
t.Fatalf("got %q, want default %q", got, defaultCloudAPIURL)
}
})
t.Run("honors valid override", func(t *testing.T) {
t.Setenv("GATEWAY_CLOUD_API_URL", "http://cloud-api.example.svc:9000")
if got := cloudAPIURL().String(); got != "http://cloud-api.example.svc:9000" {
t.Fatalf("got %q, want override", got)
}
})
t.Run("falls back on malformed override", func(t *testing.T) {
t.Setenv("GATEWAY_CLOUD_API_URL", "::not a url::")
if got := cloudAPIURL().String(); got != defaultCloudAPIURL {
t.Fatalf("got %q, want safe fallback %q", got, defaultCloudAPIURL)
}
})
}