internal/webhooks/event_filtering_test.go
Rama main · réplica de lectura del repositorio original.
package webhooks
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
svcerrors "github.com/sarathsp06/sparrow/internal/errors"
"github.com/sarathsp06/sparrow/internal/webhooks/store"
)
// ---------------------------------------------------------------------------
// validateLabels tests
// ---------------------------------------------------------------------------
func TestValidateLabels_EmptyMap(t *testing.T) {
err := validateLabels(nil, "labels")
assert.NoError(t, err)
err = validateLabels(map[string]string{}, "labels")
assert.NoError(t, err)
}
func TestValidateLabels_ValidEntries(t *testing.T) {
m := map[string]string{
"region": "us-east-1",
"environment": "production",
"tier": "premium",
"app.version": "1.2.3",
"team_name": "backend",
"release-v2": "true",
}
err := validateLabels(m, "labels")
assert.NoError(t, err)
}
func TestValidateLabels_EmptyKey(t *testing.T) {
m := map[string]string{"": "value"}
err := validateLabels(m, "labels")
require.Error(t, err)
assert.Contains(t, err.Error(), "key must not be empty")
}
func TestValidateLabels_KeyTooLong(t *testing.T) {
longKey := strings.Repeat("a", maxLabelKeyLen+1)
m := map[string]string{longKey: "value"}
err := validateLabels(m, "labels")
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds maximum length")
}
func TestValidateLabels_KeyExactlyAtLimit(t *testing.T) {
key := strings.Repeat("k", maxLabelKeyLen)
m := map[string]string{key: "value"}
err := validateLabels(m, "labels")
assert.NoError(t, err)
}
func TestValidateLabels_InvalidKeyCharacters(t *testing.T) {
tests := []struct {
name string
key string
}{
{"space", "my key"},
{"exclamation", "key!"},
{"at sign", "key@host"},
{"slash", "path/key"},
{"colon", "ns:key"},
{"equals", "key=val"},
{"bracket", "key[0]"},
{"unicode", "clé"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := map[string]string{tt.key: "value"}
err := validateLabels(m, "labels")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid characters")
})
}
}
func TestValidateLabels_ValidKeyCharacters(t *testing.T) {
// Alphanumeric, dot, underscore, hyphen are allowed.
keys := []string{"abc", "ABC", "123", "a.b", "a_b", "a-b", "A.1_2-z"}
for _, k := range keys {
m := map[string]string{k: "value"}
err := validateLabels(m, "labels")
assert.NoError(t, err, "key %q should be valid", k)
}
}
func TestValidateLabels_ValueTooLong(t *testing.T) {
longVal := strings.Repeat("v", maxLabelValueLen+1)
m := map[string]string{"key": longVal}
err := validateLabels(m, "labels")
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds maximum length")
}
func TestValidateLabels_ValueExactlyAtLimit(t *testing.T) {
val := strings.Repeat("v", maxLabelValueLen)
m := map[string]string{"key": val}
err := validateLabels(m, "labels")
assert.NoError(t, err)
}
func TestValidateLabels_EmptyValue(t *testing.T) {
// Empty values are allowed (only keys have non-empty constraint).
m := map[string]string{"key": ""}
err := validateLabels(m, "labels")
assert.NoError(t, err)
}
func TestValidateLabels_TooManyEntries(t *testing.T) {
m := make(map[string]string, maxLabelsPerMap+1)
for i := 0; i <= maxLabelsPerMap; i++ {
m[strings.Repeat("k", i+1)] = "v"
}
err := validateLabels(m, "labels")
require.Error(t, err)
assert.Contains(t, err.Error(), "too many entries")
}
func TestValidateLabels_ExactlyAtEntryLimit(t *testing.T) {
m := make(map[string]string, maxLabelsPerMap)
for i := 0; i < maxLabelsPerMap; i++ {
m[strings.Repeat("k", i+1)] = "v"
}
err := validateLabels(m, "labels")
assert.NoError(t, err)
}
func TestValidateLabels_FieldNameInError(t *testing.T) {
// The error message should reference the field name passed in.
m := map[string]string{"": "value"}
err := validateLabels(m, "label_filters")
require.Error(t, err)
assert.Contains(t, err.Error(), "label_filters")
}
// ---------------------------------------------------------------------------
// PushEvent label validation tests
// ---------------------------------------------------------------------------
func TestPushEvent_WithValidLabels(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
consumer := "default"
eventName := "user.signup"
payload := map[string]any{"user_id": "123"}
labels := map[string]string{"region": "us", "tier": "premium"}
repo.On("GetEventByName", mock.Anything, mock.Anything, eventName).Return(&store.EventRegistration{
Name: eventName,
Active: true,
}, nil)
repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.MatchedBy(func(e *store.EventRecord) bool {
// Verify labels are passed through to the stored event record.
return e.Labels["region"] == "us" && e.Labels["tier"] == "premium"
})).Return(nil)
inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.Anything).Return(nil)
eventID, _, _, _, err := service.PushEvent(ctx, consumer, eventName, payload, 0, nil, labels, nil, nil)
assert.NoError(t, err)
assert.NotEmpty(t, eventID)
repo.AssertExpectations(t)
inserter.AssertExpectations(t)
}
func TestPushEvent_WithNilLabels(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
repo.On("GetEventByName", mock.Anything, mock.Anything, "test.event").Return(&store.EventRegistration{
Name: "test.event",
Active: true,
}, nil)
repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.Anything).Return(nil)
inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.Anything).Return(nil)
eventID, _, _, _, err := service.PushEvent(ctx, "default", "test.event", nil, 0, nil, nil, nil, nil)
assert.NoError(t, err)
assert.NotEmpty(t, eventID)
repo.AssertExpectations(t)
}
func TestPushEvent_WithInvalidLabels(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
tests := []struct {
name string
labels map[string]string
errMsg string
}{
{
name: "empty key",
labels: map[string]string{"": "value"},
errMsg: "key must not be empty",
},
{
name: "invalid key chars",
labels: map[string]string{"bad key": "value"},
errMsg: "invalid characters",
},
{
name: "value too long",
labels: map[string]string{"key": strings.Repeat("x", maxLabelValueLen+1)},
errMsg: "exceeds maximum length",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, err := service.PushEvent(ctx, "default", "some.event", nil, 0, nil, tt.labels, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
// Ensure no repo calls were made — validation should short-circuit.
repo.AssertNotCalled(t, "GetEventByName", mock.Anything, mock.Anything, mock.Anything)
})
}
}
// ---------------------------------------------------------------------------
// CreateSubscription label-filter tests
// ---------------------------------------------------------------------------
func TestCreateSubscription_WithLabelFilters(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
webhookID := uuid.New().String()
consumer := "default"
eventName := "order.created"
labelFilters := map[string]string{"region": "us", "tier": "premium"}
repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil)
repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.MatchedBy(func(sub *store.EventSubscription) bool {
return sub.EventName == eventName &&
sub.Consumer == consumer &&
sub.LabelFilters["region"] == "us" &&
sub.LabelFilters["tier"] == "premium"
})).Return(nil)
id, createdAt, err := service.CreateSubscription(ctx, webhookID, eventName, consumer, nil, "POST", 30, false, "", labelFilters, SubscriptionTemplateSettings{})
assert.NoError(t, err)
assert.NotEmpty(t, id)
assert.False(t, createdAt.IsZero())
repo.AssertExpectations(t)
}
func TestCreateSubscription_WithEmptyLabelFilters(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
webhookID := uuid.New().String()
repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil)
repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.MatchedBy(func(sub *store.EventSubscription) bool {
return len(sub.LabelFilters) == 0
})).Return(nil)
id, createdAt, err := service.CreateSubscription(ctx, webhookID, "test.event", "default", nil, "POST", 30, false, "", nil, SubscriptionTemplateSettings{})
assert.NoError(t, err)
assert.NotEmpty(t, id)
assert.False(t, createdAt.IsZero())
repo.AssertExpectations(t)
}
func TestCreateSubscription_WithInvalidLabelFilters(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
webhookID := uuid.New().String()
tests := []struct {
name string
labelFilters map[string]string
errMsg string
}{
{
name: "empty key",
labelFilters: map[string]string{"": "val"},
errMsg: "key must not be empty",
},
{
name: "invalid key chars",
labelFilters: map[string]string{"key with space": "val"},
errMsg: "invalid characters",
},
{
name: "key too long",
labelFilters: map[string]string{strings.Repeat("a", maxLabelKeyLen+1): "val"},
errMsg: "exceeds maximum length",
},
{
name: "value too long",
labelFilters: map[string]string{"key": strings.Repeat("v", maxLabelValueLen+1)},
errMsg: "exceeds maximum length",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := service.CreateSubscription(ctx, webhookID, "test.event", "default", nil, "POST", 30, false, "", tt.labelFilters, SubscriptionTemplateSettings{})
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
repo.AssertNotCalled(t, "CreateSubscription", mock.Anything, mock.Anything, mock.Anything)
})
}
}
func TestCreateSubscription_CatchAllWithLabelFilters(t *testing.T) {
// A catch-all subscription (* event) can still have label filters.
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
webhookID := uuid.New().String()
labelFilters := map[string]string{"env": "prod"}
repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil)
repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.MatchedBy(func(sub *store.EventSubscription) bool {
return sub.EventName == store.CatchAllEventName && sub.LabelFilters["env"] == "prod"
})).Return(nil)
id, _, err := service.CreateSubscription(ctx, webhookID, store.CatchAllEventName, "default", nil, "POST", 30, false, "", labelFilters, SubscriptionTemplateSettings{})
assert.NoError(t, err)
assert.NotEmpty(t, id)
repo.AssertExpectations(t)
}
// ---------------------------------------------------------------------------
// GetSubscriptionsWithWebhooksByEvent mock tests (service → repo contract)
// ---------------------------------------------------------------------------
// mockRepoWithEventQuery extends mockRepo with the event-subscription query methods.
type mockRepoWithEventQuery struct {
mockRepo
}
func (m *mockRepoWithEventQuery) GetSubscriptionsWithWebhooksByEvent(ctx context.Context, tenantID uuid.UUID, consumer, event string, labels map[string]string) ([]*store.SubscriptionWithWebhook, error) {
args := m.Called(ctx, tenantID, consumer, event, labels)
res := args.Get(0)
if res == nil {
return nil, args.Error(1)
}
return res.([]*store.SubscriptionWithWebhook), args.Error(1)
}
// ---------------------------------------------------------------------------
// GetSubscriptionsWithWebhooksByEvent mock tests (active webhook check)
// ---------------------------------------------------------------------------
func TestGetSubscriptionsWithWebhooksByEvent_ActiveWebhooksOnly(t *testing.T) {
repo := new(mockRepoWithEventQuery)
ctx := testContext()
activeWebhook := &store.WebhookRegistration{
ID: uuid.New(),
Consumer: "default",
URL: "https://example.com/hook",
Active: true,
}
sub := &store.EventSubscription{
ID: uuid.New(),
WebhookID: activeWebhook.ID,
EventName: "signup",
Consumer: "default",
}
// The SQL query JOINs with wr.active = true, so only active webhooks appear.
expected := []*store.SubscriptionWithWebhook{
{Subscription: sub, Webhook: activeWebhook},
}
repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "signup", map[string]string(nil)).
Return(expected, nil)
result, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "signup", nil)
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.True(t, result[0].Webhook.Active)
repo.AssertExpectations(t)
}
func TestGetSubscriptionsWithWebhooksByEvent_InactiveExcluded(t *testing.T) {
repo := new(mockRepoWithEventQuery)
ctx := testContext()
// An inactive webhook's subscriptions should not be returned by the query.
repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "signup", map[string]string(nil)).
Return([]*store.SubscriptionWithWebhook{}, nil)
result, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "signup", nil)
assert.NoError(t, err)
assert.Empty(t, result)
repo.AssertExpectations(t)
}
func TestGetSubscriptionsWithWebhooksByEvent_MixedCatchAllAndSpecific(t *testing.T) {
repo := new(mockRepoWithEventQuery)
ctx := testContext()
webhook1 := &store.WebhookRegistration{
ID: uuid.New(), Consumer: "default", URL: "https://a.com/hook", Active: true,
}
webhook2 := &store.WebhookRegistration{
ID: uuid.New(), Consumer: "default", URL: "https://b.com/hook", Active: true,
}
specificSub := &store.EventSubscription{
ID: uuid.New(), WebhookID: webhook1.ID, EventName: "signup", Consumer: "default",
}
catchAllSub := &store.EventSubscription{
ID: uuid.New(), WebhookID: webhook2.ID, EventName: store.CatchAllEventName, Consumer: "default",
}
expected := []*store.SubscriptionWithWebhook{
{Subscription: specificSub, Webhook: webhook1},
{Subscription: catchAllSub, Webhook: webhook2},
}
repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "signup", map[string]string(nil)).
Return(expected, nil)
result, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "signup", nil)
assert.NoError(t, err)
assert.Len(t, result, 2)
names := []string{result[0].Subscription.EventName, result[1].Subscription.EventName}
assert.Contains(t, names, "signup")
assert.Contains(t, names, store.CatchAllEventName)
repo.AssertExpectations(t)
}
func TestGetSubscriptionsWithWebhooksByEvent_LabelsPassedThrough(t *testing.T) {
repo := new(mockRepoWithEventQuery)
ctx := testContext()
eventLabels := map[string]string{"region": "us", "env": "prod"}
// Verify the labels are forwarded to the repository method exactly.
repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "order.created", eventLabels).
Return([]*store.SubscriptionWithWebhook{}, nil)
_, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "order.created", eventLabels)
assert.NoError(t, err)
repo.AssertExpectations(t)
}
// ---------------------------------------------------------------------------
// Event-name length (VARCHAR(255): characters, not bytes)
// ---------------------------------------------------------------------------
func TestPushEvent_EventNameLengthCountsCharacters(t *testing.T) {
repo := new(mockRepo)
inserter := new(mockJobInserter)
service := NewWebhookService(inserter, repo, nil)
ctx := testContext()
// 255 two-byte characters = 510 bytes: fits the column, must be accepted.
fits := strings.Repeat("é", maxEventNameLength)
repo.On("GetEventByName", mock.Anything, mock.Anything, fits).Return(&store.EventRegistration{Name: fits, Active: true}, nil)
repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.Anything).Return(nil)
inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_, _, _, _, err := service.PushEvent(ctx, "default", fits, nil, 0, nil, nil, nil, nil)
require.NoError(t, err)
for _, tooLong := range []string{strings.Repeat("x", maxEventNameLength+1), strings.Repeat("é", maxEventNameLength+1)} {
_, _, _, _, err := service.PushEvent(ctx, "default", tooLong, nil, 0, nil, nil, nil, nil)
require.Error(t, err)
var svcErr *svcerrors.ServiceError
require.ErrorAs(t, err, &svcErr)
assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status)
repo.AssertNotCalled(t, "GetEventByName", mock.Anything, mock.Anything, tooLong)
}
}
func TestRegisterEvent_RejectsNamesOver255Characters(t *testing.T) {
repo := new(mockRepo)
service := NewWebhookService(nil, repo, nil)
_, _, err := service.RegisterEvent(testContext(), strings.Repeat("é", maxEventNameLength+1), "", nil, nil, true)
require.Error(t, err)
var svcErr *svcerrors.ServiceError
require.ErrorAs(t, err, &svcErr)
assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status)
repo.AssertNotCalled(t, "GetEventByName", mock.Anything, mock.Anything, mock.Anything)
}
// consumer and idempotency_key are VARCHAR(255) too: a longer value used to
// fail the INSERT with a 500.
func TestPushEvent_LongConsumerOrIdempotencyKeyIsAClientError(t *testing.T) {
service := NewWebhookService(nil, new(mockRepo), nil)
long := strings.Repeat("é", maxEventNameLength+1)
for name, push := range map[string]func() error{
"consumer": func() error {
_, _, _, _, err := service.PushEvent(testContext(), long, "order.created", nil, 0, nil, nil, nil, nil)
return err
},
"idempotency_key": func() error {
_, _, _, _, err := service.PushEvent(testContext(), "default", "order.created", nil, 0, nil, nil, &long, nil)
return err
},
} {
err := push()
var svcErr *svcerrors.ServiceError
require.ErrorAs(t, err, &svcErr, name)
assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status, name)
assert.Equal(t, name+" must be at most 255 characters, got 256", svcErr.ClientMessage())
}
}