- Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook) - HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5) - 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s) - Dead letter queue with auto-purge, auto-disable after 50 consecutive failures - Per-agent rate limiting (60 deliveries/min) - K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler) - Auto-detect in-cluster via InClusterConfig, NoopRunner fallback - REST API for webhook deliveries, dead letters, K8s job runs and logs - Web UI: webhook management, K8s handler pages, dead letters view - MultiDispatcher fan-out pattern for webhook + K8s event dispatch - SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs - 51 tests across 9 test packages, all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
195 lines
6.2 KiB
Go
195 lines
6.2 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"net"
|
|
"testing"
|
|
)
|
|
|
|
func TestComputeHMACSignature(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
secret string
|
|
payload string
|
|
}{
|
|
{name: "basic signature", secret: "my-secret", payload: `{"event":"message.received"}`},
|
|
{name: "empty payload", secret: "key", payload: ""},
|
|
{name: "empty secret", secret: "", payload: "data"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sig := ComputeHMACSignature([]byte(tt.secret), []byte(tt.payload))
|
|
if len(sig) < 10 {
|
|
t.Errorf("signature too short: %q", sig)
|
|
}
|
|
if sig[:7] != "sha256=" {
|
|
t.Errorf("signature should start with 'sha256=', got %q", sig[:7])
|
|
}
|
|
|
|
// Same inputs produce same output (deterministic)
|
|
sig2 := ComputeHMACSignature([]byte(tt.secret), []byte(tt.payload))
|
|
if sig != sig2 {
|
|
t.Errorf("non-deterministic: %q != %q", sig, sig2)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Different secrets produce different signatures
|
|
sig1 := ComputeHMACSignature([]byte("secret-a"), []byte("payload"))
|
|
sig2 := ComputeHMACSignature([]byte("secret-b"), []byte("payload"))
|
|
if sig1 == sig2 {
|
|
t.Error("different secrets should produce different signatures")
|
|
}
|
|
|
|
// Different payloads produce different signatures
|
|
sig3 := ComputeHMACSignature([]byte("key"), []byte("payload-a"))
|
|
sig4 := ComputeHMACSignature([]byte("key"), []byte("payload-b"))
|
|
if sig3 == sig4 {
|
|
t.Error("different payloads should produce different signatures")
|
|
}
|
|
}
|
|
|
|
func TestValidateWebhookURL_HTTPS(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantErr bool
|
|
}{
|
|
{name: "valid HTTPS", url: "https://example.com/webhook", wantErr: false},
|
|
{name: "HTTPS with port", url: "https://example.com:8443/hook", wantErr: false},
|
|
{name: "HTTPS with path", url: "https://hooks.example.com/v1/receive", wantErr: false},
|
|
{name: "HTTP rejected when allowHTTP=false", url: "http://example.com/webhook", wantErr: true},
|
|
{name: "FTP rejected", url: "ftp://example.com/file", wantErr: true},
|
|
{name: "no scheme", url: "example.com/webhook", wantErr: true},
|
|
{name: "empty URL", url: "", wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidateWebhookURL(tt.url, false, true) // allowHTTP=false, allowPrivate=true
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateWebhookURL_HTTP_Allowed(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantErr bool
|
|
}{
|
|
{name: "HTTP allowed", url: "http://example.com/webhook", wantErr: false},
|
|
{name: "HTTPS still works", url: "https://example.com/webhook", wantErr: false},
|
|
{name: "FTP still rejected", url: "ftp://example.com/file", wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidateWebhookURL(tt.url, true, true) // allowHTTP=true, allowPrivate=true
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateWebhookURL_PrivateIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantErr bool
|
|
}{
|
|
{name: "public IP allowed", url: "https://93.184.216.34/hook", wantErr: false},
|
|
{name: "loopback blocked", url: "https://127.0.0.1/hook", wantErr: true},
|
|
{name: "RFC1918 10.x blocked", url: "https://10.0.0.1/hook", wantErr: true},
|
|
{name: "RFC1918 172.16.x blocked", url: "https://172.16.0.1/hook", wantErr: true},
|
|
{name: "RFC1918 192.168.x blocked", url: "https://192.168.1.1/hook", wantErr: true},
|
|
{name: "link-local blocked", url: "https://169.254.0.1/hook", wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidateWebhookURL(tt.url, true, false) // allowHTTP=true, allowPrivate=false
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsPrivateIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
want bool
|
|
}{
|
|
// RFC1918
|
|
{name: "10.0.0.0/8 start", ip: "10.0.0.1", want: true},
|
|
{name: "10.0.0.0/8 end", ip: "10.255.255.255", want: true},
|
|
{name: "172.16.0.0/12 start", ip: "172.16.0.1", want: true},
|
|
{name: "172.31.255.255 end", ip: "172.31.255.255", want: true},
|
|
{name: "172.32.0.1 not private", ip: "172.32.0.1", want: false},
|
|
{name: "192.168.0.0/16 start", ip: "192.168.0.1", want: true},
|
|
{name: "192.168.255.255 end", ip: "192.168.255.255", want: true},
|
|
|
|
// Loopback
|
|
{name: "loopback 127.0.0.1", ip: "127.0.0.1", want: true},
|
|
{name: "loopback 127.255.255.255", ip: "127.255.255.255", want: true},
|
|
|
|
// Link-local
|
|
{name: "link-local 169.254.0.1", ip: "169.254.0.1", want: true},
|
|
{name: "link-local 169.254.255.255", ip: "169.254.255.255", want: true},
|
|
|
|
// Public IPs
|
|
{name: "public 8.8.8.8", ip: "8.8.8.8", want: false},
|
|
{name: "public 93.184.216.34", ip: "93.184.216.34", want: false},
|
|
{name: "public 1.1.1.1", ip: "1.1.1.1", want: false},
|
|
|
|
// IPv6
|
|
{name: "IPv6 loopback", ip: "::1", want: true},
|
|
{name: "IPv6 ULA fc00::", ip: "fc00::1", want: true},
|
|
{name: "IPv6 ULA fd00::", ip: "fd00::1", want: true},
|
|
{name: "IPv6 link-local fe80::", ip: "fe80::1", want: true},
|
|
{name: "IPv6 public 2001:db8::1", ip: "2001:db8::1", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ip := net.ParseIP(tt.ip)
|
|
if ip == nil {
|
|
t.Fatalf("failed to parse IP %q", tt.ip)
|
|
}
|
|
got := IsPrivateIP(ip)
|
|
if got != tt.want {
|
|
t.Errorf("IsPrivateIP(%s) = %v, want %v", tt.ip, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsValidEvent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
event string
|
|
want bool
|
|
}{
|
|
{name: "message.received", event: "message.received", want: true},
|
|
{name: "message.mentioned", event: "message.mentioned", want: true},
|
|
{name: "channel.message", event: "channel.message", want: true},
|
|
{name: "invalid event", event: "invalid.event", want: false},
|
|
{name: "empty string", event: "", want: false},
|
|
{name: "close but wrong", event: "message.sent", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := IsValidEvent(tt.event)
|
|
if got != tt.want {
|
|
t.Errorf("IsValidEvent(%q) = %v, want %v", tt.event, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|