fix: Enhance security handler tests and implement role-based access control
- Added role-based middleware to various security handler tests to ensure only admin users can access certain endpoints. - Created a new test file for authorization checks on security mutators, verifying that non-admin users receive forbidden responses. - Updated existing tests to include role setting for admin users, ensuring consistent access control during testing. - Introduced sensitive data masking in settings handler responses, ensuring sensitive values are not exposed in API responses. - Enhanced user handler responses to mask API keys and invite tokens, providing additional security for user-related endpoints. - Refactored routes to group security admin endpoints under a dedicated route with role-based access control. - Added tests for import handler routes to verify authorization guards, ensuring only admin users can access import functionalities.
This commit is contained in:
@@ -260,7 +260,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Register import handler with config dependencies
|
||||
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
routes.RegisterImportHandler(router, db, cfg, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
|
||||
// Check for mounted Caddyfile on startup
|
||||
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
|
||||
|
||||
@@ -170,6 +170,7 @@ func TestSecurityHandler_UpdateConfig_ApplyCaddyError(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/security/config", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -190,6 +191,7 @@ func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody)
|
||||
|
||||
h.GenerateBreakGlass(c)
|
||||
@@ -252,6 +254,7 @@ func TestSecurityHandler_UpsertRuleSet_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -277,6 +280,7 @@ func TestSecurityHandler_CreateDecision_LogError(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -297,6 +301,7 @@ func TestSecurityHandler_DeleteRuleSet_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
||||
|
||||
h.DeleteRuleSet(c)
|
||||
|
||||
@@ -93,6 +93,10 @@ func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
|
||||
// GetStatus returns current import session status.
|
||||
func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
if !requireAuthenticatedAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
@@ -155,6 +159,10 @@ func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
|
||||
// GetPreview returns parsed hosts and conflicts for review.
|
||||
func (h *ImportHandler) GetPreview(c *gin.Context) {
|
||||
if !requireAuthenticatedAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
|
||||
@@ -24,6 +24,17 @@ func requireAdmin(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func requireAuthenticatedAdmin(c *gin.Context) bool {
|
||||
if _, exists := c.Get("userID"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authorization header required",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return requireAdmin(c)
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, _ := c.Get("role")
|
||||
roleStr, _ := role.(string)
|
||||
|
||||
@@ -59,6 +59,10 @@ func TestSecurityHandler_ReloadGeoIP_NotInitialized(t *testing.T) {
|
||||
|
||||
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/reload", h.ReloadGeoIP)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -75,6 +79,10 @@ func TestSecurityHandler_ReloadGeoIP_LoadError(t *testing.T) {
|
||||
h.SetGeoIPService(&services.GeoIPService{}) // dbPath empty => Load() will error
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/reload", h.ReloadGeoIP)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -90,6 +98,10 @@ func TestSecurityHandler_LookupGeoIP_MissingIPAddress(t *testing.T) {
|
||||
|
||||
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/lookup", h.LookupGeoIP)
|
||||
|
||||
payload := []byte(`{}`)
|
||||
@@ -109,6 +121,10 @@ func TestSecurityHandler_LookupGeoIP_ServiceUnavailable(t *testing.T) {
|
||||
h.SetGeoIPService(&services.GeoIPService{}) // present but not loaded
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/lookup", h.LookupGeoIP)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{"ip_address": "8.8.8.8"})
|
||||
|
||||
@@ -261,6 +261,10 @@ func (h *SecurityHandler) GetConfig(c *gin.Context) {
|
||||
|
||||
// UpdateConfig creates or updates the SecurityConfig in DB
|
||||
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityConfig
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -290,6 +294,10 @@ func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
|
||||
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.svc.GenerateBreakGlassToken("default")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
|
||||
@@ -316,6 +324,10 @@ func (h *SecurityHandler) ListDecisions(c *gin.Context) {
|
||||
|
||||
// CreateDecision creates a manual decision (override) - for now no checks besides payload
|
||||
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityDecision
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -371,6 +383,10 @@ func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
|
||||
|
||||
// UpsertRuleSet uploads or updates a ruleset
|
||||
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityRuleSet
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -401,6 +417,10 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
|
||||
// DeleteRuleSet removes a ruleset by id
|
||||
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
idParam := c.Param("id")
|
||||
if idParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
@@ -610,6 +630,10 @@ func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
|
||||
|
||||
// ReloadGeoIP reloads the GeoIP database from disk.
|
||||
func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.geoipSvc == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "GeoIP service not initialized",
|
||||
@@ -641,6 +665,10 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
|
||||
|
||||
// LookupGeoIP performs a GeoIP lookup for a given IP address.
|
||||
func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
}
|
||||
@@ -707,6 +735,10 @@ func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
|
||||
|
||||
// AddWAFExclusion adds a rule exclusion to the WAF configuration
|
||||
func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req WAFExclusionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
|
||||
@@ -786,6 +818,10 @@ func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
|
||||
|
||||
// DeleteWAFExclusion removes a rule exclusion by rule_id
|
||||
func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
ruleIDParam := c.Param("rule_id")
|
||||
if ruleIDParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
|
||||
|
||||
@@ -100,6 +100,10 @@ func TestSecurityHandler_CreateDecision_SQLInjection(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/decisions", h.CreateDecision)
|
||||
|
||||
// Attempt SQL injection via payload fields
|
||||
@@ -143,6 +147,10 @@ func TestSecurityHandler_UpsertRuleSet_MassivePayload(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
|
||||
// Try to submit a 3MB payload (should be rejected by service)
|
||||
@@ -175,6 +183,10 @@ func TestSecurityHandler_UpsertRuleSet_EmptyName(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -203,6 +215,10 @@ func TestSecurityHandler_CreateDecision_EmptyFields(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/decisions", h.CreateDecision)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -347,6 +363,10 @@ func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/api/v1/security/rulesets/:id", h.DeleteRuleSet)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -388,6 +408,10 @@ func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
router.GET("/api/v1/security/rulesets", h.ListRuleSets)
|
||||
|
||||
@@ -433,6 +457,10 @@ func TestSecurityHandler_UpdateConfig_RateLimitBounds(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.PUT("/api/v1/security/config", h.UpdateConfig)
|
||||
|
||||
testCases := []struct {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_MutatorsRequireAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
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("userID", uint(123))
|
||||
c.Set("role", "user")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
body string
|
||||
}{
|
||||
{name: "update-config", method: http.MethodPost, url: "/security/config", body: `{"name":"default"}`},
|
||||
{name: "generate-breakglass", method: http.MethodPost, url: "/security/breakglass/generate", body: `{}`},
|
||||
{name: "create-decision", method: http.MethodPost, url: "/security/decisions", body: `{"ip":"1.2.3.4","action":"block"}`},
|
||||
{name: "upsert-ruleset", method: http.MethodPost, url: "/security/rulesets", body: `{"name":"owasp-crs","mode":"block","content":"x"}`},
|
||||
{name: "delete-ruleset", method: http.MethodDelete, url: "/security/rulesets/1", body: ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.url, bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ func TestSecurityHandler_GenerateBreakGlass_ReturnsToken(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
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()
|
||||
@@ -251,6 +255,10 @@ func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T)
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := router.Group("/api/v1")
|
||||
api.POST("/security/enable", handler.Enable)
|
||||
api.POST("/security/disable", handler.Disable)
|
||||
|
||||
@@ -27,6 +27,10 @@ func TestSecurityHandler_UpdateConfig_Success(t *testing.T) {
|
||||
|
||||
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{
|
||||
@@ -55,6 +59,10 @@ func TestSecurityHandler_UpdateConfig_DefaultName(t *testing.T) {
|
||||
|
||||
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"
|
||||
@@ -78,6 +86,10 @@ func TestSecurityHandler_UpdateConfig_InvalidPayload(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -193,6 +205,10 @@ func TestSecurityHandler_CreateDecision_Success(t *testing.T) {
|
||||
|
||||
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{
|
||||
@@ -218,6 +234,10 @@ func TestSecurityHandler_CreateDecision_MissingIP(t *testing.T) {
|
||||
|
||||
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{
|
||||
@@ -240,6 +260,10 @@ func TestSecurityHandler_CreateDecision_MissingAction(t *testing.T) {
|
||||
|
||||
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{
|
||||
@@ -262,6 +286,10 @@ func TestSecurityHandler_CreateDecision_InvalidPayload(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -306,6 +334,10 @@ func TestSecurityHandler_UpsertRuleSet_Success(t *testing.T) {
|
||||
|
||||
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{
|
||||
@@ -330,6 +362,10 @@ func TestSecurityHandler_UpsertRuleSet_MissingName(t *testing.T) {
|
||||
|
||||
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{
|
||||
@@ -353,6 +389,10 @@ func TestSecurityHandler_UpsertRuleSet_InvalidPayload(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -375,6 +415,10 @@ func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -395,6 +439,10 @@ func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -411,6 +459,10 @@ func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -427,6 +479,10 @@ func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
@@ -509,6 +565,10 @@ func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
@@ -600,6 +660,10 @@ func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) {
|
||||
|
||||
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
|
||||
@@ -689,6 +753,10 @@ func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) {
|
||||
|
||||
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()
|
||||
|
||||
@@ -30,6 +30,10 @@ func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
@@ -148,6 +152,10 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
|
||||
m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db, m)
|
||||
|
||||
@@ -110,6 +110,10 @@ func TestSecurityHandler_AddWAFExclusion_Success(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -140,6 +144,10 @@ func TestSecurityHandler_AddWAFExclusion_WithTarget(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -175,6 +183,10 @@ func TestSecurityHandler_AddWAFExclusion_ToExistingConfig(t *testing.T) {
|
||||
|
||||
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)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -215,6 +227,10 @@ func TestSecurityHandler_AddWAFExclusion_Duplicate(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
// Try to add duplicate
|
||||
@@ -244,6 +260,10 @@ func TestSecurityHandler_AddWAFExclusion_DuplicateWithDifferentTarget(t *testing
|
||||
|
||||
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)
|
||||
|
||||
// Add same rule_id with different target - should succeed
|
||||
@@ -268,6 +288,10 @@ func TestSecurityHandler_AddWAFExclusion_MissingRuleID(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -290,6 +314,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidRuleID(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
// Zero rule_id
|
||||
@@ -313,6 +341,10 @@ func TestSecurityHandler_AddWAFExclusion_NegativeRuleID(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -335,6 +367,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidPayload(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -358,6 +394,10 @@ func TestSecurityHandler_DeleteWAFExclusion_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -394,6 +434,10 @@ func TestSecurityHandler_DeleteWAFExclusion_WithTarget(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -430,6 +474,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NotFound(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -446,6 +494,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NoConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -462,6 +514,10 @@ func TestSecurityHandler_DeleteWAFExclusion_InvalidRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -478,6 +534,10 @@ func TestSecurityHandler_DeleteWAFExclusion_ZeroRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -494,6 +554,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NegativeRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -533,6 +597,10 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
@@ -75,14 +75,43 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Convert to map for easier frontend consumption
|
||||
settingsMap := make(map[string]string)
|
||||
settingsMap := make(map[string]any)
|
||||
for _, s := range settings {
|
||||
if isSensitiveSettingKey(s.Key) {
|
||||
hasSecret := strings.TrimSpace(s.Value) != ""
|
||||
settingsMap[s.Key] = "********"
|
||||
settingsMap[s.Key+".has_secret"] = hasSecret
|
||||
settingsMap[s.Key+".last_updated"] = s.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
continue
|
||||
}
|
||||
|
||||
settingsMap[s.Key] = s.Value
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settingsMap)
|
||||
}
|
||||
|
||||
func isSensitiveSettingKey(key string) bool {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
|
||||
sensitiveFragments := []string{
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"webhook",
|
||||
}
|
||||
|
||||
for _, fragment := range sensitiveFragments {
|
||||
if strings.Contains(normalizedKey, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type UpdateSettingRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
|
||||
@@ -182,6 +182,31 @@ func TestSettingsHandler_GetSettings(t *testing.T) {
|
||||
assert.Equal(t, "test_value", response["test_key"])
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings_MasksSensitiveValues(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
db.Create(&models.Setting{Key: "smtp_password", Value: "super-secret-password", Category: "smtp", Type: "string"})
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.GET("/settings", handler.GetSettings)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/settings", http.NoBody)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "********", response["smtp_password"])
|
||||
assert.Equal(t, true, response["smtp_password.has_secret"])
|
||||
_, hasRaw := response["super-secret-password"]
|
||||
assert.False(t, hasRaw)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings_DatabaseError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
@@ -189,7 +189,12 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "API key regenerated successfully",
|
||||
"has_api_key": true,
|
||||
"api_key_masked": maskSecretForResponse(apiKey),
|
||||
"api_key_updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
@@ -207,11 +212,12 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"api_key": user.APIKey,
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"has_api_key": strings.TrimSpace(user.APIKey) != "",
|
||||
"api_key_masked": maskSecretForResponse(user.APIKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,14 +554,14 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token": inviteToken, // Return token in case email fails
|
||||
"invite_url": inviteURL,
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token_masked": maskSecretForResponse(inviteToken),
|
||||
"invite_url": redactInviteURL(inviteURL),
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -862,16 +868,32 @@ func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token": inviteToken,
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token_masked": maskSecretForResponse(inviteToken),
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
})
|
||||
}
|
||||
|
||||
func maskSecretForResponse(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "********"
|
||||
}
|
||||
|
||||
func redactInviteURL(inviteURL string) string {
|
||||
if strings.TrimSpace(inviteURL) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "[REDACTED]"
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
|
||||
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
|
||||
@@ -162,15 +162,16 @@ func TestUserHandler_RegenerateAPIKey(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]string
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["api_key"])
|
||||
assert.Equal(t, "API key regenerated successfully", resp["message"])
|
||||
assert.Equal(t, "********", resp["api_key_masked"])
|
||||
|
||||
// Verify DB
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.Equal(t, resp["api_key"], updatedUser.APIKey)
|
||||
assert.NotEmpty(t, updatedUser.APIKey)
|
||||
}
|
||||
|
||||
func TestUserHandler_GetProfile(t *testing.T) {
|
||||
@@ -1376,7 +1377,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "", resp["invite_url"])
|
||||
// email_sent is false because no SMTP is configured
|
||||
assert.Equal(t, false, resp["email_sent"].(bool))
|
||||
@@ -1500,7 +1501,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "", resp["invite_url"])
|
||||
assert.Equal(t, false, resp["email_sent"].(bool))
|
||||
}
|
||||
@@ -1553,8 +1554,8 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
token := resp["invite_token"].(string)
|
||||
assert.Equal(t, "https://charon.example.com/accept-invite?token="+token, resp["invite_url"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "[REDACTED]", resp["invite_url"])
|
||||
assert.Equal(t, true, resp["email_sent"].(bool))
|
||||
}
|
||||
|
||||
@@ -1606,7 +1607,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "", resp["invite_url"])
|
||||
assert.Equal(t, false, resp["email_sent"].(bool))
|
||||
}
|
||||
@@ -1668,7 +1669,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T)
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
}
|
||||
|
||||
// Note: TestGetBaseURL and TestGetAppName have been removed as these internal helper
|
||||
@@ -2372,8 +2373,7 @@ func TestResendInvite_Success(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.NotEqual(t, "oldtoken123", resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "pending-user@example.com", resp["email"])
|
||||
assert.Equal(t, false, resp["email_sent"].(bool)) // No SMTP configured
|
||||
|
||||
@@ -2381,7 +2381,7 @@ func TestResendInvite_Success(t *testing.T) {
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.NotEqual(t, "oldtoken123", updatedUser.InviteToken)
|
||||
assert.Equal(t, resp["invite_token"], updatedUser.InviteToken)
|
||||
assert.NotEmpty(t, updatedUser.InviteToken)
|
||||
}
|
||||
|
||||
func TestResendInvite_WithExpiredInvite(t *testing.T) {
|
||||
@@ -2419,8 +2419,7 @@ func TestResendInvite_WithExpiredInvite(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.NotEqual(t, "expiredtoken", resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
|
||||
// Verify new expiration is in the future
|
||||
var updatedUser models.User
|
||||
|
||||
@@ -520,40 +520,43 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
protected.GET("/security/status", securityHandler.GetStatus)
|
||||
// Security Config management
|
||||
protected.GET("/security/config", securityHandler.GetConfig)
|
||||
protected.POST("/security/config", securityHandler.UpdateConfig)
|
||||
protected.POST("/security/enable", securityHandler.Enable)
|
||||
protected.POST("/security/disable", securityHandler.Disable)
|
||||
protected.POST("/security/breakglass/generate", securityHandler.GenerateBreakGlass)
|
||||
protected.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
protected.POST("/security/decisions", securityHandler.CreateDecision)
|
||||
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
protected.POST("/security/rulesets", securityHandler.UpsertRuleSet)
|
||||
protected.DELETE("/security/rulesets/:id", securityHandler.DeleteRuleSet)
|
||||
protected.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
|
||||
// GeoIP endpoints
|
||||
protected.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
|
||||
protected.POST("/security/geoip/reload", securityHandler.ReloadGeoIP)
|
||||
protected.POST("/security/geoip/lookup", securityHandler.LookupGeoIP)
|
||||
// WAF exclusion endpoints
|
||||
protected.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
|
||||
protected.POST("/security/waf/exclusions", securityHandler.AddWAFExclusion)
|
||||
protected.DELETE("/security/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
|
||||
|
||||
securityAdmin := protected.Group("/security")
|
||||
securityAdmin.Use(middleware.RequireRole("admin"))
|
||||
securityAdmin.POST("/config", securityHandler.UpdateConfig)
|
||||
securityAdmin.POST("/enable", securityHandler.Enable)
|
||||
securityAdmin.POST("/disable", securityHandler.Disable)
|
||||
securityAdmin.POST("/breakglass/generate", securityHandler.GenerateBreakGlass)
|
||||
securityAdmin.POST("/decisions", securityHandler.CreateDecision)
|
||||
securityAdmin.POST("/rulesets", securityHandler.UpsertRuleSet)
|
||||
securityAdmin.DELETE("/rulesets/:id", securityHandler.DeleteRuleSet)
|
||||
securityAdmin.POST("/geoip/reload", securityHandler.ReloadGeoIP)
|
||||
securityAdmin.POST("/geoip/lookup", securityHandler.LookupGeoIP)
|
||||
securityAdmin.POST("/waf/exclusions", securityHandler.AddWAFExclusion)
|
||||
securityAdmin.DELETE("/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
|
||||
|
||||
// Security module enable/disable endpoints (granular control)
|
||||
protected.POST("/security/acl/enable", securityHandler.EnableACL)
|
||||
protected.POST("/security/acl/disable", securityHandler.DisableACL)
|
||||
protected.PATCH("/security/acl", securityHandler.PatchACL) // E2E tests use PATCH
|
||||
protected.POST("/security/waf/enable", securityHandler.EnableWAF)
|
||||
protected.POST("/security/waf/disable", securityHandler.DisableWAF)
|
||||
protected.PATCH("/security/waf", securityHandler.PatchWAF) // E2E tests use PATCH
|
||||
protected.POST("/security/cerberus/enable", securityHandler.EnableCerberus)
|
||||
protected.POST("/security/cerberus/disable", securityHandler.DisableCerberus)
|
||||
protected.POST("/security/crowdsec/enable", securityHandler.EnableCrowdSec)
|
||||
protected.POST("/security/crowdsec/disable", securityHandler.DisableCrowdSec)
|
||||
protected.PATCH("/security/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH
|
||||
protected.POST("/security/rate-limit/enable", securityHandler.EnableRateLimit)
|
||||
protected.POST("/security/rate-limit/disable", securityHandler.DisableRateLimit)
|
||||
protected.PATCH("/security/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH
|
||||
securityAdmin.POST("/acl/enable", securityHandler.EnableACL)
|
||||
securityAdmin.POST("/acl/disable", securityHandler.DisableACL)
|
||||
securityAdmin.PATCH("/acl", securityHandler.PatchACL) // E2E tests use PATCH
|
||||
securityAdmin.POST("/waf/enable", securityHandler.EnableWAF)
|
||||
securityAdmin.POST("/waf/disable", securityHandler.DisableWAF)
|
||||
securityAdmin.PATCH("/waf", securityHandler.PatchWAF) // E2E tests use PATCH
|
||||
securityAdmin.POST("/cerberus/enable", securityHandler.EnableCerberus)
|
||||
securityAdmin.POST("/cerberus/disable", securityHandler.DisableCerberus)
|
||||
securityAdmin.POST("/crowdsec/enable", securityHandler.EnableCrowdSec)
|
||||
securityAdmin.POST("/crowdsec/disable", securityHandler.DisableCrowdSec)
|
||||
securityAdmin.PATCH("/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH
|
||||
securityAdmin.POST("/rate-limit/enable", securityHandler.EnableRateLimit)
|
||||
securityAdmin.POST("/rate-limit/disable", securityHandler.DisableRateLimit)
|
||||
securityAdmin.PATCH("/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH
|
||||
|
||||
// CrowdSec process management and import
|
||||
// Data dir for crowdsec (persisted on host via volumes)
|
||||
@@ -674,17 +677,20 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
}
|
||||
|
||||
// RegisterImportHandler wires up import routes with config dependencies.
|
||||
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
|
||||
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyBinary, importDir, mountPath string) {
|
||||
securityService := services.NewSecurityService(db)
|
||||
importHandler := handlers.NewImportHandlerWithDeps(db, caddyBinary, importDir, mountPath, securityService)
|
||||
api := router.Group("/api/v1")
|
||||
importHandler.RegisterRoutes(api)
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authenticatedAdmin := api.Group("/")
|
||||
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole("admin"))
|
||||
importHandler.RegisterRoutes(authenticatedAdmin)
|
||||
|
||||
// NPM Import Handler - supports Nginx Proxy Manager export format
|
||||
npmImportHandler := handlers.NewNPMImportHandler(db)
|
||||
npmImportHandler.RegisterRoutes(api)
|
||||
npmImportHandler.RegisterRoutes(authenticatedAdmin)
|
||||
|
||||
// JSON Import Handler - supports both Charon and NPM export formats
|
||||
jsonImportHandler := handlers.NewJSONImportHandler(db)
|
||||
jsonImportHandler.RegisterRoutes(api)
|
||||
jsonImportHandler.RegisterRoutes(authenticatedAdmin)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupTestImportDB(t *testing.T) *gorm.DB {
|
||||
@@ -27,7 +32,7 @@ func TestRegisterImportHandler(t *testing.T) {
|
||||
db := setupTestImportDB(t)
|
||||
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, "echo", "/tmp", "/import/Caddyfile")
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
// Verify routes are registered by checking the routes list
|
||||
routeInfo := router.Routes()
|
||||
@@ -53,3 +58,30 @@ func TestRegisterImportHandler(t *testing.T) {
|
||||
assert.True(t, found, "route %s should be registered", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImportHandler_AuthzGuards(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestImportDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, cfg, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
unauthReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody)
|
||||
unauthW := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthW, unauthReq)
|
||||
assert.Equal(t, http.StatusUnauthorized, unauthW.Code)
|
||||
|
||||
nonAdmin := &models.User{Email: "user@example.com", Role: "user", Enabled: true}
|
||||
require.NoError(t, db.Create(nonAdmin).Error)
|
||||
authSvc := services.NewAuthService(db, cfg)
|
||||
token, err := authSvc.GenerateToken(nonAdmin)
|
||||
require.NoError(t, err)
|
||||
|
||||
nonAdminReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/preview", http.NoBody)
|
||||
nonAdminReq.Header.Set("Authorization", "Bearer "+token)
|
||||
nonAdminW := httptest.NewRecorder()
|
||||
router.ServeHTTP(nonAdminW, nonAdminReq)
|
||||
assert.Equal(t, http.StatusForbidden, nonAdminW.Code)
|
||||
}
|
||||
|
||||
@@ -103,11 +103,13 @@ func TestRegisterImportHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// RegisterImportHandler should not panic
|
||||
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
|
||||
// Verify import routes exist
|
||||
routes := router.Routes()
|
||||
@@ -915,10 +917,12 @@ func TestRegisterImportHandler_RoutesExist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import_routes"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
|
||||
routes := router.Routes()
|
||||
routeMap := make(map[string]bool)
|
||||
|
||||
@@ -100,7 +100,10 @@ func TestInviteToken_MustBeUnguessable(t *testing.T) {
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
token := resp["invite_token"].(string)
|
||||
var invitedUser models.User
|
||||
require.NoError(t, db.Where("email = ?", "user@test.com").First(&invitedUser).Error)
|
||||
token := invitedUser.InviteToken
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
// Token MUST be at least 32 chars (64 hex = 32 bytes = 256 bits)
|
||||
assert.GreaterOrEqual(t, len(token), 64, "Invite token must be at least 64 hex chars (256 bits)")
|
||||
|
||||
Reference in New Issue
Block a user