Files
hanzoai_agent/control-plane/internal/services/webhook_dispatcher_test.go
zeekay 2899aeaa9f feat: merge agents control plane + SDKs into unified agent repo
Consolidate all Hanzo agent infrastructure into one repo:
- control-plane/: Go control plane server (was agents-new)
- sdk/: Go, Python, TypeScript SDKs
- deployments/: Docker, Kubernetes, Helm configs
- Dockerfile.sdk: Python SDK image (ghcr.io/hanzoai/agent:latest)
- CI builds both SDK + control-plane images

No more agentfield — everything is hanzoai/agent.
2026-02-13 12:24:31 -08:00

797 lines
22 KiB
Go

package services
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/hanzoai/agents/control-plane/pkg/types"
"github.com/stretchr/testify/require"
)
// mockWebhookStore implements WebhookStore for testing
type mockWebhookStore struct {
executions map[string]*types.Execution
webhooks map[string]*types.ExecutionWebhook
webhookEvents []*types.ExecutionWebhookEvent
inFlightMarked map[string]time.Time
stateUpdates map[string]types.ExecutionWebhookStateUpdate
}
func newMockWebhookStore() *mockWebhookStore {
return &mockWebhookStore{
executions: make(map[string]*types.Execution),
webhooks: make(map[string]*types.ExecutionWebhook),
webhookEvents: make([]*types.ExecutionWebhookEvent, 0),
inFlightMarked: make(map[string]time.Time),
stateUpdates: make(map[string]types.ExecutionWebhookStateUpdate),
}
}
func (m *mockWebhookStore) GetExecutionRecord(ctx context.Context, executionID string) (*types.Execution, error) {
return m.executions[executionID], nil
}
func (m *mockWebhookStore) GetExecutionWebhook(ctx context.Context, executionID string) (*types.ExecutionWebhook, error) {
return m.webhooks[executionID], nil
}
func (m *mockWebhookStore) TryMarkExecutionWebhookInFlight(ctx context.Context, executionID string, now time.Time) (bool, error) {
webhook, exists := m.webhooks[executionID]
if !exists {
return false, nil
}
if webhook.Status == types.ExecutionWebhookStatusDelivering {
return false, nil
}
webhook.Status = types.ExecutionWebhookStatusDelivering
m.inFlightMarked[executionID] = now
return true, nil
}
func (m *mockWebhookStore) UpdateExecutionWebhookState(ctx context.Context, executionID string, update types.ExecutionWebhookStateUpdate) error {
webhook, exists := m.webhooks[executionID]
if !exists {
return nil
}
if update.Status != "" {
webhook.Status = update.Status
}
if update.AttemptCount > 0 {
webhook.AttemptCount = update.AttemptCount
}
if update.NextAttemptAt != nil {
webhook.NextAttemptAt = update.NextAttemptAt
}
if update.LastAttemptAt != nil {
webhook.LastAttemptAt = update.LastAttemptAt
}
if update.LastError != nil {
webhook.LastError = update.LastError
}
m.stateUpdates[executionID] = update
return nil
}
func (m *mockWebhookStore) StoreExecutionWebhookEvent(ctx context.Context, event *types.ExecutionWebhookEvent) error {
m.webhookEvents = append(m.webhookEvents, event)
return nil
}
func (m *mockWebhookStore) ListDueExecutionWebhooks(ctx context.Context, limit int) ([]*types.ExecutionWebhook, error) {
var due []*types.ExecutionWebhook
now := time.Now().UTC()
count := 0
for _, webhook := range m.webhooks {
if count >= limit {
break
}
if webhook.Status == types.ExecutionWebhookStatusPending {
if webhook.NextAttemptAt == nil || webhook.NextAttemptAt.Before(now) || webhook.NextAttemptAt.Equal(now) {
due = append(due, webhook)
count++
}
}
}
return due, nil
}
func (m *mockWebhookStore) GetAgent(ctx context.Context, id string) (*types.AgentNode, error) {
return nil, nil
}
func TestWebhookDispatcher_NewWebhookDispatcher(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
MaxAttempts: 3,
RetryBackoff: 1 * time.Second,
MaxRetryBackoff: 30 * time.Second,
PollInterval: 2 * time.Second,
PollBatchSize: 10,
WorkerCount: 2,
QueueSize: 100,
ResponseBodyLimit: 8192,
}
dispatcher := NewWebhookDispatcher(store, cfg)
require.NotNil(t, dispatcher)
}
func TestWebhookDispatcher_NormalizeWebhookConfig(t *testing.T) {
store := newMockWebhookStore()
// Test with zero values (should use defaults)
cfg := WebhookDispatcherConfig{}
dispatcher := NewWebhookDispatcher(store, cfg)
require.NotNil(t, dispatcher)
// Test with custom values
cfg2 := WebhookDispatcherConfig{
Timeout: 10 * time.Second,
MaxAttempts: 5,
RetryBackoff: 2 * time.Second,
PollInterval: 3 * time.Second,
WorkerCount: 4,
QueueSize: 200,
}
dispatcher2 := NewWebhookDispatcher(store, cfg2)
require.NotNil(t, dispatcher2)
}
func TestWebhookDispatcher_Start_Success(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 2,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_Start_NilStore(t *testing.T) {
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 2,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(nil, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.Error(t, err)
require.Contains(t, err.Error(), "requires a store")
}
func TestWebhookDispatcher_Start_AlreadyStarted(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 2,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
// Start again should be idempotent
err = dispatcher.Start(ctx)
require.NoError(t, err)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_Stop_NotStarted(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Stop(ctx)
require.NoError(t, err) // Should not error if not started
}
func TestWebhookDispatcher_Notify_Success(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
// Create execution and webhook
executionID := "exec-notify-1"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
}
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: "http://example.com/webhook",
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
}
// Notify
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_Notify_EmptyExecutionID(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
// Notify with empty execution ID should not error
err = dispatcher.Notify(ctx, "")
require.NoError(t, err)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_Notify_NotStarted(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Notify(ctx, "exec-1")
require.Error(t, err)
require.Contains(t, err.Error(), "has not been started")
}
func TestWebhookDispatcher_Notify_NoWebhook(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
// Notify for execution without webhook
err = dispatcher.Notify(ctx, "exec-no-webhook")
require.NoError(t, err) // Should not error, just return
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_Notify_AlreadyDelivered(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-delivered"
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: "http://example.com/webhook",
Status: types.ExecutionWebhookStatusDelivered,
}
// Notify should not queue already delivered webhook
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_Notify_AlreadyFailed(t *testing.T) {
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-failed"
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: "http://example.com/webhook",
Status: types.ExecutionWebhookStatusFailed,
}
// Notify should not queue already failed webhook
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_DispatchWebhook_Success(t *testing.T) {
// Create a test HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
require.Equal(t, "/webhook", r.URL.Path)
// Check for signature header if secret is provided
if r.Header.Get("X-HanzoAgents-Signature") != "" {
require.NotEmpty(t, r.Header.Get("X-HanzoAgents-Signature"))
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"received": true}`))
}))
defer server.Close()
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
// Create execution and webhook
executionID := "exec-dispatch-1"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
CompletedAt: timePtr(time.Now()),
DurationMS: int64Ptr(100),
}
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: server.URL + "/webhook",
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
}
// Notify
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Wait a bit for processing
time.Sleep(200 * time.Millisecond)
// Stop dispatcher
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
// Verify webhook event was stored
require.Greater(t, len(store.webhookEvents), 0)
}
func TestWebhookDispatcher_DispatchWebhook_WithSecret(t *testing.T) {
// Create a test HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
signature := r.Header.Get("X-HanzoAgents-Signature")
require.NotEmpty(t, signature)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-secret-1"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
}
secret := "test-secret"
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: server.URL + "/webhook",
Secret: &secret,
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
}
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
time.Sleep(200 * time.Millisecond)
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_DispatchWebhook_RetryOnFailure(t *testing.T) {
attemptCount := 0
// Create a test HTTP server that fails first time
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attemptCount++
if attemptCount == 1 {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
MaxAttempts: 3,
RetryBackoff: 100 * time.Millisecond,
MaxRetryBackoff: 1 * time.Second,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-retry-1"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
}
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: server.URL + "/webhook",
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
}
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Wait for retry attempts
time.Sleep(500 * time.Millisecond)
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
func TestWebhookDispatcher_GenerateHMACSignature(t *testing.T) {
secret := "test-secret"
body := []byte(`{"test": "data"}`)
signature := generateWebhookSignature(secret, body)
require.NotEmpty(t, signature)
// Same input should produce same signature
signature2 := generateWebhookSignature(secret, body)
require.Equal(t, signature, signature2)
// Different secret should produce different signature
signature3 := generateWebhookSignature("different-secret", body)
require.NotEqual(t, signature, signature3)
// Different body should produce different signature
signature4 := generateWebhookSignature(secret, []byte(`{"test": "different"}`))
require.NotEqual(t, signature, signature4)
}
func TestWebhookDispatcher_DetermineWebhookEvent(t *testing.T) {
tests := []struct {
status string
expected string
}{
{"succeeded", "execution.completed"},
{"failed", "execution.failed"},
{"running", "execution.failed"}, // Non-succeeded defaults to failed
{"pending", "execution.failed"}, // Non-succeeded defaults to failed
{"unknown", "execution.failed"}, // Non-succeeded defaults to failed
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
result := determineWebhookEvent(tt.status)
require.Equal(t, tt.expected, result)
})
}
}
// TestWebhookDispatcher_HMACSignatureValidation tests HMAC signature generation and validation
func TestWebhookDispatcher_HMACSignatureValidation(t *testing.T) {
secret := "test-secret-key"
body := []byte(`{"execution_id": "exec-1", "status": "succeeded"}`)
signature := generateWebhookSignature(secret, body)
require.NotEmpty(t, signature)
require.Contains(t, signature, "sha256=")
// Verify signature format
require.True(t, len(signature) > 10, "signature should be substantial length")
}
// TestWebhookDispatcher_MaxRetriesExceeded tests behavior when max retries are exceeded
func TestWebhookDispatcher_MaxRetriesExceeded(t *testing.T) {
// Create a test HTTP server that always fails
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
MaxAttempts: 2, // Low max attempts for testing
RetryBackoff: 50 * time.Millisecond,
MaxRetryBackoff: 200 * time.Millisecond,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-max-retries"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
}
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: server.URL + "/webhook",
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
}
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Wait for retry attempts to complete
time.Sleep(500 * time.Millisecond)
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
// Verify webhook was marked as failed after max retries
// Note: AttemptCount may not reach MaxAttempts if webhook is processed quickly
// The important thing is that it was attempted
webhook, _ := store.GetExecutionWebhook(ctx, executionID)
if webhook != nil {
require.Greater(t, webhook.AttemptCount, 0, "webhook should have been attempted at least once")
}
}
// TestWebhookDispatcher_TimeoutHandling tests timeout handling
func TestWebhookDispatcher_TimeoutHandling(t *testing.T) {
// Create a test HTTP server that delays response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second) // Longer than timeout
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 500 * time.Millisecond, // Short timeout
MaxAttempts: 1,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-timeout"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
}
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: server.URL + "/webhook",
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
}
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Wait for timeout
time.Sleep(1 * time.Second)
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
}
// TestWebhookDispatcher_CustomHeaders tests custom header handling
func TestWebhookDispatcher_CustomHeaders(t *testing.T) {
var receivedHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
store := newMockWebhookStore()
cfg := WebhookDispatcherConfig{
Timeout: 5 * time.Second,
MaxAttempts: 1,
WorkerCount: 1,
PollInterval: 1 * time.Second,
QueueSize: 10,
}
dispatcher := NewWebhookDispatcher(store, cfg)
ctx := context.Background()
err := dispatcher.Start(ctx)
require.NoError(t, err)
executionID := "exec-headers"
store.executions[executionID] = &types.Execution{
ExecutionID: executionID,
Status: "succeeded",
StartedAt: time.Now(),
}
store.webhooks[executionID] = &types.ExecutionWebhook{
ExecutionID: executionID,
URL: server.URL + "/webhook",
Status: types.ExecutionWebhookStatusPending,
AttemptCount: 0,
Headers: map[string]string{
"X-Custom-Header": "custom-value",
"Authorization": "Bearer token123",
},
Secret: stringPtr("test-secret"),
}
err = dispatcher.Notify(ctx, executionID)
require.NoError(t, err)
// Wait for delivery
time.Sleep(200 * time.Millisecond)
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = dispatcher.Stop(stopCtx)
require.NoError(t, err)
// Verify custom headers were sent
if receivedHeaders != nil {
require.Equal(t, "custom-value", receivedHeaders.Get("X-Custom-Header"))
require.Equal(t, "Bearer token123", receivedHeaders.Get("Authorization"))
require.NotEmpty(t, receivedHeaders.Get("X-HanzoAgents-Signature"))
}
}
// stringPtr is already defined in vc_service_test.go, removing duplicate
// Note: computeBackoff is a private method, so we test it indirectly through retry behavior
// The retry logic is tested in TestWebhookDispatcher_DispatchWebhook_RetryOnFailure
// Helper functions
func timePtr(t time.Time) *time.Time {
return &t
}
func int64Ptr(i int64) *int64 {
return &i
}