Ctrl K
ComunidadGuías…

internal/webhooks/webhook_service_update_test.go

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

Archivo original ↗Preguntar a KiBot
package webhooks

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	neturl "net/url"
	"strings"
	"testing"

	"github.com/google/uuid"
	"github.com/lib/pq"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	cryptosvc "github.com/sarathsp06/sparrow/internal/crypto"
	svcerrors "github.com/sarathsp06/sparrow/internal/errors"
	"github.com/sarathsp06/sparrow/internal/webhooks/store"
)

func (m *mockRepo) ReplaceWebhookSubscriptions(ctx context.Context, tenantID uuid.UUID, webhookID uuid.UUID, consumer string, subs []*store.EventSubscription) error {
	return m.Called(ctx, tenantID, webhookID, consumer, subs).Error(0)
}

func newCryptoService(t *testing.T, repo *mockRepo) *WebhookService {
	t.Helper()
	c, err := cryptosvc.NewService(bytes.Repeat([]byte{7}, 32))
	require.NoError(t, err)
	return NewWebhookService(nil, repo, c)
}

func storedWebhook(id uuid.UUID) *store.WebhookRegistration {
	return &store.WebhookRegistration{
		ID:                    id,
		Consumer:              "default",
		URL:                   "https://example.com/hook",
		Active:                true,
		MaxRetries:            3,
		RetryBackoffSeconds:   60,
		RequestTimeoutSeconds: 30,
		ExpectedStatusCodes:   pq.Int64Array{200},
		ContentType:           "application/json",
		SignatureType:         store.SignatureTypeHMAC,
	}
}

// TestUpdateWebhookConfig_LegacyAppliesEveryField covers the no-mask path:
// every non-zero field of the request lands on the stored webhook, events
// are replaced, and a rate limit keeps its limiter state.
func TestUpdateWebhookConfig_LegacyAppliesEveryField(t *testing.T) {
	repo := new(mockRepo)
	svc := newCryptoService(t, repo)
	id := uuid.New()
	repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(storedWebhook(id), nil)
	repo.On("ReplaceWebhookSubscriptions", mock.Anything, mock.Anything, id, "default", mock.MatchedBy(func(subs []*store.EventSubscription) bool {
		return len(subs) == 2 && subs[0].EventName == "order.created" && subs[1].EventName == "order.paid"
	})).Return(nil)
	var saved *store.WebhookRegistration
	repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
		saved = a.Get(2).(*store.WebhookRegistration)
	}).Return(nil)
	repo.On("UpsertRateLimitState", mock.Anything, id).Return(errors.New("ignored: only logged"))

	rps := 5.0
	err := svc.UpdateWebhookConfig(context.Background(), id.String(), "default",
		[]string{"order.created", "order.paid"}, "  https://example.org/new  ", map[string]string{"X-Team": "ops"},
		false, "new description", &WebhookHTTPConfig{
			MaxRetries:            new(7),
			RetryBackoffSeconds:   120,
			RequestTimeoutSeconds: 10,
			ExpectedStatusCodes:   IntArray{200, 202},
			WebhookSecret:         "whsec_new",
			UserAgent:             "Custom/2",
			ContentType:           "application/cloudevents+json",
			CaptureResponseBody:   new(true),
			FollowRedirects:       new(false),
			VerifySSL:             new(false),
			RateLimitRPS:          &rps,
		}, nil, "", false, nil)
	require.NoError(t, err)
	require.NotNil(t, saved)

	assert.Equal(t, "https://example.org/new", saved.URL)
	assert.Equal(t, map[string]string{"X-Team": "ops"}, map[string]string(saved.Headers))
	assert.False(t, saved.Active)
	assert.Equal(t, "new description", saved.Description)
	assert.Equal(t, 7, saved.MaxRetries)
	assert.Equal(t, 120, saved.RetryBackoffSeconds)
	assert.Equal(t, 10, saved.RequestTimeoutSeconds)
	assert.Equal(t, pq.Int64Array{200, 202}, saved.ExpectedStatusCodes)
	assert.Equal(t, "Custom/2", saved.UserAgent)
	assert.Equal(t, "application/cloudevents+json", saved.ContentType)
	assert.True(t, saved.CaptureResponseBody)
	assert.False(t, saved.FollowRedirects)
	assert.False(t, saved.VerifySSL)
	require.NotNil(t, saved.RateLimitRPS)
	assert.InDelta(t, 5.0, *saved.RateLimitRPS, 0)
	secret, err := svc.DecryptWebhookSecret(saved.WebhookSecret)
	require.NoError(t, err)
	assert.Equal(t, "whsec_new", secret)
	repo.AssertExpectations(t)
}

