mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-05 23:03:48 +00:00
498 lines
17 KiB
Go
498 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
|
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
|
"github.com/sony/gobreaker"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
// Integration tests that test the interactions between different components
|
|
|
|
// TestCachingAndCircuitBreakerInteraction tests the interaction between
|
|
// caching system and circuit breaker
|
|
func (suite *Tests) TestCachingAndCircuitBreakerInteraction() {
|
|
// Original values to restore later
|
|
originalCircuitBreaker := cfg.CircuitBreaker
|
|
originalCache := cfg.Cache
|
|
originalClient := cfg.Client.FastProxyClient
|
|
|
|
// Restore after test
|
|
defer func() {
|
|
cfg.CircuitBreaker = originalCircuitBreaker
|
|
cfg.Cache = originalCache
|
|
cfg.Client.FastProxyClient = originalClient
|
|
// Reset the circuit breaker
|
|
cbMutex.Lock()
|
|
cb = nil
|
|
cbMetrics = nil
|
|
cbMutex.Unlock()
|
|
}()
|
|
|
|
// Ensure cache is enabled
|
|
cfg.Cache.CacheEnable = true
|
|
cfg.Cache.CacheTTL = 60 // 60 seconds
|
|
|
|
// Configure circuit breaker
|
|
cfg.CircuitBreaker.Enable = true
|
|
cfg.CircuitBreaker.MaxFailures = 3
|
|
cfg.CircuitBreaker.Timeout = 5 // 5 seconds to half-open
|
|
cfg.CircuitBreaker.ReturnCachedOnOpen = true
|
|
cfg.CircuitBreaker.TripOn5xx = true
|
|
|
|
// Initialize circuit breaker
|
|
initCircuitBreaker(cfg)
|
|
|
|
// Set up test server with variable behavior
|
|
responseStatus := http.StatusOK
|
|
responseBody := `{"data":{"test":"original"}}`
|
|
responseDelay := time.Duration(0)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Apply configured delay
|
|
time.Sleep(responseDelay)
|
|
|
|
// Return configured response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(responseStatus)
|
|
_, _ = w.Write([]byte(responseBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Configure client
|
|
cfg.Client.ClientTimeout = 2 // 2 seconds (shorter than server delay for timeout tests)
|
|
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
|
|
|
// Configure server URL
|
|
cfg.Server.HostGraphQL = server.URL
|
|
|
|
// Track metrics
|
|
trackedMetrics := []string{
|
|
libpack_monitoring.MetricsCacheHit,
|
|
libpack_monitoring.MetricsCacheMiss,
|
|
libpack_monitoring.MetricsCircuitFallbackSuccess,
|
|
libpack_monitoring.MetricsCircuitFallbackFailed,
|
|
}
|
|
metricCounts := make(map[string]int, len(trackedMetrics))
|
|
|
|
// Capture initial metric values
|
|
for _, metric := range trackedMetrics {
|
|
metricCounts[metric] = getMetricValue(metric)
|
|
}
|
|
|
|
// Test Case 1: Initial request is successful and cached
|
|
t := suite.T()
|
|
|
|
// Create request context
|
|
reqCtx := &fasthttp.RequestCtx{}
|
|
reqCtx.Request.SetRequestURI("/graphql")
|
|
reqCtx.Request.Header.SetMethod("POST")
|
|
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
|
reqBody := `{"query": "query { test }"}`
|
|
reqCtx.Request.SetBody([]byte(reqBody))
|
|
|
|
// Initialize the cache
|
|
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
|
Logger: cfg.Logger,
|
|
TTL: cfg.Cache.CacheTTL,
|
|
})
|
|
|
|
// First request: should succeed and be cached
|
|
ctx := suite.app.AcquireCtx(reqCtx)
|
|
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
|
|
// Save response before releasing context
|
|
firstResponseBody := string(ctx.Response().Body())
|
|
suite.Nil(err, "First request should succeed")
|
|
suite.Equal(responseBody, firstResponseBody, "Response body should match server response")
|
|
|
|
// Calculate hash the same way the system does, before releasing context
|
|
// Use default user context ("-", "-") since no auth headers are set in this test
|
|
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
|
|
|
// Store in cache directly for test
|
|
libpack_cache.CacheStore(cacheKey, []byte(responseBody))
|
|
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// Verify cache was populated
|
|
cachedResponse := libpack_cache.CacheLookup(cacheKey)
|
|
suite.NotNil(cachedResponse, "Response should be cached")
|
|
suite.Equal(responseBody, string(cachedResponse), "Cached response should match server response")
|
|
|
|
// Test Case 2: Server begins failing, trips circuit breaker, fallback to cache
|
|
|
|
// Update server to fail with 500 errors
|
|
responseStatus = http.StatusInternalServerError
|
|
responseBody = `{"errors":[{"message":"Server error"}]}`
|
|
|
|
// Make enough failing requests to trip the circuit
|
|
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
|
ctx = suite.app.AcquireCtx(reqCtx)
|
|
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
suite.app.ReleaseCtx(ctx)
|
|
}
|
|
|
|
// Verify circuit is now open
|
|
suite.Equal(gobreaker.StateOpen.String(), cb.State().String(), "Circuit should be open after failures")
|
|
|
|
// Update server to return success again (but circuit is open, so this shouldn't be called)
|
|
responseStatus = http.StatusOK
|
|
responseBody = `{"data":{"test":"updated"}}`
|
|
|
|
// Next request should use cache fallback
|
|
ctx = suite.app.AcquireCtx(reqCtx)
|
|
err = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
|
|
// Save response before releasing context
|
|
fallbackResponseBody := ""
|
|
if ctx.Response() != nil {
|
|
fallbackResponseBody = string(ctx.Response().Body())
|
|
}
|
|
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// Verify request succeeded via cache fallback
|
|
suite.Nil(err, "Request with open circuit should succeed with cache fallback")
|
|
suite.Equal(`{"data":{"test":"original"}}`, fallbackResponseBody,
|
|
"Response should match cached version, not updated server response")
|
|
|
|
// Verify metrics were incremented
|
|
newCacheHitCount := getMetricValue(libpack_monitoring.MetricsCacheHit)
|
|
newFallbackSuccessCount := getMetricValue(libpack_monitoring.MetricsCircuitFallbackSuccess)
|
|
|
|
suite.Greater(newCacheHitCount, metricCounts[libpack_monitoring.MetricsCacheHit],
|
|
"Cache hit metric should be incremented")
|
|
suite.Greater(newFallbackSuccessCount, metricCounts[libpack_monitoring.MetricsCircuitFallbackSuccess],
|
|
"Circuit fallback success metric should be incremented")
|
|
|
|
// Test Case 3: Request with different query missing in cache while circuit is open
|
|
|
|
// Create new request with different query
|
|
reqCtx = &fasthttp.RequestCtx{}
|
|
reqCtx.Request.SetRequestURI("/graphql")
|
|
reqCtx.Request.Header.SetMethod("POST")
|
|
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
|
newReqBody := `{"query": "query { differentQuery }"}`
|
|
reqCtx.Request.SetBody([]byte(newReqBody))
|
|
|
|
// Capture metrics before request
|
|
fallbackFailedBefore := getMetricValue(libpack_monitoring.MetricsCircuitFallbackFailed)
|
|
|
|
// Request should fail as circuit is open and cache has no matching entry
|
|
ctx = suite.app.AcquireCtx(reqCtx)
|
|
err = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// Verify request failed with circuit open error
|
|
suite.NotNil(err, "Request with open circuit and no cache should fail")
|
|
suite.Equal(ErrCircuitOpen.Error(), err.Error(), "Error should be ErrCircuitOpen")
|
|
|
|
// Verify metrics were incremented
|
|
fallbackFailedAfter := getMetricValue(libpack_monitoring.MetricsCircuitFallbackFailed)
|
|
suite.Greater(fallbackFailedAfter, fallbackFailedBefore,
|
|
"Circuit fallback failed metric should be incremented")
|
|
|
|
// Test Case 4: Circuit timeout and transition to half-open state
|
|
t.Log("Waiting for circuit timeout to transition to half-open state...")
|
|
|
|
// Wait for the circuit timeout plus a bit more
|
|
time.Sleep(time.Duration(cfg.CircuitBreaker.Timeout+1) * time.Second)
|
|
// Reset server to success again for when the circuit allows a probe request
|
|
responseStatus = http.StatusOK
|
|
responseBody = `{"data":{"test":"after recovery"}}`
|
|
|
|
// The first request will transition circuit to half-open and probe the server
|
|
// We don't need to check the actual response here, just that the circuit
|
|
// has properly transitioned from open
|
|
reqCtx = &fasthttp.RequestCtx{}
|
|
reqCtx.Request.SetRequestURI("/graphql")
|
|
reqCtx.Request.Header.SetMethod("POST")
|
|
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx.Request.SetBody([]byte(reqBody))
|
|
|
|
ctx = suite.app.AcquireCtx(reqCtx)
|
|
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// Allow time for circuit state to fully update
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Just verify circuit state changed - don't try to test the actual half-open behavior
|
|
// as it's timing sensitive and can lead to flaky tests
|
|
t.Logf("Final circuit state: %s", cb.State().String())
|
|
suite.NotEqual(gobreaker.StateOpen.String(), cb.State().String(),
|
|
"Circuit should no longer be fully open after recovery")
|
|
}
|
|
|
|
// TestGzipHandlingAndCachingInteraction tests the interaction between
|
|
// the gzip handling and caching system
|
|
func (suite *Tests) TestGzipHandlingAndCachingInteraction() {
|
|
// Original values to restore later
|
|
originalCache := cfg.Cache
|
|
originalClient := cfg.Client.FastProxyClient
|
|
|
|
// Restore after test
|
|
defer func() {
|
|
cfg.Cache = originalCache
|
|
cfg.Client.FastProxyClient = originalClient
|
|
}()
|
|
|
|
// Ensure cache is enabled
|
|
cfg.Cache.CacheEnable = true
|
|
cfg.Cache.CacheTTL = 60 // 60 seconds
|
|
|
|
// Initialize monitoring - re-initialize from scratch for testing
|
|
cfg.Monitoring = libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
|
|
|
// Initialize cache - must be done after initializing monitoring
|
|
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
|
Logger: cfg.Logger,
|
|
TTL: cfg.Cache.CacheTTL,
|
|
})
|
|
|
|
// Make sure old cache entries are cleared
|
|
libpack_cache.CacheClear()
|
|
|
|
// Create a test server that returns gzipped content
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Set the Content-Encoding header to indicate gzipped content
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
|
|
// Create a gzipped response with query-specific data
|
|
reqBody := make([]byte, r.ContentLength)
|
|
_, _ = r.Body.Read(reqBody)
|
|
var queryStr string
|
|
if strings.Contains(string(reqBody), "query1") {
|
|
queryStr = "query1"
|
|
} else if strings.Contains(string(reqBody), "query2") {
|
|
queryStr = "query2"
|
|
} else {
|
|
queryStr = "unknown"
|
|
}
|
|
|
|
payload := fmt.Sprintf(`{"data":{"test":"%s response"}}`, queryStr)
|
|
gzipped := createGzippedData([]byte(payload))
|
|
|
|
// Send the gzipped data
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(gzipped)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Configure client
|
|
cfg.Client.ClientTimeout = 5
|
|
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
|
|
|
// Configure server URL
|
|
cfg.Server.HostGraphQL = server.URL
|
|
|
|
// Instead of using metrics, we'll manually track cache hits and misses
|
|
cacheHits := 0
|
|
cacheMisses := 0
|
|
|
|
// First request - query1, should be a cache miss
|
|
reqCtx1 := &fasthttp.RequestCtx{}
|
|
reqCtx1.Request.SetRequestURI("/graphql")
|
|
reqCtx1.Request.Header.SetMethod("POST")
|
|
reqCtx1.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx1.Request.SetBody([]byte(`{"query": "query { query1 }"}`))
|
|
|
|
ctx := suite.app.AcquireCtx(reqCtx1)
|
|
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
|
|
// Save response data before releasing context
|
|
firstResponseStatus := ctx.Response().StatusCode()
|
|
firstResponseBody := string(ctx.Response().Body())
|
|
firstResponseHeaders := string(ctx.Response().Header.Peek("Content-Encoding"))
|
|
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// First request is a cache miss
|
|
cacheMisses++
|
|
|
|
// Check response
|
|
suite.Nil(err, "First request should succeed")
|
|
suite.Equal(fiber.StatusOK, firstResponseStatus, "Status should be 200 OK")
|
|
suite.Contains(firstResponseBody, "query1 response",
|
|
"Response should contain uncompressed query1 content")
|
|
|
|
// Content-Encoding header should be removed after decompression
|
|
suite.Equal("", firstResponseHeaders,
|
|
"Content-Encoding header should be removed")
|
|
|
|
// Verify cache metrics - should have one miss, no hits yet
|
|
suite.Equal(1, cacheMisses, "Should have one cache miss")
|
|
suite.Equal(0, cacheHits, "Should have no cache hits yet")
|
|
|
|
// Second request - repeat query1, should be a cache hit
|
|
reqCtx2 := &fasthttp.RequestCtx{}
|
|
reqCtx2.Request.SetRequestURI("/graphql")
|
|
reqCtx2.Request.Header.SetMethod("POST")
|
|
reqCtx2.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx2.Request.SetBody([]byte(`{"query": "query { query1 }"}`))
|
|
|
|
ctx = suite.app.AcquireCtx(reqCtx2)
|
|
err = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
|
|
// Save response data before releasing context
|
|
secondResponseStatus := ctx.Response().StatusCode()
|
|
secondResponseBody := string(ctx.Response().Body())
|
|
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// Second request is a cache hit
|
|
cacheHits++
|
|
|
|
suite.Nil(err, "Second request should succeed")
|
|
suite.Equal(fiber.StatusOK, secondResponseStatus, "Status should be 200 OK")
|
|
suite.Contains(secondResponseBody, "query1 response",
|
|
"Response should contain correct content")
|
|
|
|
// Verify cache metrics - should have one hit now
|
|
suite.Equal(1, cacheHits, "Should have one cache hit")
|
|
|
|
// Third request - different query, should be a cache miss
|
|
reqCtx3 := &fasthttp.RequestCtx{}
|
|
reqCtx3.Request.SetRequestURI("/graphql")
|
|
reqCtx3.Request.Header.SetMethod("POST")
|
|
reqCtx3.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx3.Request.SetBody([]byte(`{"query": "query { query2 }"}`))
|
|
|
|
ctx = suite.app.AcquireCtx(reqCtx3)
|
|
err = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
|
|
|
// Save response data before releasing context
|
|
thirdResponseStatus := ctx.Response().StatusCode()
|
|
thirdResponseBody := string(ctx.Response().Body())
|
|
|
|
suite.app.ReleaseCtx(ctx)
|
|
|
|
// Third request is a cache miss
|
|
cacheMisses++
|
|
|
|
suite.Nil(err, "Third request should succeed")
|
|
suite.Equal(fiber.StatusOK, thirdResponseStatus, "Status should be 200 OK")
|
|
suite.Contains(thirdResponseBody, "query2 response", "Response should contain query2 content")
|
|
|
|
// Verify cache metrics - should have one hit and two misses
|
|
suite.Equal(2, cacheMisses, "Should have two cache misses total")
|
|
suite.Equal(1, cacheHits, "Should have one cache hit total")
|
|
}
|
|
|
|
// TestGraphQLQueryParsing tests GraphQL parsing with various query types
|
|
func (suite *Tests) TestGraphQLQueryParsing() {
|
|
testCases := []struct {
|
|
name string
|
|
query string
|
|
expectEndpoint string
|
|
expectParseErr bool
|
|
expectReadOnly bool
|
|
}{
|
|
{
|
|
name: "simple_query",
|
|
query: `{"query": "query { users { id name } }"}`,
|
|
expectParseErr: false,
|
|
expectReadOnly: true,
|
|
},
|
|
{
|
|
name: "mutation",
|
|
query: `{"query": "mutation { createUser(name: \"Test\") { id } }"}`,
|
|
expectParseErr: false,
|
|
expectReadOnly: false,
|
|
},
|
|
{
|
|
name: "query_with_variables",
|
|
query: `{"query": "query($id: ID!) { user(id: $id) { name } }", "variables": {"id": "123"}}`,
|
|
expectParseErr: false,
|
|
expectReadOnly: true,
|
|
},
|
|
{
|
|
name: "malformed_query",
|
|
query: `{"query": "query { unclosed }"}`,
|
|
expectParseErr: false, // Should handle malformed queries gracefully
|
|
expectReadOnly: true, // Default to read-only for safety
|
|
},
|
|
{
|
|
name: "subscription",
|
|
query: `{"query": "subscription { userUpdated { id name } }"}`,
|
|
expectParseErr: false,
|
|
expectReadOnly: true, // Subscriptions are read-only
|
|
},
|
|
{
|
|
name: "mixed_query_and_mutation",
|
|
query: `{"query": "query { users { id } } mutation { createUser(name: \"Test\") { id } }"}`,
|
|
expectParseErr: false,
|
|
expectReadOnly: false, // Should detect mutation
|
|
},
|
|
{
|
|
name: "introspection_query",
|
|
query: `{"query": "query { __schema { types { name } } }"}`,
|
|
expectParseErr: false,
|
|
expectReadOnly: true, // Introspection is read-only
|
|
},
|
|
}
|
|
|
|
// Setup test environment
|
|
originalHost := cfg.Server.HostGraphQL
|
|
originalHostRO := cfg.Server.HostGraphQLReadOnly
|
|
|
|
defer func() {
|
|
cfg.Server.HostGraphQL = originalHost
|
|
cfg.Server.HostGraphQLReadOnly = originalHostRO
|
|
}()
|
|
|
|
// Set distinct endpoints for clear testing
|
|
cfg.Server.HostGraphQL = "https://write.example.com"
|
|
cfg.Server.HostGraphQLReadOnly = "https://read.example.com"
|
|
|
|
for _, tc := range testCases {
|
|
suite.Run(tc.name, func() {
|
|
// Create request context
|
|
reqCtx := &fasthttp.RequestCtx{}
|
|
reqCtx.Request.SetRequestURI("/graphql")
|
|
reqCtx.Request.Header.SetMethod("POST")
|
|
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx.Request.SetBody([]byte(tc.query))
|
|
|
|
// Create fiber context
|
|
ctx := suite.app.AcquireCtx(reqCtx)
|
|
defer suite.app.ReleaseCtx(ctx)
|
|
|
|
// Parse GraphQL query
|
|
result := parseGraphQLQuery(ctx)
|
|
|
|
// Verify parsing result
|
|
if tc.expectParseErr {
|
|
suite.True(result.shouldIgnore, "Should report parse error via shouldIgnore")
|
|
} else {
|
|
suite.False(result.shouldIgnore, "Should not report parse error via shouldIgnore")
|
|
}
|
|
|
|
if tc.expectReadOnly {
|
|
suite.Equal(cfg.Server.HostGraphQLReadOnly, result.activeEndpoint,
|
|
"Should use read-only endpoint")
|
|
} else {
|
|
suite.Equal(cfg.Server.HostGraphQL, result.activeEndpoint,
|
|
"Should use write endpoint")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to get current metric value
|
|
func getMetricValue(metricName string) int {
|
|
counter := cfg.Monitoring.RegisterMetricsCounter(metricName, nil)
|
|
if counter == nil {
|
|
return 0
|
|
}
|
|
return int(counter.Get())
|
|
}
|