package capture import ( "net" "strings" "testing" "time" "ja4sentinel/api" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/google/gopacket/pcap" ) func TestCaptureImpl_Run_EmptyInterface(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } cfg := api.Config{ Interface: "", ListenPorts: []uint16{443}, } out := make(chan api.RawPacket, 10) err := c.Run(cfg, out) if err == nil { t.Error("Run() with empty interface should return error") } if err.Error() != "interface cannot be empty" { t.Errorf("Run() error = %v, want 'interface cannot be empty'", err) } } func TestCaptureImpl_Run_NonExistentInterface(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } cfg := api.Config{ Interface: "nonexistent_interface_xyz123", ListenPorts: []uint16{443}, } out := make(chan api.RawPacket, 10) err := c.Run(cfg, out) if err == nil { t.Error("Run() with non-existent interface should return error") } } func TestCaptureImpl_Run_InvalidBPFFilter(t *testing.T) { // Get a real interface name ifaces, err := pcap.FindAllDevs() if err != nil || len(ifaces) == 0 { t.Skip("No network interfaces available for testing") } c := New() cfg := api.Config{ Interface: ifaces[0].Name, ListenPorts: []uint16{443}, BPFFilter: "invalid; rm -rf /", // Invalid characters } out := make(chan api.RawPacket, 10) err = c.Run(cfg, out) if err == nil { t.Error("Run() with invalid BPF filter should return error") } } func TestCaptureImpl_Run_ChannelFull_DropsPackets(t *testing.T) { // This test verifies that when the output channel is full, // packets are dropped gracefully (non-blocking write) // We can't easily test the full Run() loop without real interfaces, // but we can verify the channel behavior with a small buffer out := make(chan api.RawPacket, 1) // Fill the channel out <- api.RawPacket{Data: []byte{1, 2, 3}, Timestamp: time.Now().UnixNano()} // Channel should be full now, select default should trigger done := make(chan bool) go func() { select { case out <- api.RawPacket{Data: []byte{4, 5, 6}, Timestamp: time.Now().UnixNano()}: done <- false // Would block default: done <- true // Dropped as expected } }() dropped := <-done if !dropped { t.Error("Expected packet to be dropped when channel is full") } } func TestPacketToRawPacket(t *testing.T) { t.Run("valid_packet", func(t *testing.T) { // Create a simple TCP packet eth := layers.Ethernet{ SrcMAC: []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, DstMAC: []byte{0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB}, EthernetType: layers.EthernetTypeIPv4, } ip := layers.IPv4{ Version: 4, TTL: 64, Protocol: layers.IPProtocolTCP, SrcIP: []byte{192, 168, 1, 1}, DstIP: []byte{10, 0, 0, 1}, } tcp := layers.TCP{ SrcPort: 12345, DstPort: 443, } tcp.SetNetworkLayerForChecksum(&ip) buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{} gopacket.SerializeLayers(buf, opts, ð, &ip, &tcp) packet := gopacket.NewPacket(buf.Bytes(), layers.LinkTypeEthernet, gopacket.Default) // Create capture instance for method call c := New() rawPkt := c.packetToRawPacket(packet) if rawPkt == nil { t.Fatal("packetToRawPacket() returned nil for valid packet") } if len(rawPkt.Data) == 0 { t.Error("packetToRawPacket() returned empty data") } if rawPkt.Timestamp == 0 { t.Error("packetToRawPacket() returned zero timestamp") } }) t.Run("empty_packet", func(t *testing.T) { // Create packet with no data packet := gopacket.NewPacket([]byte{}, layers.LinkTypeEthernet, gopacket.Default) c := New() rawPkt := c.packetToRawPacket(packet) if rawPkt != nil { t.Error("packetToRawPacket() should return nil for empty packet") } }) t.Run("nil_packet", func(t *testing.T) { // packetToRawPacket will panic with nil packet due to Metadata() call // This is expected behavior - the function is not designed to handle nil defer func() { if r := recover(); r == nil { t.Error("packetToRawPacket() with nil packet should panic") } }() c := New() var packet gopacket.Packet _ = c.packetToRawPacket(packet) }) } func TestGetInterfaceNames(t *testing.T) { t.Run("empty_list", func(t *testing.T) { names := getInterfaceNames([]pcap.Interface{}) if len(names) != 0 { t.Errorf("getInterfaceNames() with empty list = %v, want []", names) } }) t.Run("single_interface", func(t *testing.T) { ifaces := []pcap.Interface{ {Name: "eth0"}, } names := getInterfaceNames(ifaces) if len(names) != 1 || names[0] != "eth0" { t.Errorf("getInterfaceNames() = %v, want [eth0]", names) } }) t.Run("multiple_interfaces", func(t *testing.T) { ifaces := []pcap.Interface{ {Name: "eth0"}, {Name: "lo"}, {Name: "docker0"}, } names := getInterfaceNames(ifaces) if len(names) != 3 { t.Errorf("getInterfaceNames() returned %d names, want 3", len(names)) } expected := []string{"eth0", "lo", "docker0"} for i, name := range names { if name != expected[i] { t.Errorf("getInterfaceNames()[%d] = %s, want %s", i, name, expected[i]) } } }) } func TestValidateBPFFilter(t *testing.T) { tests := []struct { name string filter string wantErr bool }{ { name: "empty filter", filter: "", wantErr: false, }, { name: "valid simple filter", filter: "tcp port 443", wantErr: false, }, { name: "valid complex filter", filter: "(tcp port 443) or (tcp port 8443)", wantErr: false, }, { name: "filter with special chars", filter: "tcp port 443 and host 192.168.1.1", wantErr: false, }, { name: "too long filter", filter: string(make([]byte, MaxBPFFilterLength+1)), wantErr: true, }, { name: "unbalanced parentheses - extra open", filter: "(tcp port 443", wantErr: true, }, { name: "unbalanced parentheses - extra close", filter: "tcp port 443)", wantErr: true, }, { name: "invalid characters - semicolon", filter: "tcp port 443; rm -rf /", wantErr: true, }, { name: "invalid characters - backtick", filter: "tcp port `whoami`", wantErr: true, }, { name: "invalid characters - dollar", filter: "tcp port $HOME", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateBPFFilter(tt.filter) if (err != nil) != tt.wantErr { t.Errorf("validateBPFFilter() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestJoinString(t *testing.T) { tests := []struct { name string parts []string sep string want string }{ { name: "empty slice", parts: []string{}, sep: ") or (", want: "", }, { name: "single element", parts: []string{"tcp port 443"}, sep: ") or (", want: "tcp port 443", }, { name: "multiple elements", parts: []string{"tcp port 443", "tcp port 8443"}, sep: ") or (", want: "tcp port 443) or (tcp port 8443", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := joinString(tt.parts, tt.sep) if got != tt.want { t.Errorf("joinString() = %v, want %v", got, tt.want) } }) } } func TestNewCapture(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } if c.snapLen != DefaultSnapLen { t.Errorf("snapLen = %d, want %d", c.snapLen, DefaultSnapLen) } if c.promisc != DefaultPromiscuous { t.Errorf("promisc = %v, want %v", c.promisc, DefaultPromiscuous) } } func TestNewWithSnapLen(t *testing.T) { tests := []struct { name string snapLen int wantSnapLen int }{ { name: "valid snapLen", snapLen: 2048, wantSnapLen: 2048, }, { name: "zero snapLen uses default", snapLen: 0, wantSnapLen: DefaultSnapLen, }, { name: "negative snapLen uses default", snapLen: -100, wantSnapLen: DefaultSnapLen, }, { name: "too large snapLen uses default", snapLen: 100000, wantSnapLen: DefaultSnapLen, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewWithSnapLen(tt.snapLen) if c == nil { t.Fatal("NewWithSnapLen() returned nil") } if c.snapLen != tt.wantSnapLen { t.Errorf("snapLen = %d, want %d", c.snapLen, tt.wantSnapLen) } }) } } func TestCaptureImpl_Close(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } // Close should not panic on fresh instance if err := c.Close(); err != nil { t.Errorf("Close() error = %v", err) } // Multiple closes should be safe if err := c.Close(); err != nil { t.Errorf("Close() second call error = %v", err) } } func TestValidateBPFFilter_BalancedParentheses(t *testing.T) { // Test various balanced parentheses scenarios validFilters := []string{ "(tcp port 443)", "((tcp port 443))", "(tcp port 443) or (tcp port 8443)", "((tcp port 443) or (tcp port 8443))", "(tcp port 443 and host 1.2.3.4) or (tcp port 8443)", } for _, filter := range validFilters { t.Run(filter, func(t *testing.T) { if err := validateBPFFilter(filter); err != nil { t.Errorf("validateBPFFilter(%q) unexpected error = %v", filter, err) } }) } } func TestCaptureImpl_detectLocalIPs(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } t.Run("any_interface", func(t *testing.T) { ips, err := c.detectLocalIPs("any") if err != nil { t.Errorf("detectLocalIPs(any) error = %v", err) } // Should return at least one non-loopback IP or empty if none available for _, ip := range ips { if ip == "127.0.0.1" || ip == "::1" { t.Errorf("detectLocalIPs(any) should exclude loopback, got %s", ip) } } }) t.Run("loopback_excluded", func(t *testing.T) { ips, err := c.detectLocalIPs("any") if err != nil { t.Skipf("Skipping loopback test: %v", err) } // Verify no loopback addresses are included for _, ip := range ips { if ip == "127.0.0.1" { t.Error("detectLocalIPs should exclude 127.0.0.1") } } }) } func TestCaptureImpl_detectLocalIPs_SpecificInterface(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } // Test with a non-existent interface _, err := c.detectLocalIPs("nonexistent_interface_xyz") if err == nil { t.Error("detectLocalIPs with non-existent interface should return error") } } func TestCaptureImpl_extractIP(t *testing.T) { tests := []struct { name string addr net.Addr wantIPv4 bool wantIPv6 bool }{ { name: "IPv4", addr: &net.IPNet{ IP: net.ParseIP("192.168.1.10"), Mask: net.CIDRMask(24, 32), }, wantIPv4: true, }, { name: "IPv6", addr: &net.IPNet{ IP: net.ParseIP("2001:db8::1"), Mask: net.CIDRMask(64, 128), }, wantIPv6: true, }, { name: "nil", addr: nil, wantIPv4: false, wantIPv6: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractIP(tt.addr) if tt.wantIPv4 { if got == nil || got.To4() == nil { t.Error("extractIP() should return IPv4 address") } } if tt.wantIPv6 { if got == nil || got.To4() != nil { t.Error("extractIP() should return IPv6 address") } } if !tt.wantIPv4 && !tt.wantIPv6 { if got != nil { t.Error("extractIP() should return nil for nil address") } } }) } } func TestCaptureImpl_buildBPFFilter(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } tests := []struct { name string ports []uint16 localIPs []string wantParts []string // Parts that should be in the filter }{ { name: "no ports", ports: []uint16{}, localIPs: []string{}, wantParts: []string{"tcp"}, }, { name: "single port no IPs", ports: []uint16{443}, localIPs: []string{}, wantParts: []string{"tcp dst port 443"}, }, { name: "single port with single IP", ports: []uint16{443}, localIPs: []string{"192.168.1.10"}, wantParts: []string{"tcp dst port 443", "dst host 192.168.1.10"}, }, { name: "multiple ports with multiple IPs", ports: []uint16{443, 8443}, localIPs: []string{"192.168.1.10", "10.0.0.5"}, wantParts: []string{"tcp dst port 443", "tcp dst port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"}, }, { name: "IPv6 address", ports: []uint16{443}, localIPs: []string{"2001:db8::1"}, wantParts: []string{"tcp dst port 443", "dst host 2001:db8::1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := c.buildBPFFilter(tt.ports, tt.localIPs) for _, part := range tt.wantParts { if !strings.Contains(got, part) { t.Errorf("buildBPFFilter() = %q, should contain %q", got, part) } } }) } } func TestCaptureImpl_Run_AnyInterface(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } // Test that "any" interface is accepted (validation only, won't actually run) cfg := api.Config{ Interface: "any", ListenPorts: []uint16{443}, LocalIPs: []string{"192.168.1.10"}, // Provide manual IPs to avoid detection } // We can't actually run capture without root permissions, but we can test validation // This test will fail at the pcap.OpenLive stage without root, which is expected out := make(chan api.RawPacket, 10) err := c.Run(cfg, out) // If we get "operation not permitted" or similar, that's expected without root // If we get "interface not found", that's a bug if err != nil { if strings.Contains(err.Error(), "not found") { t.Errorf("Run() with 'any' interface should be valid, got: %v", err) } // Permission errors are expected in non-root environments t.Logf("Run() error (expected without root): %v", err) } } func TestCaptureImpl_Run_WithManualLocalIPs(t *testing.T) { c := New() if c == nil { t.Fatal("New() returned nil") } // Test with manually specified local IPs cfg := api.Config{ Interface: "any", ListenPorts: []uint16{443}, LocalIPs: []string{"192.168.1.10", "10.0.0.5"}, } out := make(chan api.RawPacket, 10) err := c.Run(cfg, out) // Same as above - permission errors are expected if err != nil && strings.Contains(err.Error(), "not found") { t.Errorf("Run() with manual LocalIPs should be valid, got: %v", err) } }