chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package alertnotification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
)
|
||||
|
||||
const maxErrorBody = 1024
|
||||
|
||||
type webhookEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type Processor struct {
|
||||
endpoint string
|
||||
secret []byte
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewProcessor(endpoint, secret string, timeout time.Duration) (*Processor, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
secret = strings.TrimSpace(secret)
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("alert webhook timeout must be positive")
|
||||
}
|
||||
if endpoint == "" {
|
||||
return &Processor{client: &http.Client{Timeout: timeout}, now: time.Now}, nil
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, errors.New("alert webhook URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if len(secret) < 32 {
|
||||
return nil, errors.New("alert webhook secret must contain at least 32 characters")
|
||||
}
|
||||
return &Processor{endpoint: endpoint, secret: []byte(secret), client: &http.Client{Timeout: timeout}, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
if p.endpoint == "" {
|
||||
if event.EventType == "alert.webhook_test_requested" {
|
||||
return errors.New("alert webhook delivery is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if event.ID == "" || !supportedEventType(event.EventType) || !json.Valid(event.Payload) {
|
||||
return errors.New("invalid alert webhook event")
|
||||
}
|
||||
body, err := json.Marshal(webhookEnvelope{EventID: event.ID, EventType: event.EventType, Data: event.Payload})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode alert webhook: %w", err)
|
||||
}
|
||||
timestamp := strconv.FormatInt(p.now().UTC().Unix(), 10)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build alert webhook request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "voice-platform-alert-webhook/1.0")
|
||||
req.Header.Set("X-Voice-Event-ID", event.ID)
|
||||
req.Header.Set("X-Voice-Timestamp", timestamp)
|
||||
req.Header.Set("X-Voice-Signature", sign(p.secret, timestamp, body))
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return outbox.Retryable(fmt.Errorf("deliver alert webhook: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBody))
|
||||
return nil
|
||||
}
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
||||
requestErr := fmt.Errorf("alert webhook returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
return outbox.Retryable(requestErr)
|
||||
}
|
||||
return requestErr
|
||||
}
|
||||
|
||||
func supportedEventType(eventType string) bool {
|
||||
return eventType == "alert.webhook_requested" || eventType == "alert.webhook_test_requested"
|
||||
}
|
||||
|
||||
func sign(secret []byte, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("."))
|
||||
_, _ = mac.Write(body)
|
||||
return "v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package alertnotification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
)
|
||||
|
||||
func TestProcessorDeliversSignedEnvelope(t *testing.T) {
|
||||
secret := "test-alert-webhook-secret-at-least-32-bytes"
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
var received bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received = true
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Header.Get("X-Voice-Event-ID") != "10000000-0000-0000-0000-000000000001" || r.Header.Get("X-Voice-Timestamp") != "1800000000" {
|
||||
t.Fatalf("missing delivery identity headers")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte("1800000000."))
|
||||
_, _ = mac.Write(body)
|
||||
want := "v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if r.Header.Get("X-Voice-Signature") != want {
|
||||
t.Fatalf("signature=%q want=%q", r.Header.Get("X-Voice-Signature"), want)
|
||||
}
|
||||
var envelope webhookEnvelope
|
||||
if err = json.Unmarshal(body, &envelope); err != nil || envelope.EventType != "alert.webhook_requested" || string(envelope.Data) != `{"severity":"HIGH"}` {
|
||||
t.Fatalf("envelope=%+v err=%v", envelope, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
processor, err := NewProcessor(server.URL, secret, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processor.now = func() time.Time { return now }
|
||||
event := outbox.Event{ID: "10000000-0000-0000-0000-000000000001", EventType: "alert.webhook_requested", Payload: json.RawMessage(`{"severity":"HIGH"}`)}
|
||||
if err = processor.Handle(context.Background(), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !received {
|
||||
t.Fatal("webhook was not delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorRetryClassificationAndDisabledMode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
processor, err := NewProcessor(server.URL, "test-alert-webhook-secret-at-least-32-bytes", time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := outbox.Event{ID: "10000000-0000-0000-0000-000000000001", EventType: "alert.webhook_requested", Payload: json.RawMessage(`{}`)}
|
||||
if err = processor.Handle(context.Background(), event); !outbox.IsRetryable(err) {
|
||||
t.Fatalf("503 error should be retryable: %v", err)
|
||||
}
|
||||
disabled, err := NewProcessor("", "", time.Second)
|
||||
if err != nil || disabled.Handle(context.Background(), event) != nil {
|
||||
t.Fatalf("disabled processor err=%v", err)
|
||||
}
|
||||
if _, err = NewProcessor(server.URL, "short", time.Second); err == nil {
|
||||
t.Fatal("short signing secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorRequiresConfigurationForDeliveryTest(t *testing.T) {
|
||||
t.Parallel()
|
||||
processor, err := NewProcessor("", "", time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testEvent := outbox.Event{ID: "10000000-0000-0000-0000-000000000001", EventType: "alert.webhook_test_requested", Payload: json.RawMessage(`{}`)}
|
||||
if err = processor.Handle(context.Background(), testEvent); err == nil || outbox.IsRetryable(err) {
|
||||
t.Fatalf("expected a permanent configuration error, got %v", err)
|
||||
}
|
||||
regularEvent := outbox.Event{ID: testEvent.ID, EventType: "alert.webhook_requested", Payload: testEvent.Payload}
|
||||
if err = processor.Handle(context.Background(), regularEvent); err != nil {
|
||||
t.Fatalf("regular alert delivery must remain disabled-safe: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user