Pulse/internal/api/router_test.go
rcourtman 02419827b6 test: Add tests for hostAgentSearchCandidates parameter variants
Tests all code paths: strict mode (both params), platform only,
and no params (generic paths).
2025-12-02 00:03:19 +00:00

846 lines
17 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestIsDirectLoopbackRequest(t *testing.T) {
t.Helper()
tests := []struct {
name string
req *http.Request
remoteAddr string
headers map[string]string
want bool
}{
// Nil request
{
name: "nil request",
req: nil,
want: false,
},
// Valid loopback IPs without proxy headers
{
name: "loopback IPv4 without port",
remoteAddr: "127.0.0.1",
want: true,
},
{
name: "loopback IPv4 with port",
remoteAddr: "127.0.0.1:8080",
want: true,
},
{
name: "loopback IPv4 alternate",
remoteAddr: "127.0.0.2:54321",
want: true,
},
{
name: "loopback IPv6 without port",
remoteAddr: "::1",
want: true,
},
{
name: "loopback IPv6 with port",
remoteAddr: "[::1]:8080",
want: true,
},
{
name: "loopback IPv6 with brackets no port",
remoteAddr: "[::1]",
want: true,
},
// Loopback with proxy headers (should reject)
{
name: "loopback with X-Forwarded-For",
remoteAddr: "127.0.0.1:8080",
headers: map[string]string{
"X-Forwarded-For": "192.168.1.1",
},
want: false,
},
{
name: "loopback with Forwarded",
remoteAddr: "127.0.0.1:8080",
headers: map[string]string{
"Forwarded": "for=192.168.1.1",
},
want: false,
},
{
name: "loopback with X-Real-IP",
remoteAddr: "127.0.0.1:8080",
headers: map[string]string{
"X-Real-IP": "192.168.1.1",
},
want: false,
},
{
name: "loopback with multiple proxy headers",
remoteAddr: "127.0.0.1:8080",
headers: map[string]string{
"X-Forwarded-For": "192.168.1.1",
"X-Real-IP": "10.0.0.1",
},
want: false,
},
{
name: "loopback IPv6 with X-Forwarded-For",
remoteAddr: "[::1]:8080",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.42",
},
want: false,
},
// Non-loopback IPs (should reject)
{
name: "private IPv4",
remoteAddr: "192.168.1.1:8080",
want: false,
},
{
name: "private IPv4 10.x",
remoteAddr: "10.0.0.1:54321",
want: false,
},
{
name: "private IPv4 172.x",
remoteAddr: "172.16.0.1:8080",
want: false,
},
{
name: "public IPv4",
remoteAddr: "203.0.113.42:8080",
want: false,
},
{
name: "public IPv6",
remoteAddr: "[2001:db8::1]:8080",
want: false,
},
{
name: "link-local IPv6",
remoteAddr: "[fe80::1]:8080",
want: false,
},
// Edge cases
{
name: "empty RemoteAddr",
remoteAddr: "",
want: false,
},
{
name: "invalid IP format",
remoteAddr: "not-an-ip:8080",
want: false,
},
{
name: "invalid IP with port",
remoteAddr: "999.999.999.999:8080",
want: false,
},
{
name: "malformed IPv6",
remoteAddr: "[::g]:8080",
want: false,
},
{
name: "just port",
remoteAddr: ":8080",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
var req *http.Request
if tt.req != nil {
req = tt.req
} else if tt.name != "nil request" {
req = httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
for key, value := range tt.headers {
req.Header.Set(key, value)
}
}
got := isDirectLoopbackRequest(req)
if got != tt.want {
t.Errorf("isDirectLoopbackRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestFirstForwardedValue(t *testing.T) {
tests := []struct {
name string
header string
want string
}{
// Empty/nil cases
{
name: "empty string",
header: "",
want: "",
},
// Single value
{
name: "single IP",
header: "192.168.1.1",
want: "192.168.1.1",
},
{
name: "single IP with whitespace",
header: " 192.168.1.1 ",
want: "192.168.1.1",
},
{
name: "single IPv6",
header: "2001:db8::1",
want: "2001:db8::1",
},
// Multiple values (comma-separated)
{
name: "multiple IPs returns first",
header: "192.168.1.1, 10.0.0.1, 172.16.0.1",
want: "192.168.1.1",
},
{
name: "multiple IPs with extra whitespace",
header: " 203.0.113.42 , 192.168.1.1 ",
want: "203.0.113.42",
},
{
name: "first value empty after split",
header: ", 192.168.1.1",
want: "",
},
{
name: "only commas",
header: ",,,",
want: "",
},
// Realistic proxy chain scenarios
{
name: "proxy chain client first",
header: "client.example.com, proxy1.example.com, proxy2.example.com",
want: "client.example.com",
},
{
name: "mixed IPv4 and IPv6 chain",
header: "2001:db8::1, 192.168.1.1, 10.0.0.1",
want: "2001:db8::1",
},
// Edge cases
{
name: "value with port (non-standard but seen in wild)",
header: "192.168.1.1:8080, 10.0.0.1",
want: "192.168.1.1:8080",
},
{
name: "hostname instead of IP",
header: "client.example.com",
want: "client.example.com",
},
{
name: "tabs and newlines stripped",
header: "\t192.168.1.1\n",
want: "192.168.1.1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := firstForwardedValue(tt.header)
if got != tt.want {
t.Errorf("firstForwardedValue(%q) = %q, want %q", tt.header, got, tt.want)
}
})
}
}
func TestSanitizeForwardedHost(t *testing.T) {
tests := []struct {
name string
raw string
wantFull string // host with port preserved
wantHostOnly string // host without port, brackets stripped
}{
// Empty cases
{
name: "empty string",
raw: "",
wantFull: "",
wantHostOnly: "",
},
{
name: "only whitespace",
raw: " ",
wantFull: "",
wantHostOnly: "",
},
{
name: "only scheme http",
raw: "http://",
wantFull: "",
wantHostOnly: "",
},
{
name: "only scheme https",
raw: "https://",
wantFull: "",
wantHostOnly: "",
},
// Simple hostnames
{
name: "simple hostname",
raw: "example.com",
wantFull: "example.com",
wantHostOnly: "example.com",
},
{
name: "hostname with whitespace",
raw: " example.com ",
wantFull: "example.com",
wantHostOnly: "example.com",
},
{
name: "fqdn",
raw: "api.example.com",
wantFull: "api.example.com",
wantHostOnly: "api.example.com",
},
// Hostnames with ports
{
name: "hostname with port",
raw: "example.com:8080",
wantFull: "example.com:8080",
wantHostOnly: "example.com",
},
{
name: "hostname with standard https port",
raw: "example.com:443",
wantFull: "example.com:443",
wantHostOnly: "example.com",
},
{
name: "hostname with standard http port",
raw: "example.com:80",
wantFull: "example.com:80",
wantHostOnly: "example.com",
},
// With scheme prefixes
{
name: "http scheme stripped",
raw: "http://example.com",
wantFull: "example.com",
wantHostOnly: "example.com",
},
{
name: "https scheme stripped",
raw: "https://example.com",
wantFull: "example.com",
wantHostOnly: "example.com",
},
{
name: "http scheme with port",
raw: "http://example.com:8080",
wantFull: "example.com:8080",
wantHostOnly: "example.com",
},
{
name: "https scheme with port",
raw: "https://example.com:9443",
wantFull: "example.com:9443",
wantHostOnly: "example.com",
},
// Trailing slashes/paths
{
name: "trailing slash stripped",
raw: "example.com/",
wantFull: "example.com",
wantHostOnly: "example.com",
},
{
name: "scheme and trailing slash",
raw: "https://example.com/",
wantFull: "example.com",
wantHostOnly: "example.com",
},
// IPv4 addresses
{
name: "IPv4 address",
raw: "192.168.1.1",
wantFull: "192.168.1.1",
wantHostOnly: "192.168.1.1",
},
{
name: "IPv4 with port",
raw: "192.168.1.1:8080",
wantFull: "192.168.1.1:8080",
wantHostOnly: "192.168.1.1",
},
{
name: "IPv4 with scheme",
raw: "http://10.0.0.1",
wantFull: "10.0.0.1",
wantHostOnly: "10.0.0.1",
},
// IPv6 addresses (key edge case - bracket handling)
{
name: "IPv6 with brackets",
raw: "[::1]",
wantFull: "[::1]",
wantHostOnly: "::1",
},
{
name: "IPv6 with brackets and port",
raw: "[::1]:8080",
wantFull: "[::1]:8080",
wantHostOnly: "::1",
},
{
name: "IPv6 full address with brackets",
raw: "[2001:db8::1]",
wantFull: "[2001:db8::1]",
wantHostOnly: "2001:db8::1",
},
{
name: "IPv6 full address with brackets and port",
raw: "[2001:db8::1]:443",
wantFull: "[2001:db8::1]:443",
wantHostOnly: "2001:db8::1",
},
{
name: "IPv6 with scheme",
raw: "https://[::1]:9443",
wantFull: "[::1]:9443",
wantHostOnly: "::1",
},
{
name: "IPv6 without brackets (raw - no port possible)",
raw: "::1",
wantFull: "::1",
wantHostOnly: "::1",
},
// Realistic forwarded host headers
{
name: "X-Forwarded-Host typical",
raw: "api.myservice.com",
wantFull: "api.myservice.com",
wantHostOnly: "api.myservice.com",
},
{
name: "reverse proxy with non-standard port",
raw: "internal.corp.local:7655",
wantFull: "internal.corp.local:7655",
wantHostOnly: "internal.corp.local",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotFull, gotHostOnly := sanitizeForwardedHost(tt.raw)
if gotFull != tt.wantFull {
t.Errorf("sanitizeForwardedHost(%q) full = %q, want %q", tt.raw, gotFull, tt.wantFull)
}
if gotHostOnly != tt.wantHostOnly {
t.Errorf("sanitizeForwardedHost(%q) hostOnly = %q, want %q", tt.raw, gotHostOnly, tt.wantHostOnly)
}
})
}
}
func TestIsLoopbackHost(t *testing.T) {
tests := []struct {
name string
host string
want bool
}{
// Empty/special cases (treated as loopback for safety)
{
name: "empty string",
host: "",
want: true,
},
// Localhost keyword
{
name: "localhost lowercase",
host: "localhost",
want: true,
},
{
name: "localhost uppercase",
host: "LOCALHOST",
want: true,
},
{
name: "localhost mixed case",
host: "LocalHost",
want: true,
},
// IPv4 loopback range
{
name: "127.0.0.1",
host: "127.0.0.1",
want: true,
},
{
name: "127.0.0.2 (full loopback range)",
host: "127.0.0.2",
want: true,
},
{
name: "127.255.255.255 (end of loopback range)",
host: "127.255.255.255",
want: true,
},
// IPv6 loopback
{
name: "::1",
host: "::1",
want: true,
},
// Unspecified addresses (treated as loopback)
{
name: "0.0.0.0 unspecified IPv4",
host: "0.0.0.0",
want: true,
},
{
name: ":: unspecified IPv6",
host: "::",
want: true,
},
// Non-loopback private addresses
{
name: "private 192.168.x",
host: "192.168.1.1",
want: false,
},
{
name: "private 10.x",
host: "10.0.0.1",
want: false,
},
{
name: "private 172.16.x",
host: "172.16.0.1",
want: false,
},
// Non-loopback public addresses
{
name: "public IPv4",
host: "203.0.113.42",
want: false,
},
{
name: "public IPv6",
host: "2001:db8::1",
want: false,
},
// Hostnames (not IPs)
{
name: "regular hostname",
host: "example.com",
want: false,
},
{
name: "fqdn",
host: "api.example.com",
want: false,
},
{
name: "localhost-like but not localhost",
host: "localhost.example.com",
want: false,
},
{
name: "hostname starting with local",
host: "local.example.com",
want: false,
},
// Edge cases
{
name: "link-local IPv6",
host: "fe80::1",
want: false,
},
{
name: "multicast",
host: "224.0.0.1",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isLoopbackHost(tt.host)
if got != tt.want {
t.Errorf("isLoopbackHost(%q) = %v, want %v", tt.host, got, tt.want)
}
})
}
}
func TestShouldAppendForwardedPort(t *testing.T) {
tests := []struct {
name string
port string
scheme string
want bool
}{
// Empty port
{
name: "empty port",
port: "",
scheme: "https",
want: false,
},
// Invalid port (non-numeric)
{
name: "non-numeric port",
port: "abc",
scheme: "https",
want: false,
},
{
name: "port with letters",
port: "80a",
scheme: "http",
want: false,
},
{
name: "negative port string (Atoi accepts it)",
port: "-80",
scheme: "http",
want: true, // strconv.Atoi parses "-80" as -80 (valid int)
},
// Default ports that should NOT be appended
{
name: "https with 443",
port: "443",
scheme: "https",
want: false,
},
{
name: "http with 80",
port: "80",
scheme: "http",
want: false,
},
// Default ports for wrong scheme SHOULD be appended
{
name: "http with 443 (unusual)",
port: "443",
scheme: "http",
want: true,
},
{
name: "https with 80 (unusual)",
port: "80",
scheme: "https",
want: true,
},
// Non-default ports should be appended
{
name: "https with 8443",
port: "8443",
scheme: "https",
want: true,
},
{
name: "http with 8080",
port: "8080",
scheme: "http",
want: true,
},
{
name: "https with custom port",
port: "9443",
scheme: "https",
want: true,
},
{
name: "pulse default port",
port: "7655",
scheme: "https",
want: true,
},
// Edge cases
{
name: "port 0",
port: "0",
scheme: "http",
want: true,
},
{
name: "high port number",
port: "65535",
scheme: "https",
want: true,
},
{
name: "empty scheme with non-default port",
port: "8080",
scheme: "",
want: true,
},
{
name: "unknown scheme",
port: "443",
scheme: "wss",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldAppendForwardedPort(tt.port, tt.scheme)
if got != tt.want {
t.Errorf("shouldAppendForwardedPort(%q, %q) = %v, want %v", tt.port, tt.scheme, got, tt.want)
}
})
}
}
func TestCanCapturePublicURL_NilInputs(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
cfg := &config.Config{}
tests := []struct {
name string
cfg *config.Config
req *http.Request
want bool
}{
{
name: "nil config",
cfg: nil,
req: req,
want: false,
},
{
name: "nil request",
cfg: cfg,
req: nil,
want: false,
},
{
name: "both nil",
cfg: nil,
req: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := canCapturePublicURL(tt.cfg, tt.req)
if got != tt.want {
t.Errorf("canCapturePublicURL() = %v, want %v", got, tt.want)
}
})
}
}
func TestCapturePublicURLFromRequest_NilInputs(t *testing.T) {
t.Parallel()
t.Run("nil router", func(t *testing.T) {
var r *Router
req := httptest.NewRequest(http.MethodGet, "/", nil)
// Should not panic
r.capturePublicURLFromRequest(req)
})
t.Run("nil request", func(t *testing.T) {
r := &Router{config: &config.Config{}}
// Should not panic
r.capturePublicURLFromRequest(nil)
})
t.Run("nil config", func(t *testing.T) {
r := &Router{config: nil}
req := httptest.NewRequest(http.MethodGet, "/", nil)
// Should not panic
r.capturePublicURLFromRequest(req)
})
}
func TestHostAgentSearchCandidates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
platform string
arch string
wantLen int // expected number of search paths
}{
{
name: "strict mode with both params",
platform: "linux",
arch: "amd64",
wantLen: 3, // 3 paths with platform-arch suffix
},
{
name: "platform only, no arch",
platform: "linux",
arch: "",
wantLen: 3, // 3 paths with platform suffix only
},
{
name: "no params returns generic paths",
platform: "",
arch: "",
wantLen: 3, // 3 generic paths
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths := hostAgentSearchCandidates(tt.platform, tt.arch)
if len(paths) != tt.wantLen {
t.Errorf("hostAgentSearchCandidates(%q, %q) returned %d paths, want %d",
tt.platform, tt.arch, len(paths), tt.wantLen)
}
})
}
}