mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
The shutdown component could observe the app before Listen ran. fasthttp then treated Shutdown as a no-op and reset its stop flag. The proxy started to serve after shutdown was requested. Publish httpProxyApp from the OnListen hook and clear it after Listen returns. Honor the shutdown context and retry until the drain completes or the context expires. Gate the tests on the publish signal with a bounded context.
705 lines
20 KiB
Go
705 lines
20 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 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")
|
|
}
|
|
}
|