chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package mockalertreceiver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReceiverVerifiesAndRecordsSignedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := "test-alert-receiver-secret-at-least-32-bytes"
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
server, err := New(Options{Secret: secret, ClockSkew: 5 * time.Minute}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now }
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
body, _ := json.Marshal(envelope{EventID: "10000000-0000-0000-0000-000000000001", EventType: "alert.webhook_test_requested", Data: json.RawMessage(`{"test_ref":"10000000-0000-0000-0000-000000000099"}`)})
|
||||
timestamp := strconv.FormatInt(now.Unix(), 10)
|
||||
request, _ := http.NewRequest(http.MethodPost, httpServer.URL+"/webhooks/alerts", bytes.NewReader(body))
|
||||
request.Header.Set("X-Voice-Event-ID", "10000000-0000-0000-0000-000000000001")
|
||||
request.Header.Set("X-Voice-Timestamp", timestamp)
|
||||
request.Header.Set("X-Voice-Signature", testSignature([]byte(secret), timestamp, body))
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delivery status=%d", response.StatusCode)
|
||||
}
|
||||
response, err = http.Get(httpServer.URL + "/events?test_ref=10000000-0000-0000-0000-000000000099")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result struct {
|
||||
Items []Event `json:"items"`
|
||||
}
|
||||
if err = json.NewDecoder(response.Body).Decode(&result); err != nil || len(result.Items) != 1 || result.Items[0].EventType != "alert.webhook_test_requested" {
|
||||
t.Fatalf("events=%+v err=%v", result.Items, err)
|
||||
}
|
||||
|
||||
invalid, _ := http.NewRequest(http.MethodPost, httpServer.URL+"/webhooks/alerts", bytes.NewReader(body))
|
||||
invalid.Header.Set("X-Voice-Event-ID", "10000000-0000-0000-0000-000000000001")
|
||||
invalid.Header.Set("X-Voice-Timestamp", timestamp)
|
||||
invalid.Header.Set("X-Voice-Signature", "v1=00")
|
||||
response, err = http.DefaultClient.Do(invalid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("invalid signature status=%d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func testSignature(secret []byte, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(body)
|
||||
return "v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user