Files
Charon/backend/internal/api/handlers/settings_handler_test.go
GitHub Actions 1de29fe6fc fix(frontend): stabilize CrowdSec first-enable UX and guard empty-value regression
When CrowdSec is first enabled, the 10-60 second startup window caused
the toggle to immediately flicker back to unchecked, the card badge to
show 'Disabled' throughout startup, CrowdSecKeyWarning to flash before
bouncer registration completed, and CrowdSecConfig to show alarming
LAPI-not-ready banners to the user.

Root cause: the toggle, badge, and warning conditions all read from
stale sources (crowdsecStatus local state and status.crowdsec.enabled
server data) which neither reflects user intent during a pending mutation.

- Derive crowdsecChecked from crowdsecPowerMutation.variables during
  the pending window so the UI reflects intent immediately on click,
  not the lagging server state
- Show a 'Starting...' badge in warning variant throughout the startup
  window so the user knows the operation is in progress
- Suppress CrowdSecKeyWarning unconditionally while the mutation is
  pending, preventing the bouncer key alert from flashing before
  registration completes on the backend
- Broadcast the mutation's running state to the QueryClient cache via
  a synthetic crowdsec-starting key so CrowdSecConfig.tsx can read it
  without prop drilling
- In CrowdSecConfig, suppress the LAPI 'not running' (red) and
  'initializing' (yellow) banners while the startup broadcast is active,
  with a 90-second safety cap to prevent stale state from persisting
  if the tab is closed mid-mutation
- Add security.crowdsec.starting translation key to all five locales
- Add two backend regression tests confirming that empty-string setting
  values are accepted (not rejected by binding validation), preventing
  silent re-introduction of the Issue 4 bug
- Add nine RTL tests covering toggle stabilization, badge text, warning
  suppression, and LAPI banner suppression/expiry
- Add four Playwright E2E tests using route interception to simulate
  the startup delay in a real browser context

Fixes Issues 3 and 4 from the fresh-install bug report.
2026-03-18 16:57:23 +00:00

1871 lines
55 KiB
Go