// TestUpdateWebhookConfig_MaskLimitsTheChange checks that with a mask only
// the named fields change: a secret and a rate limit in http_config are
// ignored unless their own paths are in the mask.
func TestUpdateWebhookConfig_MaskLimitsTheChange(t *testing.T) {
	repo := new(mockRepo)
	svc := newCryptoService(t, repo)
	id := uuid.New()
	before := storedWebhook(id)
	repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(before, nil)
	var saved *store.WebhookRegistration
	repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
		saved = a.Get(2).(*store.WebhookRegistration)
	}).Return(nil)
	repo.On("DeleteRateLimitState", mock.Anything, id).Return(errors.New("ignored: only logged"))

	rps := 9.0
	err := svc.UpdateWebhookConfig(context.Background(), id.String(), "default",
		[]string{"order.created"}, "https://example.org/other", nil, false, "changed", &WebhookHTTPConfig{
			UserAgent: "Masked/1", WebhookSecret: "whsec_ignored", RateLimitRPS: &rps,
		}, nil, "", false, []string{"http_config"})
	require.NoError(t, err)

	assert.Equal(t, "https://example.com/hook", saved.URL, "url is not in the mask")
	assert.True(t, saved.Active, "active is not in the mask")
	assert.Empty(t, saved.Description)
	assert.Equal(t, "Masked/1", saved.UserAgent)
	assert.Nil(t, saved.WebhookSecret, "http_config.webhook_secret is not in the mask")
	assert.Nil(t, saved.RateLimitRPS, "http_config.rate_limit_rps is not in the mask")
	repo.AssertNotCalled(t, "ReplaceWebhookSubscriptions", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

func TestUpdateWebhookConfig_SignatureTypeSwitch(t *testing.T) {
	id := uuid.New()
	setup := func(t *testing.T, current store.SignatureType) (*WebhookService, **store.WebhookRegistration) {
		repo := new(mockRepo)
		svc := newCryptoService(t, repo)
		wh := storedWebhook(id)
		wh.SignatureType = current
		if current == store.SignatureTypeEd25519 {
			wh.Ed25519PrivateKey = []byte("old-key")
		}
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(wh, nil)
		var saved *store.WebhookRegistration
		repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
			saved = a.Get(2).(*store.WebhookRegistration)
		}).Return(nil)
		repo.On("DeleteRateLimitState", mock.Anything, id).Return(nil)
		return svc, &saved
	}

	t.Run("to ed25519 generates a key", func(t *testing.T) {
		svc, saved := setup(t, store.SignatureTypeHMAC)
		require.NoError(t, svc.UpdateWebhookConfig(context.Background(), id.String(), "default", nil, "", nil, true, "", nil, nil, "ed25519", false, []string{"signature_type"}))
		assert.Equal(t, store.SignatureTypeEd25519, (*saved).SignatureType)
		assert.Len(t, svc.WebhookSigningPublicKeyHex((*saved).Ed25519PrivateKey), 64)
	})
	t.Run("back to hmac drops the key", func(t *testing.T) {
		svc, saved := setup(t, store.SignatureTypeEd25519)
		require.NoError(t, svc.UpdateWebhookConfig(context.Background(), id.String(), "default", nil, "", nil, true, "", nil, nil, "hmac", false, []string{"signature_type"}))
		assert.Equal(t, store.SignatureTypeHMAC, (*saved).SignatureType)
		assert.Nil(t, (*saved).Ed25519PrivateKey)
	})
	t.Run("unknown type is refused", func(t *testing.T) {
		svc, _ := setup(t, store.SignatureTypeHMAC)
		err := svc.UpdateWebhookConfig(context.Background(), id.String(), "default", nil, "", nil, true, "", nil, nil, "rsa", false, []string{"signature_type"})
		requireStatus(t, err, svcerrors.InvalidArgument)
	})
}

