Files
gateway/router_engine_kms_test.go
Abhishek Krishna fbbeb606bf feat(gateway): KMS routes loader + configurable graceful-shutdown timeout
Closes the two PARTIAL/TODO lines that docs/SCALE_AUDIT.md flagged
after HIP-0110 landed:

1. router_engine.go — replace the TODO KMS stub in loadRoutesFromEnv
   with a pluggable KMSResolver interface (router_kms.go). The default
   resolver is a noop; an HTTP resolver auto-wires when
   GATEWAY_KMS_ENDPOINT + IAM_CLIENT_ID/SECRET (or the GATEWAY_KMS_*
   overrides) are present. The HTTP contract matches the one in
   hanzoai/base/plugins/platform/kms.go: Universal-Auth login, then
   GET /api/v3/secrets/raw/<path>, read .secret.secretValue. File
   loader path (GATEWAY_ROUTES_FILE → ./routes.yaml) is unchanged and
   remains the fallback when GATEWAY_ROUTES_KMS_PATH is unset.

2. cmd/gateway/main.go — make the existing 30s shutdown deadline
   operator-configurable via GATEWAY_SHUTDOWN_TIMEOUT (any
   time.ParseDuration string; falls back to default on empty /
   malformed / non-positive values). Surgical change: signal handling
   and the ShutdownWithContext drain that HIP-0110 introduced are
   untouched.

Tests:
- router_engine_kms_test.go — resolver injection, error surfacing,
  malformed-YAML guard, file fallback, HTTP round-trip stub
  (mimics the Universal-Auth + secrets/raw contract).
- cmd/gateway/main_test.go — shutdownGraceFromEnv table test +
  envOr smoke test. SIGTERM-triggered drain itself is exercised by
  deployment smoke tests (not safe to interrupt the in-process Go
  test runner with SIGTERM).

SCALE_AUDIT.md: graceful-shutdown row PARTIAL → PASS, new KMS routes
row added (PASS), with a post-HIP-0110 update section.

Verification:
- go vet ./...   clean
- go build ./... clean
- go test . -count=1 -short: 96/98 PASS (two pre-existing failures
  in config_loader_test.go::TestPortFromEnv* are unrelated to this
  change, repro on master)
- go test ./cmd/gateway/ -count=1 -short: 2/2 PASS
2026-06-01 11:17:42 +00:00

244 lines
6.5 KiB
Go

