internal/webhooks/webhook_service_update_test.go
Rama main · réplica de lectura del repositorio original.
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)
}
}
}
}
}