Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,28 @@ destinations:
},
want: "x-env-",
},
{
name: "whitespace disables prefix via yaml",
files: map[string][]byte{
"config.yaml": []byte(`
destinations:
webhook:
header_prefix: " "
`),
},
envVars: map[string]string{
"CONFIG": "config.yaml",
},
want: " ",
},
{
name: "whitespace disables prefix via env",
files: map[string][]byte{},
envVars: map[string]string{
"DESTINATIONS_WEBHOOK_HEADER_PREFIX": " ",
},
want: " ",
},
}

for _, tt := range tests {
Expand Down
14 changes: 12 additions & 2 deletions internal/config/destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type DestinationWebhookConfig struct {
// TODO: Implement sensitive value handling - https://github.com/hookdeck/outpost/issues/480
Mode string `yaml:"mode" env:"DESTINATIONS_WEBHOOK_MODE" desc:"Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'." required:"N"`
ProxyURL string `yaml:"proxy_url" env:"DESTINATIONS_WEBHOOK_PROXY_URL" desc:"Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy." required:"N"`
HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode." required:"N"`
HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode. Set to whitespace (e.g. ' ') to disable the prefix entirely." required:"N"`
DisableDefaultEventIDHeader bool `yaml:"disable_default_event_id_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER" desc:"If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode." required:"N"`
DisableDefaultSignatureHeader bool `yaml:"disable_default_signature_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER" desc:"If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode." required:"N"`
DisableDefaultTimestampHeader bool `yaml:"disable_default_timestamp_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER" desc:"If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode." required:"N"`
Expand All @@ -58,10 +58,20 @@ func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookCo
if mode == "" {
mode = "default"
}

// Convert HeaderPrefix string to *string for the provider:
// - empty string (zero value, unset) → nil → provider uses its default prefix
// - non-empty string (including whitespace like " ") → &value → provider applies it
// (whitespace is trimmed by the provider, so " " effectively disables the prefix)
var headerPrefix *string
if c.HeaderPrefix != "" {
headerPrefix = &c.HeaderPrefix
}

return &destregistrydefault.DestWebhookConfig{
Mode: mode,
ProxyURL: c.ProxyURL,
HeaderPrefix: c.HeaderPrefix,
HeaderPrefix: headerPrefix,
DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader,
DisableDefaultSignatureHeader: c.DisableDefaultSignatureHeader,
DisableDefaultTimestampHeader: c.DisableDefaultTimestampHeader,
Expand Down
2 changes: 1 addition & 1 deletion internal/destregistry/providers/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
type DestWebhookConfig struct {
Mode string
ProxyURL string
HeaderPrefix string
HeaderPrefix *string
DisableDefaultEventIDHeader bool
DisableDefaultSignatureHeader bool
DisableDefaultTimestampHeader bool
Expand Down
11 changes: 7 additions & 4 deletions internal/destregistry/providers/destwebhook/destwebhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,14 @@ var _ destregistry.Provider = (*WebhookDestination)(nil)
// Option is a functional option for configuring WebhookDestination
type Option func(*WebhookDestination)

// WithHeaderPrefix sets a custom prefix for webhook request headers
func WithHeaderPrefix(prefix string) Option {
// WithHeaderPrefix sets a custom prefix for webhook request headers.
// When prefix is nil, the default prefix is used.
// When prefix is non-nil, its value is used (after trimming whitespace),
// allowing an empty string to disable the prefix entirely.
func WithHeaderPrefix(prefix *string) Option {
return func(w *WebhookDestination) {
if prefix != "" {
w.headerPrefix = prefix
if prefix != nil {
w.headerPrefix = strings.TrimSpace(*prefix)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,12 @@ func (s *WebhookPublishSuite) setupExpiredSecretsSuite() {

// Custom header prefix test configuration
func (s *WebhookPublishSuite) setupCustomHeaderSuite() {
const customPrefix = "x-custom-"
consumer := NewWebhookConsumer(customPrefix)
consumer := NewWebhookConsumer("x-custom-")

provider, err := destwebhook.New(
testutil.Registry.MetadataLoader(),
nil,
destwebhook.WithHeaderPrefix(customPrefix),
destwebhook.WithHeaderPrefix(new("x-custom-")),
)
require.NoError(s.T(), err)

Expand All @@ -304,7 +303,7 @@ func (s *WebhookPublishSuite) setupCustomHeaderSuite() {
Dest: &dest,
Consumer: consumer,
Asserter: &WebhookAsserter{
headerPrefix: customPrefix,
headerPrefix: "x-custom-",
expectedSignatures: 1,
secrets: []string{"test-secret"},
},
Expand Down Expand Up @@ -431,6 +430,79 @@ func TestWebhookPublisher_DisableDefaultHeaders(t *testing.T) {
}
}

func TestWebhookPublisher_EmptyHeaderPrefix(t *testing.T) {
t.Parallel()

tests := []struct {
name string
prefix *string
want string // expected prefix on headers
}{
{
name: "nil prefix uses default",
prefix: nil,
want: "x-outpost-",
},
{
name: "empty string disables prefix",
prefix: new(""),
want: "",
},
{
name: "whitespace-only disables prefix",
prefix: new(" "),
want: "",
},
{
name: "custom prefix is applied",
prefix: new("x-custom-"),
want: "x-custom-",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

opts := []destwebhook.Option{}
if tt.prefix != nil {
opts = append(opts, destwebhook.WithHeaderPrefix(tt.prefix))
}

provider, err := destwebhook.New(testutil.Registry.MetadataLoader(), nil, opts...)
require.NoError(t, err)

destination := testutil.DestinationFactory.Any(
testutil.DestinationFactory.WithType("webhook"),
testutil.DestinationFactory.WithConfig(map[string]string{
"url": "http://example.com/webhook",
}),
testutil.DestinationFactory.WithCredentials(map[string]string{
"secret": "test-secret",
}),
)

publisher, err := provider.CreatePublisher(context.Background(), &destination)
require.NoError(t, err)

event := testutil.EventFactory.Any(
testutil.EventFactory.WithID("evt_123"),
testutil.EventFactory.WithTopic("user.created"),
testutil.EventFactory.WithDataMap(map[string]interface{}{"key": "value"}),
)

req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event)
require.NoError(t, err)

// Verify headers use the expected prefix
assert.Equal(t, "evt_123", req.Header.Get(tt.want+"event-id"))
assert.NotEmpty(t, req.Header.Get(tt.want+"timestamp"))
assert.Equal(t, "user.created", req.Header.Get(tt.want+"topic"))
assert.NotEmpty(t, req.Header.Get(tt.want+"signature"))
})
}
}

func TestWebhookPublisher_DeliveryMetadata(t *testing.T) {
t.Parallel()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,14 @@ func WithProxyURL(proxyURL string) Option {
}
}

// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-")
func WithHeaderPrefix(prefix string) Option {
// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-").
// When prefix is nil, the default prefix is used.
// When prefix is non-nil, its value is used (after trimming whitespace),
// allowing an empty string to disable the prefix entirely.
func WithHeaderPrefix(prefix *string) Option {
return func(d *StandardWebhookDestination) {
if prefix != "" {
d.headerPrefix = prefix
if prefix != nil {
d.headerPrefix = strings.TrimSpace(*prefix)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func TestNew(t *testing.T) {
provider, err := destwebhookstandard.New(
testutil.Registry.MetadataLoader(),
nil,
destwebhookstandard.WithHeaderPrefix("x-custom-"),
destwebhookstandard.WithHeaderPrefix(new("x-custom-")),
)
require.NoError(t, err)
assert.NotNil(t, provider)
Expand All @@ -139,7 +139,7 @@ func TestNew(t *testing.T) {
nil,
destwebhookstandard.WithUserAgent("test-agent"),
destwebhookstandard.WithProxyURL("http://proxy.example.com"),
destwebhookstandard.WithHeaderPrefix("x-outpost-"),
destwebhookstandard.WithHeaderPrefix(new("x-outpost-")),
)
require.NoError(t, err)
assert.NotNil(t, provider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) {
provider, err := destwebhookstandard.New(
testutil.Registry.MetadataLoader(),
nil,
destwebhookstandard.WithHeaderPrefix("x-custom-"),
destwebhookstandard.WithHeaderPrefix(new("x-custom-")),
)
require.NoError(t, err)

Expand Down Expand Up @@ -434,6 +434,79 @@ func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) {
}
}

func TestStandardWebhookPublisher_EmptyHeaderPrefix(t *testing.T) {
t.Parallel()

tests := []struct {
name string
prefix *string
want string // expected prefix on headers
}{
{
name: "nil prefix uses default",
prefix: nil,
want: "webhook-",
},
{
name: "empty string disables prefix",
prefix: new(""),
want: "",
},
{
name: "whitespace-only disables prefix",
prefix: new(" "),
want: "",
},
{
name: "custom prefix is applied",
prefix: new("x-custom-"),
want: "x-custom-",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

opts := []destwebhookstandard.Option{}
if tt.prefix != nil {
opts = append(opts, destwebhookstandard.WithHeaderPrefix(tt.prefix))
}

provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil, opts...)
require.NoError(t, err)

dest := testutil.DestinationFactory.Any(
testutil.DestinationFactory.WithType("webhook"),
testutil.DestinationFactory.WithConfig(map[string]string{
"url": "http://example.com/webhook",
}),
testutil.DestinationFactory.WithCredentials(map[string]string{
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
}),
)

publisher, err := provider.CreatePublisher(context.Background(), &dest)
require.NoError(t, err)

event := testutil.EventFactory.Any(
testutil.EventFactory.WithID("msg_test123"),
testutil.EventFactory.WithTopic("user.created"),
testutil.EventFactory.WithDataMap(map[string]interface{}{"key": "value"}),
)

req, err := publisher.(*destwebhookstandard.StandardWebhookPublisher).Format(context.Background(), &event)
require.NoError(t, err)

// Verify headers use the expected prefix
assert.Equal(t, "msg_test123", req.Header.Get(tt.want+"id"))
assert.NotEmpty(t, req.Header.Get(tt.want+"timestamp"))
assert.NotEmpty(t, req.Header.Get(tt.want+"signature"))
assert.Equal(t, "user.created", req.Header.Get(tt.want+"topic"))
})
}
}

func TestStandardWebhookPublisher_CustomHeaders(t *testing.T) {
t.Parallel()

Expand Down
Loading