package gateway
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// stubResolver lets tests script KMS responses without hitting the network.
type stubResolver struct {
out []byte
err error
}
func (s stubResolver) FetchRoutes(path string) ([]byte, error) {
if s.err != nil {
return nil, s.err
}
return s.out, nil
}
func resetResolver(t *testing.T, r KMSResolver) {
t.Helper()
prev := SetKMSResolver(r)
t.Cleanup(func() { SetKMSResolver(prev) })
}
// withEnv sets env vars for the duration of the subtest and restores them.
func withEnv(t *testing.T, kv map[string]string) {
t.Helper()
saved := map[string]*string{}
for k := range kv {
if v, ok := os.LookupEnv(k); ok {
saved[k] = &v
} else {
saved[k] = nil
}
}
for k, v := range kv {
if v == "" {
os.Unsetenv(k)
} else {
os.Setenv(k, v)
}
}
t.Cleanup(func() {
for k, v := range saved {
if v == nil {
os.Unsetenv(k)
} else {
os.Setenv(k, *v)
}
}
})
}
const sampleRoutesYAML = `
routes:
example.com:
- prefix: /api
backend: http://api.example.internal:8080
redirects:
www.example.com: https://example.com
subdomains:
".tenant.example.com": http://tenants.internal:9000
`
func TestLoadRoutesFromEnv_KMSPath_UsesResolver(t *testing.T) {
resetResolver(t, stubResolver{out: []byte(sampleRoutesYAML)})
withEnv(t, map[string]string{
"GATEWAY_ROUTES_KMS_PATH": "gateway/routes",
"GATEWAY_ROUTES_FILE": "",
// Disable HTTP resolver auto-wire so stub is honored.
"GATEWAY_KMS_ENDPOINT": "",
})
if err := loadRoutesFromEnv(); err != nil {
t.Fatalf("loadRoutesFromEnv: %v", err)
}
routes.mu.RLock()
got, ok := routes.exactRoutes["example.com"]
redirects := routes.redirects
subs := routes.subProxies
routes.mu.RUnlock()
if !ok {
t.Fatalf("expected example.com to be loaded from KMS payload")
}
if len(got) != 1 || got[0].prefix != "/api" {
t.Fatalf("expected /api prefix, got %+v", got)
}
if redirects["www.example.com"] != "https://example.com" {
t.Fatalf("expected www.example.com redirect, got %+v", redirects)
}
if len(subs) != 1 {
t.Fatalf("expected 1 subdomain proxy, got %d", len(subs))
}
}
func TestLoadRoutesFromEnv_KMSError_Surfaces(t *testing.T) {
wantErr := errors.New("kms: 500 backend down")
resetResolver(t, stubResolver{err: wantErr})
withEnv(t, map[string]string{
"GATEWAY_ROUTES_KMS_PATH": "gateway/routes",
"GATEWAY_KMS_ENDPOINT": "",
})
err := loadRoutesFromEnv()
if err == nil || !strings.Contains(err.Error(), "kms: 500") {
t.Fatalf("expected KMS error to surface, got %v", err)
}
}
func TestLoadRoutesFromEnv_KMSMalformedYAML_Surfaces(t *testing.T) {
resetResolver(t, stubResolver{out: []byte("this is: not: valid: yaml: ::\n -")})
withEnv(t, map[string]string{
"GATEWAY_ROUTES_KMS_PATH": "gateway/routes",
"GATEWAY_KMS_ENDPOINT": "",
})
err := loadRoutesFromEnv()
if err == nil {
t.Fatalf("expected malformed YAML error, got nil")
}
if !strings.Contains(err.Error(), "kms routes payload invalid") {
t.Fatalf("expected wrapped invalid-payload error, got %v", err)
}
}
func TestLoadRoutesFromEnv_NoKMSPath_FallsBackToFile(t *testing.T) {
// Write a temp routes file.
dir := t.TempDir()
path := dir + "/routes.yaml"
if err := os.WriteFile(path, []byte(sampleRoutesYAML), 0o600); err != nil {
t.Fatalf("write tempfile: %v", err)
}
// Resolver should NOT be called when GATEWAY_ROUTES_KMS_PATH is empty.
resetResolver(t, stubResolver{err: errors.New("resolver must not be invoked")})
withEnv(t, map[string]string{
"GATEWAY_ROUTES_KMS_PATH": "",
"GATEWAY_ROUTES_FILE": path,
"GATEWAY_KMS_ENDPOINT": "",
})
if err := loadRoutesFromEnv(); err != nil {
t.Fatalf("loadRoutesFromEnv (file fallback): %v", err)
}
routes.mu.RLock()
_, ok := routes.exactRoutes["example.com"]
routes.mu.RUnlock()
if !ok {
t.Fatalf("expected file fallback to populate routes")
}
}
func TestNoopKMSResolver_AlwaysErrs(t *testing.T) {
if _, err := (noopKMSResolver{}).FetchRoutes("anything"); err == nil {
t.Fatalf("expected noop resolver to err")
}
}
func TestNewHTTPKMSResolverFromEnv_MissingCreds_Disabled(t *testing.T) {
withEnv(t, map[string]string{
"GATEWAY_KMS_ENDPOINT": "https://kms.example",
"GATEWAY_KMS_CLIENT_ID": "",
"GATEWAY_KMS_CLIENT_SECRET": "",
"IAM_CLIENT_ID": "",
"IAM_CLIENT_SECRET": "",
})
if _, ok := newHTTPKMSResolverFromEnv(); ok {
t.Fatalf("expected resolver disabled when credentials missing")
}
}
func TestNewHTTPKMSResolverFromEnv_NoEndpoint_Disabled(t *testing.T) {
withEnv(t, map[string]string{
"GATEWAY_KMS_ENDPOINT": "",
"GATEWAY_KMS_CLIENT_ID": "id",
"GATEWAY_KMS_CLIENT_SECRET": "secret",
})
if _, ok := newHTTPKMSResolverFromEnv(); ok {
t.Fatalf("expected resolver disabled when endpoint missing")
}
}
// TestHTTPKMSResolver_FetchRoutes_RoundTrip exercises the auth + fetch flow
// against a stub HTTP server that mimics the contract documented in
// hanzoai/base/plugins/platform/kms.go.
func TestHTTPKMSResolver_FetchRoutes_RoundTrip(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/auth/universal-auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("login: want POST, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"accessToken": "test-token"})
})
mux.HandleFunc("/api/v3/secrets/raw/", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Fatalf("missing/wrong bearer: %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"secret": map[string]string{"secretValue": sampleRoutesYAML},
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
withEnv(t, map[string]string{
"GATEWAY_KMS_ENDPOINT": srv.URL,
"GATEWAY_KMS_CLIENT_ID": "id",
"GATEWAY_KMS_CLIENT_SECRET": "secret",
})
r, ok := newHTTPKMSResolverFromEnv()
if !ok {
t.Fatalf("expected resolver enabled")
}
data, err := r.FetchRoutes("gateway/routes")
if err != nil {
t.Fatalf("FetchRoutes: %v", err)
}
if !strings.Contains(string(data), "example.com") {
t.Fatalf("unexpected payload: %s", data)
}
}
func TestSetKMSResolver_NilRestoresNoop(t *testing.T) {
prev := SetKMSResolver(stubResolver{out: []byte("ok")})
t.Cleanup(func() { SetKMSResolver(prev) })
SetKMSResolver(nil)
if _, err := kmsResolver.FetchRoutes("x"); err == nil {
t.Fatalf("expected noop resolver to err after nil reset")
}
}