mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
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.
797 lines
22 KiB
Go
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
|
|
}
|