From 39d02d62a3330d27d13c61fd9e7d41fc9116b5a3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 14:07:04 +0000 Subject: [PATCH] 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. --- internal/api/temperature_proxy_test.go | 420 +++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 internal/api/temperature_proxy_test.go diff --git a/internal/api/temperature_proxy_test.go b/internal/api/temperature_proxy_test.go new file mode 100644 index 0000000..c79c8f8 --- /dev/null +++ b/internal/api/temperature_proxy_test.go @@ -0,0 +1,420 @@ +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) + } + }) +}