package mockalertreceiver import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net/http" "strconv" "strings" "sync" "time" ) const maxBodyBytes = 64 * 1024 type Options struct { Secret string ClockSkew time.Duration } type Event struct { EventID string `json:"event_id"` EventType string `json:"event_type"` TestRef string `json:"test_ref,omitempty"` Data json.RawMessage `json:"data"` ReceivedAt time.Time `json:"received_at"` } type envelope struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Data json.RawMessage `json:"data"` } type Server struct { secret []byte clockSkew time.Duration logger *slog.Logger now func() time.Time mu sync.RWMutex events []Event } func New(options Options, logger *slog.Logger) (*Server, error) { options.Secret = strings.TrimSpace(options.Secret) if len(options.Secret) < 32 { return nil, errors.New("mock alert receiver secret must contain at least 32 characters") } if options.ClockSkew <= 0 { return nil, errors.New("mock alert receiver clock skew must be positive") } if logger == nil { return nil, errors.New("mock alert receiver logger is required") } return &Server{secret: []byte(options.Secret), clockSkew: options.ClockSkew, logger: logger, now: time.Now, events: []Event{}}, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "alive", "service": "mock-alert-receiver"}) }) mux.HandleFunc("POST /webhooks/alerts", s.receive) mux.HandleFunc("GET /events", s.list) return mux } func (s *Server) receive(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) if err != nil || len(body) > maxBodyBytes { http.Error(w, "invalid body", http.StatusBadRequest) return } timestamp := strings.TrimSpace(r.Header.Get("X-Voice-Timestamp")) eventID := strings.TrimSpace(r.Header.Get("X-Voice-Event-ID")) unixSeconds, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || eventID == "" || s.now().UTC().Sub(time.Unix(unixSeconds, 0).UTC()).Abs() > s.clockSkew || !validSignature(s.secret, timestamp, body, r.Header.Get("X-Voice-Signature")) { http.Error(w, "invalid webhook authentication", http.StatusUnauthorized) return } var incoming envelope if err = json.Unmarshal(body, &incoming); err != nil || incoming.EventID != eventID || incoming.EventType == "" || !json.Valid(incoming.Data) { http.Error(w, "invalid webhook envelope", http.StatusBadRequest) return } var metadata struct { TestRef string `json:"test_ref"` } _ = json.Unmarshal(incoming.Data, &metadata) received := Event{EventID: incoming.EventID, EventType: incoming.EventType, TestRef: metadata.TestRef, Data: incoming.Data, ReceivedAt: s.now().UTC()} s.mu.Lock() found := false for index := range s.events { if s.events[index].EventID == received.EventID { s.events[index] = received found = true break } } if !found { s.events = append([]Event{received}, s.events...) if len(s.events) > 100 { s.events = s.events[:100] } } s.mu.Unlock() s.logger.InfoContext(r.Context(), "mock alert webhook received", "event_id", received.EventID, "event_type", received.EventType, "test_ref", received.TestRef) w.WriteHeader(http.StatusNoContent) } func (s *Server) list(w http.ResponseWriter, r *http.Request) { testRef := strings.TrimSpace(r.URL.Query().Get("test_ref")) s.mu.RLock() items := make([]Event, 0, len(s.events)) for _, item := range s.events { if testRef == "" || item.TestRef == testRef { items = append(items, item) } } s.mu.RUnlock() w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, map[string]any{"items": items}) } func validSignature(secret []byte, timestamp string, body []byte, provided string) bool { mac := hmac.New(sha256.New, secret) _, _ = mac.Write([]byte(timestamp)) _, _ = mac.Write([]byte(".")) _, _ = mac.Write(body) want := mac.Sum(nil) provided = strings.TrimSpace(provided) if !strings.HasPrefix(provided, "v1=") { return false } provided = strings.TrimPrefix(provided, "v1=") got, err := hex.DecodeString(provided) return err == nil && hmac.Equal(want, got) } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }