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}, }, } 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) } } 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, }, } 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") } }