Files
synapbus/internal/webhooks/delivery_test.go
T
Algis DumbrisandClaude Opus 4.6 42775df65f feat: webhooks & Kubernetes Job runner for event-driven agents
- Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook)
- HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5)
- 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s)
- Dead letter queue with auto-purge, auto-disable after 50 consecutive failures
- Per-agent rate limiting (60 deliveries/min)
- K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler)
- Auto-detect in-cluster via InClusterConfig, NoopRunner fallback
- REST API for webhook deliveries, dead letters, K8s job runs and logs
- Web UI: webhook management, K8s handler pages, dead letters view
- MultiDispatcher fan-out pattern for webhook + K8s event dispatch
- SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs
- 51 tests across 9 test packages, all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:14:04 +02:00

384 lines
9.8 KiB
Go

package webhooks
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/dispatcher"
)
func setupDeliveryTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
// Create tables
for _, ddl := range []string{
`CREATE TABLE webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_name TEXT NOT NULL,
url TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '[]',
secret_hash TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL,
agent_name TEXT NOT NULL,
event TEXT NOT NULL,
message_id INTEGER NOT NULL,
payload TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
http_status INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
last_error TEXT,
next_retry_at DATETIME,
depth INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
delivered_at DATETIME,
FOREIGN KEY (webhook_id) REFERENCES webhooks(id)
)`,
} {
if _, err := db.Exec(ddl); err != nil {
t.Fatal(err)
}
}
t.Cleanup(func() { db.Close() })
return db
}
func TestDeliveryEngine_SuccessfulDelivery(t *testing.T) {
db := setupDeliveryTestDB(t)
store := NewSQLiteWebhookStore(db)
var received atomic.Int32
var receivedHeaders http.Header
var receivedBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received.Add(1)
receivedHeaders = r.Header.Clone()
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
receivedBody = buf[:n]
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Register webhook
wh := &Webhook{
AgentName: "test-agent",
URL: srv.URL,
Events: []string{"message.received"},
SecretHash: "testhash",
Status: WebhookStatusActive,
}
_, err := store.InsertWebhook(context.Background(), wh)
if err != nil {
t.Fatal(err)
}
service := NewWebhookService(store, true, true)
rateLimiter := NewAgentRateLimiter(60)
engine := NewDeliveryEngine(service, rateLimiter, true)
engine.Start()
defer engine.Stop()
// Dispatch event
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 42,
FromAgent: "sender",
ToAgent: "test-agent",
Body: "hello world",
Priority: 1,
Depth: 0,
}
err = engine.Dispatch(context.Background(), event)
if err != nil {
t.Fatalf("dispatch error: %v", err)
}
// Wait for delivery
deadline := time.After(5 * time.Second)
for received.Load() == 0 {
select {
case <-deadline:
t.Fatal("timed out waiting for delivery")
default:
time.Sleep(50 * time.Millisecond)
}
}
if received.Load() != 1 {
t.Errorf("expected 1 delivery, got %d", received.Load())
}
// Verify headers
if receivedHeaders.Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", receivedHeaders.Get("Content-Type"))
}
if receivedHeaders.Get("X-Synapbus-Event") != "message.received" {
t.Errorf("expected X-Synapbus-Event message.received, got %q", receivedHeaders.Get("X-Synapbus-Event"))
}
if receivedHeaders.Get("X-Synapbus-Signature") == "" {
t.Error("expected X-Synapbus-Signature header to be set")
}
if receivedHeaders.Get("X-Synapbus-Depth") != "1" {
t.Errorf("expected X-Synapbus-Depth 1, got %q", receivedHeaders.Get("X-Synapbus-Depth"))
}
// Verify payload structure
var payload DeliveryPayload
if err := json.Unmarshal(receivedBody, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.Event != "message.received" {
t.Errorf("expected event message.received, got %q", payload.Event)
}
if payload.MessageID != 42 {
t.Errorf("expected message_id 42, got %d", payload.MessageID)
}
if payload.FromAgent != "sender" {
t.Errorf("expected from_agent sender, got %q", payload.FromAgent)
}
if payload.Body != "hello world" {
t.Errorf("expected body 'hello world', got %q", payload.Body)
}
}
func TestDeliveryEngine_DepthExceeded(t *testing.T) {
db := setupDeliveryTestDB(t)
store := NewSQLiteWebhookStore(db)
var received atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Register webhook
wh := &Webhook{
AgentName: "test-agent",
URL: srv.URL,
Events: []string{"message.received"},
SecretHash: "testhash",
Status: WebhookStatusActive,
}
_, err := store.InsertWebhook(context.Background(), wh)
if err != nil {
t.Fatal(err)
}
service := NewWebhookService(store, true, true)
rateLimiter := NewAgentRateLimiter(60)
engine := NewDeliveryEngine(service, rateLimiter, true)
engine.Start()
defer engine.Stop()
// Depth 4 should succeed
event4 := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "sender",
ToAgent: "test-agent",
Body: "depth 4",
Depth: 4,
}
if err := engine.Dispatch(context.Background(), event4); err != nil {
t.Fatalf("dispatch depth 4: %v", err)
}
// Wait for depth 4 delivery
deadline := time.After(5 * time.Second)
for received.Load() == 0 {
select {
case <-deadline:
t.Fatal("timed out waiting for depth 4 delivery")
default:
time.Sleep(50 * time.Millisecond)
}
}
if received.Load() != 1 {
t.Errorf("expected 1 delivery for depth 4, got %d", received.Load())
}
// Depth 5 should be dropped (MaxDepth = 5)
event5 := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 2,
FromAgent: "sender",
ToAgent: "test-agent",
Body: "depth 5",
Depth: 5,
}
if err := engine.Dispatch(context.Background(), event5); err != nil {
t.Fatalf("dispatch depth 5: %v", err)
}
// Short wait — delivery should NOT happen
time.Sleep(500 * time.Millisecond)
if received.Load() != 1 {
t.Errorf("expected depth 5 to be dropped, but got %d deliveries", received.Load())
}
}
func TestDeliveryEngine_RetryOnFailure(t *testing.T) {
db := setupDeliveryTestDB(t)
store := NewSQLiteWebhookStore(db)
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := attempts.Add(1)
if count <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Register webhook
wh := &Webhook{
AgentName: "test-agent",
URL: srv.URL,
Events: []string{"message.received"},
SecretHash: "testhash",
Status: WebhookStatusActive,
}
_, err := store.InsertWebhook(context.Background(), wh)
if err != nil {
t.Fatal(err)
}
service := NewWebhookService(store, true, true)
rateLimiter := NewAgentRateLimiter(60)
engine := NewDeliveryEngine(service, rateLimiter, true)
// Use shorter retry intervals for testing
origIntervals := retryIntervals
retryIntervals = []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 500 * time.Millisecond}
defer func() { retryIntervals = origIntervals }()
engine.Start()
defer engine.Stop()
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "sender",
ToAgent: "test-agent",
Body: "retry test",
Depth: 0,
}
if err := engine.Dispatch(context.Background(), event); err != nil {
t.Fatalf("dispatch error: %v", err)
}
// Wait for retries to complete (server returns 200 on 3rd attempt)
deadline := time.After(15 * time.Second)
for attempts.Load() < 3 {
select {
case <-deadline:
t.Fatalf("timed out waiting for retries, got %d attempts", attempts.Load())
default:
time.Sleep(100 * time.Millisecond)
}
}
if attempts.Load() < 3 {
t.Errorf("expected at least 3 attempts, got %d", attempts.Load())
}
}
func TestDeliveryEngine_DeadLetterAfterMaxRetries(t *testing.T) {
db := setupDeliveryTestDB(t)
store := NewSQLiteWebhookStore(db)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
// Register webhook
wh := &Webhook{
AgentName: "test-agent",
URL: srv.URL,
Events: []string{"message.received"},
SecretHash: "testhash",
Status: WebhookStatusActive,
}
_, err := store.InsertWebhook(context.Background(), wh)
if err != nil {
t.Fatal(err)
}
service := NewWebhookService(store, true, true)
rateLimiter := NewAgentRateLimiter(60)
engine := NewDeliveryEngine(service, rateLimiter, true)
// Use shorter retry intervals for testing
origIntervals := retryIntervals
retryIntervals = []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 500 * time.Millisecond}
defer func() { retryIntervals = origIntervals }()
engine.Start()
defer engine.Stop()
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "sender",
ToAgent: "test-agent",
Body: "dead letter test",
Depth: 0,
}
if err := engine.Dispatch(context.Background(), event); err != nil {
t.Fatalf("dispatch error: %v", err)
}
// Wait for all retries to exhaust
deadline := time.After(20 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for dead letter")
default:
time.Sleep(200 * time.Millisecond)
}
deliveries, err := store.GetDeliveriesByAgent(context.Background(), "test-agent", DeliveryStatusDeadLettered, 10)
if err != nil {
t.Fatalf("get deliveries: %v", err)
}
if len(deliveries) > 0 {
if deliveries[0].Attempts < maxAttempts {
t.Errorf("expected %d attempts, got %d", maxAttempts, deliveries[0].Attempts)
}
return // Test passes
}
}
}