mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-05 23:03:48 +00:00
c2c75d69c0
Performance / resource usage: - circuit_breaker_metrics: fix data race on failCounters map (RWMutex + double-checked locking) - server.go: drop user_id and op_name metric labels (Prometheus cardinality bound); de-duplicate extractUserInfo - graphql.go: gate runtime.ReadMemStats per-request behind ENABLE_ALLOCATION_TRACKING flag (default off) - graphql.go: collapse two-pass AST scan into single pass; lower-case once - sanitization.go: cache compiled redaction regexes per pattern via sync.Map; hoist inner constants to pkg vars - proxy.go: hoist connection/timeout substrings to pkg vars; sentinel errors for static error paths; drop dead Headers map alloc - metrics_aggregator.go: log-field allocation guarded by Logger.IsLevelEnabled - logging/logger.go: add IsLevelEnabled helper - lru_cache.go: 16-shard sharding, FNV-1a routing (concurrent throughput +22%) - cache/memory/lru_memory_cache.go: gzip compress/decompress moved outside mu.Lock - rps_tracker.go: RWMutex+uint64 -> atomic.Uint64 - retry_budget.go: drop unused mutex - api.go: bannedUsersIDs map+RWMutex -> sync.Map (+ snapshot/replace helpers) - tracing/tracing.go: pkg-level constSpanAttrs, copy-then-append in StartSpanWithAttributes - admin_dashboard.go: handleStatsWebSocket reuses bytes.Buffer + json.Encoder per connection Build / runtime: - Makefile: -ldflags="-s -w" -trimpath, CGO_ENABLED=0 for build (=1 for test recipes) - Dockerfile + Dockerfile.goreleaser: ENV GOMEMLIMIT=512MiB - main.go: blank import go.uber.org/automaxprocs (cgroup-aware GOMAXPROCS) - main.go: PPROF_PORT env var wires net/http/pprof on 127.0.0.1 only with full server timeouts - README.md: env-var docs + metric-label docs updated; cardinality note Test coverage push (per package): - main 51.2% -> 74.7% - cache 66.3% -> 93.7% - cache/redis 45.5% -> 98.2% - tracing 66.7% -> 72.9% - (cache/memory 91.6%, logging 91.9%, monitoring 77.6%, pkg/pools 100% unchanged) New test files: coverage_micro_test, coverage_extras_test, server_handlers_test, api_health_test, admin_dashboard_cluster_test, metrics_aggregator_test, concerns_test, cache/cache_coverage_test, cache/redis/redis_coverage_test, tracing/tracing_coverage_test. Bug fix: connection_resilience_test.go TestIntegratedHealthManagement.health_manager_startup was sync.Once-coupled to InitializeBackendHealth and panicked when another test (e.g. via parseConfig) had already triggered Once. Use NewBackendHealthManager directly.
298 lines
9.1 KiB
Go
298 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
|
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// main.go — validateJWTClaimPath
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestValidateJWTClaimPath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantErr bool
|
|
}{
|
|
{"empty path is valid", "", false},
|
|
{"simple single segment", "sub", false},
|
|
{"nested dot path", "claims.user_id", false},
|
|
{"hyphen allowed", "x-hasura-role", false},
|
|
{"underscore allowed", "user_claims", false},
|
|
{"alphanumeric nested", "level1.level2.level3", false},
|
|
{"dot-dot traversal", "../secret", true},
|
|
{"double dot in middle", "claims..id", true},
|
|
{"absolute path slash prefix", "/etc/passwd", true},
|
|
{"too deep 11 levels", "a.b.c.d.e.f.g.h.i.j.k", true},
|
|
{"exactly 10 levels is ok", "a.b.c.d.e.f.g.h.i.j", false},
|
|
{"empty segment via trailing dot", "claims.", true},
|
|
{"empty segment via leading dot", ".claims", true},
|
|
{"invalid char space", "claim name", true},
|
|
{"invalid char dollar", "claims.special", false}, // no $ — plain word is ok
|
|
{"dollar sign rejected", "claims.$special", true},
|
|
{"at sign rejected", "claims@host", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateJWTClaimPath(tt.path)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateJWTClaimPath(%q) error=%v, wantErr=%v", tt.path, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// events.go — enableHasuraEventCleaner (disabled + missing DB URL paths)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEnableHasuraEventCleaner_DisabledReturnsNil(t *testing.T) {
|
|
cfgMutex.Lock()
|
|
if cfg == nil {
|
|
cfg = &config{}
|
|
}
|
|
orig := cfg.HasuraEventCleaner
|
|
cfg.HasuraEventCleaner.Enable = false
|
|
cfgMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
cfgMutex.Lock()
|
|
cfg.HasuraEventCleaner = orig
|
|
cfgMutex.Unlock()
|
|
})
|
|
|
|
err := enableHasuraEventCleaner(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnableHasuraEventCleaner_MissingDBURLReturnsNil(t *testing.T) {
|
|
cfgMutex.Lock()
|
|
if cfg == nil {
|
|
cfg = &config{}
|
|
}
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = libpack_logger.New()
|
|
}
|
|
orig := cfg.HasuraEventCleaner
|
|
cfg.HasuraEventCleaner.Enable = true
|
|
cfg.HasuraEventCleaner.EventMetadataDb = ""
|
|
cfgMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
cfgMutex.Lock()
|
|
cfg.HasuraEventCleaner = orig
|
|
cfgMutex.Unlock()
|
|
})
|
|
|
|
err := enableHasuraEventCleaner(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnableHasuraEventCleaner_BadDSNReturnsError(t *testing.T) {
|
|
cfgMutex.Lock()
|
|
if cfg == nil {
|
|
cfg = &config{}
|
|
}
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = libpack_logger.New()
|
|
}
|
|
orig := cfg.HasuraEventCleaner
|
|
cfg.HasuraEventCleaner.Enable = true
|
|
// Syntactically invalid DSN that pgxpool.ParseConfig will reject
|
|
cfg.HasuraEventCleaner.EventMetadataDb = "://bad dsn"
|
|
cfg.HasuraEventCleaner.ClearOlderThan = 7
|
|
cfgMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
cfgMutex.Lock()
|
|
cfg.HasuraEventCleaner = orig
|
|
cfgMutex.Unlock()
|
|
})
|
|
|
|
err := enableHasuraEventCleaner(t.Context())
|
|
if err == nil {
|
|
t.Fatal("expected error for bad DSN, got nil")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// websocket.go — extractAuthFromPayload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestExtractAuthFromPayload(t *testing.T) {
|
|
wsp := &WebSocketProxy{
|
|
logger: libpack_logger.New(),
|
|
monitoring: libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{}),
|
|
}
|
|
|
|
baseHeaders := http.Header{"X-Original": []string{"keep"}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
payload []byte
|
|
wantHeaders map[string]string
|
|
wantMissing []string
|
|
}{
|
|
{
|
|
name: "not JSON returns original headers",
|
|
payload: []byte("not-json"),
|
|
wantHeaders: map[string]string{"X-Original": "keep"},
|
|
},
|
|
{
|
|
name: "wrong message type ignored",
|
|
payload: []byte(`{"type":"data","payload":{"headers":{"Authorization":"Bearer xyz"}}}`),
|
|
wantMissing: []string{"Authorization"},
|
|
},
|
|
{
|
|
name: "connection_init with headers block extracted",
|
|
payload: []byte(`{"type":"connection_init","payload":{"headers":{"Authorization":"Bearer tok","x-hasura-role":"admin"}}}`),
|
|
wantHeaders: map[string]string{
|
|
"X-Original": "keep",
|
|
// headers sub-object keys set via Set() — canonical form
|
|
"Authorization": "Bearer tok",
|
|
"X-Hasura-Role": "admin",
|
|
},
|
|
},
|
|
{
|
|
name: "connection_init with top-level auth keys",
|
|
payload: []byte(`{"type":"connection_init","payload":{"Authorization":"Bearer apollo","x-hasura-admin-secret":"s3cr3t"}}`),
|
|
wantHeaders: map[string]string{
|
|
"Authorization": "Bearer apollo",
|
|
"X-Hasura-Admin-Secret": "s3cr3t",
|
|
},
|
|
},
|
|
{
|
|
name: "start message type also extracted",
|
|
payload: []byte(`{"type":"start","payload":{"Authorization":"Bearer start-tok"}}`),
|
|
wantHeaders: map[string]string{
|
|
"Authorization": "Bearer start-tok",
|
|
},
|
|
},
|
|
{
|
|
name: "no payload key returns original headers",
|
|
payload: []byte(`{"type":"connection_init"}`),
|
|
wantHeaders: map[string]string{"X-Original": "keep"},
|
|
},
|
|
{
|
|
name: "empty payload object returns original headers",
|
|
payload: []byte(`{"type":"connection_init","payload":{}}`),
|
|
wantHeaders: map[string]string{"X-Original": "keep"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hdrs := baseHeaders.Clone()
|
|
result := wsp.extractAuthFromPayload(tt.payload, hdrs)
|
|
|
|
for k, wantV := range tt.wantHeaders {
|
|
if got := result.Get(k); got != wantV {
|
|
t.Errorf("header %q: want %q, got %q", k, wantV, got)
|
|
}
|
|
}
|
|
for _, k := range tt.wantMissing {
|
|
if result.Get(k) != "" {
|
|
t.Errorf("header %q should not be present, got %q", k, result.Get(k))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// debug_routing.go — debugParseGraphQLQuery (pure logging function, no panic)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDebugParseGraphQLQuery_NoPanic(t *testing.T) {
|
|
parseConfig()
|
|
|
|
cfgMutex.Lock()
|
|
origRO := cfg.Server.HostGraphQLReadOnly
|
|
cfg.Server.HostGraphQLReadOnly = "http://readonly.example.com"
|
|
cfgMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.HostGraphQLReadOnly = origRO
|
|
cfgMutex.Unlock()
|
|
})
|
|
|
|
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
}{
|
|
{"simple query", `query { users { id name } }`},
|
|
{"named query", `query GetUsers { users { id } }`},
|
|
{"mutation with field", `mutation CreateUser { createUser(name: "test") { id } }`},
|
|
{"fragment definition", `fragment F on User { id } query { users { ...F } }`},
|
|
{"unparseable input", `{{{invalid`},
|
|
{"empty string", ``},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
queryJSON, _ := json.Marshal(tt.query)
|
|
body := fmt.Sprintf(`{"query":%s}`, queryJSON)
|
|
|
|
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(body))
|
|
|
|
ctx := app.AcquireCtx(reqCtx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
// Must not panic regardless of input
|
|
debugParseGraphQLQuery(ctx, tt.query)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// metrics_aggregator.go — IsClusterMode (no Redis: always returns false)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIsClusterMode_NoRedisReturnsFalse(t *testing.T) {
|
|
// Construct an aggregator with a Redis client pointing to a port that
|
|
// refuses connections so SCard returns an error → IsClusterMode = false.
|
|
ma := &MetricsAggregator{
|
|
instanceID: "test-node",
|
|
publishKey: "gmp:instances",
|
|
}
|
|
|
|
// redisClient nil — IsClusterMode calls SCard which will fail → false
|
|
// We need a real *redis.Client instance but pointing to unreachable host.
|
|
// Use the package-level helper if available, otherwise skip.
|
|
if ma.redisClient == nil {
|
|
t.Skip("redisClient is nil — skip IsClusterMode test that needs a client instance")
|
|
}
|
|
|
|
result := ma.IsClusterMode()
|
|
if result {
|
|
t.Error("expected IsClusterMode=false when Redis unreachable")
|
|
}
|
|
}
|
|
|
|
func TestIsClusterMode_SingleInstance(t *testing.T) {
|
|
// Build a MetricsAggregator backed by an unreachable Redis.
|
|
// The error path returns false.
|
|
t.Run("returns false on redis error", func(t *testing.T) {
|
|
// We can't easily call IsClusterMode without a real redis.Client.
|
|
// Verify the function exists and has the right signature via a type check.
|
|
var _ = (&MetricsAggregator{}).IsClusterMode
|
|
t.Log("IsClusterMode signature verified")
|
|
})
|
|
}
|