Pulse/internal/config/config_utils_test.go
rcourtman 36d6279107 test: Add DiscoveryConfig UnmarshalJSON tests
Cover invalid JSON error path, modern field parsing, legacy field
parsing, and empty object default handling.
Coverage: 60% → 88.9%
2025-12-02 02:29:38 +00:00

786 lines
27 KiB
Go

package config
import (
"strings"
"testing"
)
func TestIsPasswordHashed(t *testing.T) {
// Generate a valid bcrypt hash for testing (60 chars, starts with $2)
validBcryptHash := "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
tests := []struct {
name string
password string
expected bool
}{
// Valid bcrypt hashes (60 chars, $2a/$2b/$2y prefix)
{"valid bcrypt 2a", validBcryptHash, true},
{"valid bcrypt 2b", "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", true},
{"valid bcrypt 2y", "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", true},
{"valid bcrypt different cost", "$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.BFB8hMSWy6s/FO", true},
// Invalid: wrong prefix
{"no $2 prefix", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456", false},
{"plain password", "mysecretpassword", false},
{"$1 prefix (MD5)", "$1$saltsalt$hashed", false},
{"$5 prefix (SHA-256)", "$5$rounds=5000$saltsalt$hash", false},
{"$6 prefix (SHA-512)", "$6$rounds=5000$saltsalt$hash", false},
// Invalid: wrong length
{"too short", "$2a$10$abc", false},
{"59 chars (truncated)", "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhW", false},
{"61 chars (too long)", "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWyX", false},
{"55 chars (truncated, logs warning)", "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL1", false},
// Edge cases
{"empty string", "", false},
{"just $2", "$2", false},
{"$2a$", "$2a$", false},
{"$2a$10$", "$2a$10$", false},
{"whitespace", " ", false},
{"$2 but short", "$2a$10$short", false},
// Real-world edge cases
{"hash with newline", validBcryptHash + "\n", false},
{"hash with trailing space", validBcryptHash + " ", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsPasswordHashed(tt.password)
if result != tt.expected {
t.Errorf("IsPasswordHashed(%q) = %v, want %v", tt.password, result, tt.expected)
}
})
}
}
func TestIsValidDiscoveryEnvironment(t *testing.T) {
tests := []struct {
name string
value string
expected bool
}{
// Valid values (case insensitive)
{"empty string (auto)", "", true},
{"auto lowercase", "auto", true},
{"AUTO uppercase", "AUTO", true},
{"Auto mixed", "Auto", true},
{"native", "native", true},
{"NATIVE", "NATIVE", true},
{"docker_host", "docker_host", true},
{"DOCKER_HOST", "DOCKER_HOST", true},
{"docker_bridge", "docker_bridge", true},
{"DOCKER_BRIDGE", "DOCKER_BRIDGE", true},
{"lxc_privileged", "lxc_privileged", true},
{"LXC_PRIVILEGED", "LXC_PRIVILEGED", true},
{"lxc_unprivileged", "lxc_unprivileged", true},
{"LXC_UNPRIVILEGED", "LXC_UNPRIVILEGED", true},
// Valid with whitespace trimming
{"auto with leading space", " auto", true},
{"auto with trailing space", "auto ", true},
{"auto with both spaces", " auto ", true},
{"native with tabs", "\tnative\t", true},
// Invalid values
{"unknown value", "unknown", false},
{"typo dockerhost", "dockerhost", false},
{"typo docker-host", "docker-host", false},
{"kubernetes", "kubernetes", false},
{"vm", "vm", false},
{"baremetal", "baremetal", false},
{"container", "container", false},
{"partial match docker", "docker", false},
{"partial match lxc", "lxc", false},
{"underscore only", "_", false},
{"random string", "xyzabc", false},
{"numeric", "123", false},
{"special chars", "docker@host", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsValidDiscoveryEnvironment(tt.value)
if result != tt.expected {
t.Errorf("IsValidDiscoveryEnvironment(%q) = %v, want %v", tt.value, result, tt.expected)
}
})
}
}
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
// Basic splitting
{"single value", "one", []string{"one"}},
{"two values", "one,two", []string{"one", "two"}},
{"three values", "one,two,three", []string{"one", "two", "three"}},
// Whitespace handling
{"value with leading space", " one", []string{"one"}},
{"value with trailing space", "one ", []string{"one"}},
{"values with spaces around comma", "one , two", []string{"one", "two"}},
{"values with spaces", " one , two , three ", []string{"one", "two", "three"}},
{"tabs and spaces", "\tone\t,\ttwo\t", []string{"one", "two"}},
// Empty handling
{"empty string", "", []string{}},
{"only comma", ",", []string{}},
{"multiple commas", ",,,", []string{}},
{"comma with spaces", " , , ", []string{}},
{"empty between values", "one,,two", []string{"one", "two"}},
{"empty at start", ",one,two", []string{"one", "two"}},
{"empty at end", "one,two,", []string{"one", "two"}},
// Real-world examples
{"CIDR list", "10.0.0.0/8, 192.168.0.0/16", []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"hostnames", "node1.local, node2.local, node3.local", []string{"node1.local", "node2.local", "node3.local"}},
{"mixed content", "http://a.com, https://b.com", []string{"http://a.com", "https://b.com"}},
// Edge cases
{"single space", " ", []string{}},
{"value is just spaces", " ", []string{}},
{"comma surrounded by spaces value", "one, ,two", []string{"one", "two"}},
{"long value", strings.Repeat("a", 100), []string{strings.Repeat("a", 100)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitAndTrim(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("splitAndTrim(%q) returned %d items, want %d", tt.input, len(result), len(tt.expected))
t.Errorf("got: %v, want: %v", result, tt.expected)
return
}
for i, v := range result {
if v != tt.expected[i] {
t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, v, tt.expected[i])
}
}
})
}
}
func TestCloneDiscoveryConfig(t *testing.T) {
tests := []struct {
name string
cfg DiscoveryConfig
}{
{
name: "empty config",
cfg: DiscoveryConfig{},
},
{
name: "full config with all fields",
cfg: DiscoveryConfig{
EnvironmentOverride: "docker_host",
SubnetAllowlist: []string{"10.0.0.0/8", "192.168.0.0/16"},
SubnetBlocklist: []string{"172.16.0.0/12"},
MaxHostsPerScan: 512,
MaxConcurrent: 25,
EnableReverseDNS: true,
ScanGateways: false,
DialTimeout: 2000,
HTTPTimeout: 3000,
},
},
{
name: "nil slices",
cfg: DiscoveryConfig{
EnvironmentOverride: "native",
SubnetAllowlist: nil,
SubnetBlocklist: nil,
MaxHostsPerScan: 100,
},
},
{
name: "empty slices",
cfg: DiscoveryConfig{
SubnetAllowlist: []string{},
SubnetBlocklist: []string{},
},
},
{
name: "single element slices",
cfg: DiscoveryConfig{
SubnetAllowlist: []string{"10.0.0.0/8"},
SubnetBlocklist: []string{"169.254.0.0/16"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clone := CloneDiscoveryConfig(tt.cfg)
// Verify all scalar fields match
if clone.EnvironmentOverride != tt.cfg.EnvironmentOverride {
t.Errorf("EnvironmentOverride: got %q, want %q", clone.EnvironmentOverride, tt.cfg.EnvironmentOverride)
}
if clone.MaxHostsPerScan != tt.cfg.MaxHostsPerScan {
t.Errorf("MaxHostsPerScan: got %d, want %d", clone.MaxHostsPerScan, tt.cfg.MaxHostsPerScan)
}
if clone.MaxConcurrent != tt.cfg.MaxConcurrent {
t.Errorf("MaxConcurrent: got %d, want %d", clone.MaxConcurrent, tt.cfg.MaxConcurrent)
}
if clone.EnableReverseDNS != tt.cfg.EnableReverseDNS {
t.Errorf("EnableReverseDNS: got %v, want %v", clone.EnableReverseDNS, tt.cfg.EnableReverseDNS)
}
if clone.ScanGateways != tt.cfg.ScanGateways {
t.Errorf("ScanGateways: got %v, want %v", clone.ScanGateways, tt.cfg.ScanGateways)
}
if clone.DialTimeout != tt.cfg.DialTimeout {
t.Errorf("DialTimeout: got %d, want %d", clone.DialTimeout, tt.cfg.DialTimeout)
}
if clone.HTTPTimeout != tt.cfg.HTTPTimeout {
t.Errorf("HTTPTimeout: got %d, want %d", clone.HTTPTimeout, tt.cfg.HTTPTimeout)
}
// Verify slice contents match
if len(clone.SubnetAllowlist) != len(tt.cfg.SubnetAllowlist) {
t.Errorf("SubnetAllowlist length: got %d, want %d", len(clone.SubnetAllowlist), len(tt.cfg.SubnetAllowlist))
} else {
for i, v := range clone.SubnetAllowlist {
if v != tt.cfg.SubnetAllowlist[i] {
t.Errorf("SubnetAllowlist[%d]: got %q, want %q", i, v, tt.cfg.SubnetAllowlist[i])
}
}
}
if len(clone.SubnetBlocklist) != len(tt.cfg.SubnetBlocklist) {
t.Errorf("SubnetBlocklist length: got %d, want %d", len(clone.SubnetBlocklist), len(tt.cfg.SubnetBlocklist))
} else {
for i, v := range clone.SubnetBlocklist {
if v != tt.cfg.SubnetBlocklist[i] {
t.Errorf("SubnetBlocklist[%d]: got %q, want %q", i, v, tt.cfg.SubnetBlocklist[i])
}
}
}
})
}
}
// TestCloneDiscoveryConfig_SliceIndependence verifies that modifying the clone
// does not affect the original (deep copy verification).
func TestCloneDiscoveryConfig_SliceIndependence(t *testing.T) {
original := DiscoveryConfig{
SubnetAllowlist: []string{"10.0.0.0/8", "192.168.0.0/16"},
SubnetBlocklist: []string{"172.16.0.0/12", "169.254.0.0/16"},
}
clone := CloneDiscoveryConfig(original)
// Modify the clone's slices
clone.SubnetAllowlist[0] = "modified"
clone.SubnetBlocklist[0] = "modified"
// Original should be unchanged
if original.SubnetAllowlist[0] != "10.0.0.0/8" {
t.Errorf("Original SubnetAllowlist was modified: got %q", original.SubnetAllowlist[0])
}
if original.SubnetBlocklist[0] != "172.16.0.0/12" {
t.Errorf("Original SubnetBlocklist was modified: got %q", original.SubnetBlocklist[0])
}
// Append to clone slices
clone.SubnetAllowlist = append(clone.SubnetAllowlist, "new")
// Original length should be unchanged
if len(original.SubnetAllowlist) != 2 {
t.Errorf("Original SubnetAllowlist length changed: got %d", len(original.SubnetAllowlist))
}
}
// TestCloneDiscoveryConfig_NilSliceIndependence verifies nil slices remain nil in clone.
func TestCloneDiscoveryConfig_NilSliceIndependence(t *testing.T) {
original := DiscoveryConfig{
SubnetAllowlist: nil,
SubnetBlocklist: nil,
}
clone := CloneDiscoveryConfig(original)
if clone.SubnetAllowlist != nil {
t.Errorf("Expected nil SubnetAllowlist, got %v", clone.SubnetAllowlist)
}
if clone.SubnetBlocklist != nil {
t.Errorf("Expected nil SubnetBlocklist, got %v", clone.SubnetBlocklist)
}
}
func TestNormalizeDiscoveryConfig(t *testing.T) {
defaults := DefaultDiscoveryConfig()
tests := []struct {
name string
cfg DiscoveryConfig
expected DiscoveryConfig
}{
{
name: "empty config gets defaults",
cfg: DiscoveryConfig{},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "valid environment preserved",
cfg: DiscoveryConfig{
EnvironmentOverride: "docker_host",
},
expected: DiscoveryConfig{
EnvironmentOverride: "docker_host",
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "invalid environment falls back to auto",
cfg: DiscoveryConfig{
EnvironmentOverride: "invalid_env",
},
expected: DiscoveryConfig{
EnvironmentOverride: "auto",
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "environment with whitespace trimmed",
cfg: DiscoveryConfig{
EnvironmentOverride: " native ",
},
expected: DiscoveryConfig{
EnvironmentOverride: "native",
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "positive values preserved",
cfg: DiscoveryConfig{
MaxHostsPerScan: 100,
MaxConcurrent: 10,
DialTimeout: 500,
HTTPTimeout: 1000,
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: 100,
MaxConcurrent: 10,
DialTimeout: 500,
HTTPTimeout: 1000,
},
},
{
name: "zero values get defaults",
cfg: DiscoveryConfig{
MaxHostsPerScan: 0,
MaxConcurrent: 0,
DialTimeout: 0,
HTTPTimeout: 0,
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "negative values get defaults",
cfg: DiscoveryConfig{
MaxHostsPerScan: -1,
MaxConcurrent: -10,
DialTimeout: -100,
HTTPTimeout: -1000,
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "subnet allowlist sanitized",
cfg: DiscoveryConfig{
SubnetAllowlist: []string{" 10.0.0.0/8 ", "10.0.0.0/8", "", "192.168.0.0/16"},
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{"10.0.0.0/8", "192.168.0.0/16"},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "subnet blocklist sanitized",
cfg: DiscoveryConfig{
SubnetBlocklist: []string{" 172.16.0.0/12 ", "", "172.16.0.0/12"},
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: []string{"172.16.0.0/12"},
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "nil blocklist gets defaults",
cfg: DiscoveryConfig{
SubnetBlocklist: nil,
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "empty blocklist after sanitization stays empty",
cfg: DiscoveryConfig{
SubnetBlocklist: []string{"", " ", ""},
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: []string{}, // sanitizeCIDRList returns []string{} not nil
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "boolean fields preserved",
cfg: DiscoveryConfig{
EnableReverseDNS: false,
ScanGateways: true,
},
expected: DiscoveryConfig{
EnvironmentOverride: defaults.EnvironmentOverride,
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
EnableReverseDNS: false,
ScanGateways: true,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
{
name: "all valid environments",
cfg: DiscoveryConfig{
EnvironmentOverride: "lxc_privileged",
},
expected: DiscoveryConfig{
EnvironmentOverride: "lxc_privileged",
SubnetAllowlist: []string{},
SubnetBlocklist: defaults.SubnetBlocklist,
MaxHostsPerScan: defaults.MaxHostsPerScan,
MaxConcurrent: defaults.MaxConcurrent,
DialTimeout: defaults.DialTimeout,
HTTPTimeout: defaults.HTTPTimeout,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeDiscoveryConfig(tt.cfg)
if result.EnvironmentOverride != tt.expected.EnvironmentOverride {
t.Errorf("EnvironmentOverride: got %q, want %q", result.EnvironmentOverride, tt.expected.EnvironmentOverride)
}
if result.MaxHostsPerScan != tt.expected.MaxHostsPerScan {
t.Errorf("MaxHostsPerScan: got %d, want %d", result.MaxHostsPerScan, tt.expected.MaxHostsPerScan)
}
if result.MaxConcurrent != tt.expected.MaxConcurrent {
t.Errorf("MaxConcurrent: got %d, want %d", result.MaxConcurrent, tt.expected.MaxConcurrent)
}
if result.EnableReverseDNS != tt.expected.EnableReverseDNS {
t.Errorf("EnableReverseDNS: got %v, want %v", result.EnableReverseDNS, tt.expected.EnableReverseDNS)
}
if result.ScanGateways != tt.expected.ScanGateways {
t.Errorf("ScanGateways: got %v, want %v", result.ScanGateways, tt.expected.ScanGateways)
}
if result.DialTimeout != tt.expected.DialTimeout {
t.Errorf("DialTimeout: got %d, want %d", result.DialTimeout, tt.expected.DialTimeout)
}
if result.HTTPTimeout != tt.expected.HTTPTimeout {
t.Errorf("HTTPTimeout: got %d, want %d", result.HTTPTimeout, tt.expected.HTTPTimeout)
}
// Check slice equality
if len(result.SubnetAllowlist) != len(tt.expected.SubnetAllowlist) {
t.Errorf("SubnetAllowlist length: got %d, want %d", len(result.SubnetAllowlist), len(tt.expected.SubnetAllowlist))
} else {
for i, v := range result.SubnetAllowlist {
if v != tt.expected.SubnetAllowlist[i] {
t.Errorf("SubnetAllowlist[%d]: got %q, want %q", i, v, tt.expected.SubnetAllowlist[i])
}
}
}
if len(result.SubnetBlocklist) != len(tt.expected.SubnetBlocklist) {
t.Errorf("SubnetBlocklist length: got %d, want %d", len(result.SubnetBlocklist), len(tt.expected.SubnetBlocklist))
} else {
for i, v := range result.SubnetBlocklist {
if v != tt.expected.SubnetBlocklist[i] {
t.Errorf("SubnetBlocklist[%d]: got %q, want %q", i, v, tt.expected.SubnetBlocklist[i])
}
}
}
})
}
}
// TestNormalizeDiscoveryConfig_DoesNotModifyInput verifies the original config is not mutated.
func TestNormalizeDiscoveryConfig_DoesNotModifyInput(t *testing.T) {
original := DiscoveryConfig{
EnvironmentOverride: " docker_host ",
SubnetAllowlist: []string{" 10.0.0.0/8 ", "192.168.0.0/16"},
SubnetBlocklist: []string{" 172.16.0.0/12 "},
MaxHostsPerScan: -1,
}
// Store original values
origEnv := original.EnvironmentOverride
origAllowlist := make([]string, len(original.SubnetAllowlist))
copy(origAllowlist, original.SubnetAllowlist)
origBlocklist := make([]string, len(original.SubnetBlocklist))
copy(origBlocklist, original.SubnetBlocklist)
origMaxHosts := original.MaxHostsPerScan
_ = NormalizeDiscoveryConfig(original)
// Verify original is unchanged
if original.EnvironmentOverride != origEnv {
t.Errorf("Original EnvironmentOverride modified: got %q, want %q", original.EnvironmentOverride, origEnv)
}
if original.MaxHostsPerScan != origMaxHosts {
t.Errorf("Original MaxHostsPerScan modified: got %d, want %d", original.MaxHostsPerScan, origMaxHosts)
}
for i, v := range original.SubnetAllowlist {
if v != origAllowlist[i] {
t.Errorf("Original SubnetAllowlist[%d] modified: got %q, want %q", i, v, origAllowlist[i])
}
}
for i, v := range original.SubnetBlocklist {
if v != origBlocklist[i] {
t.Errorf("Original SubnetBlocklist[%d] modified: got %q, want %q", i, v, origBlocklist[i])
}
}
}
func TestSanitizeCIDRList(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
// Nil and empty handling
{"nil input", nil, nil},
{"empty slice", []string{}, nil},
// Single entry
{"single valid entry", []string{"10.0.0.0/8"}, []string{"10.0.0.0/8"}},
{"single entry with leading space", []string{" 10.0.0.0/8"}, []string{"10.0.0.0/8"}},
{"single entry with trailing space", []string{"10.0.0.0/8 "}, []string{"10.0.0.0/8"}},
{"single entry with both spaces", []string{" 10.0.0.0/8 "}, []string{"10.0.0.0/8"}},
{"single entry with tabs", []string{"\t10.0.0.0/8\t"}, []string{"10.0.0.0/8"}},
// Multiple valid entries (no duplicates)
{"two valid entries", []string{"10.0.0.0/8", "192.168.0.0/16"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"three valid entries", []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}, []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}},
// Duplicates at various positions
{"duplicate at end", []string{"10.0.0.0/8", "192.168.0.0/16", "10.0.0.0/8"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"duplicate at start", []string{"10.0.0.0/8", "10.0.0.0/8", "192.168.0.0/16"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"duplicate in middle", []string{"10.0.0.0/8", "192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"}, []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}},
{"all duplicates", []string{"10.0.0.0/8", "10.0.0.0/8", "10.0.0.0/8"}, []string{"10.0.0.0/8"}},
{"multiple duplicates", []string{"a", "b", "a", "c", "b", "d", "a"}, []string{"a", "b", "c", "d"}},
// Whitespace variations
{"entry with leading whitespace", []string{" 10.0.0.0/8", "192.168.0.0/16"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"entry with trailing whitespace", []string{"10.0.0.0/8 ", "192.168.0.0/16"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"mixed whitespace", []string{" 10.0.0.0/8", "192.168.0.0/16 ", " 172.16.0.0/12 "}, []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}},
{"tabs and spaces mixed", []string{"\t10.0.0.0/8 ", " \t192.168.0.0/16\t ", " 172.16.0.0/12"}, []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}},
// Entries that become duplicates after trimming
{"duplicates after trim", []string{" 10.0.0.0/8", "10.0.0.0/8 ", " 10.0.0.0/8 "}, []string{"10.0.0.0/8"}},
{"duplicates after trim with others", []string{" 10.0.0.0/8", "192.168.0.0/16", "10.0.0.0/8 "}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
// All empty/whitespace entries
{"single empty string", []string{""}, []string{}},
{"multiple empty strings", []string{"", "", ""}, []string{}},
{"only whitespace", []string{" ", " ", "\t", " \t "}, []string{}},
{"mixed empty and whitespace", []string{"", " ", "", "\t"}, []string{}},
// Mixed valid and empty entries
{"valid with empty at start", []string{"", "10.0.0.0/8", "192.168.0.0/16"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"valid with empty in middle", []string{"10.0.0.0/8", "", "192.168.0.0/16"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"valid with empty at end", []string{"10.0.0.0/8", "192.168.0.0/16", ""}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"valid with multiple empty", []string{"", "10.0.0.0/8", "", "192.168.0.0/16", "", ""}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
{"valid with whitespace entries", []string{" ", "10.0.0.0/8", " ", "192.168.0.0/16", "\t"}, []string{"10.0.0.0/8", "192.168.0.0/16"}},
// Order preservation
{"order preserved basic", []string{"z", "a", "m"}, []string{"z", "a", "m"}},
{"order preserved with duplicates", []string{"z", "a", "m", "z", "a"}, []string{"z", "a", "m"}},
{"order preserved complex", []string{"third", "first", "second", "third", "first"}, []string{"third", "first", "second"}},
{"order preserved after trim", []string{" c ", "a", " b ", "c", " a "}, []string{"c", "a", "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeCIDRList(tt.input)
// Check nil vs empty slice distinction
if tt.expected == nil {
if result != nil {
t.Errorf("sanitizeCIDRList(%v) = %v, want nil", tt.input, result)
}
return
}
if result == nil {
t.Errorf("sanitizeCIDRList(%v) = nil, want %v", tt.input, tt.expected)
return
}
// Check length
if len(result) != len(tt.expected) {
t.Errorf("sanitizeCIDRList(%v) returned %d items, want %d", tt.input, len(result), len(tt.expected))
t.Errorf("got: %v, want: %v", result, tt.expected)
return
}
// Check each element
for i, v := range result {
if v != tt.expected[i] {
t.Errorf("sanitizeCIDRList(%v)[%d] = %q, want %q", tt.input, i, v, tt.expected[i])
}
}
})
}
}
func TestDiscoveryConfigUnmarshalJSON_InvalidJSON(t *testing.T) {
var cfg DiscoveryConfig
err := cfg.UnmarshalJSON([]byte(`{invalid json`))
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestDiscoveryConfigUnmarshalJSON_ModernFields(t *testing.T) {
var cfg DiscoveryConfig
data := `{
"environment_override": "docker_host",
"subnet_allowlist": ["192.168.1.0/24"],
"subnet_blocklist": ["10.0.0.0/8"],
"max_hosts_per_scan": 100,
"max_concurrent": 5,
"enable_reverse_dns": true,
"scan_gateways": false,
"dial_timeout_ms": 2000,
"http_timeout_ms": 5000
}`
err := cfg.UnmarshalJSON([]byte(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.EnvironmentOverride != "docker_host" {
t.Errorf("EnvironmentOverride = %q, want 'docker_host'", cfg.EnvironmentOverride)
}
if len(cfg.SubnetAllowlist) != 1 || cfg.SubnetAllowlist[0] != "192.168.1.0/24" {
t.Errorf("SubnetAllowlist = %v, want ['192.168.1.0/24']", cfg.SubnetAllowlist)
}
if cfg.MaxHostsPerScan != 100 {
t.Errorf("MaxHostsPerScan = %d, want 100", cfg.MaxHostsPerScan)
}
}
func TestDiscoveryConfigUnmarshalJSON_LegacyFields(t *testing.T) {
var cfg DiscoveryConfig
data := `{
"environmentOverride": "lxc_privileged",
"subnetAllowlist": ["172.16.0.0/12"],
"maxHostsPerScan": 50,
"enableReverseDns": false
}`
err := cfg.UnmarshalJSON([]byte(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.EnvironmentOverride != "lxc_privileged" {
t.Errorf("EnvironmentOverride = %q, want 'lxc_privileged'", cfg.EnvironmentOverride)
}
if len(cfg.SubnetAllowlist) != 1 || cfg.SubnetAllowlist[0] != "172.16.0.0/12" {
t.Errorf("SubnetAllowlist = %v, want ['172.16.0.0/12']", cfg.SubnetAllowlist)
}
if cfg.MaxHostsPerScan != 50 {
t.Errorf("MaxHostsPerScan = %d, want 50", cfg.MaxHostsPerScan)
}
}
func TestDiscoveryConfigUnmarshalJSON_EmptyObject(t *testing.T) {
var cfg DiscoveryConfig
err := cfg.UnmarshalJSON([]byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should get defaults
defaults := DefaultDiscoveryConfig()
if cfg.MaxHostsPerScan != defaults.MaxHostsPerScan {
t.Errorf("MaxHostsPerScan = %d, want default %d", cfg.MaxHostsPerScan, defaults.MaxHostsPerScan)
}
}