Files
Charon/backend/internal/api/handlers/security_handler_coverage_test.go
T
akanealw eec8c28fb3
Go Benchmark / Performance Regression Check (push) Has been cancelled
Cerberus Integration / Cerberus Security Stack Integration (push) Has been cancelled
Upload Coverage to Codecov / Backend Codecov Upload (push) Has been cancelled
Upload Coverage to Codecov / Frontend Codecov Upload (push) Has been cancelled
CodeQL - Analyze / CodeQL analysis (go) (push) Has been cancelled
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Has been cancelled
CrowdSec Integration / CrowdSec Bouncer Integration (push) Has been cancelled
Docker Build, Publish & Test / build-and-push (push) Has been cancelled
Quality Checks / Auth Route Protection Contract (push) Has been cancelled
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Has been cancelled
Quality Checks / Backend (Go) (push) Has been cancelled
Quality Checks / Frontend (React) (push) Has been cancelled
Rate Limit integration / Rate Limiting Integration (push) Has been cancelled
Security Scan (PR) / Trivy Binary Scan (push) Has been cancelled
Supply Chain Verification (PR) / Verify Supply Chain (push) Has been cancelled
WAF integration / Coraza WAF Integration (push) Has been cancelled
Docker Build, Publish & Test / Security Scan PR Image (push) Has been cancelled
Repo Health Check / Repo health (push) Has been cancelled
History Rewrite Dry-Run / Dry-run preview for history rewrite (push) Has been cancelled
Prune Renovate Branches / prune (push) Has been cancelled
Renovate / renovate (push) Has been cancelled
Nightly Build & Package / sync-development-to-nightly (push) Has been cancelled
Nightly Build & Package / Trigger Nightly Validation Workflows (push) Has been cancelled
Nightly Build & Package / build-and-push-nightly (push) Has been cancelled
Nightly Build & Package / test-nightly-image (push) Has been cancelled
Nightly Build & Package / verify-nightly-supply-chain (push) Has been cancelled
Update GeoLite2 Checksum / update-checksum (push) Has been cancelled
Container Registry Prune / prune-ghcr (push) Has been cancelled
Container Registry Prune / prune-dockerhub (push) Has been cancelled
Container Registry Prune / summarize (push) Has been cancelled
Supply Chain Verification / Verify SBOM (push) Has been cancelled
Supply Chain Verification / Verify Release Artifacts (push) Has been cancelled
Supply Chain Verification / Verify Docker Image Supply Chain (push) Has been cancelled
Monitor Caddy Major Release / check-caddy-major (push) Has been cancelled
Weekly Nightly to Main Promotion / Verify Nightly Branch Health (push) Has been cancelled
Weekly Nightly to Main Promotion / Create Promotion PR (push) Has been cancelled
Weekly Nightly to Main Promotion / Trigger Missing Required Checks (push) Has been cancelled
Weekly Nightly to Main Promotion / Notify on Failure (push) Has been cancelled
Weekly Nightly to Main Promotion / Workflow Summary (push) Has been cancelled
Weekly Security Rebuild / Security Rebuild & Scan (push) Has been cancelled
changed perms
2026-04-22 18:19:14 +00:00

1049 lines
33 KiB
Go
Executable File

