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

129 lines
4.7 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
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// TestCORSPreflight_BrandAllowlist verifies that corsPreflightMiddleware
// answers OPTIONS preflight for any Hanzo/Lux/Zoo/Pars property (and its
// subdomains) using the SAME baked-in allowlist as the widget security
// middleware (defaultAllowedOrigins), without any per-origin env config.
//
// This is the fix for browser SPAs (e.g. https://cowork.hanzo.ai) whose
// AI calls to /v1/chat/completions previously failed "Failed to fetch"
// because the preflight returned no Access-Control-Allow-Origin.
func TestCORSPreflight_BrandAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
origin string
allowed bool
}{
// Subdomain of a baked-in brand domain — the cowork case.
{"cowork.hanzo.ai subdomain", "https://cowork.hanzo.ai", true},
// Apex brand domain.
{"hanzo.app apex", "https://hanzo.app", true},
// Other brand properties via suffix match.
{"hanzo.chat", "https://hanzo.chat", true},
{"lux.network subdomain", "https://app.lux.network", true},
{"zoo.ngo apex", "https://zoo.ngo", true},
// Karma (external customer) — apex + any subdomain both resolve from the
// single "karma.style" allowlist entry (suffix match, like the cowork case).
{"karma.style apex", "https://karma.style", true},
{"tryon.karma.style subdomain", "https://tryon.karma.style", true},
// Localhost default (dev).
{"localhost:3000 default", "http://localhost:3000", true},
// Not on any allowlist.
{"evil.com denied", "https://evil.com", false},
{"look-alike suffix denied", "https://nothanzo.ai", false},
{"empty origin ignored", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.Use(corsPreflightMiddleware())
// Downstream handler returns 200 if the preflight was NOT
// short-circuited by the middleware (i.e. origin not allowed).
r.OPTIONS("/v1/chat/completions", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
req.Host = "api.hanzo.ai"
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
req.Header.Set("Access-Control-Request-Method", "POST")
r.ServeHTTP(w, req)
acao := w.Header().Get("Access-Control-Allow-Origin")
if tc.allowed {
if w.Code != http.StatusNoContent {
t.Fatalf("allowed origin %q: expected 204, got %d", tc.origin, w.Code)
}
if acao != tc.origin {
t.Fatalf("allowed origin %q: expected ACAO=%q, got %q", tc.origin, tc.origin, acao)
}
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("allowed origin %q: expected ACA-Credentials=true, got %q", tc.origin, got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatalf("allowed origin %q: expected ACA-Methods set", tc.origin)
}
} else {
// Denied: preflight falls through to the handler (200) and
// MUST NOT carry an Access-Control-Allow-Origin header.
if acao != "" {
t.Fatalf("denied origin %q: expected no ACAO, got %q", tc.origin, acao)
}
if w.Code != http.StatusOK {
t.Fatalf("denied origin %q: expected fall-through 200, got %d", tc.origin, w.Code)
}
}
})
}
}
// TestCORSPreflight_EnvOriginStillHonored verifies that an explicit
// fully-qualified origin supplied via GATEWAY_CORS_ORIGINS is still allowed
// alongside the brand allowlist (additive, no regression).
func TestCORSPreflight_EnvOriginStillHonored(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("GATEWAY_CORS_ORIGINS", "https://partner.example.com")
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.Use(corsPreflightMiddleware())
r.OPTIONS("/v1/chat/completions", func(c *gin.Context) { c.Status(http.StatusOK) })
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
req.Header.Set("Origin", "https://partner.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("env origin: expected 204, got %d", w.Code)
}
if acao := w.Header().Get("Access-Control-Allow-Origin"); acao != "https://partner.example.com" {
t.Fatalf("env origin: expected ACAO echo, got %q", acao)
}
}