Unifies the two previously independent email subsystems — MailService (net/smtp transport) and NotificationService (HTTP-based providers) — so email can participate in the notification dispatch pipeline. Key changes: - SendEmail signature updated to accept context.Context and []string recipients to enable timeout propagation and multi-recipient dispatch - NotificationService.dispatchEmail() wires MailService as a first-class provider type with IsConfigured() guard and 30s context timeout - 'email' added to isSupportedNotificationProviderType() and supportsJSONTemplates() returns false for email (plain/HTML only) - settings_handler.go test-email endpoint updated to new SendEmail API - Frontend: 'email' added to provider type union in notifications.ts, Notifications.tsx shows recipient field and hides URL/token fields for email providers - All existing tests updated to match new SendEmail signature - New tests added covering dispatchEmail paths, IsConfigured guards, recipient validation, and context timeout behaviour Also fixes confirmed false-positive CodeQL go/email-injection alerts: - smtp.SendMail, sendSSL w.Write, and sendSTARTTLS w.Write sites now carry inline codeql[go/email-injection] annotations as required by the CodeQL same-line suppression spec; preceding-line annotations silently no-op in current CodeQL versions - auth_handler.go c.SetCookie annotated for intentional Secure=false on local non-HTTPS loopback (go/cookie-secure-not-set warning only) Closes part of #800
479 lines
13 KiB
Go
479 lines
13 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
)
|
|
|
|
func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
|
t.Helper()
|
|
db := handlers.OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{}))
|
|
|
|
ns := services.NewNotificationService(db, nil)
|
|
service := services.NewUptimeService(db, ns)
|
|
handler := handlers.NewUptimeHandler(service)
|
|
|
|
r := gin.Default()
|
|
api := r.Group("/api/v1")
|
|
uptime := api.Group("/uptime")
|
|
uptime.GET("", handler.List)
|
|
uptime.POST("", handler.Create)
|
|
uptime.GET(":id/history", handler.GetHistory)
|
|
uptime.PUT(":id", handler.Update)
|
|
uptime.DELETE(":id", handler.Delete)
|
|
uptime.POST(":id/check", handler.CheckMonitor)
|
|
uptime.POST("/sync", handler.Sync)
|
|
|
|
return r, db
|
|
}
|
|
|
|
func TestUptimeHandler_List(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
// Seed Monitor
|
|
monitor := models.UptimeMonitor{
|
|
ID: "monitor-1",
|
|
Name: "Test Monitor",
|
|
Type: "http",
|
|
URL: "http://example.com",
|
|
}
|
|
db.Create(&monitor)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/uptime", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var list []models.UptimeMonitor
|
|
err := json.Unmarshal(w.Body.Bytes(), &list)
|
|
require.NoError(t, err)
|
|
assert.Len(t, list, 1)
|
|
assert.Equal(t, "Test Monitor", list[0].Name)
|
|
}
|
|
|
|
func TestUptimeHandler_Create(t *testing.T) {
|
|
t.Run("success_http", func(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "New HTTP Monitor",
|
|
"url": "https://example.com",
|
|
"type": "http",
|
|
"interval": 120,
|
|
"max_retries": 5,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
var result models.UptimeMonitor
|
|
err := json.Unmarshal(w.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "New HTTP Monitor", result.Name)
|
|
assert.Equal(t, "https://example.com", result.URL)
|
|
assert.Equal(t, "http", result.Type)
|
|
assert.Equal(t, 120, result.Interval)
|
|
assert.Equal(t, 5, result.MaxRetries)
|
|
assert.True(t, result.Enabled)
|
|
assert.Equal(t, "pending", result.Status)
|
|
assert.NotEmpty(t, result.ID)
|
|
|
|
// Verify it's in the database
|
|
var dbMonitor models.UptimeMonitor
|
|
require.NoError(t, db.First(&dbMonitor, "id = ?", result.ID).Error)
|
|
assert.Equal(t, "New HTTP Monitor", dbMonitor.Name)
|
|
})
|
|
|
|
t.Run("success_tcp", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "New TCP Monitor",
|
|
"url": "example.com:8080",
|
|
"type": "tcp",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
var result models.UptimeMonitor
|
|
err := json.Unmarshal(w.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "New TCP Monitor", result.Name)
|
|
assert.Equal(t, "example.com:8080", result.URL)
|
|
assert.Equal(t, "tcp", result.Type)
|
|
assert.Equal(t, 60, result.Interval) // Default
|
|
assert.Equal(t, 3, result.MaxRetries) // Default
|
|
})
|
|
|
|
t.Run("success_defaults", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "Default Monitor",
|
|
"url": "https://example.com/health",
|
|
"type": "https",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
var result models.UptimeMonitor
|
|
err := json.Unmarshal(w.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 60, result.Interval) // Default
|
|
assert.Equal(t, 3, result.MaxRetries) // Default
|
|
})
|
|
|
|
t.Run("missing_name", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"url": "https://example.com",
|
|
"type": "http",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
t.Run("missing_url", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "No URL Monitor",
|
|
"type": "http",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
t.Run("missing_type", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "No Type Monitor",
|
|
"url": "https://example.com",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
t.Run("invalid_type", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "Invalid Type Monitor",
|
|
"url": "https://example.com",
|
|
"type": "invalid",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
t.Run("invalid_json", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer([]byte("invalid")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
t.Run("invalid_tcp_url", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
payload := map[string]any{
|
|
"name": "Bad TCP Monitor",
|
|
"url": "not-host-port",
|
|
"type": "tcp",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
})
|
|
}
|
|
|
|
func TestUptimeHandler_GetHistory(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
// Seed Monitor and Heartbeats
|
|
monitorID := "monitor-1"
|
|
monitor := models.UptimeMonitor{
|
|
ID: monitorID,
|
|
Name: "Test Monitor",
|
|
}
|
|
db.Create(&monitor)
|
|
|
|
db.Create(&models.UptimeHeartbeat{
|
|
MonitorID: monitorID,
|
|
Status: "up",
|
|
Latency: 10,
|
|
CreatedAt: time.Now().Add(-1 * time.Minute),
|
|
})
|
|
db.Create(&models.UptimeHeartbeat{
|
|
MonitorID: monitorID,
|
|
Status: "down",
|
|
Latency: 0,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/uptime/"+monitorID+"/history", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var history []models.UptimeHeartbeat
|
|
err := json.Unmarshal(w.Body.Bytes(), &history)
|
|
require.NoError(t, err)
|
|
assert.Len(t, history, 2)
|
|
// Should be ordered by created_at desc
|
|
assert.Equal(t, "down", history[0].Status)
|
|
}
|
|
|
|
func TestUptimeHandler_CheckMonitor(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
// Create monitor
|
|
monitor := models.UptimeMonitor{ID: "check-mon-1", Name: "Check Monitor", Type: "http", URL: "http://example.com"}
|
|
db.Create(&monitor)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime/check-mon-1/check", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestUptimeHandler_CheckMonitor_NotFound(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime/nonexistent/check", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func TestUptimeHandler_Update(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
monitorID := "monitor-update"
|
|
monitor := models.UptimeMonitor{
|
|
ID: monitorID,
|
|
Name: "Original Name",
|
|
Interval: 30,
|
|
MaxRetries: 3,
|
|
}
|
|
db.Create(&monitor)
|
|
|
|
updates := map[string]any{
|
|
"interval": 60,
|
|
"max_retries": 5,
|
|
}
|
|
body, _ := json.Marshal(updates)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/uptime/"+monitorID, bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var result models.UptimeMonitor
|
|
err := json.Unmarshal(w.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 60, result.Interval)
|
|
assert.Equal(t, 5, result.MaxRetries)
|
|
})
|
|
|
|
t.Run("invalid_json", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/uptime/monitor-1", bytes.NewBuffer([]byte("invalid")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
t.Run("not_found", func(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
updates := map[string]any{
|
|
"interval": 60,
|
|
}
|
|
body, _ := json.Marshal(updates)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/uptime/nonexistent", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
})
|
|
}
|
|
|
|
func TestUptimeHandler_DeleteAndSync(t *testing.T) {
|
|
t.Run("delete monitor", func(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
monitor := models.UptimeMonitor{ID: "mon-delete", Name: "ToDelete", Type: "http", URL: "http://example.com"}
|
|
db.Create(&monitor)
|
|
|
|
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/mon-delete", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var m models.UptimeMonitor
|
|
require.Error(t, db.First(&m, "id = ?", "mon-delete").Error)
|
|
})
|
|
|
|
t.Run("sync creates monitor for proxy host", func(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
// Create a proxy host to be synced to an uptime monitor
|
|
host := models.ProxyHost{UUID: "ph-up-1", Name: "Test Host", DomainNames: "sync.example.com", ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var monitors []models.UptimeMonitor
|
|
db.Where("proxy_host_id = ?", host.ID).Find(&monitors)
|
|
assert.Len(t, monitors, 1)
|
|
assert.Equal(t, "Test Host", monitors[0].Name)
|
|
})
|
|
|
|
t.Run("update enabled via PUT", func(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
|
|
monitor := models.UptimeMonitor{ID: "mon-enable", Name: "ToToggle", Type: "http", URL: "http://example.com", Enabled: true}
|
|
db.Create(&monitor)
|
|
|
|
updates := map[string]any{"enabled": false}
|
|
body, _ := json.Marshal(updates)
|
|
req, _ := http.NewRequest("PUT", "/api/v1/uptime/mon-enable", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var result models.UptimeMonitor
|
|
err := json.Unmarshal(w.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.False(t, result.Enabled)
|
|
})
|
|
}
|
|
|
|
func TestUptimeHandler_Sync_Success(t *testing.T) {
|
|
r, _ := setupUptimeHandlerTest(t)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var result map[string]string
|
|
err := json.Unmarshal(w.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Sync started", result["message"])
|
|
}
|
|
|
|
func TestUptimeHandler_Delete_Error(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
db.Exec("DROP TABLE IF EXISTS uptime_monitors")
|
|
|
|
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/nonexistent", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
}
|
|
|
|
func TestUptimeHandler_List_Error(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
db.Exec("DROP TABLE IF EXISTS uptime_monitors")
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/uptime", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
}
|
|
|
|
func TestUptimeHandler_GetHistory_Error(t *testing.T) {
|
|
r, db := setupUptimeHandlerTest(t)
|
|
db.Exec("DROP TABLE IF EXISTS uptime_heartbeats")
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/uptime/monitor-1/history", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
}
|