package backend
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestTransparentBackend(t *testing.T) {
r := gin.New()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, c.Request.Header)
})
ts := httptest.NewServer(r)
u, _ := url.Parse(ts.URL)
be, err := NewTransparentBackend(zap.NewNop(), u, []string{})
require.NoError(t, err)
handler, err := be.Run(context.Background())
require.NoError(t, err)
require.NotNil(t, handler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
var header http.Header
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &header))
require.Equal(t, "192.0.2.1", header.Get(("X-Forwarded-For")))
require.Equal(t, "example.com", header.Get(("X-Forwarded-Host")))
require.Equal(t, "http", header.Get(("X-Forwarded-Proto")))
}
func TestTransparentBackendWithProxy(t *testing.T) {
r := gin.New()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, c.Request.Header)
})
ts := httptest.NewServer(r)
u, _ := url.Parse(ts.URL)
be, err := NewTransparentBackend(zap.NewNop(), u, []string{"0.0.0.0/0"})
require.NoError(t, err)
handler, err := be.Run(context.Background())
require.NoError(t, err)
require.NotNil(t, handler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "192.0.3.1")
req.Header.Set("X-Forwarded-Host", "example.org")
req.Header.Set("X-Forwarded-Proto", "https")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
var header http.Header
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &header))
require.Equal(t, "192.0.3.1, 192.0.2.1", header.Get(("X-Forwarded-For")))
require.Equal(t, "example.org", header.Get(("X-Forwarded-Host")))
require.Equal(t, "https", header.Get(("X-Forwarded-Proto")))
}
func TestTransparentBackendWithInvalidProxy(t *testing.T) {
r := gin.New()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, c.Request.Header)
})
ts := httptest.NewServer(r)
u, _ := url.Parse(ts.URL)
be, err := NewTransparentBackend(zap.NewNop(), u, []string{"1.1.1.1/32"})
require.NoError(t, err)
handler, err := be.Run(context.Background())
require.NoError(t, err)
require.NotNil(t, handler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "192.0.3.1")
req.Header.Set("X-Forwarded-Host", "example.org")
req.Header.Set("X-Forwarded-Proto", "https")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
var header http.Header
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &header))
require.Equal(t, "192.0.2.1", header.Get(("X-Forwarded-For")))
require.Equal(t, "example.com", header.Get(("X-Forwarded-Host")))
require.Equal(t, "http", header.Get(("X-Forwarded-Proto")))
}
func TestTransparentBackendFollows307Redirect(t *testing.T) {
r := gin.New()
// Simulate Starlette's redirect_slashes: /mcp → 307 → /mcp/
r.POST("/mcp", func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, "/mcp/")
})
r.POST("/mcp/", func(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
c.JSON(http.StatusOK, gin.H{
"received": string(body),
"method": c.Request.Method,
})
})
ts := httptest.NewServer(r)
defer ts.Close()
u, _ := url.Parse(ts.URL)
be, err := NewTransparentBackend(zap.NewNop(), u, []string{})
require.NoError(t, err)
handler, err := be.Run(context.Background())
require.NoError(t, err)
body := `{"jsonrpc":"2.0","method":"initialize"}`
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code, "should follow 307 internally")
var resp map[string]string
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
require.Equal(t, body, resp["received"], "body must be preserved across redirect")
require.Equal(t, "POST", resp["method"], "method must be preserved")
}
func TestTransparentBackendRedirectLoopProtection(t *testing.T) {
r := gin.New()
r.POST("/loop", func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, "/loop")
})
ts := httptest.NewServer(r)
defer ts.Close()
u, _ := url.Parse(ts.URL)
be, err := NewTransparentBackend(zap.NewNop(), u, []string{})
require.NoError(t, err)
handler, err := be.Run(context.Background())
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/loop", strings.NewReader("{}"))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadGateway, rr.Code, "should fail on redirect loop")
}
func TestTransparentBackendRun(t *testing.T) {
r := gin.New()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, c.Request.Header)
})
ts := httptest.NewServer(r)
u, _ := url.Parse(ts.URL)
be, err := NewTransparentBackend(zap.NewNop(), u, []string{})
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, err = be.Run(ctx)
require.NoError(t, err)
checkCh := make(chan struct{})
go func() {
be.Wait()
close(checkCh)
}()
timeout := time.After(10 * time.Millisecond)
select {
case <-checkCh:
t.Error("Test completed too early")
case <-timeout:
// Test timed out
}
cancel()
timeout = time.After(10 * time.Second)
select {
case <-checkCh:
// Test completed successfully
case <-timeout:
t.Error("Test timed out")
}
}