diff --git a/docs/pages/references/configuration.mdx b/docs/pages/references/configuration.mdx
index f9d98cee..f5d814df 100644
--- a/docs/pages/references/configuration.mdx
+++ b/docs/pages/references/configuration.mdx
@@ -47,16 +47,17 @@ Global configurations are provided through env variables or a YAML file. ConfigM
| `DESTINATIONS_AWS_KINESIS_METADATA_IN_PAYLOAD` | If true, includes Outpost metadata (event ID, topic, etc.) within the Kinesis record payload. | `true` | No |
| `DESTINATIONS_INCLUDE_MILLISECOND_TIMESTAMP` | If true, includes a 'timestamp-ms' field with millisecond precision in destination metadata. Useful for load testing and debugging. | `false` | No |
| `DESTINATIONS_METADATA_PATH` | Path to the directory containing custom destination type definitions. This can be overridden by the root-level 'destination_metadata_path' if also set. | `config/outpost/destinations` | No |
-| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. | `false` | No |
-| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. | `false` | No |
-| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. | `false` | No |
-| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. | `false` | No |
-| `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-'). | `x-outpost-` | No |
+| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode. | `false` | No |
+| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode. | `false` | No |
+| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode. | `false` | No |
+| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. Only applies to 'default' mode. | `false` | No |
+| `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode. | `x-outpost-` | No |
+| `DESTINATIONS_WEBHOOK_MODE` | Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'. | `nil` | No |
| `DESTINATIONS_WEBHOOK_PROXY_URL` | 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. | `nil` | No |
-| `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). | `hmac-sha256` | No |
-| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. | `{{.Timestamp.Unix}}.{{.Body}}` | No |
-| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). | `hex` | No |
-| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | Go template for the value of the signature header. | `t={{.Timestamp.Unix}},v0={{.Signatures \| join ","}}` | No |
+| `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). Only applies to 'default' mode. | `hmac-sha256` | No |
+| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode. | `{{.Timestamp.Unix}}.{{.Body}}` | No |
+| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode. | `hex` | No |
+| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | Go template for the value of the signature header. Only applies to 'default' mode. | `t={{.Timestamp.Unix}},v0={{.Signatures \| join ","}}` | No |
| `DESTINATION_METADATA_PATH` | Path to the directory containing custom destination type definitions. Overrides 'destinations.metadata_path' if set. | `nil` | No |
| `DISABLE_TELEMETRY` | Global flag to disable all telemetry (anonymous usage statistics to Hookdeck and error reporting to Sentry). If true, overrides 'telemetry.disabled'. | `false` | No |
| `GCP_PUBSUB_DELIVERY_SUBSCRIPTION` | Name of the GCP Pub/Sub subscription for delivery events. | `outpost-delivery-sub` | No |
@@ -186,34 +187,37 @@ destinations:
# Configuration specific to webhook destinations.
webhook:
- # If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests.
+ # If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode.
disable_default_event_id_header: false
- # If true, disables adding the default 'X-Outpost-Signature' header to webhook requests.
+ # If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode.
disable_default_signature_header: false
- # If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests.
+ # If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode.
disable_default_timestamp_header: false
- # If true, disables adding the default 'X-Outpost-Topic' header to webhook requests.
+ # If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. Only applies to 'default' mode.
disable_default_topic_header: false
- # Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-').
+ # Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode.
header_prefix: "x-outpost-"
+ # Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'.
+ mode: ""
+
# 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.
proxy_url: ""
- # Algorithm used for signing webhook requests (e.g., 'hmac-sha256').
+ # Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). Only applies to 'default' mode.
signature_algorithm: "hmac-sha256"
- # Go template for constructing the content to be signed for webhook requests.
+ # Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode.
signature_content_template: "{{.Timestamp.Unix}}.{{.Body}}"
- # Encoding for the signature (e.g., 'hex', 'base64').
+ # Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode.
signature_encoding: "hex"
- # Go template for the value of the signature header.
+ # Go template for the value of the signature header. Only applies to 'default' mode.
signature_header_template: "t={{.Timestamp.Unix}},v0={{.Signatures | join \",\"}}"
diff --git a/go.mod b/go.mod
index 8dfa8a79..c370e720 100644
--- a/go.mod
+++ b/go.mod
@@ -42,6 +42,7 @@ require (
github.com/redis/go-redis/v9 v9.6.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
github.com/spf13/viper v1.19.0
+ github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c
github.com/stretchr/testify v1.10.0
github.com/testcontainers/testcontainers-go v0.36.0
github.com/testcontainers/testcontainers-go/modules/clickhouse v0.33.0
diff --git a/go.sum b/go.sum
index 57376c11..bdbe3f79 100644
--- a/go.sum
+++ b/go.sum
@@ -1249,6 +1249,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
+github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c h1:Mm99t6GdFMtZOwyyvu3q8gXeZX0sqnjvimTC9QCJwQc=
+github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
diff --git a/internal/config/destinations.go b/internal/config/destinations.go
index 13f00c39..1d9aa926 100644
--- a/internal/config/destinations.go
+++ b/internal/config/destinations.go
@@ -38,21 +38,27 @@ type DestinationWebhookConfig struct {
// ProxyURL may contain authentication credentials (e.g., http://user:pass@proxy:8080)
// and should be treated as sensitive.
// 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 custom headers added to webhook requests (e.g., 'X-MyOrg-')." 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." 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." 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." required:"N"`
- DisableDefaultTopicHeader bool `yaml:"disable_default_topic_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER" desc:"If true, disables adding the default 'X-Outpost-Topic' header to webhook requests." required:"N"`
- SignatureContentTemplate string `yaml:"signature_content_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE" desc:"Go template for constructing the content to be signed for webhook requests." required:"N"`
- SignatureHeaderTemplate string `yaml:"signature_header_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE" desc:"Go template for the value of the signature header." required:"N"`
- SignatureEncoding string `yaml:"signature_encoding" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING" desc:"Encoding for the signature (e.g., 'hex', 'base64')." required:"N"`
- SignatureAlgorithm string `yaml:"signature_algorithm" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM" desc:"Algorithm used for signing webhook requests (e.g., 'hmac-sha256')." 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"`
+ 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"`
+ DisableDefaultTopicHeader bool `yaml:"disable_default_topic_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER" desc:"If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. Only applies to 'default' mode." required:"N"`
+ SignatureContentTemplate string `yaml:"signature_content_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE" desc:"Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode." required:"N"`
+ SignatureHeaderTemplate string `yaml:"signature_header_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE" desc:"Go template for the value of the signature header. Only applies to 'default' mode." required:"N"`
+ SignatureEncoding string `yaml:"signature_encoding" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING" desc:"Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode." required:"N"`
+ SignatureAlgorithm string `yaml:"signature_algorithm" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM" desc:"Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). Only applies to 'default' mode." required:"N"`
}
// toConfig converts WebhookConfig to the provider config - private since it's only used internally
func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookConfig {
+ mode := c.Mode
+ if mode == "" {
+ mode = "default"
+ }
return &destregistrydefault.DestWebhookConfig{
+ Mode: mode,
ProxyURL: c.ProxyURL,
HeaderPrefix: c.HeaderPrefix,
DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader,
diff --git a/internal/destregistry/metadata/providers/webhook_standard/instructions.md b/internal/destregistry/metadata/providers/webhook_standard/instructions.md
new file mode 100644
index 00000000..dfc3f577
--- /dev/null
+++ b/internal/destregistry/metadata/providers/webhook_standard/instructions.md
@@ -0,0 +1,16 @@
+# Webhook Configuration Instructions
+
+To receive events from the webhook destination, you need to set up a webhook endpoint.
+
+A webhook endpoint is a URL that you provide to an HTTP server. When an event is sent to the webhook destination, an HTTP POST request is made to the webhook endpoint with a JSON payload. Information such as the event type will be sent in the HTTP headers.
+
+## Verifying Webhook Signatures
+
+Webhooks include a cryptographic signature for security. To verify:
+
+1. Extract the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers
+2. Construct the signed content: `${webhook-id}.${webhook-timestamp}.${raw_body}`
+3. Decode your `whsec_` secret (remove prefix and base64 decode)
+4. Compute HMAC-SHA256 signature and compare
+
+Verification libraries are available at: https://github.com/standard-webhooks
diff --git a/internal/destregistry/metadata/providers/webhook_standard/metadata.json b/internal/destregistry/metadata/providers/webhook_standard/metadata.json
new file mode 100644
index 00000000..377ac49c
--- /dev/null
+++ b/internal/destregistry/metadata/providers/webhook_standard/metadata.json
@@ -0,0 +1,18 @@
+{
+ "type": "webhook",
+ "config_fields": [
+ {
+ "key": "url",
+ "type": "text",
+ "label": "Webhook URL",
+ "description": "The URL to send webhook events to via HTTP POST (Standard Webhooks compliant)",
+ "required": true,
+ "pattern": "^https?:\\/\\/[\\w\\-]+(?:\\.[\\w\\-]+)*(?::\\d{1,5})?(?:\\/[\\w\\-\\/\\.~:?#\\[\\]@!$&'\\(\\)*+,;=]*)?$"
+ }
+ ],
+ "credential_fields": [],
+ "label": "Webhook",
+ "link": "https://hookdeck.com/webhooks/guides/what-are-webhooks-how-they-work",
+ "description": "Send events as webhooks (HTTP POST).",
+ "icon": ""
+}
diff --git a/internal/destregistry/providers/default.go b/internal/destregistry/providers/default.go
index 433a5e90..4622b97f 100644
--- a/internal/destregistry/providers/default.go
+++ b/internal/destregistry/providers/default.go
@@ -9,9 +9,11 @@ import (
"github.com/hookdeck/outpost/internal/destregistry/providers/desthookdeck"
"github.com/hookdeck/outpost/internal/destregistry/providers/destrabbitmq"
"github.com/hookdeck/outpost/internal/destregistry/providers/destwebhook"
+ "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard"
)
type DestWebhookConfig struct {
+ Mode string
ProxyURL string
HeaderPrefix string
DisableDefaultEventIDHeader bool
@@ -47,28 +49,44 @@ func RegisterDefault(registry destregistry.Registry, opts RegisterDefaultDestina
basePublisherOpts = append(basePublisherOpts, destregistry.WithMillisecondTimestamp(opts.IncludeMillisecondTimestamp))
}
- webhookOpts := []destwebhook.Option{
- destwebhook.WithUserAgent(opts.UserAgent),
+ // Register webhook provider based on mode
+ if opts.Webhook != nil && opts.Webhook.Mode == "standard" {
+ // Standard Webhooks mode - register webhook_standard as "webhook"
+ webhookStandardOpts := []destwebhookstandard.Option{
+ destwebhookstandard.WithUserAgent(opts.UserAgent),
+ destwebhookstandard.WithProxyURL(opts.Webhook.ProxyURL),
+ destwebhookstandard.WithHeaderPrefix(opts.Webhook.HeaderPrefix),
+ }
+ webhookStandard, err := destwebhookstandard.New(loader, basePublisherOpts, webhookStandardOpts...)
+ if err != nil {
+ return err
+ }
+ registry.RegisterProvider("webhook", webhookStandard)
+ } else {
+ // Default mode - register customizable webhook as "webhook"
+ webhookOpts := []destwebhook.Option{
+ destwebhook.WithUserAgent(opts.UserAgent),
+ }
+ if opts.Webhook != nil {
+ webhookOpts = append(webhookOpts,
+ destwebhook.WithProxyURL(opts.Webhook.ProxyURL),
+ destwebhook.WithHeaderPrefix(opts.Webhook.HeaderPrefix),
+ destwebhook.WithDisableDefaultEventIDHeader(opts.Webhook.DisableDefaultEventIDHeader),
+ destwebhook.WithDisableDefaultSignatureHeader(opts.Webhook.DisableDefaultSignatureHeader),
+ destwebhook.WithDisableDefaultTimestampHeader(opts.Webhook.DisableDefaultTimestampHeader),
+ destwebhook.WithDisableDefaultTopicHeader(opts.Webhook.DisableDefaultTopicHeader),
+ destwebhook.WithSignatureContentTemplate(opts.Webhook.SignatureContentTemplate),
+ destwebhook.WithSignatureHeaderTemplate(opts.Webhook.SignatureHeaderTemplate),
+ destwebhook.WithSignatureEncoding(opts.Webhook.SignatureEncoding),
+ destwebhook.WithSignatureAlgorithm(opts.Webhook.SignatureAlgorithm),
+ )
+ }
+ webhook, err := destwebhook.New(loader, basePublisherOpts, webhookOpts...)
+ if err != nil {
+ return err
+ }
+ registry.RegisterProvider("webhook", webhook)
}
- if opts.Webhook != nil {
- webhookOpts = append(webhookOpts,
- destwebhook.WithProxyURL(opts.Webhook.ProxyURL),
- destwebhook.WithHeaderPrefix(opts.Webhook.HeaderPrefix),
- destwebhook.WithDisableDefaultEventIDHeader(opts.Webhook.DisableDefaultEventIDHeader),
- destwebhook.WithDisableDefaultSignatureHeader(opts.Webhook.DisableDefaultSignatureHeader),
- destwebhook.WithDisableDefaultTimestampHeader(opts.Webhook.DisableDefaultTimestampHeader),
- destwebhook.WithDisableDefaultTopicHeader(opts.Webhook.DisableDefaultTopicHeader),
- destwebhook.WithSignatureContentTemplate(opts.Webhook.SignatureContentTemplate),
- destwebhook.WithSignatureHeaderTemplate(opts.Webhook.SignatureHeaderTemplate),
- destwebhook.WithSignatureEncoding(opts.Webhook.SignatureEncoding),
- destwebhook.WithSignatureAlgorithm(opts.Webhook.SignatureAlgorithm),
- )
- }
- webhook, err := destwebhook.New(loader, basePublisherOpts, webhookOpts...)
- if err != nil {
- return err
- }
- registry.RegisterProvider("webhook", webhook)
hookdeck, err := desthookdeck.New(loader, basePublisherOpts,
desthookdeck.WithUserAgent(opts.UserAgent))
diff --git a/internal/destregistry/providers/destwebhook/destwebhook.go b/internal/destregistry/providers/destwebhook/destwebhook.go
index 39493ee9..f7a120fa 100644
--- a/internal/destregistry/providers/destwebhook/destwebhook.go
+++ b/internal/destregistry/providers/destwebhook/destwebhook.go
@@ -534,18 +534,33 @@ func (p *WebhookPublisher) Publish(ctx context.Context, event *models.Event) (*d
defer resp.Body.Close()
if resp.StatusCode >= 400 {
- bodyBytes, _ := io.ReadAll(resp.Body)
delivery := &destregistry.Delivery{
Status: "failed",
Code: fmt.Sprintf("%d", resp.StatusCode),
}
parseResponse(delivery, resp)
+
+ // Extract body from delivery.Response for error details
+ var bodyStr string
+ if delivery.Response != nil {
+ if body, ok := delivery.Response["body"]; ok {
+ switch v := body.(type) {
+ case string:
+ bodyStr = v
+ case map[string]interface{}:
+ if jsonBytes, err := json.Marshal(v); err == nil {
+ bodyStr = string(jsonBytes)
+ }
+ }
+ }
+ }
+
return delivery, destregistry.NewErrDestinationPublishAttempt(
- fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)),
+ fmt.Errorf("request failed with status %d: %s", resp.StatusCode, bodyStr),
"webhook",
map[string]interface{}{
"status": resp.StatusCode,
- "body": string(bodyBytes),
+ "body": bodyStr,
})
}
diff --git a/internal/destregistry/providers/destwebhookstandard/assert_test.go b/internal/destregistry/providers/destwebhookstandard/assert_test.go
new file mode 100644
index 00000000..c82cbe20
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/assert_test.go
@@ -0,0 +1,77 @@
+package destwebhookstandard_test
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+
+ testsuite "github.com/hookdeck/outpost/internal/destregistry/testing"
+ standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// assertValidStandardWebhookSignature verifies a Standard Webhooks signature
+// using both our manual verification AND the official Standard Webhooks SDK
+func assertValidStandardWebhookSignature(t testsuite.TestingT, secret, msgID, timestamp string, body []byte, signatureHeader string) {
+ t.Helper()
+
+ // First, verify using the official Standard Webhooks SDK
+ // This ensures our implementation is compatible with the official library
+ wh, err := standardwebhooks.NewWebhook(secret)
+ require.NoError(t, err, "failed to create webhook verifier with official SDK")
+
+ headers := http.Header{}
+ headers.Set("webhook-id", msgID)
+ headers.Set("webhook-timestamp", timestamp)
+ headers.Set("webhook-signature", signatureHeader)
+
+ err = wh.Verify(body, headers)
+ assert.NoError(t, err, "official Standard Webhooks SDK should verify our signature")
+
+ // Also verify manually to ensure we understand the signature format
+ encodedPart := strings.TrimPrefix(secret, "whsec_")
+ decodedSecret, err := base64.StdEncoding.DecodeString(encodedPart)
+ require.NoError(t, err, "secret should decode successfully")
+
+ // Construct signed content: msg_id.timestamp.body
+ signedContent := fmt.Sprintf("%s.%s.%s", msgID, timestamp, string(body))
+
+ // Generate expected signature
+ mac := hmac.New(sha256.New, decodedSecret)
+ mac.Write([]byte(signedContent))
+ expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
+
+ // Check if any signature in header matches
+ signatures := strings.Split(signatureHeader, " ")
+ found := false
+ for _, sig := range signatures {
+ sigPart := strings.TrimPrefix(sig, "v1,")
+ if hmac.Equal([]byte(sigPart), []byte(expectedSig)) {
+ found = true
+ break
+ }
+ }
+
+ assert.True(t, found, "no valid signature found in header (manual verification)")
+}
+
+// assertSignatureFormat verifies the signature header format
+func assertSignatureFormat(t testsuite.TestingT, signatureHeader string, expectedCount int) {
+ t.Helper()
+
+ signatures := strings.Split(signatureHeader, " ")
+ assert.Equal(t, expectedCount, len(signatures), "signature count mismatch")
+
+ for i, sig := range signatures {
+ assert.True(t, strings.HasPrefix(sig, "v1,"), "signature %d should have v1, prefix", i)
+
+ // Verify it's valid base64
+ sigPart := strings.TrimPrefix(sig, "v1,")
+ _, err := base64.StdEncoding.DecodeString(sigPart)
+ assert.NoError(t, err, "signature %d should be valid base64", i)
+ }
+}
diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go
new file mode 100644
index 00000000..61bd5f96
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go
@@ -0,0 +1,663 @@
+package destwebhookstandard
+
+/*
+Standard Webhooks Destination Provider
+
+This implementation is based on the destwebhook provider and reuses its core
+signature management infrastructure (SignatureManager). The key differences are:
+
+1. Secret Format:
+ - destwebhook: Flexible format (any string, typically hex-encoded)
+ - destwebhookstandard: Strict "whsec_" format per Standard Webhooks spec
+
+2. Header Names:
+ - destwebhook: Customizable via templates
+ - destwebhookstandard: Uses configurable prefix for all headers: "id", "timestamp", "signature",
+ and metadata headers (topic, etc.)
+ - Prefix defaults to "webhook-" in standard mode, "x-outpost-" in default mode
+ - Examples: "webhook-id", "webhook-timestamp" OR "x-custom-id", "x-custom-timestamp"
+
+3. Signature Format:
+ - destwebhook: Customizable template
+ - destwebhookstandard: Fixed "${webhook-id}.${timestamp}.${body}" signed content
+ and "v1," signature format
+
+ARCHITECTURE NOTES:
+
+- We import and use destwebhook.SignatureManager for signature generation
+- We use destwebhook.WebhookSecret for secret storage
+- Secret validation, parsing, and rotation logic is currently duplicated here
+
+FUTURE REFACTORING CONSIDERATIONS:
+
+If this pattern proves useful, consider extracting shared logic into a common package:
+- Secret validation and parsing helpers
+- Secret rotation logic (with configurable format validators)
+- Common credential preprocessing patterns
+- Shared validation error construction
+
+This would allow both destwebhook and destwebhookstandard to share the same
+underlying infrastructure while maintaining their specific requirements.
+
+For now, we keep them separate to:
+1. Avoid breaking changes to the existing destwebhook implementation
+2. Allow independent evolution of the two providers
+3. Keep the Standard Webhooks implementation self-contained for easier review
+
+Related files to consider for refactoring:
+- internal/destregistry/providers/destwebhook/signature.go (SignatureManager)
+- internal/destregistry/providers/destwebhook/destwebhook.go (rotation logic)
+*/
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/hookdeck/outpost/internal/destregistry"
+ "github.com/hookdeck/outpost/internal/destregistry/metadata"
+ "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhook"
+ "github.com/hookdeck/outpost/internal/models"
+)
+
+type StandardWebhookDestination struct {
+ *destregistry.BaseProvider
+ userAgent string
+ proxyURL string
+ headerPrefix string // Prefix for metadata headers (defaults to "webhook-")
+}
+
+type StandardWebhookDestinationConfig struct {
+ URL string `json:"url"`
+}
+
+type StandardWebhookDestinationCredentials struct {
+ Secret string `json:"secret"`
+ PreviousSecret string `json:"previous_secret,omitempty"`
+ PreviousSecretInvalidAt *time.Time `json:"previous_secret_invalid_at,omitempty"`
+}
+
+var _ destregistry.Provider = (*StandardWebhookDestination)(nil)
+
+// Option is a functional option for configuring StandardWebhookDestination
+type Option func(*StandardWebhookDestination)
+
+// WithUserAgent sets the user agent for the webhook request
+func WithUserAgent(userAgent string) Option {
+ return func(d *StandardWebhookDestination) {
+ d.userAgent = userAgent
+ }
+}
+
+// WithProxyURL sets the proxy URL for the webhook request
+func WithProxyURL(proxyURL string) Option {
+ return func(d *StandardWebhookDestination) {
+ if proxyURL != "" {
+ d.proxyURL = proxyURL
+ }
+ }
+}
+
+// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-")
+func WithHeaderPrefix(prefix string) Option {
+ return func(d *StandardWebhookDestination) {
+ if prefix != "" {
+ d.headerPrefix = prefix
+ }
+ }
+}
+
+func New(loader metadata.MetadataLoader, basePublisherOpts []destregistry.BasePublisherOption, opts ...Option) (*StandardWebhookDestination, error) {
+ base, err := destregistry.NewBaseProvider(loader, "webhook_standard", basePublisherOpts...)
+ if err != nil {
+ return nil, err
+ }
+ destination := &StandardWebhookDestination{
+ BaseProvider: base,
+ headerPrefix: "webhook-", // Default to Standard Webhooks spec
+ }
+ for _, opt := range opts {
+ opt(destination)
+ }
+ return destination, nil
+}
+
+func (d *StandardWebhookDestination) ComputeTarget(destination *models.Destination) destregistry.DestinationTarget {
+ return destregistry.DestinationTarget{
+ Target: destination.Config["url"],
+ TargetURL: "",
+ }
+}
+
+func (d *StandardWebhookDestination) ObfuscateDestination(destination *models.Destination) *models.Destination {
+ result := *destination // shallow copy
+ result.Config = make(map[string]string, len(destination.Config))
+ result.Credentials = make(map[string]string, len(destination.Credentials))
+
+ // Copy config values
+ for key, value := range destination.Config {
+ result.Config[key] = value
+ }
+
+ // Copy credentials as is
+ // NOTE: Secrets are intentionally not obfuscated for now because:
+ // 1. They're needed for secret rotation logic
+ // 2. They're less security-critical than other provider credentials
+ for key, value := range destination.Credentials {
+ result.Credentials[key] = value
+ }
+
+ return &result
+}
+
+func (d *StandardWebhookDestination) Validate(ctx context.Context, destination *models.Destination) error {
+ if _, _, err := d.resolveConfig(ctx, destination); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (d *StandardWebhookDestination) CreatePublisher(ctx context.Context, destination *models.Destination) (destregistry.Publisher, error) {
+ config, creds, err := d.resolveConfig(ctx, destination)
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse and validate secrets
+ now := time.Now()
+ var secrets []destwebhook.WebhookSecret
+
+ // Parse current secret
+ parsedSecret, err := parseSecret(creds.Secret)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse secret: %w", err)
+ }
+ secrets = append(secrets, destwebhook.WebhookSecret{
+ Key: parsedSecret,
+ CreatedAt: now,
+ })
+
+ // Parse previous secret if present
+ if creds.PreviousSecret != "" {
+ parsedPrevSecret, err := parseSecret(creds.PreviousSecret)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse previous_secret: %w", err)
+ }
+ secrets = append(secrets, destwebhook.WebhookSecret{
+ Key: parsedPrevSecret,
+ CreatedAt: now.Add(-1 * time.Hour), // Set to 1 hour before current secret
+ InvalidAt: creds.PreviousSecretInvalidAt,
+ })
+ }
+
+ // Create SignatureManager with Standard Webhooks templates
+ sm := destwebhook.NewSignatureManager(
+ secrets,
+ destwebhook.WithSignatureFormatter(
+ destwebhook.NewSignatureFormatter("{{.EventID}}.{{.Timestamp.Unix}}.{{.Body}}"),
+ ),
+ destwebhook.WithHeaderFormatter(
+ destwebhook.NewHeaderFormatter("v1,{{index .Signatures 0}}{{range slice .Signatures 1}} v1,{{.}}{{end}}"),
+ ),
+ destwebhook.WithEncoder(destwebhook.GetEncoder("base64")),
+ destwebhook.WithAlgorithm(destwebhook.GetAlgorithm("hmac-sha256")),
+ )
+
+ var proxyURL *string
+ if d.proxyURL != "" {
+ proxyURL = &d.proxyURL
+ }
+
+ httpClient, err := d.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{
+ UserAgent: &d.userAgent,
+ ProxyURL: proxyURL,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &StandardWebhookPublisher{
+ BasePublisher: d.BaseProvider.NewPublisher(),
+ httpClient: httpClient,
+ url: config.URL,
+ secrets: secrets,
+ sm: sm,
+ headerPrefix: d.headerPrefix,
+ }, nil
+}
+
+func (d *StandardWebhookDestination) resolveConfig(ctx context.Context, destination *models.Destination) (*StandardWebhookDestinationConfig, *StandardWebhookDestinationCredentials, error) {
+ if err := d.BaseProvider.Validate(ctx, destination); err != nil {
+ return nil, nil, err
+ }
+
+ config := &StandardWebhookDestinationConfig{
+ URL: destination.Config["url"],
+ }
+
+ // Parse credentials
+ creds := &StandardWebhookDestinationCredentials{
+ Secret: destination.Credentials["secret"],
+ PreviousSecret: destination.Credentials["previous_secret"],
+ }
+
+ // Skip validation if no relevant credentials are passed
+ if destination.Credentials["secret"] == "" &&
+ destination.Credentials["previous_secret"] == "" &&
+ destination.Credentials["previous_secret_invalid_at"] == "" {
+ return config, creds, nil
+ }
+
+ // If any credentials are passed, secret is required
+ if creds.Secret == "" {
+ return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
+ Field: "credentials.secret",
+ Type: "required",
+ }})
+ }
+
+ // Validate secret format
+ if err := validateSecret(creds.Secret); err != nil {
+ return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
+ Field: "credentials.secret",
+ Type: "pattern",
+ }})
+ }
+
+ // Parse previous_secret_invalid_at if present
+ if invalidAtStr := destination.Credentials["previous_secret_invalid_at"]; invalidAtStr != "" {
+ invalidAt, err := time.Parse(time.RFC3339, invalidAtStr)
+ if err != nil {
+ return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
+ Field: "credentials.previous_secret_invalid_at",
+ Type: "pattern",
+ }})
+ }
+ creds.PreviousSecretInvalidAt = &invalidAt
+ }
+
+ // Validate previous_secret if provided
+ if creds.PreviousSecret != "" {
+ if err := validateSecret(creds.PreviousSecret); err != nil {
+ return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
+ Field: "credentials.previous_secret",
+ Type: "pattern",
+ }})
+ }
+
+ // Require invalidation time if previous secret is provided
+ if creds.PreviousSecretInvalidAt == nil {
+ return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
+ Field: "credentials.previous_secret_invalid_at",
+ Type: "required",
+ }})
+ }
+ }
+
+ // If previous_secret_invalid_at is provided, validate previous_secret
+ if creds.PreviousSecretInvalidAt != nil && creds.PreviousSecret == "" {
+ return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
+ Field: "credentials.previous_secret",
+ Type: "required",
+ }})
+ }
+
+ return config, creds, nil
+}
+
+// rotateSecret handles secret rotation and returns clean credentials
+func (d *StandardWebhookDestination) rotateSecret(newDest, origDest *models.Destination) (map[string]string, error) {
+ if origDest == nil {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.rotate_secret",
+ Type: "invalid",
+ },
+ })
+ }
+
+ if origDest.Credentials["secret"] == "" {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.secret",
+ Type: "required",
+ },
+ })
+ }
+
+ creds := make(map[string]string)
+
+ // Store the current secret as the previous secret
+ creds["previous_secret"] = origDest.Credentials["secret"]
+
+ // Generate a new secret
+ secret, err := generateStandardSecret()
+ if err != nil {
+ return nil, err
+ }
+ creds["secret"] = secret
+
+ // Keep custom invalidation time if provided, otherwise set default
+ if newDest.Credentials["previous_secret_invalid_at"] != "" {
+ creds["previous_secret_invalid_at"] = newDest.Credentials["previous_secret_invalid_at"]
+ } else {
+ creds["previous_secret_invalid_at"] = time.Now().Add(24 * time.Hour).Format(time.RFC3339)
+ }
+
+ return creds, nil
+}
+
+// updateSecret handles non-rotation updates and returns clean credentials
+func (d *StandardWebhookDestination) updateSecret(newDest, origDest *models.Destination, opts *destregistry.PreprocessDestinationOpts) (map[string]string, error) {
+ creds := make(map[string]string)
+
+ if opts.Role != "admin" {
+ // For tenants, first check if they're trying to modify any credential fields
+ if origDest != nil && origDest.Credentials != nil {
+ // Updating existing destination - must match original values
+ if newDest.Credentials["secret"] != "" && newDest.Credentials["secret"] != origDest.Credentials["secret"] {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.secret",
+ Type: "forbidden",
+ },
+ })
+ }
+ if newDest.Credentials["previous_secret"] != "" && newDest.Credentials["previous_secret"] != origDest.Credentials["previous_secret"] {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.previous_secret",
+ Type: "forbidden",
+ },
+ })
+ }
+ if newDest.Credentials["previous_secret_invalid_at"] != "" && newDest.Credentials["previous_secret_invalid_at"] != origDest.Credentials["previous_secret_invalid_at"] {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.previous_secret_invalid_at",
+ Type: "forbidden",
+ },
+ })
+ }
+ // Copy original values
+ for _, key := range []string{"secret", "previous_secret", "previous_secret_invalid_at"} {
+ if value := origDest.Credentials[key]; value != "" {
+ creds[key] = value
+ }
+ }
+ } else {
+ // First time creation - can't set any credentials
+ if newDest.Credentials["secret"] != "" {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.secret",
+ Type: "forbidden",
+ },
+ })
+ }
+ if newDest.Credentials["previous_secret"] != "" {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.previous_secret",
+ Type: "forbidden",
+ },
+ })
+ }
+ if newDest.Credentials["previous_secret_invalid_at"] != "" {
+ return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
+ {
+ Field: "credentials.previous_secret_invalid_at",
+ Type: "forbidden",
+ },
+ })
+ }
+ }
+ } else {
+ // Admin can set any values
+ for _, key := range []string{"secret", "previous_secret", "previous_secret_invalid_at"} {
+ if value := newDest.Credentials[key]; value != "" {
+ creds[key] = value
+ }
+ }
+ }
+
+ return creds, nil
+}
+
+// ensureInitializedCredentials ensures credentials are initialized for new destinations
+func (d *StandardWebhookDestination) ensureInitializedCredentials(creds map[string]string) (map[string]string, error) {
+ // If there are any credentials already, return them as is
+ if creds["secret"] != "" || creds["previous_secret"] != "" || creds["previous_secret_invalid_at"] != "" {
+ return creds, nil
+ }
+
+ // Otherwise generate a new secret
+ secret, err := generateStandardSecret()
+ if err != nil {
+ return nil, err
+ }
+ return map[string]string{
+ "secret": secret,
+ }, nil
+}
+
+// validateAndSanitizeCredentials performs final validation and cleanup
+func (d *StandardWebhookDestination) validateAndSanitizeCredentials(creds map[string]string) (map[string]string, error) {
+ // Set default previous_secret_invalid_at if previous_secret is set but invalid_at is not
+ if creds["previous_secret"] != "" && creds["previous_secret_invalid_at"] == "" {
+ creds["previous_secret_invalid_at"] = time.Now().Add(24 * time.Hour).Format(time.RFC3339)
+ }
+
+ // Clean up any extra fields
+ cleanCreds := make(map[string]string)
+ for _, key := range []string{"secret", "previous_secret", "previous_secret_invalid_at"} {
+ if value := creds[key]; value != "" {
+ cleanCreds[key] = value
+ }
+ }
+
+ return cleanCreds, nil
+}
+
+// Preprocess sets a default secret if one isn't provided and handles secret rotation
+func (d *StandardWebhookDestination) Preprocess(newDestination *models.Destination, originalDestination *models.Destination, opts *destregistry.PreprocessDestinationOpts) error {
+ // Initialize credentials if nil
+ if newDestination.Credentials == nil {
+ newDestination.Credentials = make(map[string]string)
+ }
+
+ // Get clean credentials based on operation type
+ var cleanCredentials map[string]string
+ var err error
+ if isTruthy(newDestination.Credentials["rotate_secret"]) {
+ cleanCredentials, err = d.rotateSecret(newDestination, originalDestination)
+ } else {
+ cleanCredentials, err = d.updateSecret(newDestination, originalDestination, opts)
+ // For new destinations, ensure credentials are initialized if needed
+ if err == nil && originalDestination == nil {
+ cleanCredentials, err = d.ensureInitializedCredentials(cleanCredentials)
+ }
+ }
+ if err != nil {
+ return err
+ }
+
+ // Final validation and sanitization
+ cleanCredentials, err = d.validateAndSanitizeCredentials(cleanCredentials)
+ if err != nil {
+ return err
+ }
+
+ newDestination.Credentials = cleanCredentials
+ return nil
+}
+
+type StandardWebhookPublisher struct {
+ *destregistry.BasePublisher
+ httpClient *http.Client
+ url string
+ secrets []destwebhook.WebhookSecret
+ sm *destwebhook.SignatureManager
+ headerPrefix string
+}
+
+func (p *StandardWebhookPublisher) Close() error {
+ p.BasePublisher.StartClose()
+ return nil
+}
+
+func (p *StandardWebhookPublisher) Publish(ctx context.Context, event *models.Event) (*destregistry.Delivery, error) {
+ if err := p.BasePublisher.StartPublish(); err != nil {
+ return nil, err
+ }
+ defer p.BasePublisher.FinishPublish()
+
+ httpReq, err := p.Format(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := p.httpClient.Do(httpReq)
+ if err != nil {
+ return nil, destregistry.NewErrDestinationPublishAttempt(err, "webhook_standard", map[string]interface{}{
+ "error": "request_failed",
+ "message": err.Error(),
+ })
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ delivery := &destregistry.Delivery{
+ Status: "failed",
+ Code: fmt.Sprintf("%d", resp.StatusCode),
+ }
+ parseResponse(delivery, resp)
+
+ // Extract body from delivery.Response for error details
+ var bodyStr string
+ if delivery.Response != nil {
+ if body, ok := delivery.Response["body"]; ok {
+ switch v := body.(type) {
+ case string:
+ bodyStr = v
+ case map[string]interface{}:
+ if jsonBytes, err := json.Marshal(v); err == nil {
+ bodyStr = string(jsonBytes)
+ }
+ }
+ }
+ }
+
+ return delivery, destregistry.NewErrDestinationPublishAttempt(
+ fmt.Errorf("request failed with status %d: %s", resp.StatusCode, bodyStr),
+ "webhook_standard",
+ map[string]interface{}{
+ "status": resp.StatusCode,
+ "body": bodyStr,
+ })
+ }
+
+ delivery := &destregistry.Delivery{
+ Status: "success",
+ Code: fmt.Sprintf("%d", resp.StatusCode),
+ }
+ parseResponse(delivery, resp)
+
+ return delivery, nil
+}
+
+// Format creates an HTTP request formatted according to Standard Webhooks specification
+func (p *StandardWebhookPublisher) Format(ctx context.Context, event *models.Event) (*http.Request, error) {
+ now := time.Now()
+ rawBody, err := json.Marshal(event.Data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal event data: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", p.url, bytes.NewBuffer(rawBody))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ // Use event ID directly as the message ID
+ // This ensures the same message ID is used across retry attempts
+ // TODO: Support configurable ID generator/template (e.g., "msg_" prefix)
+ messageID := event.ID
+
+ // Set Standard Webhooks headers with configurable prefix
+ req.Header.Set(p.headerPrefix+"id", messageID)
+ req.Header.Set(p.headerPrefix+"timestamp", strconv.FormatInt(now.Unix(), 10))
+
+ // Generate and set signature header
+ signatureHeader := p.sm.GenerateSignatureHeader(destwebhook.SignaturePayload{
+ EventID: messageID,
+ Topic: event.Topic,
+ Timestamp: now,
+ Body: string(rawBody),
+ })
+ if signatureHeader != "" {
+ req.Header.Set(p.headerPrefix+"signature", signatureHeader)
+ }
+
+ // Add event metadata as custom headers
+ // Get merged metadata (system + event metadata) using BasePublisher
+ metadata := p.BasePublisher.MakeMetadata(event, now)
+ for key, value := range metadata {
+ // Skip system metadata that's already handled by Standard Webhooks headers
+ // (webhook-id replaces event-id, webhook-timestamp replaces timestamp)
+ if key == "event-id" || key == "timestamp" {
+ continue
+ }
+ // Add with configured prefix (defaults to "webhook-")
+ req.Header.Set(p.headerPrefix+key, value)
+ }
+
+ // Also add custom event metadata without prefix (user-defined metadata)
+ for key, value := range event.Metadata {
+ req.Header.Set(key, value)
+ }
+
+ return req, nil
+}
+
+// isTruthy checks if a string value represents a truthy value
+func isTruthy(value string) bool {
+ switch strings.ToLower(value) {
+ case "true", "1", "on", "yes":
+ return true
+ default:
+ return false
+ }
+}
+
+func parseResponse(delivery *destregistry.Delivery, resp *http.Response) {
+ if strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ var response map[string]interface{}
+ if err := json.Unmarshal(bodyBytes, &response); err != nil {
+ delivery.Response = map[string]interface{}{
+ "status": resp.StatusCode,
+ "body": string(bodyBytes),
+ }
+ return
+ }
+ delivery.Response = map[string]interface{}{
+ "status": resp.StatusCode,
+ "body": response,
+ }
+ } else {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ delivery.Response = map[string]interface{}{
+ "status": resp.StatusCode,
+ "body": string(bodyBytes),
+ }
+ }
+}
diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go
new file mode 100644
index 00000000..dc9005ad
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go
@@ -0,0 +1,67 @@
+package destwebhookstandard_test
+
+import (
+ "testing"
+
+ "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard"
+ "github.com/hookdeck/outpost/internal/util/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNew(t *testing.T) {
+ t.Parallel()
+
+ t.Run("creates provider with defaults", func(t *testing.T) {
+ t.Parallel()
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(t, err)
+ assert.NotNil(t, provider)
+ })
+
+ t.Run("creates provider with user agent option", func(t *testing.T) {
+ t.Parallel()
+ provider, err := destwebhookstandard.New(
+ testutil.Registry.MetadataLoader(),
+ nil,
+ destwebhookstandard.WithUserAgent("test-agent"),
+ )
+ require.NoError(t, err)
+ assert.NotNil(t, provider)
+ })
+
+ t.Run("creates provider with proxy URL option", func(t *testing.T) {
+ t.Parallel()
+ provider, err := destwebhookstandard.New(
+ testutil.Registry.MetadataLoader(),
+ nil,
+ destwebhookstandard.WithProxyURL("http://proxy.example.com"),
+ )
+ require.NoError(t, err)
+ assert.NotNil(t, provider)
+ })
+
+ t.Run("creates provider with header prefix option", func(t *testing.T) {
+ t.Parallel()
+ provider, err := destwebhookstandard.New(
+ testutil.Registry.MetadataLoader(),
+ nil,
+ destwebhookstandard.WithHeaderPrefix("x-custom-"),
+ )
+ require.NoError(t, err)
+ assert.NotNil(t, provider)
+ })
+
+ t.Run("creates provider with multiple options", func(t *testing.T) {
+ t.Parallel()
+ provider, err := destwebhookstandard.New(
+ testutil.Registry.MetadataLoader(),
+ nil,
+ destwebhookstandard.WithUserAgent("test-agent"),
+ destwebhookstandard.WithProxyURL("http://proxy.example.com"),
+ destwebhookstandard.WithHeaderPrefix("x-outpost-"),
+ )
+ require.NoError(t, err)
+ assert.NotNil(t, provider)
+ })
+}
diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go
new file mode 100644
index 00000000..6d3539d3
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go
@@ -0,0 +1,417 @@
+package destwebhookstandard_test
+
+import (
+ "context"
+ "encoding/base64"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard"
+ testsuite "github.com/hookdeck/outpost/internal/destregistry/testing"
+ "github.com/hookdeck/outpost/internal/models"
+ "github.com/hookdeck/outpost/internal/util/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/stretchr/testify/suite"
+)
+
+// StandardWebhookConsumer implements testsuite.MessageConsumer
+type StandardWebhookConsumer struct {
+ server *httptest.Server
+ messages chan testsuite.Message
+ wg sync.WaitGroup
+}
+
+func NewStandardWebhookConsumer() *StandardWebhookConsumer {
+ consumer := &StandardWebhookConsumer{
+ messages: make(chan testsuite.Message, 100),
+ }
+
+ consumer.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ consumer.wg.Add(1)
+ defer consumer.wg.Done()
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ // Extract all headers as metadata
+ metadata := make(map[string]string)
+ for k, v := range r.Header {
+ if len(v) > 0 {
+ // Convert header name to lowercase for consistent access
+ metadata[strings.ToLower(k)] = v[0]
+ }
+ }
+
+ consumer.messages <- testsuite.Message{
+ Data: body,
+ Metadata: metadata,
+ Raw: r, // Store raw request for detailed assertions
+ }
+
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ return consumer
+}
+
+func (c *StandardWebhookConsumer) Consume() <-chan testsuite.Message {
+ return c.messages
+}
+
+func (c *StandardWebhookConsumer) Close() error {
+ c.wg.Wait()
+ c.server.Close()
+ close(c.messages)
+ return nil
+}
+
+// StandardWebhookAsserter implements testsuite.MessageAsserter
+type StandardWebhookAsserter struct {
+ secret string
+ expectedSignatures int
+ headerPrefix string // Defaults to "webhook-"
+}
+
+func (a *StandardWebhookAsserter) AssertMessage(t testsuite.TestingT, msg testsuite.Message, event models.Event) {
+ req := msg.Raw.(*http.Request)
+
+ // Verify HTTP properties
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+
+ // Use configured prefix or default to "webhook-"
+ prefix := a.headerPrefix
+ if prefix == "" {
+ prefix = "webhook-"
+ }
+
+ // Verify Standard Webhooks headers with configured prefix
+ webhookID := req.Header.Get(prefix + "id")
+ assert.NotEmpty(t, webhookID, prefix+"id should be present")
+ // Note: webhook-id format depends on event.ID format (user-provided)
+
+ webhookTimestamp := req.Header.Get(prefix + "timestamp")
+ assert.NotEmpty(t, webhookTimestamp, prefix+"timestamp should be present")
+ testsuite.AssertTimestampIsUnixSeconds(t, webhookTimestamp)
+
+ webhookSignature := req.Header.Get(prefix + "signature")
+ assert.NotEmpty(t, webhookSignature, prefix+"signature should be present")
+
+ // Verify signature format and count
+ assertSignatureFormat(t, webhookSignature, a.expectedSignatures)
+
+ // Verify signature with known secret (if provided)
+ if a.secret != "" {
+ assertValidStandardWebhookSignature(t, a.secret, webhookID, webhookTimestamp, msg.Data, webhookSignature)
+ }
+}
+
+// StandardWebhookPublishSuite is the test suite
+type StandardWebhookPublishSuite struct {
+ testsuite.PublisherSuite
+ consumer *StandardWebhookConsumer
+ setupFn func(*StandardWebhookPublishSuite)
+}
+
+func (s *StandardWebhookPublishSuite) SetupSuite() {
+ s.setupFn(s)
+}
+
+func (s *StandardWebhookPublishSuite) TearDownSuite() {
+ if s.consumer != nil {
+ s.consumer.Close()
+ }
+}
+
+// Basic publish test configuration
+func (s *StandardWebhookPublishSuite) setupBasicSuite() {
+ consumer := NewStandardWebhookConsumer()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(s.T(), err)
+
+ dest := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": consumer.server.URL + "/webhook",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ }),
+ )
+
+ s.InitSuite(testsuite.Config{
+ Provider: provider,
+ Dest: &dest,
+ Consumer: consumer,
+ Asserter: &StandardWebhookAsserter{
+ secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ expectedSignatures: 1,
+ },
+ })
+
+ s.consumer = consumer
+}
+
+// Multiple secrets test configuration
+func (s *StandardWebhookPublishSuite) setupMultipleSecretsSuite() {
+ consumer := NewStandardWebhookConsumer()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(s.T(), err)
+
+ now := time.Now()
+ invalidAt := now.Add(24 * time.Hour)
+ dest := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": consumer.server.URL + "/webhook",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_TmV3U2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==",
+ "previous_secret": "whsec_T2xkU2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==",
+ "previous_secret_invalid_at": invalidAt.Format(time.RFC3339),
+ }),
+ )
+
+ s.InitSuite(testsuite.Config{
+ Provider: provider,
+ Dest: &dest,
+ Consumer: consumer,
+ Asserter: &StandardWebhookAsserter{
+ secret: "whsec_TmV3U2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==",
+ expectedSignatures: 2,
+ },
+ })
+
+ s.consumer = consumer
+}
+
+// Expired secrets test configuration
+func (s *StandardWebhookPublishSuite) setupExpiredSecretsSuite() {
+ consumer := NewStandardWebhookConsumer()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(s.T(), err)
+
+ now := time.Now()
+ invalidAt := now.Add(-1 * time.Hour) // Previous secret is already invalid
+ dest := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": consumer.server.URL + "/webhook",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_QWN0aXZlU2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==",
+ "previous_secret": "whsec_RXhwaXJlZFNlY3JldEJhc2U2NEVuY29kZWRTdHJpbmcxMjM=",
+ "previous_secret_invalid_at": invalidAt.Format(time.RFC3339),
+ }),
+ )
+
+ s.InitSuite(testsuite.Config{
+ Provider: provider,
+ Dest: &dest,
+ Consumer: consumer,
+ Asserter: &StandardWebhookAsserter{
+ secret: "whsec_QWN0aXZlU2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==",
+ expectedSignatures: 1, // Only expect signature from active secret
+ },
+ })
+
+ s.consumer = consumer
+}
+
+func TestStandardWebhookPublish(t *testing.T) {
+ t.Parallel()
+
+ // Run basic publish tests
+ t.Run("Basic", func(t *testing.T) {
+ t.Parallel()
+ suite.Run(t, &StandardWebhookPublishSuite{
+ setupFn: (*StandardWebhookPublishSuite).setupBasicSuite,
+ })
+ })
+
+ // Run multiple secrets tests
+ t.Run("MultipleSecrets", func(t *testing.T) {
+ t.Parallel()
+ suite.Run(t, &StandardWebhookPublishSuite{
+ setupFn: (*StandardWebhookPublishSuite).setupMultipleSecretsSuite,
+ })
+ })
+
+ // Run expired secrets tests
+ t.Run("ExpiredSecrets", func(t *testing.T) {
+ t.Parallel()
+ suite.Run(t, &StandardWebhookPublishSuite{
+ setupFn: (*StandardWebhookPublishSuite).setupExpiredSecretsSuite,
+ })
+ })
+}
+
+func TestStandardWebhookPublisher_SignatureFormat(t *testing.T) {
+ t.Parallel()
+
+ consumer := NewStandardWebhookConsumer()
+ defer consumer.Close()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(t, err)
+
+ dest := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": consumer.server.URL + "/webhook",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ }),
+ )
+
+ publisher, err := provider.CreatePublisher(context.Background(), &dest)
+ require.NoError(t, err)
+ defer publisher.Close()
+
+ event := testutil.EventFactory.Any(
+ testutil.EventFactory.WithID("msg_2KWPBgLlAfxdpx2AI54pPJ85f4W"),
+ testutil.EventFactory.WithData(map[string]interface{}{"hello": "world"}),
+ )
+
+ _, err = publisher.Publish(context.Background(), &event)
+ require.NoError(t, err)
+
+ // Get the message
+ select {
+ case msg := <-consumer.Consume():
+ req := msg.Raw.(*http.Request)
+
+ // Verify signature format is "v1,"
+ signatureHeader := req.Header.Get("webhook-signature")
+ assert.True(t, strings.HasPrefix(signatureHeader, "v1,"))
+
+ // Verify base64
+ sigPart := strings.TrimPrefix(signatureHeader, "v1,")
+ decoded, err := base64.StdEncoding.DecodeString(sigPart)
+ assert.NoError(t, err)
+ assert.Equal(t, 32, len(decoded)) // HMAC-SHA256 produces 32 bytes
+
+ case <-time.After(5 * time.Second):
+ t.Fatal("timeout waiting for message")
+ }
+}
+
+func TestStandardWebhookPublisher_MessageIDFormat(t *testing.T) {
+ t.Parallel()
+
+ consumer := NewStandardWebhookConsumer()
+ defer consumer.Close()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(t, err)
+
+ dest := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": consumer.server.URL + "/webhook",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ }),
+ )
+
+ publisher, err := provider.CreatePublisher(context.Background(), &dest)
+ require.NoError(t, err)
+ defer publisher.Close()
+
+ event := testutil.EventFactory.Any(
+ testutil.EventFactory.WithID("msg_2KWPBgLlAfxdpx2AI54pPJ85f4W"),
+ testutil.EventFactory.WithData(map[string]interface{}{"test": "data"}),
+ )
+
+ _, err = publisher.Publish(context.Background(), &event)
+ require.NoError(t, err)
+
+ // Get the message
+ select {
+ case msg := <-consumer.Consume():
+ req := msg.Raw.(*http.Request)
+
+ // Verify webhook-id uses event ID directly and has msg_ prefix
+ webhookID := req.Header.Get("webhook-id")
+ assert.NotEmpty(t, webhookID)
+ assert.Equal(t, event.ID, webhookID)
+ assert.True(t, strings.HasPrefix(webhookID, "msg_"), "webhook-id should have msg_ prefix, got: %s", webhookID)
+
+ case <-time.After(5 * time.Second):
+ t.Fatal("timeout waiting for message")
+ }
+}
+
+func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) {
+ t.Parallel()
+
+ consumer := NewStandardWebhookConsumer()
+ defer consumer.Close()
+
+ // Create provider with custom header prefix
+ provider, err := destwebhookstandard.New(
+ testutil.Registry.MetadataLoader(),
+ nil,
+ destwebhookstandard.WithHeaderPrefix("x-custom-"),
+ )
+ require.NoError(t, err)
+
+ dest := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": consumer.server.URL + "/webhook",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ }),
+ )
+
+ publisher, err := provider.CreatePublisher(context.Background(), &dest)
+ require.NoError(t, err)
+ defer publisher.Close()
+
+ event := testutil.EventFactory.Any(
+ testutil.EventFactory.WithID("msg_2KWPBgLlAfxdpx2AI54pPJ85f4W"),
+ testutil.EventFactory.WithData(map[string]interface{}{"test": "data"}),
+ testutil.EventFactory.WithTopic("user.created"),
+ )
+
+ _, err = publisher.Publish(context.Background(), &event)
+ require.NoError(t, err)
+
+ // Get the message
+ select {
+ case msg := <-consumer.Consume():
+ req := msg.Raw.(*http.Request)
+
+ // Verify ALL headers use custom prefix (including Standard Webhooks headers)
+ assert.NotEmpty(t, req.Header.Get("x-custom-id"), "should have x-custom-id header")
+ assert.NotEmpty(t, req.Header.Get("x-custom-timestamp"), "should have x-custom-timestamp header")
+ assert.NotEmpty(t, req.Header.Get("x-custom-signature"), "should have x-custom-signature header")
+ assert.NotEmpty(t, req.Header.Get("x-custom-topic"), "should have x-custom-topic header")
+ assert.Equal(t, "user.created", req.Header.Get("x-custom-topic"))
+
+ // Verify default prefix is NOT used
+ assert.Empty(t, req.Header.Get("webhook-id"), "should not have webhook-id header")
+ assert.Empty(t, req.Header.Get("webhook-timestamp"), "should not have webhook-timestamp header")
+ assert.Empty(t, req.Header.Get("webhook-signature"), "should not have webhook-signature header")
+ assert.Empty(t, req.Header.Get("webhook-topic"), "should not have webhook-topic header")
+
+ case <-time.After(5 * time.Second):
+ t.Fatal("timeout waiting for message")
+ }
+}
diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go
new file mode 100644
index 00000000..82e4b4fc
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go
@@ -0,0 +1,460 @@
+package destwebhookstandard_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/hookdeck/outpost/internal/destregistry"
+ "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard"
+ "github.com/hookdeck/outpost/internal/util/maputil"
+ "github.com/hookdeck/outpost/internal/util/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestStandardWebhookDestination_Validate(t *testing.T) {
+ t.Parallel()
+
+ validDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ }),
+ )
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(t, err)
+
+ t.Run("should validate valid destination", func(t *testing.T) {
+ t.Parallel()
+ assert.NoError(t, provider.Validate(context.Background(), &validDestination))
+ })
+
+ t.Run("should validate invalid type", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Type = "invalid"
+ err := provider.Validate(context.Background(), &invalidDestination)
+ assert.Error(t, err)
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "type", validationErr.Errors[0].Field)
+ assert.Equal(t, "invalid_type", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate missing url", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Config = map[string]string{}
+ err := provider.Validate(context.Background(), &invalidDestination)
+
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "config.url", validationErr.Errors[0].Field)
+ assert.Equal(t, "required", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate malformed url", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Config = map[string]string{
+ "url": "not-a-valid-url",
+ }
+ err := provider.Validate(context.Background(), &invalidDestination)
+
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "config.url", validationErr.Errors[0].Field)
+ assert.Equal(t, "pattern", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate secret without whsec prefix", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Credentials = map[string]string{
+ "secret": "not-a-whsec-secret",
+ }
+ err := provider.Validate(context.Background(), &invalidDestination)
+
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "credentials.secret", validationErr.Errors[0].Field)
+ assert.Equal(t, "pattern", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate secret with invalid base64", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Credentials = map[string]string{
+ "secret": "whsec_not-valid-base64!!!",
+ }
+ err := provider.Validate(context.Background(), &invalidDestination)
+
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "credentials.secret", validationErr.Errors[0].Field)
+ assert.Equal(t, "pattern", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate previous_secret without whsec prefix", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Credentials = map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ "previous_secret": "not-a-whsec-secret",
+ "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+ }
+ err := provider.Validate(context.Background(), &invalidDestination)
+
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "credentials.previous_secret", validationErr.Errors[0].Field)
+ assert.Equal(t, "pattern", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate previous secret without invalid_at", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Credentials = map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz",
+ }
+ err := provider.Validate(context.Background(), &invalidDestination)
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "credentials.previous_secret_invalid_at", validationErr.Errors[0].Field)
+ assert.Equal(t, "required", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate malformed previous_secret_invalid_at", func(t *testing.T) {
+ t.Parallel()
+ invalidDestination := validDestination
+ invalidDestination.Credentials = map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz",
+ "previous_secret_invalid_at": "not-a-timestamp",
+ }
+ err := provider.Validate(context.Background(), &invalidDestination)
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "credentials.previous_secret_invalid_at", validationErr.Errors[0].Field)
+ assert.Equal(t, "pattern", validationErr.Errors[0].Type)
+ })
+
+ t.Run("should validate valid destination with previous secret", func(t *testing.T) {
+ t.Parallel()
+ validDestWithPrevious := validDestination
+ validDestWithPrevious.Credentials = map[string]string{
+ "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz",
+ "previous_secret_invalid_at": "2024-01-02T00:00:00Z",
+ }
+ assert.NoError(t, provider.Validate(context.Background(), &validDestWithPrevious))
+ })
+}
+
+func TestStandardWebhookDestination_ComputeTarget(t *testing.T) {
+ t.Parallel()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(t, err)
+
+ t.Run("should return url as target", func(t *testing.T) {
+ t.Parallel()
+ destination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/webhook",
+ }),
+ )
+ target := provider.ComputeTarget(&destination)
+ assert.Equal(t, "https://example.com/webhook", target.Target)
+ })
+}
+
+func TestStandardWebhookDestination_Preprocess(t *testing.T) {
+ t.Parallel()
+
+ provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil)
+ require.NoError(t, err)
+
+ t.Run("should generate default whsec secret if not provided", func(t *testing.T) {
+ t.Parallel()
+ destination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ )
+
+ err := provider.Preprocess(&destination, nil, &destregistry.PreprocessDestinationOpts{Role: "tenant"})
+ require.NoError(t, err)
+
+ // Verify that a whsec_ secret was generated
+ assert.True(t, strings.HasPrefix(destination.Credentials["secret"], "whsec_"))
+
+ // Verify it's valid
+ assert.NoError(t, provider.Validate(context.Background(), &destination))
+ })
+
+ t.Run("should preserve existing secret for admin", func(t *testing.T) {
+ t.Parallel()
+ destination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_CustomSecretBase64EncodedString",
+ }),
+ )
+
+ err := provider.Preprocess(&destination, nil, &destregistry.PreprocessDestinationOpts{Role: "admin"})
+ require.NoError(t, err)
+
+ // Verify that the custom secret was preserved
+ assert.Equal(t, "whsec_CustomSecretBase64EncodedString", destination.Credentials["secret"])
+ })
+
+ t.Run("tenant should not be able to override existing secret", func(t *testing.T) {
+ t.Parallel()
+ originalDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_CurrentSecretBase64EncodedString",
+ }),
+ )
+
+ newDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/new",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_CustomSecretBase64EncodedString",
+ }),
+ )
+
+ // Merge both config and credentials to simulate handler behavior
+ newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config)
+ newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials)
+
+ err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "tenant"})
+ var validationErr *destregistry.ErrDestinationValidation
+ assert.ErrorAs(t, err, &validationErr)
+ assert.Equal(t, "credentials.secret", validationErr.Errors[0].Field)
+ assert.Equal(t, "forbidden", validationErr.Errors[0].Type)
+ })
+
+ t.Run("tenant should be able to rotate secret", func(t *testing.T) {
+ t.Parallel()
+ originalDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_CurrentSecretBase64EncodedString",
+ }),
+ )
+
+ newDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/new",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "rotate_secret": "true",
+ }),
+ )
+
+ // Merge both config and credentials to simulate handler behavior
+ newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config)
+ newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials)
+
+ err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "tenant"})
+ require.NoError(t, err)
+
+ // Verify that the current secret became the previous secret
+ assert.Equal(t, "whsec_CurrentSecretBase64EncodedString", newDestination.Credentials["previous_secret"])
+
+ // Verify that a new secret was generated with whsec_ prefix
+ assert.NotEqual(t, "whsec_CurrentSecretBase64EncodedString", newDestination.Credentials["secret"])
+ assert.True(t, strings.HasPrefix(newDestination.Credentials["secret"], "whsec_"))
+ assert.NotEmpty(t, newDestination.Credentials["secret"])
+
+ // Verify that previous_secret_invalid_at was set to ~24h from now
+ invalidAt, err := time.Parse(time.RFC3339, newDestination.Credentials["previous_secret_invalid_at"])
+ require.NoError(t, err)
+ expectedTime := time.Now().Add(24 * time.Hour)
+ assert.WithinDuration(t, expectedTime, invalidAt, 5*time.Second)
+ })
+
+ t.Run("admin should be able to set previous_secret directly", func(t *testing.T) {
+ t.Parallel()
+ originalDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==",
+ }),
+ )
+
+ newDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/new",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz",
+ }),
+ )
+
+ // Merge both config and credentials to simulate handler behavior
+ newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config)
+ newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials)
+
+ err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "admin"})
+ require.NoError(t, err)
+
+ // Verify that previous_secret was kept
+ assert.Equal(t, "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", newDestination.Credentials["previous_secret"])
+ })
+
+ t.Run("should respect custom invalidation time during rotation", func(t *testing.T) {
+ t.Parallel()
+ customInvalidAt := time.Now().Add(48 * time.Hour).Format(time.RFC3339)
+ originalDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_CurrentSecretBase64EncodedString",
+ }),
+ )
+
+ newDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/new",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "rotate_secret": "true",
+ "previous_secret_invalid_at": customInvalidAt,
+ }),
+ )
+
+ // Merge both config and credentials to simulate handler behavior
+ newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config)
+ newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials)
+
+ err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{})
+ require.NoError(t, err)
+
+ // Verify that the custom invalidation time was preserved
+ assert.Equal(t, customInvalidAt, newDestination.Credentials["previous_secret_invalid_at"])
+ })
+
+ t.Run("should set default previous_secret_invalid_at when previous_secret is provided", func(t *testing.T) {
+ t.Parallel()
+ originalDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==",
+ }),
+ )
+
+ newDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/new",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==",
+ "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz",
+ }),
+ )
+
+ // Merge both config and credentials to simulate handler behavior
+ newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config)
+ newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials)
+
+ err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "admin"})
+ require.NoError(t, err)
+
+ // Verify that previous_secret_invalid_at was set to ~24h from now
+ invalidAt, err := time.Parse(time.RFC3339, newDestination.Credentials["previous_secret_invalid_at"])
+ require.NoError(t, err)
+ expectedTime := time.Now().Add(24 * time.Hour)
+ assert.WithinDuration(t, expectedTime, invalidAt, 5*time.Second)
+ })
+
+ t.Run("should remove extra fields from credentials map", func(t *testing.T) {
+ t.Parallel()
+ originalDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==",
+ }),
+ )
+
+ newDestination := testutil.DestinationFactory.Any(
+ testutil.DestinationFactory.WithType("webhook"),
+ testutil.DestinationFactory.WithConfig(map[string]string{
+ "url": "https://example.com/new",
+ }),
+ testutil.DestinationFactory.WithCredentials(map[string]string{
+ "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==",
+ "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz",
+ "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+ "extra_field": "should be removed",
+ "another_extra": "also removed",
+ "rotate_secret": "false",
+ }),
+ )
+
+ // Merge both config and credentials to simulate handler behavior
+ newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config)
+ newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials)
+
+ err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "admin"})
+ require.NoError(t, err)
+
+ // Verify that only expected fields are present
+ expectedFields := map[string]bool{
+ "secret": true,
+ "previous_secret": true,
+ "previous_secret_invalid_at": true,
+ }
+
+ // Check that only expected fields exist
+ for key := range newDestination.Credentials {
+ assert.True(t, expectedFields[key], "unexpected field %q found in credentials", key)
+ }
+
+ // Check that all expected fields are present
+ assert.Equal(t, len(expectedFields), len(newDestination.Credentials), "credentials map has wrong number of fields")
+
+ // Verify values are preserved for expected fields
+ assert.Equal(t, "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", newDestination.Credentials["secret"])
+ assert.Equal(t, "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", newDestination.Credentials["previous_secret"])
+ assert.NotEmpty(t, newDestination.Credentials["previous_secret_invalid_at"])
+ })
+}
diff --git a/internal/destregistry/providers/destwebhookstandard/secret.go b/internal/destregistry/providers/destwebhookstandard/secret.go
new file mode 100644
index 00000000..1c6c30f2
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/secret.go
@@ -0,0 +1,62 @@
+package destwebhookstandard
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "strings"
+)
+
+const (
+ SecretPrefix = "whsec_"
+ SecretLength = 32 // 32 bytes = 256 bits
+)
+
+// validateSecret checks if a secret has the correct whsec_ prefix and valid base64 encoding
+func validateSecret(secret string) error {
+ if !strings.HasPrefix(secret, SecretPrefix) {
+ return fmt.Errorf("secret must have %s prefix", SecretPrefix)
+ }
+
+ encodedPart := strings.TrimPrefix(secret, SecretPrefix)
+ if encodedPart == "" {
+ return fmt.Errorf("secret is empty after prefix")
+ }
+
+ if _, err := base64.StdEncoding.DecodeString(encodedPart); err != nil {
+ return fmt.Errorf("secret is not valid base64: %w", err)
+ }
+
+ return nil
+}
+
+// parseSecret extracts and decodes the secret portion after whsec_ prefix
+// Returns the decoded bytes as a string for use with SignatureManager
+func parseSecret(secret string) (string, error) {
+ if err := validateSecret(secret); err != nil {
+ return "", err
+ }
+
+ encodedPart := strings.TrimPrefix(secret, SecretPrefix)
+ decoded, err := base64.StdEncoding.DecodeString(encodedPart)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode secret: %w", err)
+ }
+
+ // Return as string - SignatureManager will convert to []byte for HMAC
+ return string(decoded), nil
+}
+
+// generateStandardSecret creates a cryptographically secure random secret in Standard Webhooks format
+// Format: whsec_
+func generateStandardSecret() (string, error) {
+ // Generate 32 random bytes (256 bits)
+ randomBytes := make([]byte, SecretLength)
+ if _, err := rand.Read(randomBytes); err != nil {
+ return "", fmt.Errorf("failed to generate random secret: %w", err)
+ }
+
+ // Encode and prefix
+ encoded := base64.StdEncoding.EncodeToString(randomBytes)
+ return SecretPrefix + encoded, nil
+}
diff --git a/internal/destregistry/providers/destwebhookstandard/secret_test.go b/internal/destregistry/providers/destwebhookstandard/secret_test.go
new file mode 100644
index 00000000..ce9d4931
--- /dev/null
+++ b/internal/destregistry/providers/destwebhookstandard/secret_test.go
@@ -0,0 +1,143 @@
+package destwebhookstandard
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateSecret(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ secret string
+ wantErr bool
+ }{
+ {
+ name: "valid whsec secret",
+ secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ wantErr: false,
+ },
+ {
+ name: "missing whsec prefix",
+ secret: "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ wantErr: true,
+ },
+ {
+ name: "empty after prefix",
+ secret: "whsec_",
+ wantErr: true,
+ },
+ {
+ name: "invalid base64",
+ secret: "whsec_not-valid-base64!!!",
+ wantErr: true,
+ },
+ {
+ name: "empty string",
+ secret: "",
+ wantErr: true,
+ },
+ {
+ name: "wrong prefix",
+ secret: "whsk_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ err := validateSecret(tt.secret)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestParseSecret(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ secret string
+ wantErr bool
+ }{
+ {
+ name: "valid whsec secret",
+ secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ wantErr: false,
+ },
+ {
+ name: "invalid prefix",
+ secret: "invalid_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
+ wantErr: true,
+ },
+ {
+ name: "invalid base64",
+ secret: "whsec_not-valid-base64!!!",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ result, err := parseSecret(tt.secret)
+ if tt.wantErr {
+ assert.Error(t, err)
+ assert.Empty(t, result)
+ } else {
+ assert.NoError(t, err)
+ assert.NotEmpty(t, result)
+
+ // Verify that the result is the decoded version
+ encodedPart := strings.TrimPrefix(tt.secret, SecretPrefix)
+ decoded, _ := base64.StdEncoding.DecodeString(encodedPart)
+ assert.Equal(t, string(decoded), result)
+ }
+ })
+ }
+}
+
+func TestGenerateStandardSecret(t *testing.T) {
+ t.Parallel()
+
+ t.Run("generates valid whsec secret", func(t *testing.T) {
+ t.Parallel()
+ secret, err := generateStandardSecret()
+ require.NoError(t, err)
+
+ // Should have whsec_ prefix
+ assert.True(t, strings.HasPrefix(secret, SecretPrefix))
+
+ // Should be valid base64 after prefix
+ err = validateSecret(secret)
+ assert.NoError(t, err)
+
+ // Should decode to 32 bytes
+ encodedPart := strings.TrimPrefix(secret, SecretPrefix)
+ decoded, err := base64.StdEncoding.DecodeString(encodedPart)
+ require.NoError(t, err)
+ assert.Equal(t, SecretLength, len(decoded))
+ })
+
+ t.Run("generates unique secrets", func(t *testing.T) {
+ t.Parallel()
+ secret1, err := generateStandardSecret()
+ require.NoError(t, err)
+
+ secret2, err := generateStandardSecret()
+ require.NoError(t, err)
+
+ // Should generate different secrets
+ assert.NotEqual(t, secret1, secret2)
+ })
+}