mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
Consolidate all Hanzo agent infrastructure into one repo: - control-plane/: Go control plane server (was agents-new) - sdk/: Go, Python, TypeScript SDKs - deployments/: Docker, Kubernetes, Helm configs - Dockerfile.sdk: Python SDK image (ghcr.io/hanzoai/agent:latest) - CI builds both SDK + control-plane images No more agentfield — everything is hanzoai/agent.
360 lines
9.9 KiB
Go
360 lines
9.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/agents/control-plane/pkg/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestValidateCallbackURL_Valid(t *testing.T) {
|
|
// Create a test HTTP server that responds to health checks
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/health" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
err := validateCallbackURL(server.URL)
|
|
assert.NoError(t, err, "Should validate reachable callback URL")
|
|
}
|
|
|
|
func TestValidateCallbackURL_Empty(t *testing.T) {
|
|
err := validateCallbackURL("")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "cannot be empty")
|
|
}
|
|
|
|
func TestValidateCallbackURL_InvalidFormat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
}{
|
|
{"no scheme", "localhost:8080"},
|
|
{"invalid scheme", "ftp://localhost:8080"},
|
|
{"no host", "http://"},
|
|
{"malformed", "ht!tp://invalid"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateCallbackURL(tt.url)
|
|
require.Error(t, err, "Should reject invalid URL format")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateCallbackURL_InvalidScheme(t *testing.T) {
|
|
err := validateCallbackURL("ftp://example.com")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "must use http or https scheme")
|
|
}
|
|
|
|
func TestValidateCallbackURL_NoHost(t *testing.T) {
|
|
err := validateCallbackURL("http://")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "must include a host")
|
|
}
|
|
|
|
func TestValidateCallbackURL_Unreachable(t *testing.T) {
|
|
// Use a valid URL format but unreachable address
|
|
err := validateCallbackURL("http://localhost:99999")
|
|
// Should not error even if unreachable (logs warning instead)
|
|
assert.NoError(t, err, "Should not error for unreachable but valid URL")
|
|
}
|
|
|
|
func TestExtractPortFromURL_WithPort(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
expected string
|
|
}{
|
|
{"explicit port", "http://localhost:8080", "8080"},
|
|
{"https port", "https://example.com:9443", "9443"},
|
|
{"default http", "http://example.com", "80"},
|
|
{"default https", "https://example.com", "443"},
|
|
{"ipv4 with port", "http://192.168.1.1:3000", "3000"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
port := extractPortFromURL(tt.url)
|
|
assert.Equal(t, tt.expected, port)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractPortFromURL_Empty(t *testing.T) {
|
|
port := extractPortFromURL("")
|
|
assert.Equal(t, "", port)
|
|
}
|
|
|
|
func TestExtractPortFromURL_InvalidURL(t *testing.T) {
|
|
port := extractPortFromURL("not-a-valid-url")
|
|
assert.Equal(t, "", port)
|
|
}
|
|
|
|
func TestExtractPortFromURL_UnknownScheme(t *testing.T) {
|
|
port := extractPortFromURL("ftp://example.com")
|
|
assert.Equal(t, "", port)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_BaseURLOnly(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, nil, "")
|
|
|
|
assert.Equal(t, 1, len(candidates))
|
|
assert.Equal(t, baseURL, candidates[0])
|
|
assert.Equal(t, "8080", defaultPort)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_WithDiscovery(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Preferred: "http://192.168.1.100:8080",
|
|
Candidates: []string{
|
|
"http://10.0.0.1:8080",
|
|
"http://172.16.0.1:8080",
|
|
},
|
|
}
|
|
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, discovery, "")
|
|
|
|
assert.GreaterOrEqual(t, len(candidates), 3)
|
|
assert.Contains(t, candidates, baseURL)
|
|
assert.Contains(t, candidates, discovery.Preferred)
|
|
assert.Equal(t, "8080", defaultPort)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_WithClientIP(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
clientIP := "192.168.1.50"
|
|
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, nil, clientIP)
|
|
|
|
assert.Equal(t, 2, len(candidates))
|
|
assert.Contains(t, candidates, baseURL)
|
|
assert.Contains(t, candidates, "http://192.168.1.50:8080")
|
|
assert.Equal(t, "8080", defaultPort)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_Deduplication(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Preferred: "http://localhost:8080", // Same as baseURL
|
|
Candidates: []string{
|
|
"http://localhost:8080", // Duplicate
|
|
"http://127.0.0.1:8080",
|
|
},
|
|
}
|
|
|
|
candidates, _ := gatherCallbackCandidates(baseURL, discovery, "")
|
|
|
|
// Should deduplicate the URL
|
|
uniqueCount := 0
|
|
for _, c := range candidates {
|
|
if c == baseURL {
|
|
uniqueCount++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, uniqueCount, "Should deduplicate identical URLs")
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_EmptyStrings(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Preferred: "",
|
|
Candidates: []string{
|
|
"",
|
|
" ",
|
|
"http://valid:8080",
|
|
},
|
|
}
|
|
|
|
candidates, _ := gatherCallbackCandidates(baseURL, discovery, "")
|
|
|
|
// Should skip empty strings and whitespace
|
|
for _, c := range candidates {
|
|
assert.NotEqual(t, "", c)
|
|
assert.NotEqual(t, " ", c)
|
|
}
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_ClientIPWithoutPort(t *testing.T) {
|
|
baseURL := "http://example.com" // No explicit port
|
|
clientIP := "192.168.1.50"
|
|
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, nil, clientIP)
|
|
|
|
// Should use default port 80 for http
|
|
assert.Equal(t, "80", defaultPort)
|
|
assert.Contains(t, candidates, fmt.Sprintf("http://%s:80", clientIP))
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_PreferredPortOverride(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Preferred: "http://192.168.1.100:9000", // Different port
|
|
}
|
|
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, discovery, "")
|
|
|
|
// Should use baseURL port as default (first seen)
|
|
assert.Equal(t, "8080", defaultPort)
|
|
assert.Contains(t, candidates, baseURL)
|
|
assert.Contains(t, candidates, discovery.Preferred)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_MultipleCandidatesOrdering(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Preferred: "http://preferred:8080",
|
|
Candidates: []string{
|
|
"http://candidate1:8080",
|
|
"http://candidate2:8080",
|
|
},
|
|
}
|
|
clientIP := "192.168.1.50"
|
|
|
|
candidates, _ := gatherCallbackCandidates(baseURL, discovery, clientIP)
|
|
|
|
// baseURL should be first
|
|
assert.Equal(t, baseURL, candidates[0], "baseURL should be first candidate")
|
|
|
|
// preferred should come before other candidates
|
|
preferredIdx := -1
|
|
for i, c := range candidates {
|
|
if c == discovery.Preferred {
|
|
preferredIdx = i
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, preferredIdx > 0, "Preferred should be in candidate list")
|
|
}
|
|
|
|
// Security Tests
|
|
|
|
func TestValidateCallbackURL_PreventSSRF_PrivateIPs(t *testing.T) {
|
|
// Note: Current implementation allows private IPs
|
|
// This test documents the current behavior
|
|
// In production, you may want to add SSRF protection
|
|
privateIPs := []string{
|
|
"http://127.0.0.1:8080",
|
|
"http://localhost:8080",
|
|
"http://192.168.1.1:8080",
|
|
"http://10.0.0.1:8080",
|
|
"http://172.16.0.1:8080",
|
|
}
|
|
|
|
for _, ip := range privateIPs {
|
|
t.Run(ip, func(t *testing.T) {
|
|
err := validateCallbackURL(ip)
|
|
// Currently allows private IPs (may want to add protection)
|
|
// This test documents current behavior
|
|
_ = err // Behavior depends on reachability
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateCallbackURL_LongURL(t *testing.T) {
|
|
// Test with very long URL
|
|
longURL := "http://example.com/" + string(make([]byte, 2000))
|
|
err := validateCallbackURL(longURL)
|
|
// Should handle long URLs gracefully (may fail reachability check)
|
|
_ = err // Test ensures no panic
|
|
}
|
|
|
|
func TestExtractPortFromURL_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
expected string
|
|
}{
|
|
{"port with path", "http://localhost:8080/path", "8080"},
|
|
{"port with query", "http://localhost:8080?query=value", "8080"},
|
|
{"port with fragment", "http://localhost:8080#fragment", "8080"},
|
|
{"port zero", "http://localhost:0", "0"},
|
|
{"max port", "http://localhost:65535", "65535"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
port := extractPortFromURL(tt.url)
|
|
assert.Equal(t, tt.expected, port)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_NilDiscovery(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
|
|
// Should not panic with nil discovery
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, nil, "")
|
|
|
|
assert.Equal(t, 1, len(candidates))
|
|
assert.Equal(t, baseURL, candidates[0])
|
|
assert.Equal(t, "8080", defaultPort)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_EmptyClientIP(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
|
|
candidates, _ := gatherCallbackCandidates(baseURL, nil, "")
|
|
|
|
// Should handle empty client IP gracefully
|
|
assert.Equal(t, 1, len(candidates))
|
|
assert.Equal(t, baseURL, candidates[0])
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_IPv6(t *testing.T) {
|
|
baseURL := "http://[::1]:8080"
|
|
|
|
candidates, defaultPort := gatherCallbackCandidates(baseURL, nil, "::1")
|
|
|
|
assert.Contains(t, candidates, baseURL)
|
|
assert.Equal(t, "8080", defaultPort)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_MalformedURLsInDiscovery(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Candidates: []string{
|
|
"not-a-url",
|
|
"",
|
|
"http://valid:8080",
|
|
"://noscheme",
|
|
},
|
|
}
|
|
|
|
// Should handle malformed URLs gracefully (skip invalid ones)
|
|
candidates, _ := gatherCallbackCandidates(baseURL, discovery, "")
|
|
|
|
// Should at least include baseURL and valid candidate
|
|
assert.GreaterOrEqual(t, len(candidates), 1)
|
|
assert.Contains(t, candidates, baseURL)
|
|
}
|
|
|
|
func TestGatherCallbackCandidates_WhitespaceInCandidates(t *testing.T) {
|
|
baseURL := "http://localhost:8080"
|
|
discovery := &types.CallbackDiscoveryInfo{
|
|
Candidates: []string{
|
|
" http://example1:8080 ",
|
|
"\t\nhttp://example2:8080\n\t",
|
|
"http://example3:8080",
|
|
},
|
|
}
|
|
|
|
candidates, _ := gatherCallbackCandidates(baseURL, discovery, "")
|
|
|
|
// Should trim whitespace from candidates
|
|
for _, c := range candidates {
|
|
assert.Equal(t, len(c), len(c), "Should not have leading/trailing whitespace")
|
|
}
|
|
}
|