package handlers_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"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/handlers"
"github.com/Wikid82/charon/backend/internal/models"
)
type mockCaddyConfigManager struct {
applyFunc func(context.Context) error
calls int
}
type mockCacheInvalidator struct {
calls int
}
func (m *mockCacheInvalidator) InvalidateCache() {
m.calls++
}
func (m *mockCaddyConfigManager) ApplyConfig(ctx context.Context) error {
m.calls++
if m.applyFunc != nil {
return m.applyFunc(ctx)
}
return nil
}
func startTestSMTPServer(t *testing.T) (host string, port int) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen for smtp test server: %v", err)
}
var wg sync.WaitGroup
acceptDone := make(chan struct{})
go func() {
defer close(acceptDone)
for {
conn, acceptErr := ln.Accept()
if acceptErr != nil {
return
}
wg.Add(1)
go func(c net.Conn) {
defer wg.Done()
defer func() { _ = c.Close() }()
handleSMTPConnection(c)
}(conn)
}
}()
t.Cleanup(func() {
_ = ln.Close()
<-acceptDone
wg.Wait()
})
host, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("failed to split smtp listener addr: %v", err)
}
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
t.Fatalf("failed to parse smtp listener port: %v", err)
}
return host, port
}
func handleSMTPConnection(conn net.Conn) {
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
writeLine := func(line string) {
_, _ = w.WriteString(line + "\r\n")
_ = w.Flush()
}
writeLine("220 localhost ESMTP test")
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimSpace(line)
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "EHLO") || strings.HasPrefix(upper, "HELO"):
writeLine("250-localhost")
writeLine("250 OK")
case strings.HasPrefix(upper, "MAIL FROM:"):
writeLine("250 OK")
case strings.HasPrefix(upper, "RCPT TO:"):
writeLine("250 OK")
case strings.HasPrefix(upper, "DATA"):
writeLine("354 End data with <CR><LF>.<CR><LF>")
for {
dataLine, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dataLine, "\r\n") == "." {
break
}
}
writeLine("250 OK")
case strings.HasPrefix(upper, "RSET"):
writeLine("250 OK")
case strings.HasPrefix(upper, "NOOP"):
writeLine("250 OK")
case strings.HasPrefix(upper, "QUIT"):
writeLine("221 Bye")
return
default:
writeLine("250 OK")
}
}
}
func setupSettingsTestDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
_ = db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})
return db
}
func newAdminRouter() *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
return router
}
func TestSettingsHandler_GetSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
// Seed data
db.Create(&models.Setting{Key: "test_key", Value: "test_value", Category: "general", 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]string
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
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)
// Close the database to force an error
sqlDB, _ := db.DB()
_ = sqlDB.Close()
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.StatusInternalServerError, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Contains(t, response["error"], "Failed to fetch settings")
}
func TestSettingsHandler_UpdateSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
// Test Create
payload := map[string]string{
"key": "new_key",
"value": "new_value",
"category": "system",
"type": "string",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
db.Where("key = ?", "new_key").First(&setting)
assert.Equal(t, "new_value", setting.Value)
// Test Update
payload["value"] = "updated_value"
body, _ = json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
db.Where("key = ?", "new_key").First(&setting)
assert.Equal(t, "updated_value", setting.Value)
}
func TestSettingsHandler_UpdateSetting_SyncsAdminWhitelist(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "security.admin_whitelist",
"value": "192.0.2.1/32",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var cfg models.SecurityConfig
err := db.Where("name = ?", "default").First(&cfg).Error
assert.NoError(t, err)
assert.Equal(t, "192.0.2.1/32", cfg.AdminWhitelist)
}
func TestSettingsHandler_UpdateSetting_EnablesCerberusWhenACLEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "security.acl.enabled",
"value": "true",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
err := db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error
assert.NoError(t, err)
assert.Equal(t, "true", setting.Value)
var legacySetting models.Setting
err = db.Where("key = ?", "security.cerberus.enabled").First(&legacySetting).Error
assert.NoError(t, err)
assert.Equal(t, "true", legacySetting.Value)
var aclSetting models.Setting
err = db.Where("key = ?", "security.acl.enabled").First(&aclSetting).Error
assert.NoError(t, err)
assert.Equal(t, "true", aclSetting.Value)
var cfg models.SecurityConfig
err = db.Where("name = ?", "default").First(&cfg).Error
assert.NoError(t, err)
assert.True(t, cfg.Enabled)
}
func TestSettingsHandler_UpdateSetting_SecurityKeyAppliesConfigSynchronously(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
mgr := &mockCaddyConfigManager{}
handler := handlers.NewSettingsHandlerWithDeps(db, mgr, nil, nil, "")
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "security.waf.enabled",
"value": "true",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, 1, mgr.calls)
}
func TestSettingsHandler_UpdateSetting_SecurityKeyApplyFailureReturnsError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
mgr := &mockCaddyConfigManager{applyFunc: func(context.Context) error {
return fmt.Errorf("apply failed")
}}
handler := handlers.NewSettingsHandlerWithDeps(db, mgr, nil, nil, "")
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "security.waf.enabled",
"value": "true",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, 1, mgr.calls)
}
func TestSettingsHandler_UpdateSetting_NonAdminForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{"key": "security.waf.enabled", "value": "true"}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "security.admin_whitelist",
"value": "invalid-cidr-without-prefix",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
}
func TestSettingsHandler_UpdateSetting_EmptyValueAccepted(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "some.setting",
"value": "",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
require.NoError(t, db.Where("key = ?", "some.setting").First(&setting).Error)
assert.Equal(t, "some.setting", setting.Key)
assert.Equal(t, "", setting.Value)
}
func TestSettingsHandler_UpdateSetting_MissingKeyRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"value": "some-value",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Key")
}
func TestSettingsHandler_UpdateSetting_InvalidKeepaliveIdle(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "caddy.keepalive_idle",
"value": "bad-duration",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_idle")
}
func TestSettingsHandler_UpdateSetting_ValidKeepaliveCount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "caddy.keepalive_count",
"value": "9",
"category": "caddy",
"type": "number",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
err := db.Where("key = ?", "caddy.keepalive_count").First(&setting).Error
assert.NoError(t, err)
assert.Equal(t, "9", setting.Value)
}
func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
mgr := &mockCaddyConfigManager{}
inv := &mockCacheInvalidator{}
handler := handlers.NewSettingsHandlerWithDeps(db, mgr, inv, nil, "")
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "security.rate_limit.enabled",
"value": "true",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, 1, inv.calls)
assert.Equal(t, 1, mgr.calls)
}
func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"security": map[string]any{
"admin_whitelist": "bad-cidr",
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
}
func TestSettingsHandler_PatchConfig_InvalidKeepaliveCount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"caddy": map[string]any{
"keepalive_count": 0,
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_count")
}
func TestSettingsHandler_PatchConfig_ValidKeepaliveSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"caddy": map[string]any{
"keepalive_idle": "30s",
"keepalive_count": 12,
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var idle models.Setting
err := db.Where("key = ?", "caddy.keepalive_idle").First(&idle).Error
assert.NoError(t, err)
assert.Equal(t, "30s", idle.Value)
var count models.Setting
err = db.Where("key = ?", "caddy.keepalive_count").First(&count).Error
assert.NoError(t, err)
assert.Equal(t, "12", count.Value)
}
func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
mgr := &mockCaddyConfigManager{applyFunc: func(context.Context) error {
return fmt.Errorf("reload failed")
}}
inv := &mockCacheInvalidator{}
handler := handlers.NewSettingsHandlerWithDeps(db, mgr, inv, nil, "")
router := newAdminRouter()
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"security": map[string]any{
"waf": map[string]any{"enabled": true},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, 1, inv.calls)
assert.Equal(t, 1, mgr.calls)
assert.Contains(t, w.Body.String(), "Failed to reload configuration")
}
func TestSettingsHandler_PatchConfig_SyncsAdminWhitelist(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"security": map[string]any{
"admin_whitelist": "203.0.113.0/24",
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var cfg models.SecurityConfig
err := db.Where("name = ?", "default").First(&cfg).Error
assert.NoError(t, err)
assert.Equal(t, "203.0.113.0/24", cfg.AdminWhitelist)
}
func TestSettingsHandler_PatchConfig_EnablesCerberusWhenACLEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"security": map[string]any{
"acl": map[string]any{
"enabled": true,
},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
err := db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error
assert.NoError(t, err)
assert.Equal(t, "true", setting.Value)
var cfg models.SecurityConfig
err = db.Where("name = ?", "default").First(&cfg).Error
assert.NoError(t, err)
assert.True(t, cfg.Enabled)
}
func TestSettingsHandler_UpdateSetting_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
// Close the database to force an error
sqlDB, _ := db.DB()
_ = sqlDB.Close()
payload := map[string]string{
"key": "test_key",
"value": "test_value",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Contains(t, response["error"], "Failed to save setting")
}
func TestSettingsHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
// Invalid JSON
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Value omitted — allowed since binding:"required" was removed; empty string is a valid value
payload := map[string]string{
"key": "some_key",
// value intentionally absent; defaults to empty string
}
body, _ := json.Marshal(payload)
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Missing key — key is still binding:"required" so this must return 400
payloadNoKey := map[string]string{
"value": "some_value",
}
bodyNoKey, _ := json.Marshal(payloadNoKey)
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(bodyNoKey))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// ============= SMTP Settings Tests =============
func setupSettingsHandlerWithMail(t *testing.T) (*handlers.SettingsHandler, *gorm.DB) {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
_ = db.AutoMigrate(&models.Setting{})
return handlers.NewSettingsHandler(db), db
}
func TestSettingsHandler_GetSMTPConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
// Seed SMTP config
db.Create(&models.Setting{Key: "smtp_host", Value: "smtp.example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_port", Value: "587", Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_username", Value: "user@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_password", Value: "secret123", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "starttls", Category: "smtp", Type: "string"})
router := newAdminRouter()
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "smtp.example.com", resp["host"])
assert.Equal(t, float64(587), resp["port"])
assert.Equal(t, "********", resp["password"]) // Password should be masked
assert.Equal(t, true, resp["configured"])
}
func TestSettingsHandler_GetSMTPConfig_Empty(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["configured"])
}
func TestSettingsHandler_GetSMTPConfig_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
router := newAdminRouter()
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestSettingsHandler_GetSMTPConfig_NonAdminForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Set("userID", uint(2))
c.Next()
})
router.GET("/api/v1/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
body := map[string]any{
"host": "smtp.example.com",
"port": 587,
"from_address": "test@example.com",
"encryption": "starttls",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_UpdateSMTPConfig_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSettingsHandler_UpdateSMTPConfig_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
body := map[string]any{
"host": "smtp.example.com",
"port": 587,
"username": "user@example.com",
"password": "password123",
"from_address": "noreply@example.com",
"encryption": "starttls",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestSettingsHandler_UpdateSMTPConfig_KeepExistingPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
// Seed existing password
db.Create(&models.Setting{Key: "smtp_password", Value: "existingpassword", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_host", Value: "old.example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_port", Value: "25", Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_from_address", Value: "old@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
// Send masked password (simulating frontend sending back masked value)
body := map[string]any{
"host": "smtp.example.com",
"port": 587,
"password": "********", // Masked
"from_address": "noreply@example.com",
"encryption": "starttls",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify password was preserved
var setting models.Setting
db.Where("key = ?", "smtp_password").First(&setting)
assert.Equal(t, "existingpassword", setting.Value)
}
func TestSettingsHandler_TestSMTPConfig_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
router.POST("/settings/smtp/test", handler.TestSMTPConfig)
req, _ := http.NewRequest("POST", "/settings/smtp/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_TestSMTPConfig_NotConfigured(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/test", handler.TestSMTPConfig)
req, _ := http.NewRequest("POST", "/settings/smtp/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["success"])
}
func TestSettingsHandler_TestSMTPConfig_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
host, port := startTestSMTPServer(t)
// Seed SMTP config for local test server.
db.Create(&models.Setting{Key: "smtp_host", Value: host, Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_port", Value: fmt.Sprintf("%d", port), Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/test", handler.TestSMTPConfig)
req, _ := http.NewRequest("POST", "/settings/smtp/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, true, resp["success"])
}
func TestSettingsHandler_SendTestEmail_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
router.POST("/settings/smtp/send-test", handler.SendTestEmail)
body := map[string]string{"to": "test@example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_SendTestEmail_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/send-test", handler.SendTestEmail)
req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSettingsHandler_SendTestEmail_NotConfigured(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/send-test", handler.SendTestEmail)
body := map[string]string{"to": "test@example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["success"])
}
func TestSettingsHandler_SendTestEmail_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
host, port := startTestSMTPServer(t)
// Seed SMTP config for local test server.
db.Create(&models.Setting{Key: "smtp_host", Value: host, Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_port", Value: fmt.Sprintf("%d", port), Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/send-test", handler.SendTestEmail)
body := map[string]string{"to": "test@example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, true, resp["success"])
}
func TestMaskPassword(t *testing.T) {
// Empty password
assert.Equal(t, "", handlers.MaskPasswordForTest(""))
// Non-empty password
assert.Equal(t, "********", handlers.MaskPasswordForTest("secret"))
}
// ============= URL Testing Tests =============
func TestSettingsHandler_ValidatePublicURL_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
body := map[string]string{"url": "https://example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_ValidatePublicURL_InvalidFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
testCases := []struct {
name string
url string
}{
{"Missing scheme", "example.com"},
{"Invalid scheme", "ftp://example.com"},
{"URL with path", "https://example.com/path"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := map[string]string{"url": tc.url}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["valid"])
})
}
}
func TestSettingsHandler_ValidatePublicURL_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
testCases := []struct {
name string
url string
expected string
}{
{"HTTPS URL", "https://example.com", "https://example.com"},
{"HTTP URL", "http://example.com", "http://example.com"},
{"URL with port", "https://example.com:8080", "https://example.com:8080"},
{"URL with trailing slash", "https://example.com/", "https://example.com"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := map[string]string{"url": tc.url}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, true, resp["valid"])
assert.Equal(t, tc.expected, resp["normalized"])
})
}
}
func TestSettingsHandler_TestPublicURL_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
body := map[string]string{"url": "https://example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_TestPublicURL_NoRole(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
// No role set in context
router.POST("/settings/test-url", handler.TestPublicURL)
body := map[string]string{"url": "https://example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_TestPublicURL_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBufferString("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSettingsHandler_TestPublicURL_InvalidURL(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
body := map[string]string{"url": "not-a-valid-url"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
// BadRequest responses only have 'error' field, not 'reachable'
assert.Contains(t, resp["error"].(string), "parse")
}
func TestSettingsHandler_TestPublicURL_PrivateIPBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
// Test various private IPs that should be blocked
testCases := []struct {
name string
url string
}{
{"localhost", "http://localhost"},
{"127.0.0.1", "http://127.0.0.1"},
{"Private 10.x", "http://10.0.0.1"},
{"Private 192.168.x", "http://192.168.1.1"},
{"AWS metadata", "http://169.254.169.254"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := map[string]string{"url": tc.url}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code) // Returns 200 but with reachable=false
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["reachable"])
// Verify error message contains relevant security text
errorMsg := resp["error"].(string)
assert.True(t,
contains(errorMsg, "private ip") || contains(errorMsg, "metadata") || contains(errorMsg, "blocked"),
"Expected security error message, got: %s", errorMsg)
})
}
}
// Helper function for case-insensitive contains
func contains(s, substr string) bool {
return bytes.Contains([]byte(s), []byte(substr))
}
func TestSettingsHandler_TestPublicURL_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
// NOTE: Using a real public URL instead of httptest.NewServer() because
// SSRF protection (correctly) blocks localhost/127.0.0.1.
// Using example.com which is guaranteed to be reachable and is designed for testing
// Alternative: Refactor handler to accept injectable URL validator (future improvement).
publicTestURL := "https://example.com"
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
body := map[string]string{"url": publicTestURL}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
// The test verifies the handler works with a real public URL
assert.Equal(t, true, resp["reachable"], "example.com should be reachable")
assert.NotNil(t, resp["latency"])
// Note: message field is no longer included in response
}
func TestSettingsHandler_TestPublicURL_DNSFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
body := map[string]string{"url": "http://nonexistent-domain-12345.invalid"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code) // Returns 200 but with reachable=false
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["reachable"])
// DNS errors contain "dns" or "resolution" keywords (case-insensitive)
errorMsg := resp["error"].(string)
assert.True(t,
contains(errorMsg, "dns") || contains(errorMsg, "resolution"),
"Expected DNS error message, got: %s", errorMsg)
}
func TestSettingsHandler_TestPublicURL_ConnectivityError(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
// 192.0.2.0/24 is reserved for documentation/testing and is not considered private by
// network.IsPrivateIP(). Using a closed port should trigger a deterministic connect error
// after passing SSRF validation.
body := map[string]string{"url": "http://192.0.2.1:1"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, false, resp["reachable"])
_, ok := resp["error"].(string)
assert.True(t, ok)
}
// ============= SSRF Protection Tests =============
func TestSettingsHandler_TestPublicURL_SSRFProtection(t *testing.T) {
tests := []struct {
name string
url string
expectedStatus int
expectedReachable bool
errorContains string
}{
{
name: "blocks RFC 1918 - 10.x",
url: "http://10.0.0.1",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
},
{
name: "blocks RFC 1918 - 192.168.x",
url: "http://192.168.1.1",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
},
{
name: "blocks RFC 1918 - 172.16.x",
url: "http://172.16.0.1",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
},
{
name: "blocks localhost",
url: "http://localhost",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
},
{
name: "blocks 127.0.0.1",
url: "http://127.0.0.1",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
},
{
name: "blocks cloud metadata",
url: "http://169.254.169.254",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "cloud metadata",
},
{
name: "blocks link-local",
url: "http://169.254.1.1",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
body := map[string]string{"url": tt.url}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, tt.expectedReachable, resp["reachable"])
if tt.errorContains != "" {
errorMsg, ok := resp["error"].(string)
assert.True(t, ok, "error field should be a string")
assert.Contains(t, errorMsg, tt.errorContains)
}
})
}
}
func TestSettingsHandler_TestPublicURL_EmbeddedCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
// Test URL with embedded credentials (parser differential attack)
body := map[string]string{"url": "http://evil.com@127.0.0.1/"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.False(t, resp["reachable"].(bool))
assert.Contains(t, resp["error"].(string), "credentials")
}
func TestSettingsHandler_TestPublicURL_EmptyURL(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
tests := []struct {
name string
payload string
}{
{"empty string", `{"url": ""}`},
{"missing field", `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBufferString(tt.payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
}
func TestSettingsHandler_TestPublicURL_InvalidScheme(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
tests := []struct {
name string
url string
}{
{"ftp scheme", "ftp://example.com"},
{"file scheme", "file:///etc/passwd"},
{"javascript scheme", "javascript:alert(1)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := map[string]string{"url": tt.url}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
// BadRequest responses only have 'error' field, not 'reachable'
assert.Contains(t, resp["error"].(string), "parse")
})
}
}
func TestSettingsHandler_ValidatePublicURL_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBufferString("not-json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSettingsHandler_ValidatePublicURL_URLWithWarning(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
// URL with HTTP scheme may generate a warning
body := map[string]string{"url": "http://example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
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.Equal(t, true, resp["valid"])
// May have a warning about HTTP vs HTTPS
}
func TestSettingsHandler_UpdateSMTPConfig_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
// Close the database to force an error
sqlDB, _ := db.DB()
_ = sqlDB.Close()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
// Include password (not masked) to skip GetSMTPConfig path which would also fail
body := map[string]any{
"host": "smtp.example.com",
"port": 587,
"from_address": "test@example.com",
"encryption": "starttls",
"password": "test-password", // Provide password to skip GetSMTPConfig call
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to save")
}
func TestSettingsHandler_TestPublicURL_IPv6LocalhostBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
// Test IPv6 loopback address
body := map[string]string{"url": "http://[::1]"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
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["reachable"].(bool))
// IPv6 loopback should be blocked
}
// TestUpdateSetting_EmptyValueIsAccepted guards the PR-1 fix: Value must NOT carry
// binding:"required". Gin treats "" as missing for string fields and returns 400 if
// the tag is present. Re-adding the tag would silently regress the CrowdSec enable
// flow (which sends value="" to clear the setting).
func TestUpdateSetting_EmptyValueIsAccepted(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
body := `{"key":"security.crowdsec.enabled","value":""}`
req, _ := http.NewRequest(http.MethodPost, "/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "empty Value must not trigger a 400 validation error")
var s models.Setting
require.NoError(t, db.Where("key = ?", "security.crowdsec.enabled").First(&s).Error)
assert.Equal(t, "", s.Value)
}
// TestUpdateSetting_MissingKeyRejected ensures binding:"required" was only removed
// from Value and not accidentally also from Key. A request with no "key" field must
// still return 400.
func TestUpdateSetting_MissingKeyRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
body := `{"value":"true"}`
req, _ := http.NewRequest(http.MethodPost, "/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}