Files
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

175 lines
3.7 KiB
Go

package k8s
import (
"context"
"strings"
"testing"
)
func TestSanitizeJobName(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "simple lowercase",
input: "synapbus-agent-123",
want: "synapbus-agent-123",
},
{
name: "uppercase converted to lowercase",
input: "SynapBus-Agent-ABC",
want: "synapbus-agent-abc",
},
{
name: "special characters replaced with hyphens",
input: "synapbus_agent.test@foo",
want: "synapbus-agent-test-foo",
},
{
name: "leading hyphens trimmed",
input: "---leading",
want: "leading",
},
{
name: "trailing hyphens trimmed",
input: "trailing---",
want: "trailing",
},
{
name: "max 63 characters",
input: strings.Repeat("a", 100),
want: strings.Repeat("a", 63),
},
{
name: "long name with special chars truncated to 63",
input: "synapbus-" + strings.Repeat("x", 100) + "-final",
want: "synapbus-" + strings.Repeat("x", 54),
},
{
name: "alphanumeric preserved",
input: "abc123def456",
want: "abc123def456",
},
{
name: "spaces become hyphens",
input: "my job name",
want: "my-job-name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeJobName(tt.input)
if got != tt.want {
t.Errorf("sanitizeJobName(%q) = %q, want %q", tt.input, got, tt.want)
}
// Verify invariants
if len(got) > 63 {
t.Errorf("result length %d exceeds 63", len(got))
}
if got != strings.ToLower(got) {
t.Errorf("result %q is not all lowercase", got)
}
if strings.HasPrefix(got, "-") {
t.Errorf("result %q starts with hyphen", got)
}
if strings.HasSuffix(got, "-") {
t.Errorf("result %q ends with hyphen", got)
}
// Check DNS-safe: only a-z, 0-9, -
for _, r := range got {
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
t.Errorf("result %q contains invalid rune %q", got, string(r))
}
}
})
}
}
func TestTruncateBody(t *testing.T) {
tests := []struct {
name string
body string
maxLen int
want string
}{
{
name: "short body unchanged",
body: "hello",
maxLen: 100,
want: "hello",
},
{
name: "exact length unchanged",
body: "hello",
maxLen: 5,
want: "hello",
},
{
name: "long body truncated",
body: "hello world this is a long message",
maxLen: 11,
want: "hello world",
},
{
name: "empty body",
body: "",
maxLen: 100,
want: "",
},
{
name: "max zero truncates all",
body: "hello",
maxLen: 0,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateBody(tt.body, tt.maxLen)
if got != tt.want {
t.Errorf("truncateBody(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
}
})
}
}
func TestNoopRunner(t *testing.T) {
runner := NewNoopRunner()
t.Run("IsAvailable returns false", func(t *testing.T) {
if runner.IsAvailable() {
t.Error("NoopRunner.IsAvailable() should return false")
}
})
t.Run("GetNamespace returns empty string", func(t *testing.T) {
if ns := runner.GetNamespace(); ns != "" {
t.Errorf("NoopRunner.GetNamespace() = %q, want empty string", ns)
}
})
t.Run("CreateJob returns error", func(t *testing.T) {
ctx := context.Background()
handler := &K8sHandler{AgentName: "test", Image: "test:v1"}
msg := &JobMessage{MessageID: 1, Body: "test"}
_, err := runner.CreateJob(ctx, handler, msg)
if err == nil {
t.Error("NoopRunner.CreateJob() should return error")
}
})
t.Run("GetJobLogs returns error", func(t *testing.T) {
ctx := context.Background()
_, err := runner.GetJobLogs(ctx, "ns", "job-1")
if err == nil {
t.Error("NoopRunner.GetJobLogs() should return error")
}
})
}