package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"gorm.io/gorm"
)
// Tests for UpdateConfig handler to improve coverage (currently 46%)
func TestSecurityHandler_UpdateConfig_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
payload := map[string]any{
"name": "default",
"admin_whitelist": "192.168.1.0/24",
"waf_mode": "monitor",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.NotNil(t, resp["config"])
}
func TestSecurityHandler_UpdateConfig_DefaultName(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
// Payload without name - should default to "default"
payload := map[string]any{
"admin_whitelist": "10.0.0.0/8",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_UpdateConfig_InvalidPayload(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/config", strings.NewReader("invalid json"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// Tests for GetConfig handler
func TestSecurityHandler_GetConfig_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create a config
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: "127.0.0.1"}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.GET("/security/config", handler.GetConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/config", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.NotNil(t, resp["config"])
}
func TestSecurityHandler_GetConfig_NotFound(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.GET("/security/config", handler.GetConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/config", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Nil(t, resp["config"])
}
// Tests for ListDecisions handler
func TestSecurityHandler_ListDecisions_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}))
// Create some decisions with UUIDs
db.Create(&models.SecurityDecision{UUID: uuid.New().String(), IP: "1.2.3.4", Action: "block", Source: "waf"})
db.Create(&models.SecurityDecision{UUID: uuid.New().String(), IP: "5.6.7.8", Action: "allow", Source: "acl"})
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.GET("/security/decisions", handler.ListDecisions)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/decisions", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
decisions := resp["decisions"].([]any)
assert.Len(t, decisions, 2)
}
func TestSecurityHandler_ListDecisions_WithLimit(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}))
// Create 5 decisions with unique UUIDs
for i := 0; i < 5; i++ {
db.Create(&models.SecurityDecision{UUID: uuid.New().String(), IP: fmt.Sprintf("1.2.3.%d", i), Action: "block", Source: "waf"})
}
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.GET("/security/decisions", handler.ListDecisions)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/decisions?limit=2", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
decisions := resp["decisions"].([]any)
assert.Len(t, decisions, 2)
}
// Tests for CreateDecision handler
func TestSecurityHandler_CreateDecision_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
"ip": "10.0.0.1",
"action": "block",
"reason": "manual block",
"details": "Test manual override",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_CreateDecision_MissingIP(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
"action": "block",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSecurityHandler_CreateDecision_MissingAction(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
"ip": "10.0.0.1",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSecurityHandler_CreateDecision_InvalidPayload(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/decisions", strings.NewReader("invalid"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// Tests for ListRuleSets handler
func TestSecurityHandler_ListRuleSets_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}))
// Create some rulesets with UUIDs
db.Create(&models.SecurityRuleSet{UUID: uuid.New().String(), Name: "owasp-crs", Mode: "blocking", Content: "# OWASP rules"})
db.Create(&models.SecurityRuleSet{UUID: uuid.New().String(), Name: "custom", Mode: "detection", Content: "# Custom rules"})
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.GET("/security/rulesets", handler.ListRuleSets)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/rulesets", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
rulesets := resp["rulesets"].([]any)
assert.Len(t, rulesets, 2)
}
// Tests for UpsertRuleSet handler
func TestSecurityHandler_UpsertRuleSet_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/rulesets", handler.UpsertRuleSet)
payload := map[string]any{
"name": "test-ruleset",
"mode": "blocking",
"content": "# Test rules",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_UpsertRuleSet_MissingName(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/rulesets", handler.UpsertRuleSet)
payload := map[string]any{
"mode": "blocking",
"content": "# Test rules",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSecurityHandler_UpsertRuleSet_InvalidPayload(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/rulesets", handler.UpsertRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/rulesets", strings.NewReader("invalid"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// Tests for DeleteRuleSet handler (currently 52%)
func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}, &models.SecurityAudit{}))
// Create a ruleset to delete
ruleset := models.SecurityRuleSet{Name: "delete-me", Mode: "blocking"}
db.Create(&ruleset)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/1", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.True(t, resp["deleted"].(bool))
}
func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/999", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/invalid", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityRuleSet{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
// Note: This route pattern won't match empty ID, but testing the handler directly
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
// This should hit the "id is required" check if we bypass routing
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/", http.NoBody)
router.ServeHTTP(w, req)
// Router won't match this path, so 404
assert.Equal(t, http.StatusNotFound, w.Code)
}
// Tests for Enable handler
func TestSecurityHandler_Enable_NoConfigNoWhitelist(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/enable", handler.Enable)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/enable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
// Should succeed when no config exists - creates new config
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_Enable_WithWhitelist(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with whitelist containing 127.0.0.1
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: "127.0.0.1"}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/enable", handler.Enable)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/enable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345" // Use RemoteAddr for ClientIP
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_Enable_IPNotInWhitelist(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with whitelist that doesn't include test IP
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: "10.0.0.0/8"}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/enable", handler.Enable)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/enable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.1.1:12345" // Not in 10.0.0.0/8
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
router.POST("/security/enable", handler.Enable)
// First, create a config with no whitelist
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: ""}
db.Create(&cfg)
// Generate a break-glass token
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var tokenResp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &tokenResp)
require.NoError(t, err, "Failed to unmarshal response")
token := tokenResp["token"]
// Now try to enable with the token
payload := map[string]string{"break_glass_token": token}
body, _ := json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/security/enable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_Enable_WithInvalidBreakGlassToken(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with no whitelist
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: ""}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/enable", handler.Enable)
payload := map[string]string{"break_glass_token": "invalid-token"}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/enable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
// Tests for Disable handler (currently 44%)
func TestSecurityHandler_Disable_FromLocalhost(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create enabled config
cfg := models.SecurityConfig{Name: "default", Enabled: true}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/disable", func(c *gin.Context) {
// Simulate localhost request
c.Request.RemoteAddr = "127.0.0.1:12345"
handler.Disable(c)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/disable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.False(t, resp["enabled"].(bool))
}
func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
router.POST("/security/disable", func(c *gin.Context) {
c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP
handler.Disable(c)
})
// Create enabled config
cfg := models.SecurityConfig{Name: "default", Enabled: true}
db.Create(&cfg)
// Generate token
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
var tokenResp map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &tokenResp)
token := tokenResp["token"]
// Disable with token
payload := map[string]string{"break_glass_token": token}
body, _ := json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/security/disable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_Disable_FromRemoteNoToken(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create enabled config
cfg := models.SecurityConfig{Name: "default", Enabled: true}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/disable", func(c *gin.Context) {
c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP
handler.Disable(c)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/disable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestSecurityHandler_Disable_FromRemoteInvalidToken(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create enabled config
cfg := models.SecurityConfig{Name: "default", Enabled: true}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/disable", func(c *gin.Context) {
c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP
handler.Disable(c)
})
payload := map[string]string{"break_glass_token": "invalid-token"}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/disable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
// Tests for GenerateBreakGlass handler
func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
// Should succeed and create a new config with the token
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.NotEmpty(t, resp["token"])
}
// Test Enable with IPv6 localhost
func TestSecurityHandler_Disable_FromIPv6Localhost(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create enabled config
cfg := models.SecurityConfig{Name: "default", Enabled: true}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/disable", func(c *gin.Context) {
c.Request.RemoteAddr = "[::1]:12345" // IPv6 localhost
handler.Disable(c)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/disable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// Test Enable with CIDR whitelist matching
func TestSecurityHandler_Enable_WithCIDRWhitelist(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with CIDR whitelist
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: "192.168.0.0/16, 10.0.0.0/8"}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/enable", handler.Enable)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/enable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.1.50:12345" // In 192.168.0.0/16
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// Test Enable with exact IP in whitelist
func TestSecurityHandler_Enable_WithExactIPWhitelist(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with exact IP whitelist
cfg := models.SecurityConfig{Name: "default", AdminWhitelist: "192.168.1.100"}
db.Create(&cfg)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.POST("/security/enable", handler.Enable)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/enable", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.1.100:12345"
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_GetStatus_BackwardCompatibilityOverrides(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.Setting{}, &models.CaddyConfig{}))
require.NoError(t, db.Create(&models.SecurityConfig{
Name: "default",
Enabled: true,
WAFMode: "block",
RateLimitMode: "enabled",
CrowdSecMode: "local",
}).Error)
seed := []models.Setting{
{Key: "security.cerberus.enabled", Value: "false", Category: "security", Type: "bool"},
{Key: "security.crowdsec.mode", Value: "external", Category: "security", Type: "string"},
{Key: "security.waf.enabled", Value: "true", Category: "security", Type: "bool"},
{Key: "security.rate_limit.enabled", Value: "true", Category: "security", Type: "bool"},
{Key: "security.acl.enabled", Value: "true", Category: "security", Type: "bool"},
}
for _, setting := range seed {
require.NoError(t, db.Create(&setting).Error)
}
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/security/status", http.NoBody)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
cerberus := resp["cerberus"].(map[string]any)
require.Equal(t, false, cerberus["enabled"])
crowdsec := resp["crowdsec"].(map[string]any)
require.Equal(t, "disabled", crowdsec["mode"])
require.Equal(t, false, crowdsec["enabled"])
}
func TestSecurityHandler_AddWAFExclusion_InvalidExistingJSONStillAdds(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityAudit{}))
require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", WAFExclusions: "{"}).Error)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
body := `{"rule_id":942100,"target":"ARGS:user","description":"test"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/security/waf/exclusions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
}
func TestSecurityHandler_ToggleSecurityModule_SnapshotSettingsError(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}))
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/enable", handler.EnableWAF)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/security/waf/enable", http.NoBody)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), "Failed to update security module")
}
func TestSecurityHandler_ToggleSecurityModule_SnapshotSecurityConfigError(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}))
require.NoError(t, db.Exec("DROP TABLE security_configs").Error)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/enable", handler.EnableWAF)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/security/waf/enable", http.NoBody)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), "Failed to update security module")
}
func TestSecurityHandler_SnapshotAndRestoreHelpers(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
require.NoError(t, db.Create(&models.Setting{Key: "k1", Value: "v1", Category: "security", Type: "string"}).Error)
snapshots, err := handler.snapshotSettings([]string{"k1", "k1", "k2"})
require.NoError(t, err)
require.Len(t, snapshots, 2)
require.True(t, snapshots["k1"].exists)
require.False(t, snapshots["k2"].exists)
require.NoError(t, handler.restoreSettings(map[string]settingSnapshot{
"k1": snapshots["k1"],
"k2": snapshots["k2"],
}))
require.NoError(t, db.Exec("DROP TABLE settings").Error)
err = handler.restoreSettings(map[string]settingSnapshot{
"k1": snapshots["k1"],
})
require.Error(t, err)
}
func TestSecurityHandler_DefaultSecurityConfigStateHelpers(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
exists, enabled, err := handler.snapshotDefaultSecurityConfigState()
require.NoError(t, err)
require.False(t, exists)
require.False(t, enabled)
require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true}).Error)
exists, enabled, err = handler.snapshotDefaultSecurityConfigState()
require.NoError(t, err)
require.True(t, exists)
require.True(t, enabled)
require.NoError(t, handler.restoreDefaultSecurityConfigState(true, false))
var cfg models.SecurityConfig
require.NoError(t, db.Where("name = ?", "default").First(&cfg).Error)
require.False(t, cfg.Enabled)
require.NoError(t, handler.restoreDefaultSecurityConfigState(false, false))
err = db.Where("name = ?", "default").First(&cfg).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestSecurityHandler_EnsureSecurityConfigEnabled_Helper(t *testing.T) {
handler := &SecurityHandler{db: nil}
err := handler.ensureSecurityConfigEnabled()
require.Error(t, err)
require.Contains(t, err.Error(), "database not configured")
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: false}).Error)
handler = NewSecurityHandler(config.SecurityConfig{}, db, nil)
require.NoError(t, handler.ensureSecurityConfigEnabled())
var cfg models.SecurityConfig
require.NoError(t, db.Where("name = ?", "default").First(&cfg).Error)
require.True(t, cfg.Enabled)
}
func TestLatestConfigApplyState_Helper(t *testing.T) {
state := latestConfigApplyState(nil)
require.Equal(t, false, state["available"])
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.CaddyConfig{}))
state = latestConfigApplyState(db)
require.Equal(t, false, state["available"])
require.NoError(t, db.Create(&models.CaddyConfig{Success: true}).Error)
state = latestConfigApplyState(db)
require.Equal(t, true, state["available"])
require.Equal(t, "applied", state["status"])
}
// TestSecurityHandler_CreateDecision_StripsEnrichmentFields verifies that
// clients cannot inject system-populated enrichment fields (Scenario, Country,
// ExpiresAt) via the CreateDecision endpoint.
func TestSecurityHandler_CreateDecision_StripsEnrichmentFields(t *testing.T) {
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
"ip": "10.0.0.1",
"action": "block",
"details": "test",
"scenario": "injected-scenario",
"country": "XX",
"expires_at": "2099-01-01T00:00:00Z",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Verify the stored decision has empty enrichment fields
var stored models.SecurityDecision
require.NoError(t, db.First(&stored).Error)
assert.Empty(t, stored.Scenario, "Scenario should not be settable by client")
assert.Empty(t, stored.Country, "Country should not be settable by client")
assert.Nil(t, stored.ExpiresAt, "ExpiresAt should not be settable by client")
assert.Equal(t, "manual", stored.Source, "Source must be forced to manual")
}