Files
gateway/router_engine_kms_test.go
zeekay 1f9c00568c Refuse a first-party /api path literal
Test requests drive /v1 paths, and a Go test fails on any string
literal naming /api on a bare path or one of our hosts.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:44:34 -07:00

280 lines
8.1 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: /v1/app
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 != "/v1/app" {
t.Fatalf("expected /v1/app 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 that speaks the luxfi/kms contract. The stub registers ONLY
// the canonical paths, so a regression back to the Infisical shape
// (/api/v1/auth/universal-auth/login, /api/v3/secrets/raw/...) fails here
// rather than in production — which is where it hid last time, behind an
// embedded SPA that answered every unmatched path with 200 text/html.
func TestHTTPKMSResolver_FetchRoutes_RoundTrip(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/kms/auth/login", func(w http.ResponseWriter, r *http.Request) {
var body struct{ ClientId, ClientSecret string }
_ = json.NewDecoder(r.Body).Decode(&body)
if body.ClientId != "id" || body.ClientSecret != "secret" {
t.Fatalf("login credentials not forwarded: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"accessToken": "test-token"})
})
// {rest...} is split at its LAST slash into (path, name): "gateway/routes"
// must arrive as path "gateway", name "routes". The URL names NO org —
// the server scopes the read to the bearer's org; here the bearer itself
// (minted by the login above) is the whole proof of identity.
mux.HandleFunc("GET /v1/kms/secrets/{rest...}", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Fatalf("missing/wrong bearer: %q", got)
}
if got := r.PathValue("rest"); got != "gateway/routes" {
t.Fatalf("rest = %q, want %q (path separators must survive escaping)", got, "gateway/routes")
}
if got := r.URL.Query().Get("env"); got != "default" {
t.Fatalf("env = %q, want the default", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"secret": map[string]string{"value": 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)
}
}
// A bare name has no path segment, and the server would answer 400. Catch it
// in the client with a message that says what to write instead.
func TestHTTPKMSResolver_RejectsPathWithoutName(t *testing.T) {
withEnv(t, map[string]string{
"GATEWAY_KMS_ENDPOINT": "https://api.hanzo.ai",
"GATEWAY_KMS_CLIENT_ID": "id",
"GATEWAY_KMS_CLIENT_SECRET": "secret",
})
r, ok := newHTTPKMSResolverFromEnv()
if !ok {
t.Fatalf("expected resolver enabled")
}
_, err := r.FetchRoutes("routes")
if err == nil {
t.Fatal("want an error for a path with no name segment")
}
if !strings.Contains(err.Error(), "path and a name") {
t.Fatalf("error should say what is missing, got: %v", err)
}
}
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")
}
}