Files
pulse/internal/api/alerts_test.go
T
rcourtman 80aab47da6 Stop swallowing alert config persistence errors
Forward-port of the release/5.1 fix (2c46c6c2d).

UpdateAlertConfig used to log SaveAlertConfig failures and still tell
the client "saved successfully", leaving the in-memory state with the
new override but the on-disk file untouched. On the next config reload
or process restart, the override silently vanished and the user saw
their threshold "revert" with no surfaced error. Return HTTP 500 with
the persistence error so the frontend can show a real save-failed
toast instead of false confidence.
2026-05-28 13:15:55 +01:00

1005 lines
34 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
testifymock "github.com/stretchr/testify/mock"
)
// Mock implementations for testing
type MockAlertManager struct {
testifymock.Mock
}
func (m *MockAlertManager) GetConfig() alerts.AlertConfig {
args := m.Called()
return args.Get(0).(alerts.AlertConfig)
}
func TestExportIncident_UsesCanonicalEmptyCollections(t *testing.T) {
openedAt := time.Unix(1700000000, 0).UTC()
view := exportIncident(&memory.Incident{
ID: "inc-1",
AlertIdentifier: "canonical:inc-1",
AlertType: "cpu",
Level: "warning",
ResourceID: "node/pve-1",
ResourceName: "pve-1",
Status: memory.IncidentStatusOpen,
OpenedAt: openedAt,
Events: []memory.IncidentEvent{
{
ID: "evt-1",
Type: memory.IncidentEventAlertFired,
Timestamp: openedAt,
Summary: "Alert fired",
},
},
})
payload, err := json.Marshal(view)
if err != nil {
t.Fatalf("marshal exported incident: %v", err)
}
if !strings.Contains(string(payload), `"events":[`) {
t.Fatalf("expected exported incident to retain events array, got %s", payload)
}
if !strings.Contains(string(payload), `"details":{}`) {
t.Fatalf("expected exported incident event to retain empty details map, got %s", payload)
}
}
func (m *MockAlertManager) UpdateConfig(cfg alerts.AlertConfig) {
m.Called(cfg)
}
func (m *MockAlertManager) GetActiveAlerts() []alerts.Alert {
args := m.Called()
return args.Get(0).([]alerts.Alert)
}
func (m *MockAlertManager) NotifyExistingAlert(id string) {
m.Called(id)
}
func (m *MockAlertManager) ClearAlertHistory() error {
args := m.Called()
return args.Error(0)
}
func (m *MockAlertManager) UnacknowledgeAlert(id string) error {
args := m.Called(id)
return args.Error(0)
}
func (m *MockAlertManager) AcknowledgeAlert(id, user string) error {
args := m.Called(id, user)
return args.Error(0)
}
func (m *MockAlertManager) ClearAlert(id string) bool {
args := m.Called(id)
return args.Bool(0)
}
func (m *MockAlertManager) GetAlertHistory(limit int) []alerts.Alert {
args := m.Called(limit)
return args.Get(0).([]alerts.Alert)
}
func (m *MockAlertManager) GetAlertHistorySince(since time.Time, limit int) []alerts.Alert {
args := m.Called(since, limit)
return args.Get(0).([]alerts.Alert)
}
type MockAlertMonitor struct {
testifymock.Mock
}
func (m *MockAlertMonitor) GetAlertManager() AlertManager {
args := m.Called()
return args.Get(0).(AlertManager)
}
func (m *MockAlertMonitor) GetConfigPersistence() ConfigPersistence {
args := m.Called()
return args.Get(0).(ConfigPersistence)
}
func (m *MockAlertMonitor) GetIncidentStore() *memory.IncidentStore {
args := m.Called()
if store := args.Get(0); store != nil {
return store.(*memory.IncidentStore)
}
return nil
}
func (m *MockAlertMonitor) GetNotificationManager() *notifications.NotificationManager {
args := m.Called()
return args.Get(0).(*notifications.NotificationManager)
}
func (m *MockAlertMonitor) SyncAlertState() {
m.Called()
}
func (m *MockAlertMonitor) BuildFrontendState() models.StateFrontend {
args := m.Called()
return args.Get(0).(models.StateFrontend)
}
type MockConfigPersistence struct {
testifymock.Mock
}
func (m *MockConfigPersistence) SaveAlertConfig(cfg alerts.AlertConfig) error {
args := m.Called(cfg)
return args.Error(0)
}
// Tests
func TestGetAlertConfig(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
h := NewAlertHandlers(nil, mockMonitor, nil)
cfg := alerts.AlertConfig{Enabled: true}
mockManager.On("GetConfig").Return(cfg)
req := httptest.NewRequest("GET", "/api/alerts/config", nil)
w := httptest.NewRecorder()
h.GetAlertConfig(w, req)
assert.Equal(t, 200, w.Code)
var resp alerts.AlertConfig
_ = json.NewDecoder(w.Body).Decode(&resp)
assert.True(t, resp.Enabled)
}
func TestUpdateAlertConfig(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockPersist := new(MockConfigPersistence)
notificationMgr := notifications.NewNotificationManagerWithDataDir("", t.TempDir())
defer notificationMgr.Stop()
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(mockPersist)
mockMonitor.On("GetNotificationManager").Return(notificationMgr)
h := NewAlertHandlers(nil, mockMonitor, nil)
cfg := alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationPending}
mockManager.On("UpdateConfig", testifymock.Anything).Return()
mockManager.On("GetConfig").Return(cfg)
mockPersist.On("SaveAlertConfig", testifymock.Anything).Return(nil)
body, _ := json.Marshal(cfg)
req := httptest.NewRequest("POST", "/api/alerts/config", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateAlertConfig(w, req)
assert.Equal(t, 200, w.Code)
assert.False(t, notificationMgr.IsEnabled())
}
// Regression for #1341: persistence failures used to be silently swallowed
// (logged but reported as "saved successfully"), so a user setting a custom
// override would see the value stick in memory and revert on reload with
// no clue why. Surface the error so the frontend can show a real
// save-failed signal.
func TestUpdateAlertConfig_PersistenceFailureSurfacesAsError(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockPersist := new(MockConfigPersistence)
notificationMgr := notifications.NewNotificationManagerWithDataDir("", t.TempDir())
defer notificationMgr.Stop()
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(mockPersist)
mockMonitor.On("GetNotificationManager").Return(notificationMgr)
h := NewAlertHandlers(nil, mockMonitor, nil)
cfg := alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationPending}
mockManager.On("UpdateConfig", testifymock.Anything).Return()
mockManager.On("GetConfig").Return(cfg)
mockPersist.On("SaveAlertConfig", testifymock.Anything).Return(errors.New("permission denied"))
body, _ := json.Marshal(cfg)
req := httptest.NewRequest("POST", "/api/alerts/config", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateAlertConfig(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 on persistence failure", w.Code)
}
if !strings.Contains(w.Body.String(), "permission denied") {
t.Fatalf("response body should expose persistence error, got %q", w.Body.String())
}
}
func TestActivateAlerts_EnablesNotificationManager(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockPersist := new(MockConfigPersistence)
notificationMgr := notifications.NewNotificationManagerWithDataDir("", t.TempDir())
defer notificationMgr.Stop()
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(mockPersist)
mockMonitor.On("GetNotificationManager").Return(notificationMgr)
initialConfig := alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationPending}
updatedConfig := alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationActive}
mockManager.On("GetConfig").Return(initialConfig).Once()
mockManager.On("UpdateConfig", testifymock.AnythingOfType("config.AlertConfig")).Run(func(args testifymock.Arguments) {
cfg := args.Get(0).(alerts.AlertConfig)
if cfg.ActivationState != alerts.ActivationActive {
t.Fatalf("expected activation state to be active, got %q", cfg.ActivationState)
}
}).Return()
mockPersist.On("SaveAlertConfig", testifymock.Anything).Return(nil)
mockManager.On("GetActiveAlerts").Return([]alerts.Alert{}).Maybe()
mockManager.On("GetConfig").Return(updatedConfig)
h := NewAlertHandlers(nil, mockMonitor, nil)
req := httptest.NewRequest("POST", "/api/alerts/activate", nil)
w := httptest.NewRecorder()
h.ActivateAlerts(w, req)
assert.Equal(t, 200, w.Code)
assert.True(t, notificationMgr.IsEnabled())
}
func TestGetActiveAlerts(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("GetActiveAlerts").Return([]alerts.Alert{{ID: "a1"}})
req := httptest.NewRequest("GET", "/api/alerts/active", nil)
w := httptest.NewRecorder()
h.GetActiveAlerts(w, req)
assert.Equal(t, 200, w.Code)
var resp []alerts.Alert
_ = json.NewDecoder(w.Body).Decode(&resp)
assert.Len(t, resp, 1)
assert.Equal(t, "a1", resp[0].ID)
}
func TestValidateAlertIdentifier(t *testing.T) {
testCases := []struct {
name string
id string
valid bool
}{
{name: "basic", id: "guest-powered-off-pve-101", valid: true},
{name: "with spaces", id: "cluster one-node-101-cpu", valid: true},
{name: "with slash and colon", id: "pve1:qemu/101-cpu", valid: true},
{name: "empty", id: "", valid: false},
{name: "too long", id: string(make([]byte, 501)), valid: false},
{name: "control char", id: "bad\nvalue", valid: false},
{name: "path traversal", id: "../etc/passwd", valid: false},
{name: "path traversal middle", id: "pve/../secret", valid: false},
}
for _, tc := range testCases {
if got := validateAlertIdentifier(tc.id); got != tc.valid {
t.Errorf("validateAlertIdentifier(%s) = %v, want %v", tc.name, got, tc.valid)
}
}
}
func TestAlertHandlers_SetMonitor(t *testing.T) {
mockMonitor1 := new(MockAlertMonitor)
mockMonitor2 := new(MockAlertMonitor)
h := NewAlertHandlers(nil, mockMonitor1, nil)
assert.Equal(t, mockMonitor1, h.defaultMonitor)
h.SetMonitor(mockMonitor2)
assert.Equal(t, mockMonitor2, h.defaultMonitor)
}
func TestAlertHandlers_SetMonitorConcurrentAccess(t *testing.T) {
mockMonitor1 := new(MockAlertMonitor)
mockMonitor2 := new(MockAlertMonitor)
h := NewAlertHandlers(nil, mockMonitor1, nil)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
for j := 0; j < 500; j++ {
if (worker+j)%2 == 0 {
h.SetMonitor(mockMonitor1)
} else {
h.SetMonitor(mockMonitor2)
}
_ = h.getMonitor(context.Background())
}
}(i)
}
wg.Wait()
assert.NotNil(t, h.getMonitor(context.Background()))
}
func TestGetAlertHistory(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("GetAlertHistory", testifymock.Anything).Return([]alerts.Alert{{ID: "h1"}})
req := httptest.NewRequest("GET", "/api/alerts/history?limit=10", nil)
w := httptest.NewRecorder()
h.GetAlertHistory(w, req)
assert.Equal(t, 200, w.Code)
var resp []alerts.Alert
_ = json.NewDecoder(w.Body).Decode(&resp)
assert.Len(t, resp, 1)
}
func TestClearAlertHistory(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("ClearAlertHistory").Return(nil).Once()
req := httptest.NewRequest("POST", "/api/alerts/history/clear", nil)
w := httptest.NewRecorder()
h.ClearAlertHistory(w, req)
assert.Equal(t, 200, w.Code)
}
func TestSaveAlertIncidentNote(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
h := NewAlertHandlers(nil, mockMonitor, nil)
// Create an incident first so RecordNote has something to attach to
alert := &alerts.Alert{ID: "a1", Type: "test"}
mockStore.RecordAlertFired(alert)
body := `{"alertIdentifier": "a1", "note": "test note", "user": "admin"}`
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(body))
w := httptest.NewRecorder()
h.SaveAlertIncidentNote(w, req)
assert.Equal(t, 200, w.Code)
}
func TestGetAlertIncidentTimeline_ExportsCanonicalAlertIdentifier(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
h := NewAlertHandlers(nil, mockMonitor, nil)
alert := &alerts.Alert{
ID: "canonical:a1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "resource-1",
ResourceName: "resource-1",
Message: "test",
StartTime: time.Now(),
}
mockStore.RecordAlertFired(alert)
req := httptest.NewRequest("GET", "/api/alerts/incidents?alertIdentifier=canonical:a1", nil)
w := httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, req)
assert.Equal(t, 200, w.Code)
var incident map[string]interface{}
_ = json.NewDecoder(w.Body).Decode(&incident)
assert.Equal(t, "canonical:a1", incident["alertIdentifier"])
}
func TestGetAlertIncidentTimeline_AcceptsCamelCaseAlertIdentifierQuery(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
h := NewAlertHandlers(nil, mockMonitor, nil)
alert := &alerts.Alert{
ID: "canonical:a1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "resource-1",
ResourceName: "resource-1",
Message: "test",
StartTime: time.Now(),
}
mockStore.RecordAlertFired(alert)
req := httptest.NewRequest("GET", "/api/alerts/incidents?alertIdentifier=canonical:a1", nil)
w := httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, req)
assert.Equal(t, 200, w.Code)
var incident map[string]interface{}
_ = json.NewDecoder(w.Body).Decode(&incident)
assert.Equal(t, "canonical:a1", incident["alertIdentifier"])
}
func TestGetAlertIncidentTimeline_ListExportsCanonicalAlertIdentifier(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
h := NewAlertHandlers(nil, mockMonitor, nil)
alert := &alerts.Alert{
ID: "canonical:a1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "resource-1",
ResourceName: "resource-1",
Message: "test",
StartTime: time.Now(),
}
mockStore.RecordAlertFired(alert)
req := httptest.NewRequest("GET", "/api/alerts/incidents?resource_id=resource-1", nil)
w := httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, req)
assert.Equal(t, 200, w.Code)
var incidents []map[string]interface{}
_ = json.NewDecoder(w.Body).Decode(&incidents)
assert.Len(t, incidents, 1)
assert.Equal(t, "canonical:a1", incidents[0]["alertIdentifier"])
}
func TestGetAlertIncidentTimeline_ProjectsCanonicalLifecycleAndRemediation(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
canonicalStore := unifiedresources.NewMemoryStore()
incidentStore.SetResourceTimelineStore(canonicalStore)
mockMonitor.On("GetIncidentStore").Return(incidentStore)
h := NewAlertHandlers(nil, mockMonitor, nil)
alertStartedAt := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second)
alert := &alerts.Alert{
ID: "canonical:projected-a1",
Type: "cpu",
Level: alerts.AlertLevelCritical,
ResourceID: "resource-1",
ResourceName: "resource-1",
Message: "CPU high",
StartTime: alertStartedAt,
Value: 95,
Threshold: 80,
}
incidentStore.RecordAlertFired(alert)
incidentStore.RecordAnalysis(alert.ID, "Pulse Patrol analysis completed", map[string]interface{}{"source": "test"})
changes := []*unifiedresources.ResourceChange{
unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertFired, alert.StartTime, "", unifiedresources.AlertTimelineChange{
AlertIdentifier: alert.ID,
AlertType: alert.Type,
AlertLevel: string(alert.Level),
AlertMessage: alert.Message,
AlertValue: alert.Value,
AlertThreshold: alert.Threshold,
}),
unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertAcknowledged, alert.StartTime.Add(2*time.Minute), "operator", unifiedresources.AlertTimelineChange{
AlertIdentifier: alert.ID,
AlertType: alert.Type,
AlertLevel: string(alert.Level),
AlertMessage: alert.Message,
}),
unifiedresources.BuildRunbookExecutionChange(alert.ResourceID, alert.ID, "agent:pulse-patrol", "rb-1", "Restart service", "resolved", true, "Recovered", nil),
unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertResolved, alert.StartTime.Add(5*time.Minute), "", unifiedresources.AlertTimelineChange{
AlertIdentifier: alert.ID,
AlertType: alert.Type,
AlertLevel: string(alert.Level),
AlertMessage: "CPU normalized",
}),
}
for _, change := range changes {
if change == nil {
t.Fatal("expected canonical change to be built")
}
if err := canonicalStore.RecordChange(*change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
req := httptest.NewRequest("GET", "/api/alerts/incidents?alertIdentifier=canonical:projected-a1", nil)
w := httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, req)
assert.Equal(t, 200, w.Code)
var incident map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&incident); err != nil {
t.Fatalf("decode incident response: %v", err)
}
assert.Equal(t, "canonical:projected-a1", incident["alertIdentifier"])
assert.Equal(t, string(memory.IncidentStatusResolved), incident["status"])
assert.Equal(t, true, incident["acknowledged"])
assert.Equal(t, "operator", incident["ackUser"])
events, ok := incident["events"].([]interface{})
if !ok {
t.Fatalf("expected events array, got %#v", incident["events"])
}
foundAnalysis := false
foundAck := false
foundRunbook := false
foundResolved := false
for _, rawEvent := range events {
event, ok := rawEvent.(map[string]interface{})
if !ok {
t.Fatalf("expected event object, got %#v", rawEvent)
}
switch event["type"] {
case string(memory.IncidentEventAnalysis):
foundAnalysis = true
case string(memory.IncidentEventAlertAcknowledged):
foundAck = true
case string(memory.IncidentEventRunbook):
foundRunbook = true
case string(memory.IncidentEventAlertResolved):
foundResolved = true
}
}
assert.True(t, foundAnalysis, "expected analysis annotation in exported timeline")
assert.True(t, foundAck, "expected canonical acknowledgement in exported timeline")
assert.True(t, foundRunbook, "expected canonical runbook in exported timeline")
assert.True(t, foundResolved, "expected canonical resolution in exported timeline")
}
func TestBulkAcknowledgeAlerts(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("AcknowledgeAlert", "a1", testifymock.Anything).Return(nil)
mockManager.On("AcknowledgeAlert", "a2", testifymock.Anything).Return(fmt.Errorf("error"))
body := `{"alertIdentifiers": ["a1", "a2"], "user": "admin"}`
req := httptest.NewRequest("POST", "/api/alerts/bulk/acknowledge", strings.NewReader(body))
w := httptest.NewRecorder()
h.BulkAcknowledgeAlerts(w, req)
assert.Equal(t, 200, w.Code)
var resp struct {
Results []map[string]interface{} `json:"results"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
assert.Len(t, resp.Results, 2)
}
func TestHandleAlerts(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(new(MockConfigPersistence))
mockMonitor.On("GetNotificationManager").Return(&notifications.NotificationManager{})
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
type route struct {
method string
path string
setup func()
}
routes := []route{
{"GET", "/api/alerts/active", func() { mockManager.On("GetActiveAlerts").Return([]alerts.Alert{}).Once() }},
{"GET", "/api/alerts/history", func() {
mockManager.On("GetAlertHistory", mock.MatchedBy(func(int) bool { return true })).Return([]alerts.Alert{}).Once()
}},
{"GET", "/api/alerts/incidents?alertIdentifier=a1", func() {
mockMonitor.On("GetIncidentStore").Return(memory.NewIncidentStore(memory.IncidentStoreConfig{})).Once()
}},
{"POST", "/api/alerts/incidents/note", func() {
store := memory.NewIncidentStore(memory.IncidentStoreConfig{})
store.RecordAlertFired(&alerts.Alert{ID: "a1", Type: "test"})
mockMonitor.On("GetIncidentStore").Return(store).Once()
}},
{"DELETE", "/api/alerts/history", func() { mockManager.On("ClearAlertHistory").Return(nil).Once() }},
{"POST", "/api/alerts/bulk/acknowledge", func() {
mockManager.On("AcknowledgeAlert", mock.Anything, mock.Anything).Return(nil)
mockMonitor.On("SyncAlertState").Return()
}},
{"POST", "/api/alerts/bulk/clear", func() {
mockManager.On("ClearAlert", mock.Anything).Return(true)
mockMonitor.On("SyncAlertState").Return()
}},
{"POST", "/api/alerts/acknowledge", func() {
mockManager.On("AcknowledgeAlert", "a1", testifymock.Anything).Return(nil).Once()
mockMonitor.On("SyncAlertState").Return()
}},
{"POST", "/api/alerts/unacknowledge", func() {
mockManager.On("UnacknowledgeAlert", "a1").Return(nil).Once()
mockMonitor.On("SyncAlertState").Return()
}},
{"POST", "/api/alerts/clear", func() {
mockManager.On("ClearAlert", "a1").Return(true).Once()
mockMonitor.On("SyncAlertState").Return()
}},
}
for _, rt := range routes {
t.Run(rt.method+"_"+rt.path, func(t *testing.T) {
rt.setup()
var body []byte
if rt.method == "POST" || rt.method == "PUT" || rt.method == "DELETE" {
if strings.Contains(rt.path, "bulk") {
body = []byte(`{"alertIdentifiers": ["a1"]}`)
} else if strings.Contains(rt.path, "note") {
body = []byte(`{"alertIdentifier": "a1", "note": "test"}`)
} else {
body = []byte(`{"alertIdentifier": "a1", "user": "admin"}`)
}
}
req := httptest.NewRequest(rt.method, rt.path, bytes.NewReader(body))
w := httptest.NewRecorder()
h.HandleAlerts(w, req)
assert.Equal(t, 200, w.Code)
})
}
// Test NotFound
req := httptest.NewRequest("GET", "/api/alerts/unknown", nil)
w := httptest.NewRecorder()
h.HandleAlerts(w, req)
assert.Equal(t, 404, w.Code)
}
func TestBulkClearAlerts(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("ClearAlert", "a1").Return(true)
mockManager.On("ClearAlert", "a2").Return(false)
body := `{"alertIdentifiers": ["a1", "a2"]}`
req := httptest.NewRequest("POST", "/api/alerts/bulk/clear", strings.NewReader(body))
w := httptest.NewRecorder()
h.BulkClearAlerts(w, req)
assert.Equal(t, 200, w.Code)
}
func TestAcknowledgeAlertByBody_Success(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("AcknowledgeAlert", "a1", testifymock.Anything).Return(nil)
body := `{"alertIdentifier": "a1", "user": "admin"}`
req := httptest.NewRequest("POST", "/api/alerts/acknowledge", strings.NewReader(body))
w := httptest.NewRecorder()
h.AcknowledgeAlertByBody(w, req)
assert.Equal(t, 200, w.Code)
}
func TestAcknowledgeAlertByBody_CanonicalIdentifierSuccess(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("AcknowledgeAlert", "canonical:a1", testifymock.Anything).Return(nil)
body := `{"alertIdentifier": "canonical:a1"}`
req := httptest.NewRequest("POST", "/api/alerts/acknowledge", strings.NewReader(body))
w := httptest.NewRecorder()
h.AcknowledgeAlertByBody(w, req)
assert.Equal(t, 200, w.Code)
}
func TestUnacknowledgeAlertByBody_Success(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("UnacknowledgeAlert", "a1").Return(nil)
body := `{"alertIdentifier": "a1"}`
req := httptest.NewRequest("POST", "/api/alerts/unacknowledge", strings.NewReader(body))
w := httptest.NewRecorder()
h.UnacknowledgeAlertByBody(w, req)
assert.Equal(t, 200, w.Code)
}
func TestUnacknowledgeAlertByBody_CanonicalIdentifierSuccess(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("UnacknowledgeAlert", "canonical:a1").Return(nil)
body := `{"alertIdentifier": "canonical:a1"}`
req := httptest.NewRequest("POST", "/api/alerts/unacknowledge", strings.NewReader(body))
w := httptest.NewRecorder()
h.UnacknowledgeAlertByBody(w, req)
assert.Equal(t, 200, w.Code)
}
func TestClearAlertByBody_Success(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("ClearAlert", "a1").Return(true)
body := `{"alertIdentifier": "a1"}`
req := httptest.NewRequest("POST", "/api/alerts/clear", strings.NewReader(body))
w := httptest.NewRecorder()
h.ClearAlertByBody(w, req)
assert.Equal(t, 200, w.Code)
}
func TestClearAlertByBody_CanonicalIdentifierSuccess(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
mockMonitor.On("SyncAlertState").Return()
h := NewAlertHandlers(nil, mockMonitor, nil)
mockManager.On("ClearAlert", "canonical:a1").Return(true)
body := `{"alertIdentifier": "canonical:a1"}`
req := httptest.NewRequest("POST", "/api/alerts/clear", strings.NewReader(body))
w := httptest.NewRecorder()
h.ClearAlertByBody(w, req)
assert.Equal(t, 200, w.Code)
}
func TestAlertHandlers_ErrorCases(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
mockManager := new(MockAlertManager)
mockMonitor.On("GetAlertManager").Return(mockManager)
h := NewAlertHandlers(nil, mockMonitor, nil)
t.Run("AcknowledgeAlertByBody_InvalidJSON", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/acknowledge", strings.NewReader(`{invalid`))
w := httptest.NewRecorder()
h.AcknowledgeAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("AcknowledgeAlertByBody_MissingID", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/acknowledge", strings.NewReader(`{"alertIdentifier": ""}`))
w := httptest.NewRecorder()
h.AcknowledgeAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("AcknowledgeAlertByBody_InvalidID", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/acknowledge", strings.NewReader(`{"alertIdentifier": "bad\x01"}`))
w := httptest.NewRecorder()
h.AcknowledgeAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("AcknowledgeAlertByBody_ManagerError", func(t *testing.T) {
mockManager.On("AcknowledgeAlert", "a1", testifymock.Anything).Return(fmt.Errorf("error")).Once()
req := httptest.NewRequest("POST", "/api/alerts/acknowledge", strings.NewReader(`{"alertIdentifier": "a1", "user": "admin"}`))
w := httptest.NewRecorder()
h.AcknowledgeAlertByBody(w, req)
assert.Equal(t, 404, w.Code)
})
t.Run("UnacknowledgeAlertByBody_MissingID", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/unacknowledge", strings.NewReader(`{"alertIdentifier": ""}`))
w := httptest.NewRecorder()
h.UnacknowledgeAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("UnacknowledgeAlertByBody_InvalidID", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/unacknowledge", strings.NewReader(`{"alertIdentifier": "bad\x01"}`))
w := httptest.NewRecorder()
h.UnacknowledgeAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("ClearAlertByBody_MissingID", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/clear", strings.NewReader(`{"alertIdentifier": ""}`))
w := httptest.NewRecorder()
h.ClearAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("ClearAlertByBody_InvalidID", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/clear", strings.NewReader(`{"alertIdentifier": "bad\x01"}`))
w := httptest.NewRecorder()
h.ClearAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("ClearAlertByBody_NotFound", func(t *testing.T) {
mockManager.On("ClearAlert", "unknown").Return(false).Once()
req := httptest.NewRequest("POST", "/api/alerts/clear", strings.NewReader(`{"alertIdentifier": "unknown"}`))
w := httptest.NewRecorder()
h.ClearAlertByBody(w, req)
assert.Equal(t, 404, w.Code)
})
t.Run("BulkAcknowledgeAlerts_InvalidJSON", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/bulk/acknowledge", strings.NewReader(`{invalid`))
w := httptest.NewRecorder()
h.BulkAcknowledgeAlerts(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("BulkAcknowledgeAlerts_NoIDs", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/bulk/acknowledge", strings.NewReader(`{"alertIdentifiers": []}`))
w := httptest.NewRecorder()
h.BulkAcknowledgeAlerts(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("BulkAcknowledgeAlerts_CanonicalIdentifiers", func(t *testing.T) {
mockManager.On("AcknowledgeAlert", "canonical:a1", testifymock.Anything).Return(nil).Once()
mockMonitor.On("SyncAlertState").Return().Once()
req := httptest.NewRequest("POST", "/api/alerts/bulk/acknowledge", strings.NewReader(`{"alertIdentifiers": ["canonical:a1"]}`))
w := httptest.NewRecorder()
h.BulkAcknowledgeAlerts(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("UnacknowledgeAlertByBody_InvalidJSON", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/unacknowledge", strings.NewReader(`{invalid`))
w := httptest.NewRecorder()
h.UnacknowledgeAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("ClearAlertByBody_InvalidJSON", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/clear", strings.NewReader(`{invalid`))
w := httptest.NewRecorder()
h.ClearAlertByBody(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("SaveAlertIncidentNote_NoStore", func(t *testing.T) {
mockMonitor2 := new(MockAlertMonitor)
mockMonitor2.On("GetIncidentStore").Return(nil)
h2 := NewAlertHandlers(nil, mockMonitor2, nil)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h2.SaveAlertIncidentNote(w, req)
assert.Equal(t, 503, w.Code)
})
t.Run("SaveAlertIncidentNote_InvalidBody", func(t *testing.T) {
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{invalid`))
w := httptest.NewRecorder()
h.SaveAlertIncidentNote(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("SaveAlertIncidentNote_MissingIDs", func(t *testing.T) {
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{"note": "test"}`))
w := httptest.NewRecorder()
h.SaveAlertIncidentNote(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("SaveAlertIncidentNote_CanonicalIdentifier", func(t *testing.T) {
mockMonitor3 := new(MockAlertMonitor)
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor3.On("GetIncidentStore").Return(mockStore)
h3 := NewAlertHandlers(nil, mockMonitor3, nil)
incidentAlert := &alerts.Alert{
ID: "canonical:a1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "resource-1",
ResourceName: "resource-1",
Message: "test",
StartTime: time.Now(),
}
mockStore.RecordAlertFired(incidentAlert)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{"alertIdentifier": "canonical:a1", "note": "test"}`))
w := httptest.NewRecorder()
h3.SaveAlertIncidentNote(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("SaveAlertIncidentNote_InvalidAlertIdentifier", func(t *testing.T) {
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{"alertIdentifier": "bad\x01", "note": "test"}`))
w := httptest.NewRecorder()
h.SaveAlertIncidentNote(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("SaveAlertIncidentNote_MissingNote", func(t *testing.T) {
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{"alertIdentifier": "a1", "note": ""}`))
w := httptest.NewRecorder()
h.SaveAlertIncidentNote(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("SaveAlertIncidentNote_NotFound", func(t *testing.T) {
mockStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
mockMonitor.On("GetIncidentStore").Return(mockStore)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{"alertIdentifier": "none", "note": "test"}`))
w := httptest.NewRecorder()
h.SaveAlertIncidentNote(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("ClearAlertHistory_Error", func(t *testing.T) {
mockManager.On("ClearAlertHistory").Return(errors.New("failed")).Once()
req := httptest.NewRequest("POST", "/api/alerts/history/clear", nil)
w := httptest.NewRecorder()
h.ClearAlertHistory(w, req)
assert.Equal(t, 500, w.Code)
})
}