package webhooks import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" "github.com/sarathsp06/sparrow/internal/crypto" svcerrors "github.com/sarathsp06/sparrow/internal/errors" "github.com/sarathsp06/sparrow/internal/webhooks/client" "github.com/sarathsp06/sparrow/internal/webhooks/store" "github.com/sarathsp06/sparrow/pkg/signature" ) // readOnlyRepo answers the three reads a test send needs. Every other // method is the nil embedded interface and panics, so a test send that // wrote a delivery or touched health would fail the test. type readOnlyRepo struct { store.RepositoryInterface webhook *store.WebhookRegistration sub *store.EventSubscription event *store.EventRegistration } func (r *readOnlyRepo) GetWebhookByID(_ context.Context, _ uuid.UUID, id uuid.UUID, consumer string) (*store.WebhookRegistration, error) { if id != r.webhook.ID || consumer != r.webhook.Consumer { return nil, svcerrors.Error(svcerrors.NotFound, "webhook not found") } return r.webhook, nil } func (r *readOnlyRepo) GetSubscription(_ context.Context, _ uuid.UUID, id uuid.UUID) (*store.EventSubscription, error) { if id != r.sub.ID { return nil, svcerrors.Error(svcerrors.NotFound, "subscription not found") } return r.sub, nil } func (r *readOnlyRepo) GetEventByName(_ context.Context, _ uuid.UUID, name string) (*store.EventRegistration, error) { if name != r.event.Name { return nil, nil // as the store answers for an unknown name } return r.event, nil } type received struct { header http.Header body []byte } func testEventSetup(t *testing.T, status int) (*WebhookService, *readOnlyRepo, chan received) { t.Helper() got := make(chan received, 4) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) got <- received{header: r.Header.Clone(), body: body} w.WriteHeader(status) _, _ = w.Write([]byte(strings.Repeat("x", 2000))) })) t.Cleanup(srv.Close) cryptoSvc, err := crypto.NewService(make([]byte, 32)) if err != nil { t.Fatal(err) } secret, err := cryptoSvc.EncryptString("whsec_dGVzdC1zZWNyZXQtZm9yLXRlc3QtZXZlbnRz") if err != nil { t.Fatal(err) } webhook := &store.WebhookRegistration{ID: uuid.New(), Consumer: "acme", URL: srv.URL + "/hook?token=x", WebhookSecret: secret, VerifySSL: true, RequestTimeoutSeconds: 5, Headers: map[string]string{"X-Sparrow-Test": "spoofed"}} repo := &readOnlyRepo{ webhook: webhook, sub: &store.EventSubscription{ID: uuid.New(), WebhookID: webhook.ID, Consumer: "acme", EventName: "order.paid"}, event: &store.EventRegistration{Name: "order.paid", Version: 3, SamplePayload: store.JSONMap{"id": "ord_1"}}, } clientCfg := client.DefaultConfig() clientCfg.AllowPrivateNetworks = true svc := NewWebhookService(new(mockJobInserter), repo, cryptoSvc, WithDeliveryClient(client.NewWebhookClient(clientCfg))) return svc, repo, got } func TestSendTestEventSendsASignedTestDelivery(t *testing.T) { svc, repo, got := testEventSetup(t, http.StatusAccepted) res, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil) if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusAccepted || res.ErrorClass != "" { t.Errorf("result = %+v", res) } r := <-got for name, want := range map[string]string{ "X-Sparrow-Test": "true", "X-Sparrow-Event-Type": "order.paid", "X-Sparrow-Event-Version": "3", "X-Sparrow-Subscription-ID": repo.sub.ID.String(), } { if got := r.header.Get(name); got != want { t.Errorf("%s = %q, want %q", name, got, want) } } if err := signature.VerifyHMAC(r.body, r.header, "whsec_dGVzdC1zZWNyZXQtZm9yLXRlc3QtZXZlbnRz"); err != nil { t.Errorf("test delivery is not signed like a real one: %v", err) } if !strings.Contains(string(r.body), `"payload":{"id":"ord_1"}`) { t.Errorf("body = %s, want the envelope of the sample", r.body) } // A second send within the interval is refused without sending. _, err = svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil) var svcErr *svcerrors.ServiceError if !errors.As(err, &svcErr) || svcErr.Status != svcerrors.ResourceExhausted { t.Fatalf("second send err = %v, want ResourceExhausted", err) } if len(got) != 0 { t.Error("rate-limited send reached the receiver") } } func TestSendTestEventSendsAGivenPayload(t *testing.T) { svc, repo, got := testEventSetup(t, http.StatusOK) repo.sub.TransformEnabled = true repo.sub.TransformTemplate = `order {{.payload.id}}` if _, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", map[string]any{"id": "ord_edited"}); err != nil { t.Fatal(err) } if r := <-got; string(r.body) != "order ord_edited" { t.Errorf("body = %q, want the given payload through the transform", r.body) } } func TestSendTestEventReportsReceiverErrors(t *testing.T) { svc, repo, _ := testEventSetup(t, http.StatusBadRequest) res, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil) if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusBadRequest || res.ErrorClass != string(svcerrors.CategoryClientError) { t.Errorf("result = %+v", res) } } func TestSendTestEventRendersTheTransform(t *testing.T) { svc, repo, got := testEventSetup(t, http.StatusOK) repo.sub.TransformEnabled = true repo.sub.TransformTemplate = `order {{.payload.id}}` if _, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil); err != nil { t.Fatal(err) } if r := <-got; string(r.body) != "order ord_1" { t.Errorf("body = %q", r.body) } // A template that fails to render is reported, and nothing is sent. svc.testEvents = testEventLimiter{} repo.sub.TransformTemplate = `{{.payload.missing.deeper}}` repo.sub.TemplateMissingKey = store.TemplateMissingKeyError res, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil) if err != nil { t.Fatal(err) } if res.ErrorClass != string(svcerrors.CategoryTemplateError) || len(got) != 0 { t.Errorf("result = %+v, sent %d", res, len(got)) } // With on_transform_error=fallback it sends the envelope instead. svc.testEvents = testEventLimiter{} repo.sub.OnTransformError = store.OnTransformErrorFallback if res, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil); err != nil || res.StatusCode != http.StatusOK { t.Fatalf("fallback: %+v %v", res, err) } if r := <-got; !strings.HasPrefix(string(r.body), `{"version":"1"`) { t.Errorf("fallback body = %s", r.body) } } func TestSendTestEventRefusals(t *testing.T) { svc, repo, got := testEventSetup(t, http.StatusOK) other := uuid.NewString() cases := map[string]struct{ consumer, sub string }{ "other consumer": {"globex", repo.sub.ID.String()}, "unknown subscription": {"acme", other}, "invalid subscription id": {"acme", "nope"}, } for name, c := range cases { if _, err := svc.SendTestEvent(testContext(), c.consumer, c.sub, "order.paid", nil); err == nil { t.Errorf("%s: no error", name) } } // A subscription whose webhook is gone. repo.sub.WebhookID = uuid.New() if _, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil); err == nil { t.Error("subscription of a missing webhook: no error") } repo.sub.WebhookID = repo.webhook.ID if len(got) != 0 { t.Error("a refused test event reached the receiver") } // Without a delivery client the feature is off. off := NewWebhookService(new(mockJobInserter), repo, nil) if _, err := off.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil); err == nil { t.Error("no client: no error") } } func TestSendTestEventCatchAllAndUnregisteredEvents(t *testing.T) { svc, repo, got := testEventSetup(t, http.StatusOK) repo.sub.EventName = "*" send := func(event string, payload map[string]any) error { svc.testEvents = testEventLimiter{} _, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), event, payload) return err } // A catch-all subscription sends the named event's sample. if err := send("order.paid", nil); err != nil { t.Fatal(err) } if r := <-got; r.header.Get("X-Sparrow-Event-Type") != "order.paid" || !strings.Contains(string(r.body), `"id":"ord_1"`) { t.Errorf("catch-all: type %q body %s", r.header.Get("X-Sparrow-Event-Type"), r.body) } // An unregistered event is fine with a payload, refused without one. if err := send("invoice.sent", map[string]any{"id": "inv_1"}); err != nil { t.Fatal(err) } if r := <-got; r.header.Get("X-Sparrow-Event-Type") != "invoice.sent" || !strings.Contains(string(r.body), `"id":"inv_1"`) { t.Errorf("unregistered: type %q body %s", r.header.Get("X-Sparrow-Event-Type"), r.body) } for name, event := range map[string]string{"no event_name": "", "event_name *": "*", "unregistered, no payload": "invoice.sent"} { var svcErr *svcerrors.ServiceError if err := send(event, nil); !errors.As(err, &svcErr) || svcErr.Status != svcerrors.InvalidArgument { t.Errorf("%s: err = %v, want InvalidArgument", name, err) } } // A named subscription needs its own event. repo.sub.EventName = "order.paid" for _, event := range []string{"", "invoice.sent"} { if err := send(event, map[string]any{}); err == nil { t.Errorf("event %q on a named subscription: no error", event) } } if len(got) != 0 { t.Error("a refused test event reached the receiver") } } func TestSendTestEventReportsTransportErrors(t *testing.T) { svc, repo, _ := testEventSetup(t, http.StatusOK) repo.webhook.URL = "http://127.0.0.1:1/unreachable" res, err := svc.SendTestEvent(testContext(), "acme", repo.sub.ID.String(), "order.paid", nil) if err != nil { t.Fatal(err) } if res.StatusCode != 0 || res.ErrorClass == "" { t.Errorf("result = %+v", res) } } func TestTestEventLimiter(t *testing.T) { var l testEventLimiter a, b := uuid.New(), uuid.New() now := time.Now() if !l.allow(a, now) || l.allow(a, now.Add(time.Second)) || !l.allow(b, now) { t.Fatal("limiter does not limit per webhook") } if !l.allow(a, now.Add(TestEventInterval)) { t.Fatal("limiter does not allow after the interval") } if len(l.last) != 1 { t.Errorf("stale entries kept: %d", len(l.last)) } }