Files
graphql-monitoring-proxy/server_handlers_test.go
T
lukaszraczylo db2216973e fix(server): gracefully shut down HTTP proxy to drain in-flight requests
On signal, main waited on wg for StartHTTPProxy's server.Listen, which never
returns because nothing called server.Shutdown(); it timed out after 10s and
process-exited, abruptly killing in-flight GraphQL requests.

Expose the proxy Fiber app and register an http-proxy Shutdown component so
server.Listen returns after Shutdown, letting connections drain. Guard the
shared app handle with a mutex (written by the StartHTTPProxy goroutine, read
by the shutdown component). Verified with build, vet, full test and full
race suite.
2026-08-13 02:37:14 +01:00

663 lines
18 KiB
Go

package main
import (
"context"
"encoding/json"
"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/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 server to bind
deadline := time.Now().Add(3 * time.Second)
var conn net.Conn
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if conn == nil {
t.Fatalf("server did not start on port %d within 3s", port)
}
_ = conn.Close()
// 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)
}
}
// 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()
}()
suite.NoError(shutdownHTTPProxy(context.Background()))
// 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)
listenDone := make(chan struct{})
go func() {
defer close(listenDone)
_ = app.Listener(ln)
}()
// Wait until the listener is actually serving (poll the port) before
// shutting down; otherwise Shutdown can race server startup and no-op.
addr := ln.Addr().String()
deadline := time.Now().Add(3 * time.Second)
for {
conn, err := net.Dial("tcp", addr)
if err == nil {
_ = conn.Close()
break
}
if time.Now().After(deadline) {
suite.Fail("proxy server never started listening on " + addr)
return
}
time.Sleep(10 * time.Millisecond)
}
httpProxyMu.Lock()
httpProxyApp = app
httpProxyMu.Unlock()
suite.NoError(shutdownHTTPProxy(context.Background()))
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")
}
}