mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
- 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.
421 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|