Files
Charon/backend/internal/api/handlers/uptime_handler_test.go
GitHub Actions ed89295012 feat: wire MailService into notification dispatch pipeline (Stage 3)
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
2026-03-06 02:06:49 +00:00

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)
}