ci: run perf asserts in CI (backend quality & benchmark jobs)

This commit is contained in:
GitHub Actions
2025-12-04 20:58:18 +00:00
parent 05cb8046d6
commit cecf0ef9d6
5 changed files with 705 additions and 3 deletions
+11
View File
@@ -50,3 +50,14 @@ jobs:
fail-on-alert: false
# Enable Job Summary for PRs
summary-always: true
- name: Run Perf Asserts
working-directory: backend
env:
PERF_MAX_MS_GETSTATUS_P95: 500ms
PERF_MAX_MS_GETSTATUS_P95_PARALLEL: 1500ms
PERF_MAX_MS_LISTDECISIONS_P95: 2000ms
run: |
echo "## 🔍 Running performance assertions (TestPerf)" >> $GITHUB_STEP_SUMMARY
go test -run TestPerf -v ./internal/api/handlers -count=1 | tee perf-output.txt
exit ${PIPESTATUS[0]}
+12
View File
@@ -58,6 +58,18 @@ jobs:
args: --timeout=5m
continue-on-error: true
- name: Run Perf Asserts
working-directory: backend
env:
# Conservative defaults to avoid flakiness on CI; tune as necessary
PERF_MAX_MS_GETSTATUS_P95: 500ms
PERF_MAX_MS_GETSTATUS_P95_PARALLEL: 1500ms
PERF_MAX_MS_LISTDECISIONS_P95: 2000ms
run: |
echo "## 🔍 Running performance assertions (TestPerf)" >> $GITHUB_STEP_SUMMARY
go test -run TestPerf -v ./internal/api/handlers -count=1 | tee perf-output.txt
exit ${PIPESTATUS[0]}
frontend-quality:
name: Frontend (React)
runs-on: ubuntu-latest
+33 -3
View File
@@ -182,7 +182,37 @@
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Backend: Run Benchmarks",
"type": "shell",
"command": "cd backend && go test -bench=. -benchmem -benchtime=1s ./internal/api/handlers/... -run=^$",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": ["$go"]
},
{
"label": "Backend: Run Benchmarks (Quick)",
"type": "shell",
"command": "cd backend && go test -bench=GetStatus -benchmem -benchtime=500ms ./internal/api/handlers/... -run=^$",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": ["$go"]
},
{
"label": "Backend: Run Perf Asserts",
"type": "shell",
"command": "cd backend && go test -run TestPerf -v ./internal/api/handlers -count=1",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": ["$go"]
}
]
}
@@ -0,0 +1,463 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"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"
)
// setupBenchmarkDB creates an in-memory SQLite database for benchmarks
func setupBenchmarkDB(b *testing.B) *gorm.DB {
b.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
b.Fatal(err)
}
if err := db.AutoMigrate(
&models.SecurityConfig{},
&models.SecurityRuleSet{},
&models.SecurityDecision{},
&models.SecurityAudit{},
&models.Setting{},
&models.ProxyHost{},
&models.AccessList{},
&models.User{},
); err != nil {
b.Fatal(err)
}
return db
}
// =============================================================================
// SECURITY HANDLER BENCHMARKS
// =============================================================================
func BenchmarkSecurityHandler_GetStatus(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
// Seed settings
settings := []models.Setting{
{Key: "security.cerberus.enabled", Value: "true", Category: "security"},
{Key: "security.waf.enabled", Value: "true", Category: "security"},
{Key: "security.rate_limit.enabled", Value: "true", Category: "security"},
{Key: "security.crowdsec.enabled", Value: "true", Category: "security"},
{Key: "security.acl.enabled", Value: "true", Category: "security"},
}
for _, s := range settings {
db.Create(&s)
}
cfg := config.SecurityConfig{CerberusEnabled: true}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_GetStatus_NoSettings(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
cfg := config.SecurityConfig{CerberusEnabled: true}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_ListDecisions(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
// Seed some decisions
for i := 0; i < 100; i++ {
db.Create(&models.SecurityDecision{
UUID: "test-uuid-" + string(rune(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)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_ListRuleSets(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
// Seed some rulesets
for i := 0; i < 10; i++ {
db.Create(&models.SecurityRuleSet{
UUID: "ruleset-uuid-" + string(rune(i)),
Name: "Ruleset " + string(rune('A'+i)),
Content: "SecRule REQUEST_URI \"@contains /admin\" \"id:1000,phase:1,deny\"",
Mode: "blocking",
})
}
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.GET("/api/v1/security/rulesets", h.ListRuleSets)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/rulesets", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_UpsertRuleSet(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
payload := map[string]interface{}{
"name": "bench-ruleset",
"content": "SecRule REQUEST_URI \"@contains /admin\" \"id:1000,phase:1,deny\"",
"mode": "blocking",
}
body, _ := json.Marshal(payload)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_CreateDecision(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.POST("/api/v1/security/decisions", h.CreateDecision)
payload := map[string]interface{}{
"ip": "192.168.1.100",
"action": "block",
"details": "benchmark test",
}
body, _ := json.Marshal(payload)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("POST", "/api/v1/security/decisions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_GetConfig(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
// Seed a config
db.Create(&models.SecurityConfig{
Name: "default",
Enabled: true,
AdminWhitelist: "192.168.1.0/24",
WAFMode: "block",
RateLimitEnable: true,
RateLimitBurst: 10,
})
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.GET("/api/v1/security/config", h.GetConfig)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/config", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_UpdateConfig(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.PUT("/api/v1/security/config", h.UpdateConfig)
payload := map[string]interface{}{
"name": "default",
"enabled": true,
"rate_limit_enable": true,
"rate_limit_burst": 10,
"rate_limit_requests": 100,
}
body, _ := json.Marshal(payload)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("PUT", "/api/v1/security/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
// =============================================================================
// PARALLEL BENCHMARKS (Concurrency Testing)
// =============================================================================
func BenchmarkSecurityHandler_GetStatus_Parallel(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
settings := []models.Setting{
{Key: "security.cerberus.enabled", Value: "true", Category: "security"},
{Key: "security.waf.enabled", Value: "true", Category: "security"},
}
for _, s := range settings {
db.Create(&s)
}
cfg := config.SecurityConfig{CerberusEnabled: true}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
})
}
func BenchmarkSecurityHandler_ListDecisions_Parallel(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
// Use file-based SQLite with WAL mode for parallel testing
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
b.Fatal(err)
}
if err := db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityAudit{}); err != nil {
b.Fatal(err)
}
for i := 0; i < 100; i++ {
db.Create(&models.SecurityDecision{
UUID: "test-uuid-" + string(rune(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)
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
})
}
// =============================================================================
// MEMORY PRESSURE BENCHMARKS
// =============================================================================
func BenchmarkSecurityHandler_LargeRuleSetContent(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
// 100KB ruleset content (under 2MB limit)
largeContent := ""
for i := 0; i < 1000; i++ {
largeContent += "SecRule REQUEST_URI \"@contains /path" + string(rune(i)) + "\" \"id:" + string(rune(1000+i)) + ",phase:1,deny\"\n"
}
payload := map[string]interface{}{
"name": "large-ruleset",
"content": largeContent,
"mode": "blocking",
}
body, _ := json.Marshal(payload)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
func BenchmarkSecurityHandler_ManySettingsLookups(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
db := setupBenchmarkDB(b)
// Seed many settings
for i := 0; i < 100; i++ {
db.Create(&models.Setting{
Key: "setting.key." + string(rune(i)),
Value: "value",
Category: "misc",
})
}
// Security settings
settings := []models.Setting{
{Key: "security.cerberus.enabled", Value: "true", Category: "security"},
{Key: "security.waf.enabled", Value: "true", Category: "security"},
{Key: "security.rate_limit.enabled", Value: "true", Category: "security"},
{Key: "security.crowdsec.enabled", Value: "true", Category: "security"},
{Key: "security.crowdsec.mode", Value: "local", Category: "security"},
{Key: "security.crowdsec.api_url", Value: "http://localhost:8080", Category: "security"},
{Key: "security.acl.enabled", Value: "true", Category: "security"},
}
for _, s := range settings {
db.Create(&s)
}
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatalf("unexpected status: %d", w.Code)
}
}
}
@@ -0,0 +1,186 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"sort"
"testing"
"time"
"fmt"
"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
func thresholdFromEnv(envKey string, defaultMs float64) float64 {
if v := os.Getenv(envKey); v != "" {
// try parse as float
if parsed, err := time.ParseDuration(v); err == nil {
return ms(parsed)
}
// fallback try parse as number ms
var f float64
if _, err := fmt.Sscanf(v, "%f", &f); err == nil {
return f
}
}
return defaultMs
}
// 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, max 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)
max = samples[len(samples)-1]
return
}
func perfLogStats(t *testing.T, title string, samples []float64) {
av, p50, p95, p99, max := computePercentiles(samples)
t.Logf("%s - avg=%.3fms p50=%.3fms p95=%.3fms p99=%.3fms max=%.3fms", title, av, p50, p95, p99, max)
// no assert by default, individual tests decide how to fail
}
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: "security.cerberus.enabled", Value: "true", Category: "security"})
_ = 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", nil)
samples := gatherStats(t, req, router, counts)
avg, _, p95, _, max := 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, max)
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", nil)
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, _, max := 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, max)
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", nil)
samples := gatherStats(t, req, router, counts)
avg, _, p95, _, max := 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, max)
if p95 > thresholdP95 {
t.Fatalf("ListDecisions P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95)
}
}