The settings handler SSRF test table expected the generic "private ip" error string for the cloud-metadata case (169.254.169.254). After the url_validator was updated to return a distinct "cloud metadata" error for that address, the handler test's errorContains check failed on every CI run. Updated the test case expectation from "private" to "cloud metadata" to match the more precise error message now produced by the validator.
1826 lines
53 KiB
Go
1826 lines
53 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
|
|
}
|