package handlers_test import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/jobs" "github.com/yan1h/agent-coding-workflow/internal/jobs/handlers" ) func mkPayload(t *testing.T, topic string) json.RawMessage { t.Helper() p, err := json.Marshal(map[string]any{ "topic": topic, "severity": "error", "title": "x", "body": "y", "metadata": map[string]any{"k": "v"}, "occurred_at": time.Now().UTC().Format(time.RFC3339), }) require.NoError(t, err) return p } func TestWebhook_2xxCompletes(t *testing.T) { t.Parallel() got := make(chan struct{}, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got <- struct{}{} w.WriteHeader(http.StatusOK) })) defer ts.Close() h := handlers.NewWebhookHandler(handlers.WebhookConfig{ URL: ts.URL, Secret: "s3cr3t", Timeout: time.Second, }, clock.Real()) err := h.Handle(context.Background(), jobs.Job{ ID: uuid.New(), Type: jobs.TypeWebhookDeliver, Payload: mkPayload(t, "x"), }) require.NoError(t, err) <-got } func TestWebhook_4xxPermanent(t *testing.T) { t.Parallel() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("bad token")) })) defer ts.Close() h := handlers.NewWebhookHandler(handlers.WebhookConfig{URL: ts.URL, Secret: "s", Timeout: time.Second}, clock.Real()) err := h.Handle(context.Background(), jobs.Job{ID: uuid.New(), Type: jobs.TypeWebhookDeliver, Payload: mkPayload(t, "x")}) require.Error(t, err) assert.True(t, jobs.IsPermanent(err), "4xx should be permanent") assert.Contains(t, err.Error(), "401") } func TestWebhook_5xxTransient(t *testing.T) { t.Parallel() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer ts.Close() h := handlers.NewWebhookHandler(handlers.WebhookConfig{URL: ts.URL, Secret: "s", Timeout: time.Second}, clock.Real()) err := h.Handle(context.Background(), jobs.Job{ID: uuid.New(), Type: jobs.TypeWebhookDeliver, Payload: mkPayload(t, "x")}) require.Error(t, err) assert.False(t, jobs.IsPermanent(err)) } func TestWebhook_HMACSignatureValidByReceiver(t *testing.T) { t.Parallel() const secret = "s3cr3t" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) ts := r.Header.Get("X-ACW-Timestamp") sig := r.Header.Get("X-ACW-Signature") mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + "." + string(body))) want := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(want), []byte(sig)) { http.Error(w, "bad sig", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) })) defer ts.Close() h := handlers.NewWebhookHandler(handlers.WebhookConfig{URL: ts.URL, Secret: secret, Timeout: time.Second}, clock.Real()) err := h.Handle(context.Background(), jobs.Job{ID: uuid.New(), Type: jobs.TypeWebhookDeliver, Payload: mkPayload(t, "x")}) require.NoError(t, err) } func TestWebhook_HeadersIncluded(t *testing.T) { t.Parallel() got := make(chan http.Header, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got <- r.Header.Clone() w.WriteHeader(http.StatusOK) })) defer ts.Close() id := uuid.New() h := handlers.NewWebhookHandler(handlers.WebhookConfig{URL: ts.URL, Secret: "s", Timeout: time.Second}, clock.Real()) err := h.Handle(context.Background(), jobs.Job{ID: id, Type: jobs.TypeWebhookDeliver, Payload: mkPayload(t, "workspace.sync_failed")}) require.NoError(t, err) hdr := <-got assert.Equal(t, "workspace.sync_failed", hdr.Get("X-ACW-Topic")) assert.Equal(t, id.String(), hdr.Get("X-ACW-Event-Id")) assert.True(t, strings.HasPrefix(hdr.Get("X-ACW-Signature"), "sha256=")) assert.NotEmpty(t, hdr.Get("X-ACW-Timestamp")) }