Files
graphql-monitoring-proxy/server_handlers_test.go
T
lukaszraczylo 4d87dee2c6 fix(deps): update sony/gobreaker to v2
The v2 module path is github.com/sony/gobreaker/v2 and the breaker is
generic. The code instantiates it with any, which matches the v1
Execute signature.
2026-10-04 10:16:43 +01:00

877 lines
25 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v3"
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
"github.com/sony/gobreaker/v2"
"github.com/valyala/fasthttp"
)
// ---------------------------------------------------------------------------
// AddRequestUUID
// ---------------------------------------------------------------------------
func TestAddRequestUUID_SetsLocalsAndCallsNext(t *testing.T) {
app := fiber.New()
app.Use(AddRequestUUID)
var captured string
app.Get("/", func(c fiber.Ctx) error {
if v, ok := c.Locals("request_uuid").(string); ok {
captured = v
}
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 0})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
if captured == "" {
t.Fatal("request_uuid not set in Locals")
}
// UUIDs are 36 chars (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
if len(captured) != 36 {
t.Errorf("unexpected UUID length: %q", captured)
}
}
func TestAddRequestUUID_UniquePerRequest(t *testing.T) {
app := fiber.New()
app.Use(AddRequestUUID)
seen := make([]string, 0, 5)
app.Get("/", func(c fiber.Ctx) error {
if v, ok := c.Locals("request_uuid").(string); ok {
seen = append(seen, v)
}
return c.SendStatus(200)
})
for i := range 5 {
req := httptest.NewRequest("GET", "/", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 0})
if err != nil {
t.Fatalf("request %d: %v", i, err)
}
_ = resp.Body.Close()
}
set := make(map[string]struct{}, len(seen))
for _, id := range seen {
set[id] = struct{}{}
}
if len(set) != 5 {
t.Errorf("expected 5 unique UUIDs, got %d unique in %v", len(set), seen)
}
}
// ---------------------------------------------------------------------------
// healthCheck
// ---------------------------------------------------------------------------
func TestHealthCheck_Returns200WithJSON(t *testing.T) {
// Ensure cfg is ready and GraphQL check is disabled via query param
parseConfig()
_ = StartMonitoringServer()
app := fiber.New()
app.Get("/health", healthCheck)
// Pass check_graphql=false to avoid real network call
req := httptest.NewRequest("GET", "/health?check_graphql=false&check_redis=false", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if _, ok := body["status"]; !ok {
t.Error("response missing 'status' field")
}
if _, ok := body["timestamp"]; !ok {
t.Error("response missing 'timestamp' field")
}
if body["status"] != "healthy" {
t.Errorf("want status=healthy, got %v", body["status"])
}
}
func TestHealthCheck_UnhealthyWhenGraphQLDown(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
// Point to a server that refuses connections
cfgMutex.Lock()
origHost := cfg.Server.HostGraphQL
cfg.Server.HostGraphQL = "http://127.0.0.1:1" // port 1 always refused
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Server.HostGraphQL = origHost
cfgMutex.Unlock()
}()
app := fiber.New()
app.Get("/health", healthCheck)
req := httptest.NewRequest("GET", "/health?check_redis=false", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 15000 * time.Millisecond})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// Should return 503 when backend is unreachable
if resp.StatusCode != fiber.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", resp.StatusCode)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["status"] != "unhealthy" {
t.Errorf("want unhealthy, got %v", body["status"])
}
}
// ---------------------------------------------------------------------------
// processGraphQLRequest
// ---------------------------------------------------------------------------
func TestProcessGraphQLRequest_ValidBodyProxiesToBackend(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"data":{"test":"ok"}}`))
}))
defer backend.Close()
cfgMutex.Lock()
origHost := cfg.Server.HostGraphQL
origHostRO := cfg.Server.HostGraphQLReadOnly
origCache := cfg.Cache.CacheEnable
cfg.Server.HostGraphQL = backend.URL
cfg.Server.HostGraphQLReadOnly = backend.URL
cfg.Cache.CacheEnable = false
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Server.HostGraphQL = origHost
cfg.Server.HostGraphQLReadOnly = origHostRO
cfg.Cache.CacheEnable = origCache
cfgMutex.Unlock()
}()
app := fiber.New()
app.Post("/*", processGraphQLRequest)
body := `{"query":"query { __typename }"}`
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
t.Errorf("want 200, got %d", resp.StatusCode)
}
}
func TestProcessGraphQLRequest_MalformedBodyStillHandled(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
// Backend that always returns 200 (malformed body is handled by proxy layer)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"errors":[{"message":"parse error"}]}`))
}))
defer backend.Close()
cfgMutex.Lock()
origHost := cfg.Server.HostGraphQL
origHostRO := cfg.Server.HostGraphQLReadOnly
origCache := cfg.Cache.CacheEnable
cfg.Server.HostGraphQL = backend.URL
cfg.Server.HostGraphQLReadOnly = backend.URL
cfg.Cache.CacheEnable = false
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Server.HostGraphQL = origHost
cfg.Server.HostGraphQLReadOnly = origHostRO
cfg.Cache.CacheEnable = origCache
cfgMutex.Unlock()
}()
app := fiber.New()
app.Post("/*", processGraphQLRequest)
// Not valid JSON — proxy should still forward or return gracefully
body := `not-json-at-all`
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// Should not panic; any 2xx or 5xx is acceptable — just must not crash
if resp.StatusCode < 100 || resp.StatusCode > 599 {
t.Errorf("unexpected status %d", resp.StatusCode)
}
}
// ---------------------------------------------------------------------------
// handleCaching — wasCached=true path (cache hit)
// ---------------------------------------------------------------------------
func TestHandleCaching_CacheHitReturnsStoredResponse(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
// Enable in-memory cache
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
Logger: cfg.Logger,
TTL: 60,
})
libpack_cache.CacheClear()
cfgMutex.Lock()
origEnable := cfg.Cache.CacheEnable
cfg.Cache.CacheEnable = true
cfg.Cache.CacheTTL = 60
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Cache.CacheEnable = origEnable
cfgMutex.Unlock()
}()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"data":{"users":[]}}`))
}))
defer backend.Close()
cfgMutex.Lock()
origHost := cfg.Server.HostGraphQL
origHostRO := cfg.Server.HostGraphQLReadOnly
cfg.Server.HostGraphQL = backend.URL
cfg.Server.HostGraphQLReadOnly = backend.URL
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Server.HostGraphQL = origHost
cfg.Server.HostGraphQLReadOnly = origHostRO
cfgMutex.Unlock()
}()
app := fiber.New()
app.Post("/*", processGraphQLRequest)
queryBody := `{"query":"query { users { id } }"}`
// First request — cache miss, hits backend
req1 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
req1.Header.Set("Content-Type", "application/json")
resp1, err := app.Test(req1, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
if err != nil {
t.Fatalf("first request: %v", err)
}
_ = resp1.Body.Close()
if resp1.StatusCode != 200 {
t.Fatalf("first request want 200, got %d", resp1.StatusCode)
}
// Second identical request — should hit cache
req2 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
req2.Header.Set("Content-Type", "application/json")
resp2, err := app.Test(req2, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
if err != nil {
t.Fatalf("second request: %v", err)
}
_ = resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Fatalf("second request want 200, got %d", resp2.StatusCode)
}
if resp2.Header.Get("X-Cache-Hit") != "true" {
t.Error("second request should have X-Cache-Hit: true header")
}
}
func TestHandleCaching_CacheMissProxiesRequest(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
Logger: cfg.Logger,
TTL: 60,
})
libpack_cache.CacheClear()
cfgMutex.Lock()
origEnable := cfg.Cache.CacheEnable
cfg.Cache.CacheEnable = true
cfg.Cache.CacheTTL = 60
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Cache.CacheEnable = origEnable
cfgMutex.Unlock()
}()
backendCalled := 0
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backendCalled++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = fmt.Fprintf(w, `{"data":{"call":%d}}`, backendCalled)
}))
defer backend.Close()
cfgMutex.Lock()
origHost := cfg.Server.HostGraphQL
origHostRO := cfg.Server.HostGraphQLReadOnly
cfg.Server.HostGraphQL = backend.URL
cfg.Server.HostGraphQLReadOnly = backend.URL
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Server.HostGraphQL = origHost
cfg.Server.HostGraphQLReadOnly = origHostRO
cfgMutex.Unlock()
}()
app := fiber.New()
app.Post("/*", processGraphQLRequest)
// Unique query so no prior cache entry
queryBody := `{"query":"query { uniqueMissTest_12345 { id } }"}`
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("want 200, got %d", resp.StatusCode)
}
if resp.Header.Get("X-Cache-Hit") == "true" {
t.Error("first request should not be a cache hit")
}
if backendCalled == 0 {
t.Error("backend should have been called on cache miss")
}
}
// ---------------------------------------------------------------------------
// handleCaching — direct unit test for wasCached=true branch
// ---------------------------------------------------------------------------
func TestHandleCaching_DirectCacheHitBranch(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
Logger: cfg.Logger,
TTL: 60,
})
libpack_cache.CacheClear()
cfgMutex.Lock()
origEnable := cfg.Cache.CacheEnable
cfg.Cache.CacheEnable = true
cfg.Cache.CacheTTL = 60
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Cache.CacheEnable = origEnable
cfgMutex.Unlock()
}()
app := fiber.New()
var wasCachedResult bool
app.Post("/test", func(c fiber.Ctx) error {
parsedResult := &parseGraphQLQueryResult{
cacheTime: 60,
cacheRequest: true,
activeEndpoint: cfg.Server.HostGraphQL,
}
// Pre-populate the cache so lookup hits
cacheKey := libpack_cache.CalculateHash(c, "-", "-")
libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`))
var err error
wasCachedResult, err = handleCaching(c, parsedResult, "-", "-")
return err
})
reqCtx := &fasthttp.RequestCtx{}
reqCtx.Request.SetRequestURI("/test")
reqCtx.Request.Header.SetMethod("POST")
reqCtx.Request.Header.Set("Content-Type", "application/json")
reqCtx.Request.SetBody([]byte(`{"query":"query { cachedQuery }"}`))
ctx := app.AcquireCtx(reqCtx)
defer app.ReleaseCtx(ctx)
parsedResult := &parseGraphQLQueryResult{
cacheTime: 60,
cacheRequest: true,
activeEndpoint: cfg.Server.HostGraphQL,
}
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`))
wasCached, err := handleCaching(ctx, parsedResult, "-", "-")
if err != nil {
t.Fatalf("handleCaching returned error: %v", err)
}
if !wasCached {
t.Error("expected wasCached=true when cache hit")
}
_ = wasCachedResult
}
func TestHandleCaching_NoCacheEnabled_ProxiesDirect(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"data":{"noCacheTest":true}}`))
}))
defer backend.Close()
cfgMutex.Lock()
origEnable := cfg.Cache.CacheEnable
origRedis := cfg.Cache.CacheRedisEnable
origHost := cfg.Server.HostGraphQL
origHostRO := cfg.Server.HostGraphQLReadOnly
cfg.Cache.CacheEnable = false
cfg.Cache.CacheRedisEnable = false
cfg.Server.HostGraphQL = backend.URL
cfg.Server.HostGraphQLReadOnly = backend.URL
cfgMutex.Unlock()
defer func() {
cfgMutex.Lock()
cfg.Cache.CacheEnable = origEnable
cfg.Cache.CacheRedisEnable = origRedis
cfg.Server.HostGraphQL = origHost
cfg.Server.HostGraphQLReadOnly = origHostRO
cfgMutex.Unlock()
}()
app := fiber.New()
reqCtx := &fasthttp.RequestCtx{}
reqCtx.Request.SetRequestURI("/v1/graphql")
reqCtx.Request.Header.SetMethod("POST")
reqCtx.Request.Header.Set("Content-Type", "application/json")
reqCtx.Request.SetBody([]byte(`{"query":"query { noCacheTest }"}`))
fCtx := app.AcquireCtx(reqCtx)
defer app.ReleaseCtx(fCtx)
parsedResult := &parseGraphQLQueryResult{
cacheRequest: false,
cacheTime: 0,
activeEndpoint: backend.URL,
}
wasCached, err := handleCaching(fCtx, parsedResult, "-", "-")
if err != nil {
t.Fatalf("handleCaching error: %v", err)
}
if wasCached {
t.Error("expected wasCached=false when cache disabled")
}
}
// ---------------------------------------------------------------------------
// StartHTTPProxy — starts then shuts down cleanly
// ---------------------------------------------------------------------------
func TestStartHTTPProxy_StartsAndShutdown(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
// Grab a free port
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
port := l.Addr().(*net.TCPAddr).Port
_ = l.Close()
cfgMutex.Lock()
origPort := cfg.Server.PortGraphQL
origTimeout := cfg.Client.ClientTimeout
origWS := cfg.WebSocket.Enable
origAdmin := cfg.AdminDashboard.Enable
cfg.Server.PortGraphQL = port
cfg.Client.ClientTimeout = 5
cfg.WebSocket.Enable = false
cfg.AdminDashboard.Enable = false
cfgMutex.Unlock()
t.Cleanup(func() {
cfgMutex.Lock()
cfg.Server.PortGraphQL = origPort
cfg.Client.ClientTimeout = origTimeout
cfg.WebSocket.Enable = origWS
cfg.AdminDashboard.Enable = origAdmin
cfgMutex.Unlock()
})
errCh := make(chan error, 1)
go func() {
errCh <- StartHTTPProxy()
}()
// Wait for the OnListen hook to publish httpProxyApp, i.e. for fiber to
// actually be serving. A bare TCP dial is not a reliable "serving" signal:
// the OS accepts connections into the listener's backlog as soon as it is
// bound, before fiber's Serve loop starts pulling them off, so dialling a
// freshly bound listener races server startup under -race.
deadline := time.Now().Add(3 * time.Second)
var serving bool
for time.Now().Before(deadline) {
httpProxyMu.RLock()
serving = httpProxyApp != nil
httpProxyMu.RUnlock()
if serving {
break
}
time.Sleep(10 * time.Millisecond)
}
if !serving {
t.Fatalf("server did not start serving on port %d within 3s", port)
}
// Send a health check to confirm it's serving
httpResp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health?check_graphql=false&check_redis=false", port))
if err != nil {
t.Fatalf("GET /health: %v", err)
}
_ = httpResp.Body.Close()
if httpResp.StatusCode != 200 {
t.Errorf("want 200, got %d", httpResp.StatusCode)
}
// Shut down and confirm StartHTTPProxy's server.Listen returns instead of
// blocking forever, so in-flight requests get a chance to drain.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := shutdownHTTPProxy(shutdownCtx); err != nil {
t.Fatalf("shutdownHTTPProxy: %v", err)
}
select {
case err := <-errCh:
if err != nil {
t.Errorf("StartHTTPProxy returned error after shutdown: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("StartHTTPProxy did not return after shutdownHTTPProxy")
}
}
// Test_shutdownHTTPProxy verifies the graceful-shutdown helper: it is a no-op
// when no server is running, and it stops a listening proxy server so that
// server.Listen returns (enabling a clean in-flight-request drain instead of
// process-exit cutoff).
func (suite *Tests) Test_shutdownHTTPProxy() {
// No server running: must be a safe no-op.
httpProxyMu.Lock()
original := httpProxyApp
httpProxyApp = nil
httpProxyMu.Unlock()
defer func() {
httpProxyMu.Lock()
httpProxyApp = original
httpProxyMu.Unlock()
}()
// Bound the context: if a clear-signal regression made the retry loop
// spin forever, context.Background() would hang the whole package for
// its default 10m test timeout instead of failing fast here.
noopCtx, noopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer noopCancel()
suite.NoError(shutdownHTTPProxy(noopCtx))
// With a listening server assigned, shutdown must stop it.
app := fiber.New()
app.Get("/ping", func(c fiber.Ctx) error { return c.SendStatus(200) })
ln, err := net.Listen("tcp", "127.0.0.1:0")
suite.Require().NoError(err)
// Signal off fiber's OnListen hook rather than dialling the raw listener:
// the OS accepts connections into the listener's backlog as soon as it is
// bound, before fiber's Serve loop is actually running, so a bare TCP
// dial races server startup and makes shutdownHTTPProxy's no-op check
// (fasthttp's listener isn't set yet) flaky under -race.
serving := make(chan struct{})
app.Hooks().OnListen(func(fiber.ListenData) error {
close(serving)
return nil
})
listenDone := make(chan struct{})
go func() {
defer close(listenDone)
_ = app.Listener(ln)
// Mirror StartHTTPProxy's contract: clear httpProxyApp once Listener
// returns, so shutdownHTTPProxy's retry loop (which waits for that
// clear as proof the drain actually happened) can observe completion
// instead of retrying until its context expires.
httpProxyMu.Lock()
if httpProxyApp == app {
httpProxyApp = nil
}
httpProxyMu.Unlock()
}()
select {
case <-serving:
case <-time.After(3 * time.Second):
suite.Fail("proxy server never started serving on " + ln.Addr().String())
return
}
httpProxyMu.Lock()
httpProxyApp = app
httpProxyMu.Unlock()
// Bound the context: if a clear-signal regression made the retry loop
// spin forever, context.Background() would hang the whole package for
// its default 10m test timeout instead of failing fast here.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
suite.NoError(shutdownHTTPProxy(shutdownCtx))
select {
case <-listenDone:
// Listen returned because we called Shutdown.
case <-time.After(3 * time.Second):
suite.Fail("proxy server did not shut down; in-flight requests would be cut off")
}
}
// ---------------------------------------------------------------------------
// proxyErrorHandler (C4 - circuit-open must surface as 503, not the generic
// 500 fiber's DefaultErrorHandler would return; *fiber.Error keeps its own
// code; any other error still maps to 500)
// ---------------------------------------------------------------------------
func TestProxyErrorHandler_MapsErrorsToStatusCodes(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
}{
{
name: "circuit open sentinel yields 503",
err: ErrCircuitOpen,
wantStatus: fiber.StatusServiceUnavailable,
},
{
name: "gobreaker open state yields 503",
err: gobreaker.ErrOpenState,
wantStatus: fiber.StatusServiceUnavailable,
},
{
name: "wrapped circuit open sentinel yields 503",
err: fmt.Errorf("proxy attempt failed: %w", ErrCircuitOpen),
wantStatus: fiber.StatusServiceUnavailable,
},
{
name: "generic error yields 500",
err: errors.New("boom"),
wantStatus: fiber.StatusInternalServerError,
},
{
name: "proxy error keeps its own status code",
err: NewProxyError(ErrCodeBadGateway, "bad gateway", fiber.StatusBadGateway, false),
wantStatus: fiber.StatusBadGateway,
},
{
name: "fiber.Error keeps its own code",
err: fiber.NewError(fiber.StatusTeapot, "teapot"),
wantStatus: fiber.StatusTeapot,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := fiber.New(fiber.Config{ErrorHandler: proxyErrorHandler})
app.Get("/err", func(c fiber.Ctx) error {
return tt.err
})
req := httptest.NewRequest("GET", "/err", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5000 * time.Millisecond})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != tt.wantStatus {
t.Errorf("want status %d, got %d", tt.wantStatus, resp.StatusCode)
}
})
}
}
// TestStartHTTPProxy_ErrorHandlerConfigured is a narrow regression check that
// StartHTTPProxy actually wires proxyErrorHandler into the fiber app (the
// bug this fixes was a missing ErrorHandler in that Config), rather than
// only testing proxyErrorHandler in isolation above.
func TestStartHTTPProxy_ErrorHandlerConfigured(t *testing.T) {
parseConfig()
_ = StartMonitoringServer()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
port := l.Addr().(*net.TCPAddr).Port
_ = l.Close()
cfgMutex.Lock()
origPort := cfg.Server.PortGraphQL
origTimeout := cfg.Client.ClientTimeout
origRetryBase := cfg.Client.RetryBaseDelayMs
origRetryMax := cfg.Client.RetryMaxDelayMs
origWS := cfg.WebSocket.Enable
origAdmin := cfg.AdminDashboard.Enable
origHost := cfg.Server.HostGraphQL
origHostRO := cfg.Server.HostGraphQLReadOnly
cfg.Server.PortGraphQL = port
cfg.Client.ClientTimeout = 5
// Use short retry delays so the 7-attempt retry loop for the unreachable
// backend completes in milliseconds instead of ~25 seconds.
cfg.Client.RetryBaseDelayMs = 10
cfg.Client.RetryMaxDelayMs = 10
cfg.WebSocket.Enable = false
cfg.AdminDashboard.Enable = false
// Point at a host that always refuses connections so proxyTheRequest
// fails and processGraphQLRequest's proxy-error path (mapped via
// StatusCodeForError, not a bare 500) gets exercised.
cfg.Server.HostGraphQL = "http://127.0.0.1:1"
cfg.Server.HostGraphQLReadOnly = "http://127.0.0.1:1"
cfgMutex.Unlock()
t.Cleanup(func() {
cfgMutex.Lock()
cfg.Server.PortGraphQL = origPort
cfg.Client.ClientTimeout = origTimeout
cfg.Client.RetryBaseDelayMs = origRetryBase
cfg.Client.RetryMaxDelayMs = origRetryMax
cfg.WebSocket.Enable = origWS
cfg.AdminDashboard.Enable = origAdmin
cfg.Server.HostGraphQL = origHost
cfg.Server.HostGraphQLReadOnly = origHostRO
cfgMutex.Unlock()
})
errCh := make(chan error, 1)
go func() {
errCh <- StartHTTPProxy()
}()
deadline := time.Now().Add(3 * time.Second)
var serving bool
for time.Now().Before(deadline) {
httpProxyMu.RLock()
serving = httpProxyApp != nil
httpProxyMu.RUnlock()
if serving {
break
}
time.Sleep(10 * time.Millisecond)
}
if !serving {
t.Fatalf("server did not start serving on port %d within 3s", port)
}
httpResp, err := http.Post(
fmt.Sprintf("http://127.0.0.1:%d/v1/graphql", port),
"application/json",
strings.NewReader(`{"query":"query { __typename }"}`),
)
if err != nil {
t.Fatalf("POST /v1/graphql: %v", err)
}
_ = httpResp.Body.Close()
// An unreachable backend is a generic proxy failure, not circuit-open, so
// this must stay 500 - proving the ErrorHandler wiring did not change the
// status for genuinely-500 errors.
if httpResp.StatusCode != fiber.StatusInternalServerError {
t.Errorf("want %d for unreachable backend, got %d", fiber.StatusInternalServerError, httpResp.StatusCode)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := shutdownHTTPProxy(shutdownCtx); err != nil {
t.Fatalf("shutdownHTTPProxy: %v", err)
}
select {
case err := <-errCh:
if err != nil {
t.Errorf("StartHTTPProxy returned error after shutdown: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("StartHTTPProxy did not return after shutdownHTTPProxy")
}
}