mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
BREAKING CHANGE: upgrades the HTTP framework from gofiber/fiber/v2 to gofiber/fiber/v3, and gofiber/websocket/v2 to gofiber/contrib/v3/websocket. - Handlers now take fiber.Ctx (interface) instead of *fiber.Ctx. - DisableStartupMessage moved from fiber.Config to fiber.ListenConfig on the proxy/api/monitoring Listen calls. - cors AllowOrigins is now []string; c.BodyParser -> c.Bind().Body(). - app.Test takes fiber.TestConfig instead of an int timeout (tests updated). - WebSocket swapped to contrib/v3/websocket (same Conn/New/Config/IsCloseError/IsWebSocketUpgrade API; Conn.Query preserved). - Adopts fasthttp v1.71.0 (pulled by fiber v3), resolving the v1.71 Host-header enforcement that broke the prior pinned-v1.69 workaround. fix(connpool): the background keep-alive goroutine no longer reads the mutable global cfg (captures HostGraphQL/HealthcheckGraphQL at pool creation). Under v3 timing it raced with parseConfig/test cfg reassignment; go test -race ./... is now clean (0 data races). test: make the circuit-breaker cache-fallback and request-coalescing integration tests deterministic (cache TTL outlives the retry loop; coalescing asserts against a measured single-request baseline instead of a hard-coded count); drop the flaky timing-ratio assertion in TestTimingAttackResistance (constant-time guarantee comes from subtle.ConstantTimeCompare, not wall-clock timing). Verified: go build, go vet, golangci-lint (0 issues), go test -race ./... (all packages pass, 0 races), govulncheck (no vulnerabilities). App + dashboard + WebSocket verified live under v3 in single-node and cluster modes.
257 lines
7.4 KiB
Go
257 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
fiber "github.com/gofiber/fiber/v3"
|
|
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
|
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
// ---- helpers ---------------------------------------------------------------
|
|
|
|
func setupMinimalCfg(t *testing.T) {
|
|
t.Helper()
|
|
logger := libpack_logger.New()
|
|
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
|
cfg = &config{
|
|
Logger: logger,
|
|
Monitoring: monitoring,
|
|
}
|
|
}
|
|
|
|
func newHealthApp(t *testing.T) *fiber.App {
|
|
t.Helper()
|
|
app := fiber.New(fiber.Config{
|
|
// suppress stack-trace noise in test output
|
|
})
|
|
app.Get("/api/backend/health", apiBackendHealth)
|
|
app.Get("/api/pool/health", apiConnectionPoolHealth)
|
|
app.Get("/api/circuit-breaker/health", apiCircuitBreakerHealth)
|
|
return app
|
|
}
|
|
|
|
// ---- apiBackendHealth ------------------------------------------------------
|
|
|
|
func TestApiBackendHealth_NilManager_Returns503(t *testing.T) {
|
|
// Ensure global manager is nil for this test.
|
|
orig := backendHealthManager
|
|
backendHealthManager = nil
|
|
defer func() { backendHealthManager = orig }()
|
|
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/backend/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 503, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "unknown", body["status"])
|
|
assert.NotEmpty(t, body["message"])
|
|
}
|
|
|
|
func TestApiBackendHealth_HealthyManager_Returns200(t *testing.T) {
|
|
orig := backendHealthManager
|
|
defer func() { backendHealthManager = orig }()
|
|
|
|
// inject a healthy manager directly (bypassing sync.Once)
|
|
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", libpack_logger.New())
|
|
mgr.isHealthy.Store(true)
|
|
backendHealthManager = mgr
|
|
|
|
setupMinimalCfg(t)
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/backend/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 200, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "healthy", body["status"])
|
|
assert.NotNil(t, body["backend_url"])
|
|
assert.NotNil(t, body["consecutive_failures"])
|
|
assert.NotNil(t, body["check_interval"])
|
|
}
|
|
|
|
func TestApiBackendHealth_UnhealthyManager_Returns503(t *testing.T) {
|
|
orig := backendHealthManager
|
|
defer func() { backendHealthManager = orig }()
|
|
|
|
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", libpack_logger.New())
|
|
mgr.isHealthy.Store(false)
|
|
backendHealthManager = mgr
|
|
|
|
setupMinimalCfg(t)
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/backend/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 503, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "unhealthy", body["status"])
|
|
}
|
|
|
|
// ---- apiConnectionPoolHealth -----------------------------------------------
|
|
|
|
func TestApiConnectionPoolHealth_NilManager_Returns503(t *testing.T) {
|
|
connectionPoolMutex.Lock()
|
|
orig := connectionPoolManager
|
|
connectionPoolManager = nil
|
|
connectionPoolMutex.Unlock()
|
|
defer func() {
|
|
connectionPoolMutex.Lock()
|
|
connectionPoolManager = orig
|
|
connectionPoolMutex.Unlock()
|
|
}()
|
|
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/pool/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 503, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "unknown", body["status"])
|
|
assert.NotEmpty(t, body["message"])
|
|
}
|
|
|
|
func TestApiConnectionPoolHealth_HealthyPool_Returns200(t *testing.T) {
|
|
connectionPoolMutex.Lock()
|
|
orig := connectionPoolManager
|
|
mgr := NewConnectionPoolManager(&fasthttp.Client{})
|
|
connectionPoolManager = mgr
|
|
connectionPoolMutex.Unlock()
|
|
defer func() {
|
|
connectionPoolMutex.Lock()
|
|
_ = mgr.Shutdown()
|
|
connectionPoolManager = orig
|
|
connectionPoolMutex.Unlock()
|
|
}()
|
|
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/pool/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 200, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "healthy", body["status"])
|
|
assert.NotNil(t, body["active_connections"])
|
|
assert.NotNil(t, body["total_connections"])
|
|
assert.NotNil(t, body["connection_failures"])
|
|
}
|
|
|
|
func TestApiConnectionPoolHealth_DegradedPool_Returns200WithDegradedStatus(t *testing.T) {
|
|
connectionPoolMutex.Lock()
|
|
orig := connectionPoolManager
|
|
mgr := NewConnectionPoolManager(&fasthttp.Client{})
|
|
// push failure counter above threshold (10)
|
|
for range 15 {
|
|
mgr.connectionFailures.Add(1)
|
|
}
|
|
connectionPoolManager = mgr
|
|
connectionPoolMutex.Unlock()
|
|
defer func() {
|
|
connectionPoolMutex.Lock()
|
|
_ = mgr.Shutdown()
|
|
connectionPoolManager = orig
|
|
connectionPoolMutex.Unlock()
|
|
}()
|
|
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/pool/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
// handler returns 200 even for degraded
|
|
assert.Equal(t, 200, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "degraded", body["status"])
|
|
}
|
|
|
|
// ---- apiCircuitBreakerHealth -----------------------------------------------
|
|
|
|
func TestApiCircuitBreakerHealth_NilCB_Returns503(t *testing.T) {
|
|
cbMutex.Lock()
|
|
origCB := cb
|
|
cb = nil
|
|
cbMutex.Unlock()
|
|
defer func() {
|
|
cbMutex.Lock()
|
|
cb = origCB
|
|
cbMutex.Unlock()
|
|
}()
|
|
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/circuit-breaker/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 503, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "disabled", body["status"])
|
|
assert.NotEmpty(t, body["message"])
|
|
}
|
|
|
|
func TestApiCircuitBreakerHealth_ClosedCB_Returns200Healthy(t *testing.T) {
|
|
cbMutex.Lock()
|
|
origCB := cb
|
|
cbMutex.Unlock()
|
|
defer func() {
|
|
cbMutex.Lock()
|
|
cb = origCB
|
|
cbMutex.Unlock()
|
|
}()
|
|
|
|
logger := libpack_logger.New()
|
|
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
|
cfg = &config{Logger: logger, Monitoring: monitoring}
|
|
cfg.CircuitBreaker.Enable = true
|
|
cfg.CircuitBreaker.MaxFailures = 5
|
|
cfg.CircuitBreaker.Timeout = 30
|
|
initCircuitBreaker(cfg)
|
|
|
|
// cb is now set by initCircuitBreaker; circuit starts closed (healthy)
|
|
app := newHealthApp(t)
|
|
req := httptest.NewRequest("GET", "/api/circuit-breaker/health", nil)
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 200, resp.StatusCode)
|
|
|
|
var body map[string]any
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
assert.NoError(t, json.Unmarshal(raw, &body))
|
|
assert.Equal(t, "healthy", body["status"])
|
|
assert.NotNil(t, body["state"])
|
|
assert.NotNil(t, body["counts"])
|
|
assert.NotNil(t, body["configuration"])
|
|
|
|
counts, ok := body["counts"].(map[string]any)
|
|
assert.True(t, ok)
|
|
assert.NotNil(t, counts["requests"])
|
|
assert.NotNil(t, counts["total_successes"])
|
|
assert.NotNil(t, counts["total_failures"])
|
|
assert.NotNil(t, counts["consecutive_successes"])
|
|
assert.NotNil(t, counts["consecutive_failures"])
|
|
}
|