Files
gateway/router_engine_routing_test.go
zooqueen 15bd5d1d0d fix(edge): build the KrakenD HTTP edge (tags=legacy) so /v1/* serves, not 404
The v2.14.x HIP-0110 rearchitecture (#17, ZAP relay #649ff8b) flipped the
default `make build` output to cmd/gateway/main.go — the pure ZAP->ZAP
relay. That binary's BuildApp serves ONLY /healthz+/readyz on :8080 and
expects all client traffic over ZAP to cloud:9090 + base:9091. But those
ZAP peers don't exist (cloud:9090 is cloud's health/metrics listener, the
base service is absent), and the Dockerfile CMD still drives the KrakenD
`run -c gateway.json` path — which the relay-only binary ignores. Net:
api.hanzo.ai 404s every model call (/v1/chat/completions, /v1/models, ...).

2.14.1 worked because it predates the build-tag split: `make build` then
produced the single KrakenD main that `run -c` drives -> NewEngine ->
hostProxyMiddleware -> HTTP passthrough of api.hanzo.ai /v1/* straight to
cloud-api.hanzo.svc.cluster.local:8000 (the HIP-0106 cloud binary). That
HTTP relay target already lives in router_engine.go; it just wasn't being
built/run.

Forward fix (no revert to a stale lineage):
- Makefile: BUILD_TAGS ?= legacy so `make build` (and thus the Dockerfile)
  produces the KrakenD edge binary that the `run -c` CMD drives. This
  mounts the proven HTTP completions path AND corsPreflightMiddleware
  (the *.hanzo.ai CORS allowlist from #29). The ZAP relay (build_app.go)
  stays available behind the default build for HIP-0110 Phase C, but is
  not required for HTTP serving today.
- router_engine.go: make the cloud-api target overridable via
  GATEWAY_CLOUD_API_URL (operator + test), with a safe fallback so a
  malformed override can never silently break routing.
- router_engine_routing_test.go: regression guard — every
  apiHanzoAIEndpoints prefix on api.hanzo.ai proxies to cloud-api (200,
  path unchanged), never 404; non-api hosts are not hijacked; the env
  override resolves/falls back correctly.

Verified locally: `gateway run -c gateway.json` with GATEWAY_CLOUD_API_URL
-> stub returns /healthz 200, POST /v1/chat/completions 200 (proxied to
stub, not 404), OPTIONS preflight from https://cowork.hanzo.ai -> 204 with
Access-Control-Allow-Origin.
2026-06-19 23:39:13 -07:00

184 lines
6.1 KiB
Go

// 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
// KrakenD `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-api 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-api 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-api 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
// KrakenD 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-api
// (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-api; body=%q", p, body)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: got status %d, want 200 from cloud-api stub; body=%q", p, resp.StatusCode, body)
}
if !strings.Contains(body, `"ok":true`) {
t.Fatalf("%s: response %q did not come from cloud-api stub", p, body)
}
})
}
// The passthrough proxy must forward the path unchanged (cloud-api
// 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-api
// passthrough is scoped to api.hanzo.ai only — a /v1/* path on some
// other host must not be hijacked to cloud-api.
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-api 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-api 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)
}
})
}