package handlers import ( "fmt" "net/http" "net/http/httptest" "os" "sort" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" ) // quick helper to form float ms from duration func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000.0 } // setupPerfDB - uses a file-backed sqlite to avoid concurrency panics in parallel tests func setupPerfDB(t *testing.T) *gorm.DB { t.Helper() path := ":memory:?cache=shared&_journal_mode=WAL" db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityDecision{}, &models.SecurityRuleSet{}, &models.SecurityConfig{})) return db } // thresholdFromEnv loads threshold from environment var as milliseconds // thresholdFromEnv removed — tests use inline environment parsing for clarity. // gatherStats runs the request counts times and returns durations ms func gatherStats(t *testing.T, req *http.Request, router http.Handler, counts int) []float64 { t.Helper() res := make([]float64, 0, counts) for i := 0; i < counts; i++ { w := httptest.NewRecorder() s := time.Now() router.ServeHTTP(w, req) d := time.Since(s) res = append(res, ms(d)) if w.Code >= 500 { t.Fatalf("unexpected status: %d", w.Code) } } return res } // computePercentiles returns avg, p50, p95, p99, max func computePercentiles(samples []float64) (avg, p50, p95, p99, maxVal float64) { sort.Float64s(samples) var sum float64 for _, s := range samples { sum += s } avg = sum / float64(len(samples)) p := func(pct float64) float64 { idx := int(float64(len(samples)) * pct) if idx < 0 { idx = 0 } if idx >= len(samples) { idx = len(samples) - 1 } return samples[idx] } p50 = p(0.50) p95 = p(0.95) p99 = p(0.99) maxVal = samples[len(samples)-1] return } // perfLogStats removed — tests log stats inline where helpful. func TestPerf_GetStatus_AssertThreshold(t *testing.T) { gin.SetMode(gin.ReleaseMode) db := setupPerfDB(t) // seed settings to emulate production path _ = db.Create(&models.Setting{Key: "feature.cerberus.enabled", Value: "true", Category: "feature"}) _ = db.Create(&models.Setting{Key: "security.waf.enabled", Value: "true", Category: "security"}) cfg := config.SecurityConfig{CerberusEnabled: true} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) counts := 500 req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) samples := gatherStats(t, req, router, counts) avg, _, p95, _, maxVal := computePercentiles(samples) // default thresholds ms thresholdP95 := 2.0 // 2ms per request if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95"); env != "" { if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) } } // fail if p95 exceeds threshold t.Logf("GetStatus avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, maxVal) if p95 > thresholdP95 { t.Fatalf("GetStatus P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95) } } func TestPerf_GetStatus_Parallel_AssertThreshold(t *testing.T) { gin.SetMode(gin.ReleaseMode) db := setupPerfDB(t) cfg := config.SecurityConfig{CerberusEnabled: true} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) n := 200 samples := make(chan float64, n) var worker = func() { for i := 0; i < n; i++ { req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) w := httptest.NewRecorder() s := time.Now() router.ServeHTTP(w, req) d := time.Since(s) samples <- ms(d) } } // run 4 concurrent workers for k := 0; k < 4; k++ { go worker() } collected := make([]float64, 0, n*4) for i := 0; i < n*4; i++ { collected = append(collected, <-samples) } avg, _, p95, _, maxVal := computePercentiles(collected) thresholdP95 := 5.0 // 5ms default if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95_PARALLEL"); env != "" { if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) } } t.Logf("GetStatus Parallel avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, maxVal) if p95 > thresholdP95 { t.Fatalf("GetStatus Parallel P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95) } } func TestPerf_ListDecisions_AssertThreshold(t *testing.T) { gin.SetMode(gin.ReleaseMode) db := setupPerfDB(t) // seed decisions for i := 0; i < 1000; i++ { db.Create(&models.SecurityDecision{UUID: fmt.Sprintf("d-%d", i), Source: "test", Action: "block", IP: "192.168.1.1"}) } cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/decisions", h.ListDecisions) counts := 200 req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", http.NoBody) samples := gatherStats(t, req, router, counts) avg, _, p95, _, maxVal := computePercentiles(samples) thresholdP95 := 30.0 // 30ms default if env := os.Getenv("PERF_MAX_MS_LISTDECISIONS_P95"); env != "" { if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) } } t.Logf("ListDecisions avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, maxVal) if p95 > thresholdP95 { t.Fatalf("ListDecisions P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95) } }