Ctrl K
ComunidadGuías…

internal/webhooks/webhook_service_testevent_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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))
	}
}