Pulse/cmd/pulse-sensor-proxy/config_test.go
rcourtman 9e339957c6 fix: Update runtime config when toggling Docker update actions setting
The DisableDockerUpdateActions setting was being saved to disk but not
updated in h.config, causing the UI toggle to appear to revert on page
refresh since the API returned the stale runtime value.

Related to #1023
2026-01-03 11:14:17 +00:00

468 lines
14 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSanitizeDuplicateAllowedNodesBlocks_RemovesExtraBlocks(t *testing.T) {
raw := `
allowed_nodes:
- delly
- minipc
# Cluster nodes (auto-discovered during installation)
# These nodes are allowed to request temperature data when cluster IPC validation is unavailable
allowed_nodes:
- delly
- minipc
- extra
`
sanitized, out := sanitizeDuplicateAllowedNodesBlocks("", []byte(raw))
if !sanitized {
t.Fatalf("expected sanitization to occur")
}
result := string(out)
if strings.Count(result, "allowed_nodes:") != 1 {
t.Fatalf("expected only one allowed_nodes block, got %q", result)
}
if strings.Contains(result, "extra") {
t.Fatalf("duplicate entries should be removed, got %q", result)
}
if strings.Contains(result, "Cluster nodes (auto-discovered during installation)") {
t.Fatalf("duplicate comment block should be removed")
}
}
func TestSanitizeDuplicateAllowedNodesBlocks_NoChangeWhenUnique(t *testing.T) {
raw := `
metrics_address: 127.0.0.1:9127
allowed_nodes:
- delly
`
sanitized, out := sanitizeDuplicateAllowedNodesBlocks("", []byte(raw))
if sanitized {
t.Fatalf("unexpected sanitization for unique config")
}
if string(out) != raw {
t.Fatalf("expected config to remain unchanged")
}
}
func TestSanitizeDuplicateAllowedNodesBlocks_WithCommentBlocks(t *testing.T) {
raw := `
allowed_source_subnets:
- 192.168.1.0/24
# Cluster nodes (auto-discovered during installation)
# These nodes are allowed to request temperature data when cluster IPC validation is unavailable
allowed_nodes:
- delly
- minipc
# Cluster nodes (auto-discovered during installation)
# These nodes are allowed to request temperature data when cluster IPC validation is unavailable
allowed_nodes:
- delly
- minipc
`
sanitized, out := sanitizeDuplicateAllowedNodesBlocks("", []byte(raw))
if !sanitized {
t.Fatalf("expected sanitizer to run for duplicate comment blocks")
}
result := string(out)
if strings.Count(result, "allowed_nodes:") != 1 {
t.Fatalf("expected a single allowed_nodes block, got %q", result)
}
if strings.Count(result, "# Cluster nodes") != 1 {
t.Fatalf("expected duplicate comments to collapse, got %q", result)
}
}
func TestParseBool(t *testing.T) {
tests := []struct {
name string
input string
expected bool
wantErr bool
}{
// Truthy values
{"true lowercase", "true", true, false},
{"TRUE uppercase", "TRUE", true, false},
{"True mixed", "True", true, false},
{"1", "1", true, false},
{"yes", "yes", true, false},
{"YES", "YES", true, false},
{"on", "on", true, false},
{"ON", "ON", true, false},
// Falsy values
{"false lowercase", "false", false, false},
{"FALSE uppercase", "FALSE", false, false},
{"False mixed", "False", false, false},
{"0", "0", false, false},
{"no", "no", false, false},
{"NO", "NO", false, false},
{"off", "off", false, false},
{"OFF", "OFF", false, false},
// Whitespace handling
{"true with leading space", " true", true, false},
{"false with trailing space", "false ", false, false},
{"yes with surrounding spaces", " yes ", true, false},
// Invalid values
{"invalid string", "maybe", false, true},
{"empty string", "", false, true},
{"numeric 2", "2", false, true},
{"random word", "enabled", false, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseBool(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("parseBool(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Errorf("parseBool(%q) unexpected error: %v", tc.input, err)
return
}
if result != tc.expected {
t.Errorf("parseBool(%q) = %v, want %v", tc.input, result, tc.expected)
}
})
}
}
func TestParseUint32List(t *testing.T) {
tests := []struct {
name string
input string
expected []uint32
wantErr bool
}{
{"single value", "1000", []uint32{1000}, false},
{"multiple values", "1000,2000,3000", []uint32{1000, 2000, 3000}, false},
{"with spaces", "1000, 2000, 3000", []uint32{1000, 2000, 3000}, false},
{"empty string", "", nil, false},
{"only spaces", " ", nil, false},
{"zero value", "0", []uint32{0}, false},
{"max uint32", "4294967295", []uint32{4294967295}, false},
{"with empty parts", "1000,,2000", []uint32{1000, 2000}, false},
{"negative value", "-1", nil, true},
{"overflow uint32", "4294967296", nil, true},
{"non-numeric", "abc", nil, true},
{"mixed valid invalid", "1000,abc", nil, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseUint32List(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("parseUint32List(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Errorf("parseUint32List(%q) unexpected error: %v", tc.input, err)
return
}
if len(result) != len(tc.expected) {
t.Errorf("parseUint32List(%q) length = %d, want %d", tc.input, len(result), len(tc.expected))
return
}
for i, v := range result {
if v != tc.expected[i] {
t.Errorf("parseUint32List(%q)[%d] = %d, want %d", tc.input, i, v, tc.expected[i])
}
}
})
}
}
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{"simple comma separated", "a,b,c", []string{"a", "b", "c"}},
{"with spaces", "a, b, c", []string{"a", "b", "c"}},
{"with leading/trailing spaces", " a , b , c ", []string{"a", "b", "c"}},
{"empty string", "", nil},
{"single value", "single", []string{"single"}},
{"empty parts filtered", "a,,b", []string{"a", "b"}},
{"only spaces between commas", "a, ,b", []string{"a", "b"}},
{"all empty parts", ",,", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := splitAndTrim(tc.input)
if len(result) != len(tc.expected) {
t.Errorf("splitAndTrim(%q) length = %d, want %d", tc.input, len(result), len(tc.expected))
return
}
for i, v := range result {
if v != tc.expected[i] {
t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tc.input, i, v, tc.expected[i])
}
}
})
}
}
func TestNormalizeNodes(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{"simple list", []string{"node1", "node2"}, []string{"node1", "node2"}},
{"with whitespace", []string{" node1 ", "node2"}, []string{"node1", "node2"}},
{"deduplicates case-insensitive", []string{"Node1", "NODE1", "node1"}, []string{"Node1"}},
{"preserves original case", []string{"MyNode", "mynode"}, []string{"MyNode"}},
{"filters empty strings", []string{"node1", "", "node2"}, []string{"node1", "node2"}},
{"filters whitespace-only", []string{"node1", " ", "node2"}, []string{"node1", "node2"}},
{"empty input", []string{}, nil},
{"nil input", nil, nil},
{"mixed duplicates", []string{"a", "B", "a", "b", "C"}, []string{"a", "B", "C"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := normalizeNodes(tc.input)
if len(result) != len(tc.expected) {
t.Errorf("normalizeNodes(%v) length = %d, want %d", tc.input, len(result), len(tc.expected))
return
}
for i, v := range result {
if v != tc.expected[i] {
t.Errorf("normalizeNodes(%v)[%d] = %q, want %q", tc.input, i, v, tc.expected[i])
}
}
})
}
}
func TestParseAllowedSubnets(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
wantErr bool
}{
// Valid CIDRs
{"single IPv4 CIDR", []string{"192.168.1.0/24"}, []string{"192.168.1.0/24"}, false},
{"multiple IPv4 CIDRs", []string{"192.168.1.0/24", "10.0.0.0/8"}, []string{"192.168.1.0/24", "10.0.0.0/8"}, false},
{"IPv6 CIDR", []string{"2001:db8::/32"}, []string{"2001:db8::/32"}, false},
// Single IPs converted to CIDRs
{"single IPv4 converted", []string{"192.168.1.100"}, []string{"192.168.1.100/32"}, false},
{"single IPv6 converted", []string{"2001:db8::1"}, []string{"2001:db8::1/128"}, false},
// Whitespace handling
{"with whitespace", []string{" 192.168.1.0/24 "}, []string{"192.168.1.0/24"}, false},
{"empty entries filtered", []string{"192.168.1.0/24", "", "10.0.0.0/8"}, []string{"192.168.1.0/24", "10.0.0.0/8"}, false},
// Deduplication
{"deduplicates exact", []string{"192.168.1.0/24", "192.168.1.0/24"}, []string{"192.168.1.0/24"}, false},
{"deduplicates converted", []string{"192.168.1.100", "192.168.1.100"}, []string{"192.168.1.100/32"}, false},
// Invalid inputs
{"invalid format", []string{"not-a-subnet"}, nil, true},
{"invalid CIDR", []string{"192.168.1.0/33"}, nil, true},
{"partial valid", []string{"192.168.1.0/24", "invalid"}, nil, true},
// Empty input
{"empty input", []string{}, nil, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseAllowedSubnets(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("parseAllowedSubnets(%v) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Errorf("parseAllowedSubnets(%v) unexpected error: %v", tc.input, err)
return
}
if len(result) != len(tc.expected) {
t.Errorf("parseAllowedSubnets(%v) length = %d, want %d; result = %v", tc.input, len(result), len(tc.expected), result)
return
}
for i, v := range result {
if v != tc.expected[i] {
t.Errorf("parseAllowedSubnets(%v)[%d] = %q, want %q", tc.input, i, v, tc.expected[i])
}
}
})
}
}
func TestLoadConfig_Basic(t *testing.T) {
// Non-existent file should just use defaults
cfg, err := loadConfig("/non/existent/config.yaml")
if err != nil {
t.Fatal(err)
}
if cfg.LogLevel != "info" {
t.Errorf("expected default log level info, got %s", cfg.LogLevel)
}
}
func TestLoadConfig_EnvOverrides(t *testing.T) {
t.Setenv("PULSE_SENSOR_PROXY_LOG_LEVEL", "debug")
t.Setenv("PULSE_SENSOR_PROXY_READ_TIMEOUT", "10s")
t.Setenv("PULSE_SENSOR_PROXY_WRITE_TIMEOUT", "20s")
t.Setenv("PULSE_SENSOR_PROXY_ALLOWED_SUBNETS", "10.0.0.0/24,192.168.1.1")
t.Setenv("PULSE_SENSOR_PROXY_MAX_SSH_OUTPUT_BYTES", "2097152")
t.Setenv("PULSE_SENSOR_PROXY_ALLOW_IDMAPPED_ROOT", "false")
t.Setenv("PULSE_SENSOR_PROXY_ALLOWED_IDMAP_USERS", "root,admin")
t.Setenv("PULSE_SENSOR_PROXY_ALLOWED_PEER_UIDS", "1000,1001")
t.Setenv("PULSE_SENSOR_PROXY_ALLOWED_PEER_GIDS", "1000,1001")
t.Setenv("PULSE_SENSOR_PROXY_ALLOWED_NODES", "node1,node2")
t.Setenv("PULSE_SENSOR_PROXY_STRICT_NODE_VALIDATION", "true")
t.Setenv("PULSE_SENSOR_PROXY_REQUIRE_PROXMOX_HOSTKEYS", "true")
t.Setenv("PULSE_SENSOR_PROXY_METRICS_ADDR", "127.0.0.1:9999")
cfg, err := loadConfig("")
if err != nil {
t.Fatal(err)
}
if cfg.LogLevel != "debug" {
t.Errorf("expected debug log level, got %s", cfg.LogLevel)
}
if cfg.ReadTimeout != 10*time.Second {
t.Errorf("expected 10s read timeout, got %v", cfg.ReadTimeout)
}
if cfg.WriteTimeout != 20*time.Second {
t.Errorf("expected 20s write timeout, got %v", cfg.WriteTimeout)
}
if cfg.MaxSSHOutputBytes != 2097152 {
t.Errorf("expected 2MB max SSH output, got %d", cfg.MaxSSHOutputBytes)
}
if cfg.AllowIDMappedRoot {
t.Error("expected allow_idmapped_root false")
}
if len(cfg.AllowedPeerUIDs) != 2 {
t.Errorf("expected 2 UIDs, got %d", len(cfg.AllowedPeerUIDs))
}
if cfg.MetricsAddress != "127.0.0.1:9999" {
t.Errorf("expected metrics addr 127.0.0.1:9999, got %s", cfg.MetricsAddress)
}
}
func TestLoadAllowedNodesFile(t *testing.T) {
tmpDir := t.TempDir()
// YAML list format
yamlList := filepath.Join(tmpDir, "list.yaml")
os.WriteFile(yamlList, []byte("- node1\n- node2\n"), 0644)
nodes, err := loadAllowedNodesFile(yamlList)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 {
t.Errorf("expected 2 nodes, got %d", len(nodes))
}
// YAML map format
yamlMap := filepath.Join(tmpDir, "map.yaml")
os.WriteFile(yamlMap, []byte("allowed_nodes:\n - node3\n"), 0644)
nodes, err = loadAllowedNodesFile(yamlMap)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 1 || nodes[0] != "node3" {
t.Errorf("unexpected nodes: %v", nodes)
}
// Plain text format
plainText := filepath.Join(tmpDir, "plain.txt")
os.WriteFile(plainText, []byte("node4\n# comment\n- node5\n"), 0644)
nodes, err = loadAllowedNodesFile(plainText)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 || nodes[1] != "node5" {
t.Errorf("unexpected nodes: %v", nodes)
}
}
func TestLoadConfig_HTTP_Validation(t *testing.T) {
t.Setenv("PULSE_SENSOR_PROXY_HTTP_ENABLED", "true")
t.Setenv("PULSE_SENSOR_PROXY_HTTP_ADDR", ":8443")
t.Setenv("PULSE_SENSOR_PROXY_HTTP_TLS_CERT", "/tmp/cert")
t.Setenv("PULSE_SENSOR_PROXY_HTTP_TLS_KEY", "/tmp/key")
t.Setenv("PULSE_SENSOR_PROXY_HTTP_AUTH_TOKEN", "token")
cfg, err := loadConfig("")
if err != nil {
t.Fatal(err)
}
if cfg.HTTPListenAddr != ":8443" {
t.Errorf("expected addr :8443, got %s", cfg.HTTPListenAddr)
}
// Test missing token
t.Setenv("PULSE_SENSOR_PROXY_HTTP_AUTH_TOKEN", "")
_, err = loadConfig("")
if err == nil {
t.Error("expected error for missing HTTP token")
}
// Test missing cert
t.Setenv("PULSE_SENSOR_PROXY_HTTP_AUTH_TOKEN", "token")
t.Setenv("PULSE_SENSOR_PROXY_HTTP_TLS_CERT", "")
_, err = loadConfig("")
if err == nil {
t.Error("expected error for missing TLS cert")
}
}
func TestDetectHostCIDRs(t *testing.T) {
cidrs := detectHostCIDRs()
// Should at least have some IPs if running in a container with network
if len(cidrs) == 0 {
t.Log("detectHostCIDRs returned no CIDRs (might be expected if no non-loopback IPs)")
}
for _, cidr := range cidrs {
if !strings.Contains(cidr, "/") {
t.Errorf("invalid CIDR: %s", cidr)
}
}
}
func TestLoadConfig_TimeoutValidation(t *testing.T) {
t.Setenv("PULSE_SENSOR_PROXY_READ_TIMEOUT", "0s")
t.Setenv("PULSE_SENSOR_PROXY_WRITE_TIMEOUT", "0s")
t.Setenv("PULSE_SENSOR_PROXY_MAX_SSH_OUTPUT_BYTES", "0")
cfg, err := loadConfig("")
if err != nil {
t.Fatal(err)
}
if cfg.ReadTimeout <= 0 {
t.Error("expected positive read timeout default")
}
if cfg.WriteTimeout <= 0 {
t.Error("expected positive write timeout default")
}
}