From 277ec7e9daa68742ad0a753d870599b2bd5ffe91 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 29 Nov 2025 19:08:58 +0000 Subject: [PATCH] ADA: Add unit tests for validateSystemSettings --- internal/api/system_settings_validate_test.go | 1095 +++++++++++++++++ 1 file changed, 1095 insertions(+) create mode 100644 internal/api/system_settings_validate_test.go diff --git a/internal/api/system_settings_validate_test.go b/internal/api/system_settings_validate_test.go new file mode 100644 index 0000000..d84620a --- /dev/null +++ b/internal/api/system_settings_validate_test.go @@ -0,0 +1,1095 @@ +package api + +import ( + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// TestValidateSystemSettings provides comprehensive coverage of all validation paths +// in the validateSystemSettings function using table-driven tests. +func TestValidateSystemSettings(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expectError bool + errorText string // key term to check with strings.Contains, empty means don't check + }{ + // ================================================================= + // pvePollingInterval validation + // ================================================================= + { + name: "pvePollingInterval: valid minimum (10)", + input: map[string]interface{}{"pvePollingInterval": float64(10)}, + expectError: false, + }, + { + name: "pvePollingInterval: valid maximum (3600)", + input: map[string]interface{}{"pvePollingInterval": float64(3600)}, + expectError: false, + }, + { + name: "pvePollingInterval: valid middle value (60)", + input: map[string]interface{}{"pvePollingInterval": float64(60)}, + expectError: false, + }, + { + name: "pvePollingInterval: zero is invalid", + input: map[string]interface{}{"pvePollingInterval": float64(0)}, + expectError: true, + errorText: "positive", + }, + { + name: "pvePollingInterval: negative is invalid", + input: map[string]interface{}{"pvePollingInterval": float64(-1)}, + expectError: true, + errorText: "positive", + }, + { + name: "pvePollingInterval: below minimum (9)", + input: map[string]interface{}{"pvePollingInterval": float64(9)}, + expectError: true, + errorText: "10 seconds", + }, + { + name: "pvePollingInterval: above maximum (3601)", + input: map[string]interface{}{"pvePollingInterval": float64(3601)}, + expectError: true, + errorText: "3600", + }, + { + name: "pvePollingInterval: string instead of number", + input: map[string]interface{}{"pvePollingInterval": "60"}, + expectError: true, + errorText: "number", + }, + { + name: "pvePollingInterval: boolean instead of number", + input: map[string]interface{}{"pvePollingInterval": true}, + expectError: true, + errorText: "number", + }, + + // ================================================================= + // pbsPollingInterval validation (same rules as PVE) + // ================================================================= + { + name: "pbsPollingInterval: valid minimum (10)", + input: map[string]interface{}{"pbsPollingInterval": float64(10)}, + expectError: false, + }, + { + name: "pbsPollingInterval: valid maximum (3600)", + input: map[string]interface{}{"pbsPollingInterval": float64(3600)}, + expectError: false, + }, + { + name: "pbsPollingInterval: zero is invalid", + input: map[string]interface{}{"pbsPollingInterval": float64(0)}, + expectError: true, + errorText: "positive", + }, + { + name: "pbsPollingInterval: below minimum (9)", + input: map[string]interface{}{"pbsPollingInterval": float64(9)}, + expectError: true, + errorText: "10 seconds", + }, + { + name: "pbsPollingInterval: above maximum (3601)", + input: map[string]interface{}{"pbsPollingInterval": float64(3601)}, + expectError: true, + errorText: "3600", + }, + { + name: "pbsPollingInterval: string instead of number", + input: map[string]interface{}{"pbsPollingInterval": "60"}, + expectError: true, + errorText: "number", + }, + + // ================================================================= + // pmgPollingInterval validation (same rules as PVE) + // ================================================================= + { + name: "pmgPollingInterval: valid minimum (10)", + input: map[string]interface{}{"pmgPollingInterval": float64(10)}, + expectError: false, + }, + { + name: "pmgPollingInterval: valid maximum (3600)", + input: map[string]interface{}{"pmgPollingInterval": float64(3600)}, + expectError: false, + }, + { + name: "pmgPollingInterval: zero is invalid", + input: map[string]interface{}{"pmgPollingInterval": float64(0)}, + expectError: true, + errorText: "positive", + }, + { + name: "pmgPollingInterval: below minimum (9)", + input: map[string]interface{}{"pmgPollingInterval": float64(9)}, + expectError: true, + errorText: "10 seconds", + }, + { + name: "pmgPollingInterval: above maximum (3601)", + input: map[string]interface{}{"pmgPollingInterval": float64(3601)}, + expectError: true, + errorText: "3600", + }, + { + name: "pmgPollingInterval: string instead of number", + input: map[string]interface{}{"pmgPollingInterval": "60"}, + expectError: true, + errorText: "number", + }, + + // ================================================================= + // backupPollingInterval validation + // NOTE: backupPollingInterval allows 0 (to disable backup polling), + // unlike PVE/PBS/PMG intervals which must be positive. + // This is intentional - see lines 172-185 in system_settings.go. + // ================================================================= + { + name: "backupPollingInterval: zero is valid (disables polling)", + input: map[string]interface{}{"backupPollingInterval": float64(0)}, + expectError: false, + }, + { + name: "backupPollingInterval: valid minimum positive (10)", + input: map[string]interface{}{"backupPollingInterval": float64(10)}, + expectError: false, + }, + { + name: "backupPollingInterval: valid maximum (604800)", + input: map[string]interface{}{"backupPollingInterval": float64(604800)}, + expectError: false, + }, + { + name: "backupPollingInterval: negative is invalid", + input: map[string]interface{}{"backupPollingInterval": float64(-1)}, + expectError: true, + errorText: "negative", + }, + { + name: "backupPollingInterval: below minimum if positive (9)", + input: map[string]interface{}{"backupPollingInterval": float64(9)}, + expectError: true, + errorText: "10 seconds", + }, + { + name: "backupPollingInterval: above maximum (604801)", + input: map[string]interface{}{"backupPollingInterval": float64(604801)}, + expectError: true, + errorText: "604800", + }, + { + name: "backupPollingInterval: string instead of number", + input: map[string]interface{}{"backupPollingInterval": "60"}, + expectError: true, + errorText: "number", + }, + + // ================================================================= + // Boolean field type validation + // ================================================================= + { + name: "autoUpdateEnabled: true is valid", + input: map[string]interface{}{"autoUpdateEnabled": true}, + expectError: false, + }, + { + name: "autoUpdateEnabled: false is valid", + input: map[string]interface{}{"autoUpdateEnabled": false}, + expectError: false, + }, + { + name: "autoUpdateEnabled: string instead of bool", + input: map[string]interface{}{"autoUpdateEnabled": "true"}, + expectError: true, + errorText: "boolean", + }, + { + name: "autoUpdateEnabled: number instead of bool", + input: map[string]interface{}{"autoUpdateEnabled": float64(1)}, + expectError: true, + errorText: "boolean", + }, + { + name: "discoveryEnabled: true is valid", + input: map[string]interface{}{"discoveryEnabled": true}, + expectError: false, + }, + { + name: "discoveryEnabled: false is valid", + input: map[string]interface{}{"discoveryEnabled": false}, + expectError: false, + }, + { + name: "discoveryEnabled: string instead of bool", + input: map[string]interface{}{"discoveryEnabled": "true"}, + expectError: true, + errorText: "boolean", + }, + { + name: "allowEmbedding: true is valid", + input: map[string]interface{}{"allowEmbedding": true}, + expectError: false, + }, + { + name: "allowEmbedding: false is valid", + input: map[string]interface{}{"allowEmbedding": false}, + expectError: false, + }, + { + name: "allowEmbedding: string instead of bool", + input: map[string]interface{}{"allowEmbedding": "false"}, + expectError: true, + errorText: "boolean", + }, + { + name: "backupPollingEnabled: true is valid", + input: map[string]interface{}{"backupPollingEnabled": true}, + expectError: false, + }, + { + name: "backupPollingEnabled: false is valid", + input: map[string]interface{}{"backupPollingEnabled": false}, + expectError: false, + }, + { + name: "backupPollingEnabled: string instead of bool", + input: map[string]interface{}{"backupPollingEnabled": "true"}, + expectError: true, + errorText: "boolean", + }, + { + name: "temperatureMonitoringEnabled: true is valid", + input: map[string]interface{}{"temperatureMonitoringEnabled": true}, + expectError: false, + }, + { + name: "temperatureMonitoringEnabled: false is valid", + input: map[string]interface{}{"temperatureMonitoringEnabled": false}, + expectError: false, + }, + { + name: "temperatureMonitoringEnabled: string instead of bool", + input: map[string]interface{}{"temperatureMonitoringEnabled": "true"}, + expectError: true, + errorText: "boolean", + }, + + // ================================================================= + // autoUpdateCheckInterval validation + // NOTE: Like backupPollingInterval, this allows 0 to disable. + // ================================================================= + { + name: "autoUpdateCheckInterval: zero is valid (disables check)", + input: map[string]interface{}{"autoUpdateCheckInterval": float64(0)}, + expectError: false, + }, + { + name: "autoUpdateCheckInterval: valid minimum positive (1)", + input: map[string]interface{}{"autoUpdateCheckInterval": float64(1)}, + expectError: false, + }, + { + name: "autoUpdateCheckInterval: valid maximum (168)", + input: map[string]interface{}{"autoUpdateCheckInterval": float64(168)}, + expectError: false, + }, + { + name: "autoUpdateCheckInterval: negative is invalid", + input: map[string]interface{}{"autoUpdateCheckInterval": float64(-1)}, + expectError: true, + errorText: "negative", + }, + { + name: "autoUpdateCheckInterval: below minimum if positive (0.5)", + input: map[string]interface{}{"autoUpdateCheckInterval": float64(0.5)}, + expectError: true, + errorText: "1 hour", + }, + { + name: "autoUpdateCheckInterval: above maximum (169)", + input: map[string]interface{}{"autoUpdateCheckInterval": float64(169)}, + expectError: true, + errorText: "168", + }, + { + name: "autoUpdateCheckInterval: string instead of number", + input: map[string]interface{}{"autoUpdateCheckInterval": "24"}, + expectError: true, + errorText: "number", + }, + + // ================================================================= + // discoveryConfig validation - field name variants (camelCase and snake_case) + // ================================================================= + { + name: "discoveryConfig: camelCase field name is valid", + input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"maxHostsPerScan": float64(100)}}, + expectError: false, + }, + { + name: "discoveryConfig: snake_case field name is valid", + input: map[string]interface{}{"discovery_config": map[string]interface{}{"max_hosts_per_scan": float64(100)}}, + expectError: false, + }, + { + name: "discoveryConfig: nil/null is invalid (must be object)", + input: map[string]interface{}{"discoveryConfig": nil}, + expectError: true, + errorText: "object", + }, + { + name: "discoveryConfig: string instead of object", + input: map[string]interface{}{"discoveryConfig": "invalid"}, + expectError: true, + errorText: "object", + }, + { + name: "discoveryConfig: array instead of object", + input: map[string]interface{}{"discoveryConfig": []interface{}{}}, + expectError: true, + errorText: "object", + }, + { + name: "discoveryConfig: empty object is valid", + input: map[string]interface{}{"discoveryConfig": map[string]interface{}{}}, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.environment_override validation + // ================================================================= + { + name: "discoveryConfig.environment_override: empty string is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": ""}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: 'auto' is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "auto"}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: 'native' is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "native"}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: 'docker_host' is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "docker_host"}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: 'docker_bridge' is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "docker_bridge"}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: 'lxc_privileged' is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "lxc_privileged"}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: 'lxc_unprivileged' is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "lxc_unprivileged"}, + }, + expectError: false, + }, + { + name: "discoveryConfig.environment_override: invalid value", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": "invalid_env"}, + }, + expectError: true, + errorText: "invalid discovery environment", + }, + { + name: "discoveryConfig.environment_override: number instead of string", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environment_override": float64(123)}, + }, + expectError: true, + errorText: "string", + }, + { + name: "discoveryConfig.environmentOverride: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"environmentOverride": "auto"}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.subnet_allowlist validation + // ================================================================= + { + name: "discoveryConfig.subnet_allowlist: empty array is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": []interface{}{}}, + }, + expectError: false, + }, + { + name: "discoveryConfig.subnet_allowlist: valid CIDR", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": []interface{}{"192.168.1.0/24"}}, + }, + expectError: false, + }, + { + name: "discoveryConfig.subnet_allowlist: multiple valid CIDRs", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": []interface{}{"192.168.1.0/24", "10.0.0.0/8"}}, + }, + expectError: false, + }, + { + name: "discoveryConfig.subnet_allowlist: invalid CIDR format", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": []interface{}{"192.168.1.0"}}, + }, + expectError: true, + errorText: "invalid CIDR", + }, + { + name: "discoveryConfig.subnet_allowlist: invalid CIDR in array", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": []interface{}{"192.168.1.0/24", "not-a-cidr"}}, + }, + expectError: true, + errorText: "invalid CIDR", + }, + { + name: "discoveryConfig.subnet_allowlist: number in array instead of string", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": []interface{}{float64(123)}}, + }, + expectError: true, + errorText: "string", + }, + { + name: "discoveryConfig.subnet_allowlist: not an array", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_allowlist": "192.168.1.0/24"}, + }, + expectError: true, + errorText: "array", + }, + { + name: "discoveryConfig.subnetAllowlist: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnetAllowlist": []interface{}{"192.168.1.0/24"}}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.subnet_blocklist validation + // ================================================================= + { + name: "discoveryConfig.subnet_blocklist: empty array is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_blocklist": []interface{}{}}, + }, + expectError: false, + }, + { + name: "discoveryConfig.subnet_blocklist: valid CIDR", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_blocklist": []interface{}{"169.254.0.0/16"}}, + }, + expectError: false, + }, + { + name: "discoveryConfig.subnet_blocklist: multiple valid CIDRs", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_blocklist": []interface{}{"169.254.0.0/16", "127.0.0.0/8"}}, + }, + expectError: false, + }, + { + name: "discoveryConfig.subnet_blocklist: invalid CIDR format", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_blocklist": []interface{}{"169.254.0.0"}}, + }, + expectError: true, + errorText: "invalid CIDR", + }, + { + name: "discoveryConfig.subnet_blocklist: number in array instead of string", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_blocklist": []interface{}{float64(456)}}, + }, + expectError: true, + errorText: "string", + }, + { + name: "discoveryConfig.subnet_blocklist: not an array", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnet_blocklist": "169.254.0.0/16"}, + }, + expectError: true, + errorText: "array", + }, + { + name: "discoveryConfig.subnetBlocklist: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"subnetBlocklist": []interface{}{"169.254.0.0/16"}}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.max_hosts_per_scan validation + // ================================================================= + { + name: "discoveryConfig.max_hosts_per_scan: valid positive value", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_hosts_per_scan": float64(100)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.max_hosts_per_scan: large value", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_hosts_per_scan": float64(10000)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.max_hosts_per_scan: zero is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_hosts_per_scan": float64(0)}, + }, + expectError: true, + errorText: "greater than zero", + }, + { + name: "discoveryConfig.max_hosts_per_scan: negative is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_hosts_per_scan": float64(-1)}, + }, + expectError: true, + errorText: "greater than zero", + }, + { + name: "discoveryConfig.max_hosts_per_scan: string instead of number", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_hosts_per_scan": "100"}, + }, + expectError: true, + errorText: "number", + }, + { + name: "discoveryConfig.maxHostsPerScan: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"maxHostsPerScan": float64(100)}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.max_concurrent validation + // ================================================================= + { + name: "discoveryConfig.max_concurrent: valid minimum (1)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": float64(1)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.max_concurrent: valid maximum (1000)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": float64(1000)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.max_concurrent: valid middle value (50)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": float64(50)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.max_concurrent: zero is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": float64(0)}, + }, + expectError: true, + errorText: "between 1 and 1000", + }, + { + name: "discoveryConfig.max_concurrent: negative is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": float64(-1)}, + }, + expectError: true, + errorText: "between 1 and 1000", + }, + { + name: "discoveryConfig.max_concurrent: above maximum (1001)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": float64(1001)}, + }, + expectError: true, + errorText: "between 1 and 1000", + }, + { + name: "discoveryConfig.max_concurrent: string instead of number", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"max_concurrent": "50"}, + }, + expectError: true, + errorText: "number", + }, + { + name: "discoveryConfig.maxConcurrent: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"maxConcurrent": float64(50)}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.enable_reverse_dns validation + // ================================================================= + { + name: "discoveryConfig.enable_reverse_dns: true is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"enable_reverse_dns": true}, + }, + expectError: false, + }, + { + name: "discoveryConfig.enable_reverse_dns: false is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"enable_reverse_dns": false}, + }, + expectError: false, + }, + { + name: "discoveryConfig.enable_reverse_dns: string instead of bool", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"enable_reverse_dns": "true"}, + }, + expectError: true, + errorText: "boolean", + }, + { + name: "discoveryConfig.enableReverseDns: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"enableReverseDns": true}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.scan_gateways validation + // ================================================================= + { + name: "discoveryConfig.scan_gateways: true is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"scan_gateways": true}, + }, + expectError: false, + }, + { + name: "discoveryConfig.scan_gateways: false is valid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"scan_gateways": false}, + }, + expectError: false, + }, + { + name: "discoveryConfig.scan_gateways: string instead of bool", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"scan_gateways": "true"}, + }, + expectError: true, + errorText: "boolean", + }, + { + name: "discoveryConfig.scanGateways: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"scanGateways": true}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.dial_timeout_ms validation + // ================================================================= + { + name: "discoveryConfig.dial_timeout_ms: valid positive value (1000)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"dial_timeout_ms": float64(1000)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.dial_timeout_ms: minimum positive (1)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"dial_timeout_ms": float64(1)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.dial_timeout_ms: zero is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"dial_timeout_ms": float64(0)}, + }, + expectError: true, + errorText: "greater than zero", + }, + { + name: "discoveryConfig.dial_timeout_ms: negative is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"dial_timeout_ms": float64(-1)}, + }, + expectError: true, + errorText: "greater than zero", + }, + { + name: "discoveryConfig.dial_timeout_ms: string instead of number", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"dial_timeout_ms": "1000"}, + }, + expectError: true, + errorText: "number", + }, + { + name: "discoveryConfig.dialTimeoutMs: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"dialTimeoutMs": float64(1000)}, + }, + expectError: false, + }, + + // ================================================================= + // discoveryConfig.http_timeout_ms validation + // ================================================================= + { + name: "discoveryConfig.http_timeout_ms: valid positive value (2000)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"http_timeout_ms": float64(2000)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.http_timeout_ms: minimum positive (1)", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"http_timeout_ms": float64(1)}, + }, + expectError: false, + }, + { + name: "discoveryConfig.http_timeout_ms: zero is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"http_timeout_ms": float64(0)}, + }, + expectError: true, + errorText: "greater than zero", + }, + { + name: "discoveryConfig.http_timeout_ms: negative is invalid", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"http_timeout_ms": float64(-1)}, + }, + expectError: true, + errorText: "greater than zero", + }, + { + name: "discoveryConfig.http_timeout_ms: string instead of number", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"http_timeout_ms": "2000"}, + }, + expectError: true, + errorText: "number", + }, + { + name: "discoveryConfig.httpTimeoutMs: camelCase variant works", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{"httpTimeoutMs": float64(2000)}, + }, + expectError: false, + }, + + // ================================================================= + // connectionTimeout validation + // ================================================================= + { + name: "connectionTimeout: zero is valid (disables timeout)", + input: map[string]interface{}{"connectionTimeout": float64(0)}, + expectError: false, + }, + { + name: "connectionTimeout: valid minimum positive (1)", + input: map[string]interface{}{"connectionTimeout": float64(1)}, + expectError: false, + }, + { + name: "connectionTimeout: valid maximum (300)", + input: map[string]interface{}{"connectionTimeout": float64(300)}, + expectError: false, + }, + { + name: "connectionTimeout: negative is invalid", + input: map[string]interface{}{"connectionTimeout": float64(-1)}, + expectError: true, + errorText: "negative", + }, + { + name: "connectionTimeout: below minimum if positive (0.5)", + input: map[string]interface{}{"connectionTimeout": float64(0.5)}, + expectError: true, + errorText: "1 second", + }, + { + name: "connectionTimeout: above maximum (301)", + input: map[string]interface{}{"connectionTimeout": float64(301)}, + expectError: true, + errorText: "300", + }, + { + name: "connectionTimeout: string instead of number", + input: map[string]interface{}{"connectionTimeout": "30"}, + expectError: true, + errorText: "number", + }, + + // ================================================================= + // theme validation + // ================================================================= + { + name: "theme: empty string is valid", + input: map[string]interface{}{"theme": ""}, + expectError: false, + }, + { + name: "theme: 'light' is valid", + input: map[string]interface{}{"theme": "light"}, + expectError: false, + }, + { + name: "theme: 'dark' is valid", + input: map[string]interface{}{"theme": "dark"}, + expectError: false, + }, + { + name: "theme: invalid value", + input: map[string]interface{}{"theme": "purple"}, + expectError: true, + errorText: "light", // error message contains valid options + }, + { + name: "theme: number instead of string", + input: map[string]interface{}{"theme": float64(1)}, + expectError: true, + errorText: "string", + }, + { + name: "theme: boolean instead of string", + input: map[string]interface{}{"theme": true}, + expectError: true, + errorText: "string", + }, + + // ================================================================= + // updateChannel validation + // ================================================================= + { + name: "updateChannel: empty string is valid", + input: map[string]interface{}{"updateChannel": ""}, + expectError: false, + }, + { + name: "updateChannel: 'stable' is valid", + input: map[string]interface{}{"updateChannel": "stable"}, + expectError: false, + }, + { + name: "updateChannel: 'rc' is valid", + input: map[string]interface{}{"updateChannel": "rc"}, + expectError: false, + }, + { + name: "updateChannel: invalid value", + input: map[string]interface{}{"updateChannel": "beta"}, + expectError: true, + errorText: "stable", // error message contains valid options + }, + { + name: "updateChannel: number instead of string", + input: map[string]interface{}{"updateChannel": float64(1)}, + expectError: true, + errorText: "string", + }, + + // ================================================================= + // Complex/edge case scenarios + // ================================================================= + { + name: "multiple valid fields together", + input: map[string]interface{}{ + "pvePollingInterval": float64(60), + "pbsPollingInterval": float64(120), + "autoUpdateEnabled": true, + "theme": "dark", + "connectionTimeout": float64(30), + "backupPollingInterval": float64(3600), + "backupPollingEnabled": true, + "autoUpdateCheckInterval": float64(24), + }, + expectError: false, + }, + { + name: "complex discoveryConfig with multiple valid fields", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{ + "environment_override": "docker_host", + "subnet_allowlist": []interface{}{"192.168.1.0/24", "10.0.0.0/8"}, + "subnet_blocklist": []interface{}{"169.254.0.0/16"}, + "max_hosts_per_scan": float64(500), + "max_concurrent": float64(100), + "enable_reverse_dns": true, + "scan_gateways": false, + "dial_timeout_ms": float64(1500), + "http_timeout_ms": float64(3000), + }, + }, + expectError: false, + }, + { + name: "discoveryConfig: mixing camelCase and snake_case fields", + input: map[string]interface{}{ + "discoveryConfig": map[string]interface{}{ + "environment_override": "native", + "maxHostsPerScan": float64(1024), + "subnet_allowlist": []interface{}{"192.168.0.0/16"}, + "enableReverseDns": true, + }, + }, + expectError: false, + }, + { + name: "no fields provided (empty request)", + input: map[string]interface{}{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSystemSettings(&config.SystemSettings{}, tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got nil") + } else if tt.errorText != "" && !strings.Contains(err.Error(), tt.errorText) { + t.Errorf("expected error containing %q, got: %v", tt.errorText, err) + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +// TestValidateSystemSettings_DiscoveryConfigMissing tests the behavior when +// discoveryConfig is completely missing from the request (not provided at all). +func TestValidateSystemSettings_DiscoveryConfigMissing(t *testing.T) { + input := map[string]interface{}{ + "pvePollingInterval": float64(60), + // discoveryConfig is not provided at all + } + + err := validateSystemSettings(&config.SystemSettings{}, input) + if err != nil { + t.Errorf("expected no error when discoveryConfig is missing, got: %v", err) + } +} + +// TestValidateSystemSettings_BoundaryConditions tests exact boundary conditions +// to ensure off-by-one errors are caught. +func TestValidateSystemSettings_BoundaryConditions(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expectError bool + }{ + // PVE interval boundaries + {name: "pvePollingInterval: 9.999", input: map[string]interface{}{"pvePollingInterval": 9.999}, expectError: true}, + {name: "pvePollingInterval: 10.001", input: map[string]interface{}{"pvePollingInterval": 10.001}, expectError: false}, + {name: "pvePollingInterval: 3599.999", input: map[string]interface{}{"pvePollingInterval": 3599.999}, expectError: false}, + {name: "pvePollingInterval: 3600.001", input: map[string]interface{}{"pvePollingInterval": 3600.001}, expectError: true}, + + // Backup interval boundaries + {name: "backupPollingInterval: -0.001", input: map[string]interface{}{"backupPollingInterval": -0.001}, expectError: true}, + {name: "backupPollingInterval: 0.001", input: map[string]interface{}{"backupPollingInterval": 0.001}, expectError: true}, + {name: "backupPollingInterval: 9.999", input: map[string]interface{}{"backupPollingInterval": 9.999}, expectError: true}, + {name: "backupPollingInterval: 10.001", input: map[string]interface{}{"backupPollingInterval": 10.001}, expectError: false}, + {name: "backupPollingInterval: 604799.999", input: map[string]interface{}{"backupPollingInterval": 604799.999}, expectError: false}, + {name: "backupPollingInterval: 604800.001", input: map[string]interface{}{"backupPollingInterval": 604800.001}, expectError: true}, + + // Auto-update check interval boundaries + {name: "autoUpdateCheckInterval: 0.999", input: map[string]interface{}{"autoUpdateCheckInterval": 0.999}, expectError: true}, + {name: "autoUpdateCheckInterval: 1.001", input: map[string]interface{}{"autoUpdateCheckInterval": 1.001}, expectError: false}, + {name: "autoUpdateCheckInterval: 167.999", input: map[string]interface{}{"autoUpdateCheckInterval": 167.999}, expectError: false}, + {name: "autoUpdateCheckInterval: 168.001", input: map[string]interface{}{"autoUpdateCheckInterval": 168.001}, expectError: true}, + + // Connection timeout boundaries + {name: "connectionTimeout: 0.999", input: map[string]interface{}{"connectionTimeout": 0.999}, expectError: true}, + {name: "connectionTimeout: 1.001", input: map[string]interface{}{"connectionTimeout": 1.001}, expectError: false}, + {name: "connectionTimeout: 299.999", input: map[string]interface{}{"connectionTimeout": 299.999}, expectError: false}, + {name: "connectionTimeout: 300.001", input: map[string]interface{}{"connectionTimeout": 300.001}, expectError: true}, + + // Max concurrent boundaries + // Note: The validation is `value <= 0 || value > 1000`, so 0.999 is actually valid + { + name: "max_concurrent: 0.999", + input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"max_concurrent": 0.999}}, + expectError: false, + }, + { + name: "max_concurrent: 1000.001", + input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"max_concurrent": 1000.001}}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSystemSettings(&config.SystemSettings{}, tt.input) + if tt.expectError && err == nil { + t.Error("expected error but got nil") + } else if !tt.expectError && err != nil { + t.Errorf("expected no error but got: %v", err) + } + }) + } +}