Files
synapbus/internal/webhooks/security_test.go
T
Algis DumbrisandClaude Opus 4.6 42775df65f feat: webhooks & Kubernetes Job runner for event-driven agents
- 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>
2026-03-14 18:14:04 +02:00

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)
}
})
}
}