Files
Pulse/internal/api/temperature_proxy_test.go
rcourtman 4c39f2f58f Add unit tests for temperature proxy utility functions (api)
- extractHostPart: 22 test cases covering URL parsing, hostname extraction
- buildAuthorizedNodeList: 8 test cases for PVE instance node list building
- generateSecureToken: 5 test cases for cryptographic token generation
- proxySyncState: 6 test cases for sync status tracking and snapshots

First test file for temperature_proxy.go utility functions.
2025-11-30 14:07:04 +00:00

421 lines
12 KiB
Go

package api
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestExtractHostPart(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
// Empty/whitespace inputs
{name: "empty string", input: "", expected: ""},
{name: "whitespace only", input: " ", expected: ""},
{name: "tabs and newlines", input: "\t\n", expected: ""},
// Simple hostnames
{name: "simple hostname", input: "pve1.local", expected: "pve1.local"},
{name: "hostname with whitespace", input: " pve1.local ", expected: "pve1.local"},
{name: "ip address", input: "192.168.1.100", expected: "192.168.1.100"},
// Hostnames with ports
{name: "hostname with port", input: "pve1.local:8006", expected: "pve1.local"},
{name: "ip with port", input: "192.168.1.100:8006", expected: "192.168.1.100"},
{name: "ipv4 with default port", input: "10.0.0.1:443", expected: "10.0.0.1"},
// URLs with scheme
{name: "https url", input: "https://pve1.local", expected: "pve1.local"},
{name: "https url with port", input: "https://pve1.local:8006", expected: "pve1.local"},
{name: "http url", input: "http://pve1.local", expected: "pve1.local"},
{name: "http url with port", input: "http://pve1.local:8080", expected: "pve1.local"},
{name: "https url with ip", input: "https://192.168.1.100:8006", expected: "192.168.1.100"},
// URLs with paths
{name: "url with path", input: "https://pve1.local/api2", expected: "pve1.local"},
{name: "url with path and port", input: "https://pve1.local:8006/api2/json", expected: "pve1.local"},
// Hostnames with paths (no scheme)
{name: "hostname with path", input: "pve1.local/api", expected: "pve1.local"},
{name: "hostname with port and path", input: "pve1.local:8006/api", expected: "pve1.local"},
// Edge cases
{name: "just a port colon", input: ":8006", expected: ""},
{name: "just a slash", input: "/api", expected: ""},
{name: "double colon", input: "host::8006", expected: "host"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractHostPart(tc.input)
if result != tc.expected {
t.Errorf("extractHostPart(%q) = %q, want %q", tc.input, result, tc.expected)
}
})
}
}
func TestBuildAuthorizedNodeList(t *testing.T) {
t.Run("empty instances", func(t *testing.T) {
result := buildAuthorizedNodeList(nil)
if len(result) != 0 {
t.Errorf("expected empty list for nil input, got %d items", len(result))
}
result = buildAuthorizedNodeList([]config.PVEInstance{})
if len(result) != 0 {
t.Errorf("expected empty list for empty slice, got %d items", len(result))
}
})
t.Run("single instance with host", func(t *testing.T) {
instances := []config.PVEInstance{
{Name: "pve1", Host: "https://pve1.local:8006"},
}
result := buildAuthorizedNodeList(instances)
// Function calls add() for both Host and GuestURL (even if empty)
// With empty GuestURL, we get one entry from Host only (name=pve1, IP=pve1.local)
// But it also adds an entry for the empty GuestURL case: (name=pve1, IP="")
// So we expect 2 entries
if len(result) != 2 {
t.Fatalf("expected 2 nodes (Host + empty GuestURL), got %d: %+v", len(result), result)
}
// Verify the Host-based entry exists
foundHostEntry := false
for _, node := range result {
if node.Name == "pve1" && node.IP == "pve1.local" {
foundHostEntry = true
break
}
}
if !foundHostEntry {
t.Errorf("expected entry for Host (name=pve1, IP=pve1.local) in %+v", result)
}
})
t.Run("instance with GuestURL", func(t *testing.T) {
instances := []config.PVEInstance{
{
Name: "pve1",
Host: "https://pve1.local:8006",
GuestURL: "https://guest.local:8006",
},
}
result := buildAuthorizedNodeList(instances)
// Should have 2 entries: one from Host and one from GuestURL
if len(result) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(result))
}
// Check that both entries exist
foundHost := false
foundGuest := false
for _, node := range result {
if node.IP == "pve1.local" {
foundHost = true
}
if node.IP == "guest.local" {
foundGuest = true
}
}
if !foundHost {
t.Error("missing entry for Host URL")
}
if !foundGuest {
t.Error("missing entry for GuestURL")
}
})
t.Run("cluster endpoints", func(t *testing.T) {
instances := []config.PVEInstance{
{
Name: "cluster1",
Host: "https://pve1.local:8006",
IsCluster: true,
ClusterEndpoints: []config.ClusterEndpoint{
{NodeName: "pve2", Host: "https://pve2.local:8006", IP: ""},
{NodeName: "pve3", Host: "", IP: "192.168.1.103"},
},
},
}
result := buildAuthorizedNodeList(instances)
// Should have 4 entries:
// - (cluster1, pve1.local) from Host
// - (cluster1, "") from empty GuestURL
// - (pve2, pve2.local) from ClusterEndpoint[0]
// - (pve3, 192.168.1.103) from ClusterEndpoint[1]
if len(result) != 4 {
t.Fatalf("expected 4 nodes, got %d: %+v", len(result), result)
}
})
t.Run("deduplication", func(t *testing.T) {
instances := []config.PVEInstance{
{Name: "pve1", Host: "https://pve1.local:8006"},
{Name: "pve1", Host: "https://pve1.local:8006"}, // duplicate
}
result := buildAuthorizedNodeList(instances)
// Each instance adds 2 entries (Host and empty GuestURL)
// But deduplication should result in 2 unique entries:
// - (pve1, pve1.local) - from Host
// - (pve1, "") - from empty GuestURL
if len(result) != 2 {
t.Errorf("expected 2 nodes after dedup (host + empty guesturl), got %d: %+v", len(result), result)
}
})
t.Run("empty name and ip filtered", func(t *testing.T) {
instances := []config.PVEInstance{
{Name: "", Host: ""}, // should be skipped
{Name: "pve1", Host: ""}, // should be added with empty IP
{Name: "", Host: "pve2.local"}, // should be added with empty name
}
result := buildAuthorizedNodeList(instances)
// First entry has empty name and IP - should be skipped
// Second has name but empty IP
// Third has empty name but IP
if len(result) != 2 {
t.Errorf("expected 2 nodes, got %d: %+v", len(result), result)
}
})
t.Run("cluster endpoint with IP takes precedence", func(t *testing.T) {
instances := []config.PVEInstance{
{
Name: "cluster1",
Host: "https://pve1.local:8006",
IsCluster: true,
ClusterEndpoints: []config.ClusterEndpoint{
{NodeName: "pve2", Host: "https://pve2.local:8006", IP: "10.0.0.2"},
},
},
}
result := buildAuthorizedNodeList(instances)
// Should have 2 entries: pve1.local and 10.0.0.2 (IP from endpoint, not host)
foundIP := false
for _, node := range result {
if node.IP == "10.0.0.2" {
foundIP = true
}
if node.IP == "pve2.local" {
t.Error("expected IP to take precedence over Host extraction")
}
}
if !foundIP {
t.Error("expected to find IP 10.0.0.2 from cluster endpoint")
}
})
t.Run("cluster endpoint name fallback to host", func(t *testing.T) {
instances := []config.PVEInstance{
{
Name: "cluster1",
Host: "https://pve1.local:8006",
IsCluster: true,
ClusterEndpoints: []config.ClusterEndpoint{
{NodeName: "", Host: "pve2.local", IP: "10.0.0.2"},
},
},
}
result := buildAuthorizedNodeList(instances)
// Should find an entry where name is the host value
foundNameFallback := false
for _, node := range result {
if node.Name == "pve2.local" && node.IP == "10.0.0.2" {
foundNameFallback = true
}
}
if !foundNameFallback {
t.Errorf("expected name to fallback to host, got: %+v", result)
}
})
}
func TestGenerateSecureToken(t *testing.T) {
t.Run("generates non-empty token", func(t *testing.T) {
token, err := generateSecureToken(32)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
})
t.Run("token length corresponds to byte length", func(t *testing.T) {
// 32 bytes = 256 bits, base64 encoded = 44 chars (with padding)
token, err := generateSecureToken(32)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Base64 URL encoding: 32 bytes = ceil(32 * 4 / 3) = 43 chars + padding = 44
if len(token) != 44 {
t.Errorf("expected token length 44, got %d", len(token))
}
})
t.Run("tokens are unique", func(t *testing.T) {
tokens := make(map[string]bool)
for i := 0; i < 100; i++ {
token, err := generateSecureToken(32)
if err != nil {
t.Fatalf("unexpected error on iteration %d: %v", i, err)
}
if tokens[token] {
t.Errorf("duplicate token generated on iteration %d", i)
}
tokens[token] = true
}
})
t.Run("different byte lengths", func(t *testing.T) {
testCases := []struct {
bytes int
expectedLength int
}{
{16, 24}, // 16 bytes = 128 bits, base64 = 22 chars + 2 padding
{24, 32}, // 24 bytes = 192 bits, base64 = 32 chars
{48, 64}, // 48 bytes = 384 bits, base64 = 64 chars
}
for _, tc := range testCases {
token, err := generateSecureToken(tc.bytes)
if err != nil {
t.Errorf("unexpected error for %d bytes: %v", tc.bytes, err)
continue
}
if len(token) != tc.expectedLength {
t.Errorf("generateSecureToken(%d) length = %d, want %d", tc.bytes, len(token), tc.expectedLength)
}
}
})
t.Run("zero bytes returns empty string", func(t *testing.T) {
token, err := generateSecureToken(0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "" {
t.Errorf("expected empty token for 0 bytes, got %q", token)
}
})
}
func TestProxySyncState(t *testing.T) {
t.Run("record and snapshot sync", func(t *testing.T) {
handlers := NewTemperatureProxyHandlers(nil, nil, nil)
// Initially empty
snap := handlers.SnapshotSyncStatus()
if snap != nil {
t.Errorf("expected nil snapshot initially, got %+v", snap)
}
// Record a sync
handlers.recordSync("pve1", 60)
snap = handlers.SnapshotSyncStatus()
if snap == nil {
t.Fatal("expected non-nil snapshot after recording sync")
}
if len(snap) != 1 {
t.Errorf("expected 1 entry, got %d", len(snap))
}
// Key should be lowercase
state, ok := snap["pve1"]
if !ok {
t.Error("expected entry for 'pve1'")
}
if state.Instance != "pve1" {
t.Errorf("expected Instance 'pve1', got %q", state.Instance)
}
if state.RefreshInterval != 60 {
t.Errorf("expected RefreshInterval 60, got %d", state.RefreshInterval)
}
if state.LastPull.IsZero() {
t.Error("expected non-zero LastPull time")
}
})
t.Run("record with empty instance ignored", func(t *testing.T) {
handlers := NewTemperatureProxyHandlers(nil, nil, nil)
handlers.recordSync("", 60)
handlers.recordSync(" ", 60)
snap := handlers.SnapshotSyncStatus()
if snap != nil {
t.Errorf("expected nil snapshot for empty instances, got %+v", snap)
}
})
t.Run("default refresh interval", func(t *testing.T) {
handlers := NewTemperatureProxyHandlers(nil, nil, nil)
handlers.recordSync("pve1", 0) // 0 should use default
handlers.recordSync("pve2", -1) // negative should use default
snap := handlers.SnapshotSyncStatus()
if snap["pve1"].RefreshInterval != defaultProxyAllowlistRefreshSeconds {
t.Errorf("expected default refresh for 0, got %d", snap["pve1"].RefreshInterval)
}
if snap["pve2"].RefreshInterval != defaultProxyAllowlistRefreshSeconds {
t.Errorf("expected default refresh for -1, got %d", snap["pve2"].RefreshInterval)
}
})
t.Run("snapshot returns copy", func(t *testing.T) {
handlers := NewTemperatureProxyHandlers(nil, nil, nil)
handlers.recordSync("pve1", 60)
snap1 := handlers.SnapshotSyncStatus()
snap2 := handlers.SnapshotSyncStatus()
// Modify snap1
snap1["modified"] = proxySyncState{Instance: "modified"}
// snap2 should not be affected
if _, ok := snap2["modified"]; ok {
t.Error("snapshot should return a copy, not a reference")
}
})
t.Run("nil handlers safe", func(t *testing.T) {
var handlers *TemperatureProxyHandlers
// These should not panic
handlers.recordSync("pve1", 60)
snap := handlers.SnapshotSyncStatus()
if snap != nil {
t.Error("expected nil from nil handlers")
}
})
t.Run("case insensitive key storage", func(t *testing.T) {
handlers := NewTemperatureProxyHandlers(nil, nil, nil)
handlers.recordSync("PVE1", 60)
handlers.recordSync("pve1", 120) // should overwrite
snap := handlers.SnapshotSyncStatus()
if len(snap) != 1 {
t.Errorf("expected 1 entry after case-insensitive merge, got %d", len(snap))
}
if snap["pve1"].RefreshInterval != 120 {
t.Errorf("expected overwritten refresh 120, got %d", snap["pve1"].RefreshInterval)
}
})
}