Files
pulse/internal/api/system_settings_validate_test.go
T
courtmanr@gmail.com 193c96afc3 Remove dead autoUpdateCheckInterval and autoUpdateTime settings
Found while triaging #1643 and #1637: both fields were stored via
system.json and accepted, validated, and echoed by the settings API,
but nothing ever consumed them. The unattended update schedule is owned
entirely by the systemd timer install.sh renders, and no UI control ever
set the fields, so the API persisted a schedule preference that could
never take effect.

Remove the fields from config.Config, SystemSettings, the settings
handlers, and the frontend config type, along with the interval
validation and the .env AUTO_UPDATE_CHECK_INTERVAL rewrite (a legacy
line is now preserved verbatim). Legacy clients that still send the
keys get them silently ignored instead of validated, and a system.json
written before the removal still loads cleanly - both behaviors are
pinned by new tests. User docs no longer describe the phantom schedule
settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:13:37 +01:00

1072 lines
36 KiB
Go

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",
},
// =================================================================
// 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{}{"discoveryConfig": map[string]interface{}{"maxHostsPerScan": 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{}{"environmentOverride": ""},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: 'auto' is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "auto"},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: 'native' is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "native"},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: 'docker-host' is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "docker-host"},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: 'docker-bridge' is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "docker-bridge"},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: 'lxc-privileged' is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "lxc-privileged"},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: 'lxc-unprivileged' is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "lxc-unprivileged"},
},
expectError: false,
},
{
name: "discoveryConfig.environment_override: legacy underscore alias rejected",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "docker_host"},
},
expectError: true,
errorText: "invalid discovery environment",
},
{
name: "discoveryConfig.environment_override: invalid value",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": "invalid_env"},
},
expectError: true,
errorText: "invalid discovery environment",
},
{
name: "discoveryConfig.environment_override: number instead of string",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"environmentOverride": 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{}{"subnetAllowlist": []interface{}{}},
},
expectError: false,
},
{
name: "discoveryConfig.subnet_allowlist: valid CIDR",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"subnetAllowlist": []interface{}{"192.168.1.0/24"}},
},
expectError: false,
},
{
name: "discoveryConfig.subnet_allowlist: multiple valid CIDRs",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"subnetAllowlist": []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{}{"subnetAllowlist": []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{}{"subnetAllowlist": []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{}{"subnetAllowlist": []interface{}{float64(123)}},
},
expectError: true,
errorText: "string",
},
{
name: "discoveryConfig.subnet_allowlist: not an array",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"subnetAllowlist": "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{}{"subnetBlocklist": []interface{}{}},
},
expectError: false,
},
{
name: "discoveryConfig.subnet_blocklist: valid CIDR",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"subnetBlocklist": []interface{}{"169.254.0.0/16"}},
},
expectError: false,
},
{
name: "discoveryConfig.subnet_blocklist: multiple valid CIDRs",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"subnetBlocklist": []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{}{"subnetBlocklist": []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{}{"subnetBlocklist": []interface{}{float64(456)}},
},
expectError: true,
errorText: "string",
},
{
name: "discoveryConfig.subnet_blocklist: not an array",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"subnetBlocklist": "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{}{"maxHostsPerScan": float64(100)},
},
expectError: false,
},
{
name: "discoveryConfig.max_hosts_per_scan: large value",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxHostsPerScan": float64(10000)},
},
expectError: false,
},
{
name: "discoveryConfig.max_hosts_per_scan: zero is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxHostsPerScan": 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{}{"maxHostsPerScan": 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{}{"maxHostsPerScan": "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{}{"maxConcurrent": float64(1)},
},
expectError: false,
},
{
name: "discoveryConfig.max_concurrent: valid maximum (1000)",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxConcurrent": float64(1000)},
},
expectError: false,
},
{
name: "discoveryConfig.max_concurrent: valid middle value (50)",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxConcurrent": float64(50)},
},
expectError: false,
},
{
name: "discoveryConfig.max_concurrent: zero is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxConcurrent": float64(0)},
},
expectError: true,
errorText: "between 1 and 1000",
},
{
name: "discoveryConfig.max_concurrent: negative is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxConcurrent": float64(-1)},
},
expectError: true,
errorText: "between 1 and 1000",
},
{
name: "discoveryConfig.max_concurrent: above maximum (1001)",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"maxConcurrent": 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{}{"maxConcurrent": "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{}{"enableReverseDns": true},
},
expectError: false,
},
{
name: "discoveryConfig.enable_reverse_dns: false is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"enableReverseDns": false},
},
expectError: false,
},
{
name: "discoveryConfig.enable_reverse_dns: string instead of bool",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"enableReverseDns": "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{}{"scanGateways": true},
},
expectError: false,
},
{
name: "discoveryConfig.scan_gateways: false is valid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"scanGateways": false},
},
expectError: false,
},
{
name: "discoveryConfig.scan_gateways: string instead of bool",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"scanGateways": "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{}{"dialTimeoutMs": float64(1000)},
},
expectError: false,
},
{
name: "discoveryConfig.dial_timeout_ms: minimum positive (1)",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"dialTimeoutMs": float64(1)},
},
expectError: false,
},
{
name: "discoveryConfig.dial_timeout_ms: zero is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"dialTimeoutMs": float64(0)},
},
expectError: true,
errorText: "greater than zero",
},
{
name: "discoveryConfig.dial_timeout_ms: negative is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"dialTimeoutMs": 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{}{"dialTimeoutMs": "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{}{"httpTimeoutMs": float64(2000)},
},
expectError: false,
},
{
name: "discoveryConfig.http_timeout_ms: minimum positive (1)",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"httpTimeoutMs": float64(1)},
},
expectError: false,
},
{
name: "discoveryConfig.http_timeout_ms: zero is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"httpTimeoutMs": float64(0)},
},
expectError: true,
errorText: "greater than zero",
},
{
name: "discoveryConfig.http_timeout_ms: negative is invalid",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{"httpTimeoutMs": 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{}{"httpTimeoutMs": "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,
},
expectError: false,
},
{
name: "complex discoveryConfig with multiple valid fields",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{
"environmentOverride": "docker-host",
"subnetAllowlist": []interface{}{"192.168.1.0/24", "10.0.0.0/8"},
"subnetBlocklist": []interface{}{"169.254.0.0/16"},
"maxHostsPerScan": float64(500),
"maxConcurrent": float64(100),
"enableReverseDns": true,
"scanGateways": false,
"dialTimeoutMs": float64(1500),
"httpTimeoutMs": float64(3000),
},
},
expectError: false,
},
{
name: "discoveryConfig: mixing camelCase and snake_case fields",
input: map[string]interface{}{
"discoveryConfig": map[string]interface{}{
"environmentOverride": "native",
"maxHostsPerScan": float64(1024),
"subnetAllowlist": []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},
// 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 - fractional values are rejected since it's a goroutine count
{
name: "max_concurrent: 0.999 fractional rejected",
input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"maxConcurrent": 0.999}},
expectError: true,
},
{
name: "max_concurrent: 1000.001 fractional rejected",
input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"maxConcurrent": 1000.001}},
expectError: true,
},
{
name: "max_concurrent: 5.5 fractional rejected",
input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"maxConcurrent": 5.5}},
expectError: true,
},
{
name: "max_hosts_per_scan: 5.5 fractional rejected",
input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"maxHostsPerScan": 5.5}},
expectError: true,
},
{
name: "dial_timeout_ms: 1.5 fractional rejected",
input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"dialTimeoutMs": 1.5}},
expectError: true,
},
{
name: "http_timeout_ms: 2.5 fractional rejected",
input: map[string]interface{}{"discoveryConfig": map[string]interface{}{"httpTimeoutMs": 2.5}},
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)
}
})
}
}