func TestUpdateWebhookConfig_SecretHeadersCleared(t *testing.T) {
	repo := new(mockRepo)
	svc := newCryptoService(t, repo)
	id := uuid.New()
	wh := storedWebhook(id)
	enc, err := svc.EncryptSecretHeaders(map[string]string{"Authorization": "Bearer x"})
	require.NoError(t, err)
	wh.SecretHeaders = enc
	repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(wh, nil)
	repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.MatchedBy(func(w *store.WebhookRegistration) bool {
		return w.SecretHeaders == nil
	})).Return(nil)
	repo.On("DeleteRateLimitState", mock.Anything, id).Return(nil)

	require.NoError(t, svc.UpdateWebhookConfig(context.Background(), id.String(), "default", nil, "", nil, true, "", nil, map[string]string{}, "", false, []string{"secret_headers"}))
	repo.AssertExpectations(t)
}

// Secret header values are never returned, so moving the webhook to another
// origin must not carry them along unless the request sets them again.
func TestUpdateWebhookConfig_SecretHeadersFollowURL(t *testing.T) {
	id := uuid.New()
	ctx := context.Background()
	setup := func(t *testing.T) (*WebhookService, **store.WebhookRegistration) {
		repo := new(mockRepo)
		svc := newCryptoService(t, repo)
		wh := storedWebhook(id)
		enc, err := svc.EncryptSecretHeaders(map[string]string{"X-Api-Key": "k", "X-Other": "o"})
		require.NoError(t, err)
		wh.SecretHeaders = enc
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(wh, nil)
		var saved *store.WebhookRegistration
		repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
			saved = a.Get(2).(*store.WebhookRegistration)
		}).Return(nil)
		repo.On("DeleteRateLimitState", mock.Anything, id).Return(nil)
		return svc, &saved
	}
	update := func(svc *WebhookService, url string, secret map[string]string, mask ...string) error {
		return svc.UpdateWebhookConfig(ctx, id.String(), "default", nil, url, nil, true, "", nil, secret, "", false, mask)
	}

	t.Run("another origin without secret_headers is refused", func(t *testing.T) {
		svc, _ := setup(t)
		err := update(svc, "https://example.net/", nil, "url")
		requireStatus(t, err, svcerrors.InvalidArgument)
		assert.Contains(t, err.Error(), "X-Api-Key, X-Other")
	})
	t.Run("a header left out is refused", func(t *testing.T) {
		svc, _ := setup(t)
		err := update(svc, "https://example.org/", map[string]string{"X-Api-Key": "k2"}, "url", "secret_headers")
		requireStatus(t, err, svcerrors.InvalidArgument)
		assert.Contains(t, err.Error(), "X-Other")
	})
	t.Run("every header set or removed is allowed", func(t *testing.T) {
		svc, saved := setup(t)
		require.NoError(t, update(svc, "https://example.org/", map[string]string{"X-Api-Key": "k2", "X-Other": ""}, "url", "secret_headers"))
		got, err := svc.DecryptSecretHeaders((*saved).SecretHeaders)
		require.NoError(t, err)
		assert.Equal(t, map[string]string{"X-Api-Key": "k2"}, got)
	})
	t.Run("an empty object removes them all", func(t *testing.T) {
		svc, saved := setup(t)
		require.NoError(t, update(svc, "https://example.org/", map[string]string{}, "url", "secret_headers"))
		assert.Nil(t, (*saved).SecretHeaders)
	})
	t.Run("same origin keeps them", func(t *testing.T) {
		svc, saved := setup(t)
		require.NoError(t, update(svc, "https://example.com/v2/hook", nil, "url"))
		assert.NotNil(t, (*saved).SecretHeaders)
	})
	t.Run("scheme change is another origin", func(t *testing.T) {
		svc, _ := setup(t)
		requireStatus(t, update(svc, "http://example.com/hook", nil, "url"), svcerrors.InvalidArgument)
	})
	t.Run("an explicit default port is the same origin", func(t *testing.T) {
		svc, saved := setup(t)
		require.NoError(t, update(svc, "https://example.com:443/hook", nil, "url"))
		assert.NotNil(t, (*saved).SecretHeaders)
	})
	t.Run("header names match in any case", func(t *testing.T) {
		svc, saved := setup(t)
		require.NoError(t, update(svc, "https://example.org/", map[string]string{"x-api-key": "k2", "x-other": ""}, "url", "secret_headers"))
		got, err := svc.DecryptSecretHeaders((*saved).SecretHeaders)
		require.NoError(t, err)
		assert.Equal(t, map[string]string{"X-Api-Key": "k2"}, got)
	})
	t.Run("the same header twice in different case is refused", func(t *testing.T) {
		svc, _ := setup(t)
		err := update(svc, "https://example.com/", map[string]string{"x-api-key": "a", "X-API-KEY": "b"}, "secret_headers")
		requireStatus(t, err, svcerrors.InvalidArgument)
		assert.Contains(t, err.Error(), "same header")
	})
}

