package alertopsapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/itworx/pulse/internal/alert" "github.com/itworx/pulse/internal/audit" "github.com/itworx/pulse/internal/auth" ) type memoryStore struct { mu sync.Mutex items map[string]alert.Alert occurrences map[string]alert.Occurrence } func newMemoryStore() *memoryStore { return &memoryStore{items: map[string]alert.Alert{}, occurrences: map[string]alert.Occurrence{}} } func (s *memoryStore) ListAlerts(_ context.Context, _ int, state string) ([]alert.Alert, error) { s.mu.Lock() defer s.mu.Unlock() result := make([]alert.Alert, 0) for _, item := range s.items { if state == "" || string(item.State) == state { result = append(result, item) } } return result, nil } func (s *memoryStore) GetAlert(_ context.Context, id string, _ int) (alert.Alert, error) { s.mu.Lock() defer s.mu.Unlock() item, ok := s.items[id] if !ok { return alert.Alert{}, alert.ErrInstanceNotFound } return item, nil } func (s *memoryStore) AcknowledgeRevision(_ context.Context, id, actor, key string, at time.Time, expected int64) (alert.Instance, alert.Occurrence, bool, error) { return s.mutate(id, actor, key, at, expected, true) } func (s *memoryStore) Unacknowledge(_ context.Context, id, actor, key string, at time.Time, expected int64) (alert.Instance, alert.Occurrence, bool, error) { return s.mutate(id, actor, key, at, expected, false) } func (s *memoryStore) mutate(id, actor, key string, at time.Time, expected int64, acknowledge bool) (alert.Instance, alert.Occurrence, bool, error) { s.mu.Lock() defer s.mu.Unlock() item, ok := s.items[id] if !ok { return alert.Instance{}, alert.Occurrence{}, false, alert.ErrInstanceNotFound } if occurrence, ok := s.occurrences[key]; ok { return item.Instance, occurrence, true, nil } if item.Revision != expected { return alert.Instance{}, alert.Occurrence{}, false, alert.ErrRevisionConflict } if acknowledge { if item.State != alert.StateFiring && item.State != alert.StatePending { return alert.Instance{}, alert.Occurrence{}, false, alert.ErrStateConflict } item.State, item.RetainedState, item.AcknowledgedBy, item.AcknowledgedAt, item.Reason = alert.StateAcknowledged, alert.StateAcknowledged, actor, &at, "acknowledged" } else { if item.State != alert.StateAcknowledged { return alert.Instance{}, alert.Occurrence{}, false, alert.ErrStateConflict } item.State, item.RetainedState, item.AcknowledgedBy, item.AcknowledgedAt, item.Reason = alert.StateFiring, alert.StateFiring, "", nil, "unacknowledged" } item.Revision++ occurrence := alert.Occurrence{ID: key, InstanceID: id, EvaluationKey: key, EventType: "acknowledge", From: alert.StateFiring, To: item.State, ObservedAt: at, Reason: item.Reason} if !acknowledge { occurrence.EventType = "unacknowledge" occurrence.From = alert.StateAcknowledged } s.items[id] = item s.occurrences[key] = occurrence return item.Instance, occurrence, false, nil } func TestHandlerRoleMatrixIdempotenceAndAudit(t *testing.T) { store := newMemoryStore() store.items["instance-1"] = alert.Alert{Instance: alert.Instance{ID: "instance-1", State: alert.StateFiring, RetainedState: alert.StateFiring, Revision: 1, LastValue: 90, SourceHealth: map[string]any{}}, RuleName: "CPU", Severity: alert.SeverityCritical} auditStore := &audit.MemoryStore{} handler := Handler{Store: store, Audit: auditStore} viewer := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleViewer) response := httptest.NewRecorder() handler.ServeHTTP(response, viewer) if response.Code != http.StatusForbidden { t.Fatalf("viewer status = %d", response.Code) } operator := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleOperator) response = httptest.NewRecorder() handler.ServeHTTP(response, operator) if response.Code != http.StatusOK { t.Fatalf("ack status = %d body=%s", response.Code, response.Body.String()) } retry := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleOperator) response = httptest.NewRecorder() handler.ServeHTTP(response, retry) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"duplicate":true`) { t.Fatalf("duplicate ack = %d %s", response.Code, response.Body.String()) } unack := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/unacknowledge?revision=2", map[string]string{"evaluationKey": "unack-1"}, auth.RoleOperator) response = httptest.NewRecorder() handler.ServeHTTP(response, unack) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"state":"firing"`) { t.Fatalf("unack = %d %s", response.Code, response.Body.String()) } list := requestWithPrincipal(http.MethodGet, "/api/v1/alerts?state=firing", nil, auth.RoleViewer) response = httptest.NewRecorder() handler.ServeHTTP(response, list) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"ruleName":"CPU"`) { t.Fatalf("list = %d %s", response.Code, response.Body.String()) } if len(auditStore.Events) != 3 || auditStore.Events[1].Result != "idempotent" { t.Fatalf("audit = %#v", auditStore.Events) } } func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request { encoded := "" if body != nil { value, _ := json.Marshal(body) encoded = string(value) } request := httptest.NewRequest(method, path, strings.NewReader(encoded)).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role})) request.Header.Set("Content-Type", "application/json") return request }