package alertcontrolapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/itworx/pulse/internal/alertcontrol" "github.com/itworx/pulse/internal/audit" "github.com/itworx/pulse/internal/auth" ) type memoryStore struct { mu sync.Mutex silences map[string]alertcontrol.Silence maintenance map[string]alertcontrol.MaintenanceWindow } func newMemoryStore() *memoryStore { return &memoryStore{silences: map[string]alertcontrol.Silence{}, maintenance: map[string]alertcontrol.MaintenanceWindow{}} } func (s *memoryStore) CreateSilence(_ context.Context, actor string, item alertcontrol.Silence) (alertcontrol.Silence, error) { s.mu.Lock() defer s.mu.Unlock() if item.ID == "" { item.ID = alertcontrol.NewID() } if item.Owner == "" { item.Owner = actor } if err := item.Validate(time.Now().UTC()); err != nil { return alertcontrol.Silence{}, err } if _, ok := s.silences[item.ID]; ok { return alertcontrol.Silence{}, alertcontrol.ErrConflict } item.CreatedBy, item.CreatedAt, item.Revision = actor, time.Now().UTC(), 1 item.State = item.StateAt(time.Now().UTC()) s.silences[item.ID] = item return item, nil } func (s *memoryStore) ListSilences(_ context.Context, _ int, now time.Time) ([]alertcontrol.Silence, error) { s.mu.Lock() defer s.mu.Unlock() items := make([]alertcontrol.Silence, 0, len(s.silences)) for _, item := range s.silences { item.State = item.StateAt(now) items = append(items, item) } return items, nil } func (s *memoryStore) RevokeSilence(_ context.Context, id, actor string, expected int64, now time.Time) (alertcontrol.Silence, error) { s.mu.Lock() defer s.mu.Unlock() item, ok := s.silences[id] if !ok { return alertcontrol.Silence{}, alertcontrol.ErrNotFound } if item.Revision != expected || item.StateAt(now) != alertcontrol.StateActive { return alertcontrol.Silence{}, alertcontrol.ErrConflict } item.RevokedBy, item.RevokedAt, item.Revision, item.State = actor, &now, item.Revision+1, alertcontrol.StateRevoked s.silences[id] = item return item, nil } func (s *memoryStore) CreateMaintenance(_ context.Context, actor string, item alertcontrol.MaintenanceWindow) (alertcontrol.MaintenanceWindow, error) { s.mu.Lock() defer s.mu.Unlock() if item.ID == "" { item.ID = alertcontrol.NewID() } if err := item.Validate(time.Now().UTC()); err != nil { return alertcontrol.MaintenanceWindow{}, err } item.CreatedBy, item.CreatedAt, item.Revision = actor, time.Now().UTC(), 1 item.State = item.StateAt(time.Now().UTC()) s.maintenance[item.ID] = item return item, nil } func (s *memoryStore) ListMaintenance(_ context.Context, _ int, now time.Time) ([]alertcontrol.MaintenanceWindow, error) { s.mu.Lock() defer s.mu.Unlock() items := make([]alertcontrol.MaintenanceWindow, 0, len(s.maintenance)) for _, item := range s.maintenance { item.State = item.StateAt(now) items = append(items, item) } return items, nil } func (s *memoryStore) RevokeMaintenance(_ context.Context, id, actor string, expected int64, now time.Time) (alertcontrol.MaintenanceWindow, error) { s.mu.Lock() defer s.mu.Unlock() item, ok := s.maintenance[id] if !ok { return alertcontrol.MaintenanceWindow{}, alertcontrol.ErrNotFound } if item.Revision != expected { return alertcontrol.MaintenanceWindow{}, alertcontrol.ErrConflict } item.RevokedBy, item.RevokedAt, item.Revision, item.State = actor, &now, item.Revision+1, alertcontrol.StateRevoked s.maintenance[id] = item return item, nil } func (s *memoryStore) Expire(_ context.Context, now time.Time) (alertcontrol.ExpiryResult, error) { s.mu.Lock() defer s.mu.Unlock() var result alertcontrol.ExpiryResult for id, item := range s.silences { if item.State == alertcontrol.StateActive && !now.Before(item.ExpiresAt) { item.ExpiredAt, item.Revision = &now, item.Revision+1 s.silences[id] = item result.Silences++ } } for id, item := range s.maintenance { if item.State == alertcontrol.StateActive && !now.Before(item.EndsAt) { item.ExpiredAt, item.Revision = &now, item.Revision+1 s.maintenance[id] = item result.MaintenanceWindows++ } } return result, nil } func TestHandlerEnforcesRBACAuditPreviewAndVisibleMaintenance(t *testing.T) { store := newMemoryStore() auditStore := &audit.MemoryStore{} handler := Handler{Store: store, Audit: auditStore} now := time.Now().UTC() silence := alertcontrol.Silence{Name: "deploy", Reason: "planned", Owner: "operator", Matchers: alertcontrol.Matcher{Severities: []string{"critical"}}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)} viewer := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences", silence, auth.RoleViewer) response := httptest.NewRecorder() handler.ServeHTTP(response, viewer) if response.Code != http.StatusForbidden { t.Fatalf("viewer create status = %d", response.Code) } operator := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences", silence, auth.RoleOperator) response = httptest.NewRecorder() handler.ServeHTTP(response, operator) if response.Code != http.StatusCreated { t.Fatalf("operator create status = %d body=%s", response.Code, response.Body.String()) } var created struct { Silence alertcontrol.Silence `json:"silence"` } if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { t.Fatal(err) } if len(auditStore.Events) != 1 || auditStore.Events[0].Action != "alert_silence.create" { t.Fatalf("audit events = %#v", auditStore.Events) } previewBody := map[string]any{"matcher": alertcontrol.Matcher{Severities: []string{"critical"}}, "signals": []alertcontrol.Signal{{InstanceID: "i-1", Severity: "critical"}}} preview := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences/preview", previewBody, auth.RoleViewer) response = httptest.NewRecorder() handler.ServeHTTP(response, preview) if response.Code != http.StatusOK || !contains(response.Body.String(), `"matched":true`) { t.Fatalf("preview response = %d %s", response.Code, response.Body.String()) } window := alertcontrol.MaintenanceWindow{Name: "maintenance", Reason: "upgrade", Selector: alertcontrol.Matcher{EntityTypes: []string{"host"}}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour)} request := requestWithPrincipal(http.MethodPost, "/api/v1/maintenance-windows", window, auth.RoleOperator) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusCreated { t.Fatalf("maintenance create status = %d", response.Code) } request = requestWithPrincipal(http.MethodGet, "/api/v1/maintenance-windows", nil, auth.RoleViewer) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || !contains(response.Body.String(), `"state":"active"`) { t.Fatalf("maintenance list = %d %s", response.Code, response.Body.String()) } request = requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences/"+created.Silence.ID+"/revoke?revision=999", nil, auth.RoleOperator) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusConflict { t.Fatalf("stale revoke status = %d", response.Code) } } func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request { var reader *strings.Reader if body == nil { reader = strings.NewReader("") } else { encoded, _ := json.Marshal(body) reader = strings.NewReader(string(encoded)) } request := httptest.NewRequest(method, path, reader).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role})) request.Header.Set("Content-Type", "application/json") return request } func contains(value, part string) bool { return strings.Contains(value, part) }