package config import ( "os" "path/filepath" "strings" "testing" "ja4sentinel/api" ) func TestParsePorts(t *testing.T) { tests := []struct { name string input string want []uint16 }{ { name: "single port", input: "443", want: []uint16{443}, }, { name: "multiple ports", input: "443, 8443, 9443", want: []uint16{443, 8443, 9443}, }, { name: "empty string", input: "", want: nil, }, { name: "with spaces", input: " 443 , 8443 ", want: []uint16{443, 8443}, }, { name: "invalid port ignored", input: "443, invalid, 8443", want: []uint16{443, 8443}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parsePorts(tt.input) if len(got) != len(tt.want) { t.Errorf("parsePorts() length = %v, want %v", len(got), len(tt.want)) return } for i, v := range got { if v != tt.want[i] { t.Errorf("parsePorts()[%d] = %v, want %v", i, v, tt.want[i]) } } }) } } func TestParsePorts_DeduplicateAndIgnoreZero(t *testing.T) { got := parsePorts("443, 0, 443, 8443") want := []uint16{443, 8443} if len(got) != len(want) { t.Fatalf("parsePorts() length = %d, want %d (got: %v)", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Fatalf("parsePorts()[%d] = %d, want %d", i, got[i], want[i]) } } } func TestMergeConfigs(t *testing.T) { base := api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, BPFFilter: "", FlowTimeoutSec: 30, PacketBufferSize: 1000, }, Outputs: []api.OutputConfig{}, } override := api.AppConfig{ Core: api.Config{ Interface: "lo", ListenPorts: []uint16{8443}, BPFFilter: "tcp", FlowTimeoutSec: 60, PacketBufferSize: 2000, }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true, AsyncBuffer: 5000}, }, } result := mergeConfigs(base, override) if result.Core.Interface != "lo" { t.Errorf("Interface = %v, want lo", result.Core.Interface) } if len(result.Core.ListenPorts) != 1 || result.Core.ListenPorts[0] != 8443 { t.Errorf("ListenPorts = %v, want [8443]", result.Core.ListenPorts) } if result.Core.BPFFilter != "tcp" { t.Errorf("BPFFilter = %v, want tcp", result.Core.BPFFilter) } if len(result.Outputs) != 1 { t.Errorf("Outputs length = %v, want 1", len(result.Outputs)) } if result.Core.FlowTimeoutSec != 60 { t.Errorf("FlowTimeoutSec = %v, want 60", result.Core.FlowTimeoutSec) } if result.Core.PacketBufferSize != 2000 { t.Errorf("PacketBufferSize = %v, want 2000", result.Core.PacketBufferSize) } if result.Outputs[0].AsyncBuffer != 5000 { t.Errorf("Outputs[0].AsyncBuffer = %v, want 5000", result.Outputs[0].AsyncBuffer) } } func TestValidate(t *testing.T) { loader := &LoaderImpl{} tests := []struct { name string config api.AppConfig wantErr bool }{ { name: "valid config", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 1000, }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true}, }, }, wantErr: false, }, { name: "empty interface", config: api.AppConfig{ Core: api.Config{ Interface: "", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 1000, }, }, wantErr: true, }, { name: "no listen ports", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{}, FlowTimeoutSec: 30, PacketBufferSize: 1000, }, }, wantErr: true, }, { name: "output with empty type", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 1000, }, Outputs: []api.OutputConfig{ {Type: "", Enabled: true}, }, }, wantErr: true, }, { name: "listen port zero", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{0}, FlowTimeoutSec: 30, PacketBufferSize: 1000, }, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := loader.validate(tt.config) if (err != nil) != tt.wantErr { t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestValidate_InvalidCoreBounds(t *testing.T) { loader := &LoaderImpl{} tests := []struct { name string cfg api.AppConfig hasErr bool }{ { name: "timeout zero", cfg: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 0, PacketBufferSize: 1000, }, }, hasErr: true, }, { name: "timeout too high", cfg: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 301, PacketBufferSize: 1000, }, }, hasErr: true, }, { name: "buffer zero", cfg: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 0, }, }, hasErr: true, }, { name: "buffer too high", cfg: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 1_000_001, }, }, hasErr: true, }, { name: "valid bounds", cfg: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 1000, }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true}, }, }, hasErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := loader.validate(tt.cfg) if (err != nil) != tt.hasErr { t.Fatalf("validate() error = %v, wantErr %v", err, tt.hasErr) } }) } } func TestValidate_InvalidOutputs(t *testing.T) { loader := &LoaderImpl{} baseCore := api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, FlowTimeoutSec: 30, PacketBufferSize: 1000, } tests := []struct { name string outputs []api.OutputConfig wantErr bool }{ { name: "unknown output type", outputs: []api.OutputConfig{ {Type: "unknown", Enabled: true}, }, wantErr: true, }, { name: "file without path", outputs: []api.OutputConfig{ {Type: "file", Enabled: true, Params: map[string]string{}}, }, wantErr: true, }, { name: "unix socket without socket_path", outputs: []api.OutputConfig{ {Type: "unix_socket", Enabled: true, Params: map[string]string{}}, }, wantErr: true, }, { name: "valid file output", outputs: []api.OutputConfig{ {Type: "file", Enabled: true, Params: map[string]string{"path": "/tmp/x.log"}}, }, wantErr: false, }, { name: "valid unix socket output", outputs: []api.OutputConfig{ {Type: "unix_socket", Enabled: true, Params: map[string]string{"socket_path": "/tmp/x.sock"}}, }, wantErr: false, }, { name: "valid stdout output", outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true}, }, wantErr: false, }, { name: "output with AsyncBuffer zero (default)", outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true, AsyncBuffer: 0}, }, wantErr: false, }, { name: "output with custom AsyncBuffer", outputs: []api.OutputConfig{ {Type: "unix_socket", Enabled: true, AsyncBuffer: 5000, Params: map[string]string{"socket_path": "/tmp/x.sock"}}, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := api.AppConfig{ Core: baseCore, Outputs: tt.outputs, } err := loader.validate(cfg) if (err != nil) != tt.wantErr { t.Fatalf("validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestLoadFromEnv(t *testing.T) { // Save original env vars origInterface := os.Getenv("JA4SENTINEL_INTERFACE") origPorts := os.Getenv("JA4SENTINEL_PORTS") origFilter := os.Getenv("JA4SENTINEL_BPF_FILTER") defer func() { os.Setenv("JA4SENTINEL_INTERFACE", origInterface) os.Setenv("JA4SENTINEL_PORTS", origPorts) os.Setenv("JA4SENTINEL_BPF_FILTER", origFilter) }() // Set test env vars os.Setenv("JA4SENTINEL_INTERFACE", "lo") os.Setenv("JA4SENTINEL_PORTS", "8443,9443") os.Setenv("JA4SENTINEL_BPF_FILTER", "tcp port 8443") loader := &LoaderImpl{} config := api.DefaultConfig() result := loader.loadFromEnv(config) if result.Core.Interface != "lo" { t.Errorf("Interface = %v, want lo", result.Core.Interface) } if len(result.Core.ListenPorts) != 2 { t.Errorf("ListenPorts length = %v, want 2", len(result.Core.ListenPorts)) } if result.Core.BPFFilter != "tcp port 8443" { t.Errorf("BPFFilter = %v, want 'tcp port 8443'", result.Core.BPFFilter) } } func TestToJSON(t *testing.T) { config := api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443, 8443}, BPFFilter: "tcp", FlowTimeoutSec: 30, PacketBufferSize: 1000, }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true, Params: map[string]string{}}, }, } jsonStr := ToJSON(config) if jsonStr == "" { t.Error("ToJSON() returned empty string") } if !strings.Contains(jsonStr, "eth0") { t.Error("ToJSON() result doesn't contain 'eth0'") } } func TestLoad_DefaultConfigFileAbsent_DoesNotFail(t *testing.T) { t.Setenv("JA4SENTINEL_INTERFACE", "") t.Setenv("JA4SENTINEL_PORTS", "") t.Setenv("JA4SENTINEL_BPF_FILTER", "") t.Setenv("JA4SENTINEL_FLOW_TIMEOUT", "") tempDir := t.TempDir() oldWD, err := os.Getwd() if err != nil { t.Fatalf("Getwd() error = %v", err) } defer func() { _ = os.Chdir(oldWD) }() if err := os.Chdir(tempDir); err != nil { t.Fatalf("Chdir() error = %v", err) } _ = os.Remove(filepath.Join(tempDir, "config.yml")) loader := NewLoader("") cfg, err := loader.Load() if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Core.Interface != api.DefaultInterface { t.Errorf("Interface = %q, want %q", cfg.Core.Interface, api.DefaultInterface) } if len(cfg.Core.ListenPorts) == 0 || cfg.Core.ListenPorts[0] != api.DefaultPort { t.Errorf("ListenPorts = %v, want first port %d", cfg.Core.ListenPorts, api.DefaultPort) } } func TestLoad_ExplicitMissingConfig_Fails(t *testing.T) { t.Setenv("JA4SENTINEL_INTERFACE", "") t.Setenv("JA4SENTINEL_PORTS", "") t.Setenv("JA4SENTINEL_BPF_FILTER", "") t.Setenv("JA4SENTINEL_FLOW_TIMEOUT", "") loader := NewLoader("/tmp/definitely-missing-ja4sentinel.yml") _, err := loader.Load() if err == nil { t.Fatal("Load() should fail with explicit missing config path") } } // TestLoadFromFile_InvalidYAML tests error handling for malformed YAML func TestLoadFromFile_InvalidYAML(t *testing.T) { tmpDir := t.TempDir() badConfig := filepath.Join(tmpDir, "bad.yml") // Write invalid YAML syntax invalidYAML := ` core: interface: eth0 listen_ports: [443, 8443 bpf_filter: "" ` if err := os.WriteFile(badConfig, []byte(invalidYAML), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } loader := NewLoader(badConfig) _, err := loader.Load() if err == nil { t.Error("Load() with invalid YAML should return error") } if !strings.Contains(err.Error(), "yaml") { t.Errorf("Load() error = %v, should mention yaml", err) } } // TestLoadFromFile_PermissionDenied tests error handling for permission errors func TestLoadFromFile_PermissionDenied(t *testing.T) { if os.Getuid() == 0 { t.Skip("Skipping permission test when running as root") } tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.yml") // Create config file cfg := api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, } data := ToJSON(cfg) if err := os.WriteFile(configPath, []byte(data), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } // Remove read permissions if err := os.Chmod(configPath, 0000); err != nil { t.Fatalf("Chmod() error = %v", err) } defer os.Chmod(configPath, 0600) // Restore for cleanup loader := NewLoader(configPath) _, err := loader.Load() if err == nil { t.Error("Load() with no read permission should return error") } } // TestLoadFromEnv_InvalidValues tests handling of invalid environment variable values func TestLoadFromEnv_InvalidValues(t *testing.T) { tests := []struct { name string env map[string]string wantErr bool errContains string }{ { name: "invalid_flow_timeout", env: map[string]string{ "JA4SENTINEL_FLOW_TIMEOUT": "not-a-number", }, wantErr: false, // Uses default value when invalid errContains: "", }, { name: "invalid_packet_buffer_size", env: map[string]string{ "JA4SENTINEL_PACKET_BUFFER_SIZE": "not-a-number", }, wantErr: false, // Uses default value when invalid errContains: "", }, { name: "negative_flow_timeout", env: map[string]string{ "JA4SENTINEL_FLOW_TIMEOUT": "-100", }, wantErr: false, // Uses default value when negative errContains: "", }, { name: "flow_timeout_too_high", env: map[string]string{ "JA4SENTINEL_FLOW_TIMEOUT": "1000000", }, wantErr: true, // Validation error errContains: "flow_timeout_sec must be between", }, { name: "invalid_log_level", env: map[string]string{ "JA4SENTINEL_LOG_LEVEL": "invalid-level", }, wantErr: true, // Validation error errContains: "log_level must be one of", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set environment variables for key, value := range tt.env { t.Setenv(key, value) } loader := NewLoader("") cfg, err := loader.Load() if (err != nil) != tt.wantErr { t.Fatalf("Load() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr && tt.errContains != "" { if err == nil || !strings.Contains(err.Error(), tt.errContains) { t.Errorf("Load() error = %v, should contain %q", err, tt.errContains) } } if !tt.wantErr { // Verify defaults are used for invalid values if tt.name == "invalid_flow_timeout" || tt.name == "negative_flow_timeout" { if cfg.Core.FlowTimeoutSec != api.DefaultFlowTimeout { t.Errorf("FlowTimeoutSec = %d, want default %d", cfg.Core.FlowTimeoutSec, api.DefaultFlowTimeout) } } if tt.name == "invalid_packet_buffer_size" { if cfg.Core.PacketBufferSize != api.DefaultPacketBuffer { t.Errorf("PacketBufferSize = %d, want default %d", cfg.Core.PacketBufferSize, api.DefaultPacketBuffer) } } } }) } } // TestLoadFromEnv_AllValidValues tests that all valid environment variables are parsed correctly func TestLoadFromEnv_AllValidValues(t *testing.T) { t.Setenv("JA4SENTINEL_INTERFACE", "lo") t.Setenv("JA4SENTINEL_PORTS", "8443, 9443") t.Setenv("JA4SENTINEL_BPF_FILTER", "tcp port 8443") t.Setenv("JA4SENTINEL_FLOW_TIMEOUT", "60") t.Setenv("JA4SENTINEL_PACKET_BUFFER_SIZE", "2000") t.Setenv("JA4SENTINEL_LOG_LEVEL", "debug") loader := NewLoader("") cfg, err := loader.Load() if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Core.Interface != "lo" { t.Errorf("Interface = %q, want 'lo'", cfg.Core.Interface) } if len(cfg.Core.ListenPorts) != 2 || cfg.Core.ListenPorts[0] != 8443 { t.Errorf("ListenPorts = %v, want [8443, 9443]", cfg.Core.ListenPorts) } if cfg.Core.BPFFilter != "tcp port 8443" { t.Errorf("BPFFilter = %q, want 'tcp port 8443'", cfg.Core.BPFFilter) } if cfg.Core.FlowTimeoutSec != 60 { t.Errorf("FlowTimeoutSec = %d, want 60", cfg.Core.FlowTimeoutSec) } if cfg.Core.PacketBufferSize != 2000 { t.Errorf("PacketBufferSize = %d, want 2000", cfg.Core.PacketBufferSize) } if cfg.Core.LogLevel != "debug" { t.Errorf("LogLevel = %q, want 'debug'", cfg.Core.LogLevel) } } // TestValidate_WhitespaceOnlyInterface tests that whitespace-only interface is rejected // Note: validate() is internal, so we test through Load() with env override func TestValidate_WhitespaceOnlyInterface(t *testing.T) { t.Setenv("JA4SENTINEL_INTERFACE", " ") t.Setenv("JA4SENTINEL_PORTS", "443") loader := NewLoader("") _, err := loader.Load() if err == nil { t.Error("Load() with whitespace-only interface should return error") } } // TestMergeConfigs_EmptyBase tests merge with empty base config func TestMergeConfigs_EmptyBase(t *testing.T) { base := api.AppConfig{} override := api.AppConfig{ Core: api.Config{ Interface: "lo", }, } result := mergeConfigs(base, override) if result.Core.Interface != "lo" { t.Errorf("Merged Interface = %q, want 'lo'", result.Core.Interface) } } // TestMergeConfigs_EmptyOverride tests merge with empty override config func TestMergeConfigs_EmptyOverride(t *testing.T) { base := api.AppConfig{ Core: api.Config{ Interface: "eth0", }, } override := api.AppConfig{} result := mergeConfigs(base, override) if result.Core.Interface != "eth0" { t.Errorf("Merged Interface = %q, want 'eth0'", result.Core.Interface) } } // TestMergeConfigs_OutputMerge tests that outputs are properly merged func TestMergeConfigs_OutputMerge(t *testing.T) { base := api.AppConfig{ Core: api.Config{ Interface: "eth0", }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true}, }, } override := api.AppConfig{ Core: api.Config{ ListenPorts: []uint16{8443}, }, Outputs: []api.OutputConfig{ {Type: "file", Enabled: true, Params: map[string]string{"path": "/tmp/test.log"}}, }, } result := mergeConfigs(base, override) // Override should replace base outputs if len(result.Outputs) != 1 { t.Errorf("Merged Outputs length = %d, want 1", len(result.Outputs)) } if result.Outputs[0].Type != "file" { t.Errorf("Merged Outputs[0].Type = %q, want 'file'", result.Outputs[0].Type) } }