func TestUpdateWebhookConfig_Rejections(t *testing.T) {
	id := uuid.New()
	ctx := context.Background()

	t.Run("missing ids and bad uuid", func(t *testing.T) {
		svc := NewWebhookService(nil, new(mockRepo), nil)
		requireStatus(t, svc.UpdateWebhookConfig(ctx, "", "default", nil, "", nil, true, "", nil, nil, "", false, nil), svcerrors.InvalidArgument)
		requireStatus(t, svc.UpdateWebhookConfig(ctx, id.String(), "", nil, "", nil, true, "", nil, nil, "", false, nil), svcerrors.InvalidArgument)
		requireStatus(t, svc.UpdateWebhookConfig(ctx, "not-a-uuid", "default", nil, "", nil, true, "", nil, nil, "", false, nil), svcerrors.InvalidArgument)
	})

	t.Run("lookup fails", func(t *testing.T) {
		repo := new(mockRepo)
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(nil, errors.New("db down"))
		err := NewWebhookService(nil, repo, nil).UpdateWebhookConfig(ctx, id.String(), "default", nil, "", nil, true, "", nil, nil, "", false, nil)
		require.ErrorContains(t, err, "db down")
	})

	for _, tc := range []struct {
		name          string
		events        []string
		url           string
		headers       map[string]string
		secretHeaders map[string]string
		http          *WebhookHTTPConfig
		mask          []string
		crypto        bool
		want          svcerrors.Status
	}{
		{name: "system event for a normal consumer", events: []string{"sparrow.webhook.disabled"}, mask: []string{"events"}, want: svcerrors.InvalidArgument},
		{name: "blank url", url: "   ", mask: []string{"url"}, want: svcerrors.InvalidArgument},
		{name: "loopback url", url: "http://127.0.0.1/hook", mask: []string{"url"}, want: svcerrors.InvalidArgument},
		{name: "bad header name", headers: map[string]string{"Bad Header": "x"}, mask: []string{"headers"}, want: svcerrors.InvalidArgument},
		{name: "bad secret header name", secretHeaders: map[string]string{"Bad Header": "x"}, mask: []string{"secret_headers"}, want: svcerrors.InvalidArgument},
		{name: "secret without encryption", http: &WebhookHTTPConfig{WebhookSecret: "s"}, mask: []string{"http_config", "http_config.webhook_secret"}, want: svcerrors.FailedPrecondition},
		{name: "secret headers without encryption", secretHeaders: map[string]string{"Authorization": "x"}, mask: []string{"secret_headers"}, want: svcerrors.FailedPrecondition},
		{name: "zero rate limit", http: &WebhookHTTPConfig{RateLimitRPS: new(0.0)}, mask: []string{"http_config", "http_config.rate_limit_rps"}, want: svcerrors.InvalidArgument},
	} {
		t.Run(tc.name, func(t *testing.T) {
			repo := new(mockRepo)
			repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(storedWebhook(id), nil)
			svc := NewWebhookService(nil, repo, nil)
			err := svc.UpdateWebhookConfig(ctx, id.String(), "default", tc.events, tc.url, tc.headers, true, "", tc.http, tc.secretHeaders, "", false, tc.mask)
			requireStatus(t, err, tc.want)
			repo.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything, mock.Anything)
		})
	}

	t.Run("transaction fails", func(t *testing.T) {
		repo := new(mockRepo)
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(storedWebhook(id), nil)
		repo.On("ReplaceWebhookSubscriptions", mock.Anything, mock.Anything, id, "default", mock.Anything).Return(errors.New("constraint"))
		err := NewWebhookService(nil, repo, nil).UpdateWebhookConfig(ctx, id.String(), "default", []string{"order.created"}, "", nil, true, "", nil, nil, "", false, []string{"events"})
		require.ErrorContains(t, err, "constraint")
		repo.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("lock fails when turning on requires_transform", func(t *testing.T) {
		repo := new(mockRepo)
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(storedWebhook(id), nil)
		repo.On("LockWebhook", mock.Anything, mock.Anything, id, "default", true).Return(nil, errors.New("lock timeout"))
		err := NewWebhookService(nil, repo, nil).UpdateWebhookConfig(ctx, id.String(), "default", nil, "", nil, true, "", nil, nil, "", true, []string{"requires_transform"})
		require.ErrorContains(t, err, "lock timeout")
	})
}

func TestWebhookHTTPConfigValidation(t *testing.T) {
	valid := DefaultWebhookHTTPConfig()
	require.NoError(t, valid.ValidateConfig())

	for name, mutate := range map[string]func(*WebhookHTTPConfig){
		"max_retries too high":    func(c *WebhookHTTPConfig) { c.MaxRetries = new(MaxMaxRetries + 1) },
		"backoff zero":            func(c *WebhookHTTPConfig) { c.RetryBackoffSeconds = 0 },
		"backoff over an hour":    func(c *WebhookHTTPConfig) { c.RetryBackoffSeconds = 3601 },
		"timeout zero":            func(c *WebhookHTTPConfig) { c.RequestTimeoutSeconds = 0 },
		"no expected codes":       func(c *WebhookHTTPConfig) { c.ExpectedStatusCodes = nil },
		"expected code below 100": func(c *WebhookHTTPConfig) { c.ExpectedStatusCodes = IntArray{99} },
		"expected code above 599": func(c *WebhookHTTPConfig) { c.ExpectedStatusCodes = IntArray{600} },
		"empty content type":      func(c *WebhookHTTPConfig) { c.ContentType = "" },
		"negative rate limit":     func(c *WebhookHTTPConfig) { c.RateLimitRPS = new(-1.0) },
	} {
		t.Run(name, func(t *testing.T) {
			c := DefaultWebhookHTTPConfig()
			mutate(&c)
			requireStatus(t, c.ValidateConfig(), svcerrors.InvalidArgument)
		})
	}

	assert.Equal(t, 4, DerefIntOr(nil, 4))
	assert.Equal(t, 2, DerefIntOr(new(2), 4))
	assert.True(t, DerefBoolOr(nil, true))
	assert.False(t, DerefBoolOr(new(false), true))
}

func TestNormalizePagination(t *testing.T) {
	for _, tc := range []struct{ limit, offset, wantLimit, wantOffset int }{
		{0, 0, 50, 0},
		{-5, -3, 50, 0},
		{maxPageLimit + 1, 10, maxPageLimit, 10},
		{25, 5, 25, 5},
	} {
		l, o := normalizePagination(tc.limit, tc.offset)
		assert.Equal(t, tc.wantLimit, l, "limit for %+v", tc)
		assert.Equal(t, tc.wantOffset, o, "offset for %+v", tc)
	}
}

func TestSchemaValidationErrorText(t *testing.T) {
	bare := &SchemaValidationError{Message: "payload does not match"}
	assert.Equal(t, "payload does not match", bare.Error())
	assert.Equal(t, []string{"payload does not match"}, bare.Warnings())

	detailed := &SchemaValidationError{Message: "payload does not match", Details: map[string]string{
		"":       "missing properties: id",
		"/total": "expected number",
	}}
	msg := detailed.Error()
	assert.True(t, strings.HasPrefix(msg, "payload does not match: "), msg)
	assert.Contains(t, msg, "missing properties: id")
	assert.Contains(t, msg, "/total: expected number")
	assert.ElementsMatch(t, []string{"missing properties: id", "field '/total': expected number"}, detailed.Warnings())
}

// A payload with an error per array item reported every one of them.
func TestSchemaValidationWarningsAreCapped(t *testing.T) {
	details := map[string]string{}
	for i := range 1000 {
		details[fmt.Sprintf("/items/%d", i)] = "expected string"
	}
	e := &SchemaValidationError{Message: "payload does not match", Details: details}
	w := e.Warnings()
	require.Len(t, w, maxSchemaWarnings+1)
	assert.Equal(t, "... and 900 more schema warnings", w[maxSchemaWarnings])
	assert.Equal(t, w, e.Warnings(), "the kept warnings change between calls")
	assert.Contains(t, e.Error(), "... and 900 more schema warnings")
}

func TestSecretHelpersWithoutEncryption(t *testing.T) {
	svc := NewWebhookService(nil, new(mockRepo), nil)

	for _, f := range []func() (any, error){
		func() (any, error) { return svc.EncryptSecretHeaders(nil) },
		func() (any, error) { return svc.DecryptSecretHeaders(nil) },
		func() (any, error) { return svc.EncryptWebhookSecret("") },
		func() (any, error) { return svc.DecryptWebhookSecret(nil) },
		func() (any, error) { return svc.generateEncryptedEd25519Key() },
	} {
		_, err := f()
		require.NoError(t, err, "empty input or no encryption needs no key")
	}

	_, err := svc.EncryptSecretHeaders(map[string]string{"a": "b"})
	requireStatus(t, err, svcerrors.FailedPrecondition)
	_, err = svc.DecryptSecretHeaders([]byte("x"))
	requireStatus(t, err, svcerrors.FailedPrecondition)
	_, err = svc.EncryptWebhookSecret("s")
	requireStatus(t, err, svcerrors.FailedPrecondition)
	_, err = svc.DecryptWebhookSecret([]byte("x"))
	requireStatus(t, err, svcerrors.FailedPrecondition)
	assert.Empty(t, svc.WebhookSigningPublicKeyHex([]byte("x")))

	withKey := newCryptoService(t, new(mockRepo))
	_, err = withKey.DecryptSecretHeaders([]byte("not ciphertext"))
	require.Error(t, err)
	assert.Empty(t, withKey.WebhookSigningPublicKeyHex([]byte("not ciphertext")))
	short, err := withKey.EncryptWebhookSecret("too-short-for-a-key")
	require.NoError(t, err)
	assert.Empty(t, withKey.WebhookSigningPublicKeyHex(short), "a value that is not an Ed25519 key yields no public key")
}

// Backward compatibility for webhooks saved before secret header names were
// canonicalised: their stored names may be in any case.
func TestUpdateWebhookConfig_SecretHeadersSavedInAnyCase(t *testing.T) {
	id := uuid.New()
	ctx := context.Background()
	setup := func(t *testing.T) (*WebhookService, []byte, **store.WebhookRegistration) {
		repo := new(mockRepo)
		svc := newCryptoService(t, repo)
		wh := storedWebhook(id)
		enc, err := svc.EncryptSecretHeaders(map[string]string{"x-api-key": "k"})
		require.NoError(t, err)
		wh.SecretHeaders = enc
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, id, "default").Return(wh, nil)
		var saved *store.WebhookRegistration
		repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
			saved = a.Get(2).(*store.WebhookRegistration)
		}).Return(nil)
		repo.On("DeleteRateLimitState", mock.Anything, id).Return(nil).Maybe()
		return svc, enc, &saved
	}
	update := func(svc *WebhookService, url, description string, secret map[string]string, mask ...string) error {
		return svc.UpdateWebhookConfig(ctx, id.String(), "default", nil, url, nil, true, description, nil, secret, "", false, mask)
	}

	t.Run("an update that leaves secret_headers alone does not rewrite them", func(t *testing.T) {
		svc, enc, saved := setup(t)
		require.NoError(t, update(svc, "", "new description", nil, "description"))
		assert.Equal(t, enc, (*saved).SecretHeaders)
	})
	t.Run("a same-origin URL change keeps them without resending", func(t *testing.T) {
		svc, enc, saved := setup(t)
		require.NoError(t, update(svc, "https://example.com/v2", "", nil, "url"))
		assert.Equal(t, enc, (*saved).SecretHeaders)
	})
	t.Run("adding a header keeps the stored one", func(t *testing.T) {
		svc, _, saved := setup(t)
		require.NoError(t, update(svc, "", "", map[string]string{"X-New": "n"}, "secret_headers"))
		got, err := svc.DecryptSecretHeaders((*saved).SecretHeaders)
		require.NoError(t, err)
		assert.Equal(t, map[string]string{"X-Api-Key": "k", "X-New": "n"}, got)
	})
	t.Run("replacing it in another case replaces, not duplicates", func(t *testing.T) {
		svc, _, saved := setup(t)
		require.NoError(t, update(svc, "", "", map[string]string{"X-Api-Key": "k2"}, "secret_headers"))
		got, err := svc.DecryptSecretHeaders((*saved).SecretHeaders)
		require.NoError(t, err)
		assert.Equal(t, map[string]string{"X-Api-Key": "k2"}, got)
	})
	t.Run("moving origin accepts the name in any case", func(t *testing.T) {
		svc, _, saved := setup(t)
		require.NoError(t, update(svc, "https://example.org/", "", map[string]string{"X-API-KEY": ""}, "url", "secret_headers"))
		assert.Nil(t, (*saved).SecretHeaders)
	})
}

// Backward compatibility: every URL move allowed before (scheme and Host
// equal, ignoring case) is still allowed; only an explicit default port,
// which is the same server, is newly allowed.
func TestSameOriginNeverStricterThanBefore(t *testing.T) {
	before := func(a, b string) bool {
		ua, errA := neturl.Parse(a)
		ub, errB := neturl.Parse(b)
		if errA != nil || errB != nil {
			return false
		}
		return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
	}
	urls := []string{
		"https://example.com/a", "https://EXAMPLE.com/b", "https://example.com:443/", "https://example.com:8443/",
		"http://example.com/", "http://example.com:80/", "http://example.com:8080/", "https://example.org/",
		"https://[::1]/", "https://[::1]:443/", "https://user@example.com/", "://bad",
	}
	for _, a := range urls {
		for _, b := range urls {
			if before(a, b) && !sameOrigin(a, b) {
				t.Errorf("%q -> %q was the same origin and no longer is", a, b)
			}
			if !before(a, b) && sameOrigin(a, b) {
				ua, _ := neturl.Parse(a)
				ub, _ := neturl.Parse(b)
				if ua.Port() == ub.Port() {
					t.Errorf("%q -> %q is newly the same origin for a reason other than a default port", a, b)
				}
			}
		}
	}
}