mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-30 12:30:17 +00:00
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
468 lines
14 KiB
Go
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")
|
|
}
|
|
}
|