From 930e0035194506fa1b4207b485fb33b7d51dd651 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 15:05:37 +0000 Subject: [PATCH] Add unit tests for normalizeUnifiedAgentArch and getClientIP (api) Tests for unified_agent.go: - normalizeUnifiedAgentArch: 51 test cases covering all supported architectures (linux/darwin/windows), case insensitivity, whitespace handling, and invalid inputs Tests for updates.go: - getClientIP: 18 test cases covering X-Forwarded-For priority, X-Real-IP fallback, RemoteAddr extraction, IPv4/IPv6 support, nil header safety, and case insensitivity --- internal/api/unified_agent_test.go | 92 ++++++++++++ internal/api/updates_test.go | 226 +++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 internal/api/unified_agent_test.go create mode 100644 internal/api/updates_test.go diff --git a/internal/api/unified_agent_test.go b/internal/api/unified_agent_test.go new file mode 100644 index 0000000..586abe9 --- /dev/null +++ b/internal/api/unified_agent_test.go @@ -0,0 +1,92 @@ +package api + +import "testing" + +func TestNormalizeUnifiedAgentArch(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // Linux AMD64 variants + {name: "linux-amd64 canonical", input: "linux-amd64", expected: "linux-amd64"}, + {name: "amd64 short", input: "amd64", expected: "linux-amd64"}, + {name: "x86_64 uname style", input: "x86_64", expected: "linux-amd64"}, + + // Linux ARM64 variants + {name: "linux-arm64 canonical", input: "linux-arm64", expected: "linux-arm64"}, + {name: "arm64 short", input: "arm64", expected: "linux-arm64"}, + {name: "aarch64 uname style", input: "aarch64", expected: "linux-arm64"}, + + // Linux ARMv7 variants + {name: "linux-armv7 canonical", input: "linux-armv7", expected: "linux-armv7"}, + {name: "armv7 short", input: "armv7", expected: "linux-armv7"}, + {name: "armv7l uname style", input: "armv7l", expected: "linux-armv7"}, + {name: "armhf debian style", input: "armhf", expected: "linux-armv7"}, + + // Linux ARMv6 variants + {name: "linux-armv6 canonical", input: "linux-armv6", expected: "linux-armv6"}, + {name: "armv6 short", input: "armv6", expected: "linux-armv6"}, + + // Linux 386 variants + {name: "linux-386 canonical", input: "linux-386", expected: "linux-386"}, + {name: "386 short", input: "386", expected: "linux-386"}, + {name: "i386 style", input: "i386", expected: "linux-386"}, + {name: "i686 style", input: "i686", expected: "linux-386"}, + + // macOS variants + {name: "darwin-amd64 canonical", input: "darwin-amd64", expected: "darwin-amd64"}, + {name: "macos-amd64 alias", input: "macos-amd64", expected: "darwin-amd64"}, + {name: "darwin-arm64 canonical", input: "darwin-arm64", expected: "darwin-arm64"}, + {name: "macos-arm64 alias", input: "macos-arm64", expected: "darwin-arm64"}, + + // Windows variants + {name: "windows-amd64 canonical", input: "windows-amd64", expected: "windows-amd64"}, + {name: "windows-arm64 canonical", input: "windows-arm64", expected: "windows-arm64"}, + {name: "windows-386 canonical", input: "windows-386", expected: "windows-386"}, + + // Case insensitivity + {name: "uppercase AMD64", input: "AMD64", expected: "linux-amd64"}, + {name: "mixed case Linux-AMD64", input: "Linux-AMD64", expected: "linux-amd64"}, + {name: "uppercase X86_64", input: "X86_64", expected: "linux-amd64"}, + {name: "mixed case AARCH64", input: "AARCH64", expected: "linux-arm64"}, + {name: "uppercase ARMHF", input: "ARMHF", expected: "linux-armv7"}, + {name: "uppercase DARWIN-ARM64", input: "DARWIN-ARM64", expected: "darwin-arm64"}, + {name: "uppercase WINDOWS-AMD64", input: "WINDOWS-AMD64", expected: "windows-amd64"}, + + // Whitespace handling + {name: "leading space", input: " amd64", expected: "linux-amd64"}, + {name: "trailing space", input: "amd64 ", expected: "linux-amd64"}, + {name: "both spaces", input: " arm64 ", expected: "linux-arm64"}, + {name: "tab chars", input: "\tamd64\t", expected: "linux-amd64"}, + + // Invalid/unknown architectures + {name: "empty string", input: "", expected: ""}, + {name: "unknown arch", input: "sparc", expected: ""}, + {name: "invalid value", input: "not-a-real-arch", expected: ""}, + {name: "partial match", input: "amd", expected: ""}, + {name: "numeric only", input: "64", expected: ""}, + {name: "special characters", input: "amd64!", expected: ""}, + {name: "linux alone", input: "linux", expected: ""}, + {name: "darwin alone", input: "darwin", expected: ""}, + {name: "windows alone", input: "windows", expected: ""}, + {name: "riscv64 unsupported", input: "riscv64", expected: ""}, + {name: "mips unsupported", input: "mips", expected: ""}, + {name: "ppc64 unsupported", input: "ppc64", expected: ""}, + + // Edge cases for similar strings + {name: "armv8 not matched", input: "armv8", expected: ""}, + {name: "arm not matched", input: "arm", expected: ""}, + {name: "x86 not matched", input: "x86", expected: ""}, + {name: "x64 not matched", input: "x64", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeUnifiedAgentArch(tt.input) + if result != tt.expected { + t.Errorf("normalizeUnifiedAgentArch(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/api/updates_test.go b/internal/api/updates_test.go new file mode 100644 index 0000000..8ed5578 --- /dev/null +++ b/internal/api/updates_test.go @@ -0,0 +1,226 @@ +package api + +import ( + "net/http" + "testing" +) + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + xff string // X-Forwarded-For header + xri string // X-Real-IP header + remoteAddr string // Request.RemoteAddr + expectedIP string + }{ + // X-Forwarded-For takes priority + { + name: "XFF with valid IPv4", + xff: "192.168.1.100", + xri: "", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.100", + }, + { + name: "XFF with valid IPv6", + xff: "2001:db8::1", + xri: "", + remoteAddr: "10.0.0.1:12345", + expectedIP: "2001:db8::1", + }, + { + name: "XFF with IPv6 loopback", + xff: "::1", + xri: "", + remoteAddr: "10.0.0.1:12345", + expectedIP: "::1", + }, + + // X-Real-IP fallback when XFF not valid + { + name: "XRI with valid IPv4", + xff: "", + xri: "172.16.0.50", + remoteAddr: "10.0.0.1:12345", + expectedIP: "172.16.0.50", + }, + { + name: "XRI with valid IPv6", + xff: "", + xri: "fe80::1", + remoteAddr: "10.0.0.1:12345", + expectedIP: "fe80::1", + }, + { + name: "XRI preferred when XFF invalid", + xff: "invalid-ip", + xri: "192.168.1.1", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.1", + }, + + // RemoteAddr fallback + { + name: "RemoteAddr with port", + xff: "", + xri: "", + remoteAddr: "10.0.0.1:12345", + expectedIP: "10.0.0.1", + }, + { + name: "RemoteAddr IPv6 with port", + xff: "", + xri: "", + remoteAddr: "[::1]:12345", + expectedIP: "::1", + }, + { + name: "RemoteAddr without port", + xff: "", + xri: "", + remoteAddr: "10.0.0.1", + expectedIP: "10.0.0.1", + }, + + // Invalid headers fall through + { + name: "XFF invalid falls to XRI", + xff: "not-an-ip", + xri: "192.168.1.1", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.1", + }, + { + name: "Both headers invalid falls to RemoteAddr", + xff: "not-an-ip", + xri: "also-not-an-ip", + remoteAddr: "10.0.0.1:12345", + expectedIP: "10.0.0.1", + }, + + // Edge cases + { + name: "Empty XFF ignored", + xff: "", + xri: "192.168.1.1", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.1", + }, + { + name: "All empty uses RemoteAddr", + xff: "", + xri: "", + remoteAddr: "127.0.0.1:8080", + expectedIP: "127.0.0.1", + }, + { + name: "Loopback IPv4", + xff: "127.0.0.1", + xri: "", + remoteAddr: "10.0.0.1:12345", + expectedIP: "127.0.0.1", + }, + + // Note: The current implementation has a bug with multiple IPs in XFF + // It tries to parse the entire string as a single IP, which fails + // This test documents current behavior, not ideal behavior + { + name: "XFF with multiple IPs - current behavior", + xff: "192.168.1.100, 10.0.0.1", + xri: "172.16.0.1", + remoteAddr: "10.0.0.1:12345", + expectedIP: "172.16.0.1", // Falls through because "192.168.1.100, 10.0.0.1" is not a valid single IP + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + Header: make(http.Header), + RemoteAddr: tt.remoteAddr, + } + + if tt.xff != "" { + req.Header.Set("X-Forwarded-For", tt.xff) + } + if tt.xri != "" { + req.Header.Set("X-Real-IP", tt.xri) + } + + result := getClientIP(req) + if result != tt.expectedIP { + t.Errorf("getClientIP() = %q, want %q", result, tt.expectedIP) + } + }) + } +} + +func TestGetClientIP_NilHeaders(t *testing.T) { + // Test with a request that has nil headers (edge case) + req := &http.Request{ + Header: nil, + RemoteAddr: "10.0.0.1:12345", + } + + // This will panic if headers aren't handled correctly + // The function should gracefully handle nil headers + defer func() { + if r := recover(); r != nil { + t.Errorf("getClientIP panicked with nil headers: %v", r) + } + }() + + // Note: With nil headers, Header.Get() will panic + // This test documents that the function expects non-nil headers + // If this test panics, it's documenting current behavior + _ = getClientIP(req) +} + +func TestGetClientIP_HeaderCaseSensitivity(t *testing.T) { + // HTTP headers are case-insensitive per RFC 7230 + // http.Header.Get handles this automatically + tests := []struct { + name string + headerKey string + headerVal string + remoteAddr string + expectedIP string + }{ + { + name: "lowercase x-forwarded-for", + headerKey: "x-forwarded-for", + headerVal: "192.168.1.100", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.100", + }, + { + name: "lowercase x-real-ip", + headerKey: "x-real-ip", + headerVal: "192.168.1.100", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.100", + }, + { + name: "mixed case X-Forwarded-FOR", + headerKey: "X-Forwarded-FOR", + headerVal: "192.168.1.100", + remoteAddr: "10.0.0.1:12345", + expectedIP: "192.168.1.100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + Header: make(http.Header), + RemoteAddr: tt.remoteAddr, + } + req.Header.Set(tt.headerKey, tt.headerVal) + + result := getClientIP(req) + if result != tt.expectedIP { + t.Errorf("getClientIP() = %q, want %q", result, tt.expectedIP) + } + }) + } +}