upstream_test.go
Rama master · réplica de lectura del repositorio original.
package aastro
import (
"context"
"crypto/tls"
"crypto/x509"
"io"
"log"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/voidrunner3074/aastro/internal/circuitbreaker"
)
func TestHTTPUpstream_ResolveHeaders(t *testing.T) {
newTrustedProxyUpstream := func() *httpUpstream {
return newTestUpstream("", withTrustedProxies(mustParseCIDR("10.0.0.0/8")))
}
t.Run("when proxy is untrusted", func(t *testing.T) {
t.Run("sets X-Forwarded headers from RemoteAddr for HTTP", func(t *testing.T) {
up := newTrustedProxyUpstream()
orig, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil)
orig.RemoteAddr = "1.2.3.4:12345"
target, _ := http.NewRequest(orig.Method, orig.URL.String(), nil)
up.resolveHeaders(target, orig, zap.NewNop())
assert.Equal(t, "1.2.3.4", target.Header.Get("X-Forwarded-For"))
assert.Equal(t, "http", target.Header.Get("X-Forwarded-Proto"))
assert.Equal(t, "example.com", target.Header.Get("X-Forwarded-Host"))
assert.Equal(t, "80", target.Header.Get("X-Forwarded-Port"))
assert.Equal(t, "for=1.2.3.4; proto=http; host=example.com", target.Header.Get("Forwarded"))
})
t.Run("sets X-Forwarded headers from RemoteAddr for HTTPS", func(t *testing.T) {
up := newTrustedProxyUpstream()
orig, _ := http.NewRequest(http.MethodGet, "https://example.com:8443/test", nil)
orig.RemoteAddr = "1.2.3.4:12345"
orig.TLS = &tls.ConnectionState{}
target, _ := http.NewRequest(orig.Method, orig.URL.String(), nil)
up.resolveHeaders(target, orig, zap.NewNop())
assert.Equal(t, "1.2.3.4", target.Header.Get("X-Forwarded-For"))
assert.Equal(t, "https", target.Header.Get("X-Forwarded-Proto"))
assert.Equal(t, "example.com:8443", target.Header.Get("X-Forwarded-Host"))
assert.Equal(t, "8443", target.Header.Get("X-Forwarded-Port"))
})
t.Run("ignores incoming X-Forwarded-For from spoofed clients", func(t *testing.T) {
up := newTrustedProxyUpstream()
orig := requestWithClientIP(http.MethodGet, "http://example.com/test", "1.2.3.4:12345", "1.2.3.4")
orig.Header.Set("X-Forwarded-For", "192.168.99.1")
target, _ := http.NewRequest(orig.Method, orig.URL.String(), nil)
up.resolveHeaders(target, orig, zap.NewNop())
assert.Equal(t, "1.2.3.4", target.Header.Get("X-Forwarded-For"))
})
})
t.Run("when proxy is trusted", func(t *testing.T) {
t.Run("appends real client IP to existing X-Forwarded-For", func(t *testing.T) {
up := newTrustedProxyUpstream()
orig := requestWithClientIP(http.MethodGet, "http://example.com/test", "10.0.1.5:12345", "10.0.1.5")
orig.Header.Set("X-Forwarded-For", "5.6.7.8")
orig.Header.Set("X-Forwarded-Proto", "http")
orig.Header.Set("X-Forwarded-Host", "example.com")
orig.Header.Set("X-Forwarded-Port", "80")
target, _ := http.NewRequest(orig.Method, orig.URL.String(), nil)
up.resolveHeaders(target, orig, zap.NewNop())
assert.Equal(t, "5.6.7.8, 10.0.1.5", target.Header.Get("X-Forwarded-For"))
assert.Equal(t, "http", target.Header.Get("X-Forwarded-Proto"))
assert.Equal(t, "example.com", target.Header.Get("X-Forwarded-Host"))
assert.Equal(t, "80", target.Header.Get("X-Forwarded-Port"))
assert.Equal(t, "for=10.0.1.5; proto=http; host=example.com", target.Header.Get("Forwarded"))
})
t.Run("falls back to derived proto when incoming proto is invalid", func(t *testing.T) {
up := newTrustedProxyUpstream()
orig, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil)
orig.RemoteAddr = "10.0.1.6:12345"
orig.Header.Set("X-Forwarded-Proto", "ftp")
target, _ := http.NewRequest(orig.Method, orig.URL.String(), nil)
up.resolveHeaders(target, orig, zap.NewNop())
assert.Equal(t, "http", target.Header.Get("X-Forwarded-Proto"))
})
t.Run("falls back to default port when incoming port is invalid", func(t *testing.T) {
up := newTrustedProxyUpstream()
orig, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil)
orig.RemoteAddr = "10.0.1.7:12345"
orig.Header.Set("X-Forwarded-Port", "99999")
target, _ := http.NewRequest(orig.Method, orig.URL.String(), nil)
up.resolveHeaders(target, orig, zap.NewNop())
assert.Equal(t, "80", target.Header.Get("X-Forwarded-Port"))
})
})
}
func TestHTTPUpstream_ResolveQueries(t *testing.T) {
t.Run("forwards only configured query keys", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{forwardQueries: []string{"foo", "bar"}},
}
orig, _ := http.NewRequest(http.MethodGet, "http://example.com?foo=1&bar=2&baz=3", nil)
target, _ := http.NewRequest(http.MethodGet, "http://upstream.com", nil)
up.resolveQueries(target, orig)
q := target.URL.Query()
assert.Equal(t, "1", q.Get("foo"))
assert.Equal(t, "2", q.Get("bar"))
assert.False(t, q.Has("baz"))
})
t.Run("forwards all query keys when wildcard is configured", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{forwardQueries: []string{"*"}},
}
orig, _ := http.NewRequest(http.MethodGet, "http://example.com?a=1&b=2", nil)
target, _ := http.NewRequest(http.MethodGet, "http://upstream.com", nil)
up.resolveQueries(target, orig)
q := target.URL.Query()
assert.Equal(t, "1", q.Get("a"))
assert.Equal(t, "2", q.Get("b"))
})
t.Run("forwards only configured path params", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{forwardParams: []string{"user_id"}},
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("user_id", "42")
rctx.URLParams.Add("order_id", "99")
orig, _ := http.NewRequest(http.MethodGet, "http://example.com/users/42/orders/99", nil)
orig = orig.WithContext(context.WithValue(orig.Context(), chi.RouteCtxKey, rctx))
target, _ := http.NewRequest(http.MethodGet, "http://upstream.com", nil)
up.resolveQueries(target, orig)
q := target.URL.Query()
assert.Equal(t, "42", q.Get("user_id"))
assert.False(t, q.Has("order_id"))
})
t.Run("forwards all path params when wildcard is configured", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{forwardParams: []string{"*"}},
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("user_id", "42")
rctx.URLParams.Add("order_id", "99")
orig, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil)
orig = orig.WithContext(context.WithValue(orig.Context(), chi.RouteCtxKey, rctx))
target, _ := http.NewRequest(http.MethodGet, "http://upstream.com", nil)
up.resolveQueries(target, orig)
q := target.URL.Query()
assert.Equal(t, "42", q.Get("user_id"))
assert.Equal(t, "99", q.Get("order_id"))
})
}
func TestHTTPUpstream_FilterHeaders(t *testing.T) {
t.Run("removes blacklisted headers and preserves the rest", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{
policy: upstreamPolicy{
headerBlacklist: map[string]struct{}{"X-Secret": {}},
},
},
}
headers := http.Header{
"X-Secret": []string{"sensitive"},
"X-Forward": []string{"ok"},
}
result := up.filterHeaders(headers)
assert.Empty(t, result.Get("X-Secret"))
assert.Equal(t, "ok", result.Get("X-Forward"))
})
t.Run("clones all headers when blacklist is empty", func(t *testing.T) {
up := &httpUpstream{cfg: upstreamConfig{policy: upstreamPolicy{}}}
headers := http.Header{
"X-One": []string{"1"},
"X-Two": []string{"2"},
}
result := up.filterHeaders(headers)
assert.Equal(t, "1", result.Get("X-One"))
assert.Equal(t, "2", result.Get("X-Two"))
})
}
func TestHTTPUpstream_ExpandPathParams(t *testing.T) {
t.Run("replaces known params with their values", func(t *testing.T) {
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "123")
req, _ := http.NewRequest(http.MethodGet, "/items/123", nil)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
assert.Equal(t, "/items/123", expandPathParams("/items/{id}", req))
})
t.Run("preserves unknown params verbatim", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/items/123", nil)
assert.Equal(t, "/items/{unknown}", expandPathParams("/items/{unknown}", req))
})
}
func TestHTTPUpstream_ClassifyDoError(t *testing.T) {
tests := []struct {
name string
err error
want upstreamErrorKind
}{
{"deadline exceeded → timeout", context.DeadlineExceeded, upstreamTimeout},
{"canceled → canceled", context.Canceled, upstreamCanceled},
{"EOF → connection error", io.EOF, upstreamConnection},
}
up := &httpUpstream{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, up.classifyDoError(tt.err))
})
}
}
func TestHTTPUpstream_SelectHost(t *testing.T) {
t.Run("always returns 0 for a single host", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{hosts: []string{"http://only"}},
metrics: testMetrics,
log: zap.NewNop(),
}
for range 5 {
assert.Equal(t, int64(0), up.selectHost(zap.NewNop()))
}
})
t.Run("distributes evenly across hosts in round-robin mode", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{
hosts: []string{"a", "b", "c"},
lbMode: lbModeRoundRobin,
},
metrics: testMetrics,
log: zap.NewNop(),
}
seen := make(map[int64]int)
for range 6 {
seen[up.selectHost(zap.NewNop())]++
}
for _, count := range seen {
assert.Equal(t, 2, count)
}
})
t.Run("prefers idle host in least-connections mode", func(t *testing.T) {
up := &httpUpstream{
cfg: upstreamConfig{
hosts: []string{"busy", "idle"},
lbMode: lbModeLeastConns,
},
state: upstreamState{
activeConnections: []int64{5, 0},
},
metrics: testMetrics,
log: zap.NewNop(),
}
assert.Equal(t, int64(1), up.selectHost(zap.NewNop()))
})
}
func TestHTTPUpstream_Call(t *testing.T) {
t.Run("returns successful response body", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
up := newTestUpstream(server.URL)
resp := up.call(context.Background(), httptest.NewRequest(http.MethodGet, "/", nil), nil)
assert.Nil(t, resp.err)
assert.Equal(t, `{"ok":true}`, string(resp.body))
})
t.Run("returns canceled error if context is already canceled", func(t *testing.T) {
up := newTestUpstream("http://localhost:1")
ctx, cancel := context.WithCancel(context.Background())
cancel()
resp := up.call(ctx, httptest.NewRequest(http.MethodGet, "/", nil), nil)
require.NotNil(t, resp.err)
assert.Equal(t, upstreamCanceled, resp.err.kind)
})
t.Run("aborts retry backoff when context is canceled", func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
up := newTestUpstream(server.URL, withPolicy(upstreamPolicy{
retry: retryPolicy{
maxRetries: 5,
retryOnStatuses: []int{http.StatusInternalServerError},
backoffDelay: 500 * time.Millisecond,
},
}))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
resp := up.call(ctx, httptest.NewRequest(http.MethodGet, "/", nil), nil)
require.NotNil(t, resp.err)
assert.Equal(t, upstreamCanceled, resp.err.kind)
assert.LessOrEqual(t, calls.Load(), int32(2))
})
t.Run("respects circuit breaker after consecutive failures", func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
cb := circuitbreaker.New(2, 100*time.Millisecond)
up := newTestUpstream(server.URL, withCircuitBreaker(cb))
for range 4 {
up.call(context.Background(), httptest.NewRequest(http.MethodGet, "/", nil), nil)
}
assert.Equal(t, int32(2), calls.Load())
})
t.Run("recovers after circuit breaker reset timeout", func(t *testing.T) {
resetTimeout := 50 * time.Millisecond
cb := circuitbreaker.New(1, resetTimeout)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
up := newTestUpstream(server.URL, withCircuitBreaker(cb))
req := func() *http.Request { return httptest.NewRequest(http.MethodGet, "/", nil) }
r1 := up.call(context.Background(), req(), nil)
require.NotNil(t, r1.err)
assert.Equal(t, upstreamBadStatus, r1.err.kind)
r2 := up.call(context.Background(), req(), nil)
require.NotNil(t, r2.err)
assert.Equal(t, upstreamCircuitOpen, r2.err.kind)
time.Sleep(resetTimeout + 20*time.Millisecond)
r3 := up.call(context.Background(), req(), nil)
assert.Nil(t, r3.err)
})
}
func TestHTTPUpstream_TLS(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
startServer := func(t *testing.T, tlsConf *tls.Config) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(handler)
server.TLS = tlsConf
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.StartTLS()
t.Cleanup(server.Close)
return server
}
doRequest := func(up *httpUpstream) *upstreamResponse {
req := httptest.NewRequest(http.MethodGet, "/", nil)
return up.call(context.Background(), req, nil)
}
t.Run("when server presents a cert signed by a trusted CA completes the request successfully", func(t *testing.T) {
fx := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
RootCAs: fx.certPool(),
}))
resp := doRequest(up)
assert.Nil(t, resp.err)
assert.Equal(t, http.StatusOK, resp.status)
assert.Equal(t, `{"ok":true}`, string(resp.body))
})
t.Run("when server presents a cert signed by an untrusted CA fails the handshake with a connection error", func(t *testing.T) {
fx := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
RootCAs: x509.NewCertPool(),
}))
resp := doRequest(up)
require.NotNil(t, resp.err)
assert.Equal(t, upstreamConnection, resp.err.kind)
})
t.Run("when the client opts into InsecureSkipVerify succeeds even without a trusted CA in the pool", func(t *testing.T) {
fx := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
InsecureSkipVerify: true,
}))
resp := doRequest(up)
assert.Nil(t, resp.err)
assert.Equal(t, http.StatusOK, resp.status)
})
t.Run("when server requires mTLS and client provides a valid cert completes the handshake and returns 200", func(t *testing.T) {
fx := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: fx.certPool(),
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
RootCAs: fx.certPool(),
Certificates: []tls.Certificate{fx.issueClientCert(t, "aastro")},
}))
resp := doRequest(up)
assert.Nil(t, resp.err)
assert.Equal(t, http.StatusOK, resp.status)
})
t.Run("when server requires mTLS but client omits its cert is rejected on handshake", func(t *testing.T) {
fx := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: fx.certPool(),
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
RootCAs: fx.certPool(),
}))
resp := doRequest(up)
require.NotNil(t, resp.err)
assert.Equal(t, upstreamConnection, resp.err.kind)
})
t.Run("when server requires mTLS but client presents a cert from a foreign CA is rejected on handshake", func(t *testing.T) {
fx := newTLSFixture(t)
rogue := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: fx.certPool(),
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
RootCAs: fx.certPool(),
Certificates: []tls.Certificate{rogue.issueClientCert(t, "rogue")},
}))
resp := doRequest(up)
require.NotNil(t, resp.err)
assert.Equal(t, upstreamConnection, resp.err.kind)
})
t.Run("when client requires TLS 1.3 but server caps at 1.2 fails with a version mismatch", func(t *testing.T) {
fx := newTLSFixture(t)
server := startServer(t, &tls.Config{
Certificates: []tls.Certificate{fx.issueServerCert(t)},
MaxVersion: tls.VersionTLS12,
})
up := newTestUpstream(server.URL, withTLS(&tls.Config{
RootCAs: fx.certPool(),
MinVersion: tls.VersionTLS13,
}))
resp := doRequest(up)
require.NotNil(t, resp.err)
assert.Equal(t, upstreamConnection, resp.err.kind)
})
}