Public source validation / validate (push) Failing after 3m8s
198 lines
7.6 KiB
Go
198 lines
7.6 KiB
Go
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) }
|