779 lines
20 KiB
Go
779 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/x/exp/teatest"
|
|
"github.com/karol-broda/snitch/internal/collector"
|
|
)
|
|
|
|
func TestTUI_InitialState(t *testing.T) {
|
|
m := New(Options{
|
|
Theme: "dark",
|
|
Interval: time.Second,
|
|
})
|
|
|
|
if m.showTCP != true {
|
|
t.Error("expected showTCP to be true by default")
|
|
}
|
|
if m.showUDP != true {
|
|
t.Error("expected showUDP to be true by default")
|
|
}
|
|
if m.showListening != true {
|
|
t.Error("expected showListening to be true by default")
|
|
}
|
|
if m.showEstablished != true {
|
|
t.Error("expected showEstablished to be true by default")
|
|
}
|
|
}
|
|
|
|
func TestTUI_FilterOptions(t *testing.T) {
|
|
m := New(Options{
|
|
Theme: "dark",
|
|
Interval: time.Second,
|
|
TCP: true,
|
|
UDP: false,
|
|
FilterSet: true,
|
|
})
|
|
|
|
if m.showTCP != true {
|
|
t.Error("expected showTCP to be true")
|
|
}
|
|
if m.showUDP != false {
|
|
t.Error("expected showUDP to be false")
|
|
}
|
|
}
|
|
|
|
func TestTUI_MatchesFilters(t *testing.T) {
|
|
m := New(Options{
|
|
Theme: "dark",
|
|
Interval: time.Second,
|
|
TCP: true,
|
|
UDP: false,
|
|
Listening: true,
|
|
Established: false,
|
|
FilterSet: true,
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
conn collector.Connection
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "tcp listen matches",
|
|
conn: collector.Connection{Proto: "tcp", State: "LISTEN"},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "tcp6 listen matches",
|
|
conn: collector.Connection{Proto: "tcp6", State: "LISTEN"},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "udp listen does not match",
|
|
conn: collector.Connection{Proto: "udp", State: "LISTEN"},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "tcp established does not match",
|
|
conn: collector.Connection{Proto: "tcp", State: "ESTABLISHED"},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := m.matchesFilters(tc.conn)
|
|
if result != tc.expected {
|
|
t.Errorf("matchesFilters() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTUI_MatchesSearch(t *testing.T) {
|
|
m := New(Options{Theme: "dark"})
|
|
m.searchQuery = "firefox"
|
|
|
|
tests := []struct {
|
|
name string
|
|
conn collector.Connection
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "process name matches",
|
|
conn: collector.Connection{Process: "firefox"},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "process name case insensitive",
|
|
conn: collector.Connection{Process: "Firefox"},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "no match",
|
|
conn: collector.Connection{Process: "chrome"},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "matches in address",
|
|
conn: collector.Connection{Raddr: "firefox.com"},
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := m.matchesSearch(tc.conn)
|
|
if result != tc.expected {
|
|
t.Errorf("matchesSearch() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTUI_KeyBindings(t *testing.T) {
|
|
tm := teatest.NewTestModel(t, New(Options{Theme: "dark", Interval: time.Hour}))
|
|
|
|
// test quit with 'q'
|
|
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
|
|
|
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3))
|
|
}
|
|
|
|
func TestTUI_ToggleFilters(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
// initial state: all filters on
|
|
if m.showTCP != true || m.showUDP != true {
|
|
t.Fatal("expected all protocol filters on initially")
|
|
}
|
|
|
|
// toggle TCP with 't'
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}})
|
|
m = newModel.(model)
|
|
|
|
if m.showTCP != false {
|
|
t.Error("expected showTCP to be false after toggle")
|
|
}
|
|
|
|
// toggle UDP with 'u'
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
|
|
m = newModel.(model)
|
|
|
|
if m.showUDP != false {
|
|
t.Error("expected showUDP to be false after toggle")
|
|
}
|
|
|
|
// toggle listening with 'l'
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
|
|
m = newModel.(model)
|
|
|
|
if m.showListening != false {
|
|
t.Error("expected showListening to be false after toggle")
|
|
}
|
|
|
|
// toggle established with 'e'
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
|
m = newModel.(model)
|
|
|
|
if m.showEstablished != false {
|
|
t.Error("expected showEstablished to be false after toggle")
|
|
}
|
|
}
|
|
|
|
func TestTUI_HelpToggle(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
if m.showHelp != false {
|
|
t.Fatal("expected showHelp to be false initially")
|
|
}
|
|
|
|
// toggle help with '?'
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
|
m = newModel.(model)
|
|
|
|
if m.showHelp != true {
|
|
t.Error("expected showHelp to be true after toggle")
|
|
}
|
|
|
|
// toggle help off
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
|
m = newModel.(model)
|
|
|
|
if m.showHelp != false {
|
|
t.Error("expected showHelp to be false after second toggle")
|
|
}
|
|
}
|
|
|
|
func TestTUI_CursorNavigation(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
// add some test data
|
|
m.connections = []collector.Connection{
|
|
{PID: 1, Process: "proc1", Proto: "tcp", State: "LISTEN"},
|
|
{PID: 2, Process: "proc2", Proto: "tcp", State: "LISTEN"},
|
|
{PID: 3, Process: "proc3", Proto: "tcp", State: "LISTEN"},
|
|
}
|
|
|
|
if m.cursor != 0 {
|
|
t.Fatal("expected cursor at 0 initially")
|
|
}
|
|
|
|
// move down with 'j'
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
|
m = newModel.(model)
|
|
|
|
if m.cursor != 1 {
|
|
t.Errorf("expected cursor at 1 after down, got %d", m.cursor)
|
|
}
|
|
|
|
// move down again
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
|
m = newModel.(model)
|
|
|
|
if m.cursor != 2 {
|
|
t.Errorf("expected cursor at 2 after second down, got %d", m.cursor)
|
|
}
|
|
|
|
// move up with 'k'
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
|
|
m = newModel.(model)
|
|
|
|
if m.cursor != 1 {
|
|
t.Errorf("expected cursor at 1 after up, got %d", m.cursor)
|
|
}
|
|
|
|
// go to top with 'g'
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
|
|
m = newModel.(model)
|
|
|
|
if m.cursor != 0 {
|
|
t.Errorf("expected cursor at 0 after 'g', got %d", m.cursor)
|
|
}
|
|
|
|
// go to bottom with 'G'
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
|
|
m = newModel.(model)
|
|
|
|
if m.cursor != 2 {
|
|
t.Errorf("expected cursor at 2 after 'G', got %d", m.cursor)
|
|
}
|
|
}
|
|
|
|
func TestTUI_WindowResize(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
newModel, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
m = newModel.(model)
|
|
|
|
if m.width != 120 {
|
|
t.Errorf("expected width 120, got %d", m.width)
|
|
}
|
|
if m.height != 40 {
|
|
t.Errorf("expected height 40, got %d", m.height)
|
|
}
|
|
}
|
|
|
|
func TestTUI_ViewRenders(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.width = 120
|
|
m.height = 40
|
|
|
|
m.connections = []collector.Connection{
|
|
{PID: 1234, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
|
}
|
|
|
|
// main view should render without panic
|
|
view := m.View()
|
|
if view == "" {
|
|
t.Error("expected non-empty view")
|
|
}
|
|
|
|
// help view
|
|
m.showHelp = true
|
|
helpView := m.View()
|
|
if helpView == "" {
|
|
t.Error("expected non-empty help view")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ResolutionOptions(t *testing.T) {
|
|
// test default resolution settings
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
if m.resolveAddrs != false {
|
|
t.Error("expected resolveAddrs to be false by default (must be explicitly set)")
|
|
}
|
|
if m.resolvePorts != false {
|
|
t.Error("expected resolvePorts to be false by default")
|
|
}
|
|
|
|
// test with explicit options
|
|
m2 := New(Options{
|
|
Theme: "dark",
|
|
Interval: time.Hour,
|
|
ResolveAddrs: true,
|
|
ResolvePorts: true,
|
|
})
|
|
|
|
if m2.resolveAddrs != true {
|
|
t.Error("expected resolveAddrs to be true when set")
|
|
}
|
|
if m2.resolvePorts != true {
|
|
t.Error("expected resolvePorts to be true when set")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ToggleResolution(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour, ResolveAddrs: true})
|
|
|
|
if m.resolveAddrs != true {
|
|
t.Fatal("expected resolveAddrs to be true initially")
|
|
}
|
|
|
|
// toggle address resolution with 'n'
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
|
m = newModel.(model)
|
|
|
|
if m.resolveAddrs != false {
|
|
t.Error("expected resolveAddrs to be false after toggle")
|
|
}
|
|
|
|
// toggle back
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
|
m = newModel.(model)
|
|
|
|
if m.resolveAddrs != true {
|
|
t.Error("expected resolveAddrs to be true after second toggle")
|
|
}
|
|
|
|
// toggle port resolution with 'N'
|
|
if m.resolvePorts != false {
|
|
t.Fatal("expected resolvePorts to be false initially")
|
|
}
|
|
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}})
|
|
m = newModel.(model)
|
|
|
|
if m.resolvePorts != true {
|
|
t.Error("expected resolvePorts to be true after toggle")
|
|
}
|
|
|
|
// toggle back
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}})
|
|
m = newModel.(model)
|
|
|
|
if m.resolvePorts != false {
|
|
t.Error("expected resolvePorts to be false after second toggle")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ResolveAddrHelper(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.resolveAddrs = false
|
|
|
|
// when resolution is off, should return original address
|
|
addr := m.resolveAddr("192.168.1.1")
|
|
if addr != "192.168.1.1" {
|
|
t.Errorf("expected original address when resolution off, got %s", addr)
|
|
}
|
|
|
|
// empty and wildcard addresses should pass through unchanged
|
|
if m.resolveAddr("") != "" {
|
|
t.Error("expected empty string to pass through")
|
|
}
|
|
if m.resolveAddr("*") != "*" {
|
|
t.Error("expected wildcard to pass through")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ResolvePortHelper(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.resolvePorts = false
|
|
|
|
// when resolution is off, should return port number as string
|
|
port := m.resolvePort(80, "tcp")
|
|
if port != "80" {
|
|
t.Errorf("expected '80' when resolution off, got %s", port)
|
|
}
|
|
|
|
port = m.resolvePort(443, "tcp")
|
|
if port != "443" {
|
|
t.Errorf("expected '443' when resolution off, got %s", port)
|
|
}
|
|
}
|
|
|
|
func TestTUI_FormatRemoteHelper(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.resolveAddrs = false
|
|
m.resolvePorts = false
|
|
|
|
// empty/wildcard addresses should return dash
|
|
if m.formatRemote("", 80, "tcp") != "-" {
|
|
t.Error("expected dash for empty address")
|
|
}
|
|
if m.formatRemote("*", 80, "tcp") != "-" {
|
|
t.Error("expected dash for wildcard address")
|
|
}
|
|
if m.formatRemote("192.168.1.1", 0, "tcp") != "-" {
|
|
t.Error("expected dash for zero port")
|
|
}
|
|
|
|
// valid address:port should format correctly
|
|
result := m.formatRemote("192.168.1.1", 443, "tcp")
|
|
if result != "192.168.1.1:443" {
|
|
t.Errorf("expected '192.168.1.1:443', got %s", result)
|
|
}
|
|
}
|
|
|
|
func TestTUI_MatchesSearchPort(t *testing.T) {
|
|
m := New(Options{Theme: "dark"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
searchQuery string
|
|
conn collector.Connection
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "matches local port",
|
|
searchQuery: "3000",
|
|
conn: collector.Connection{Lport: 3000},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "matches remote port",
|
|
searchQuery: "443",
|
|
conn: collector.Connection{Rport: 443},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "matches pid",
|
|
searchQuery: "1234",
|
|
conn: collector.Connection{PID: 1234},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "partial port match",
|
|
searchQuery: "80",
|
|
conn: collector.Connection{Lport: 8080},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "no match",
|
|
searchQuery: "9999",
|
|
conn: collector.Connection{Lport: 80, Rport: 443, PID: 1234},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m.searchQuery = tc.searchQuery
|
|
result := m.matchesSearch(tc.conn)
|
|
if result != tc.expected {
|
|
t.Errorf("matchesSearch() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTUI_SortCycleIncludesRemote(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
// start at default (Lport)
|
|
if m.sortField != collector.SortByLport {
|
|
t.Fatalf("expected initial sort field to be lport, got %v", m.sortField)
|
|
}
|
|
|
|
// cycle through all fields and verify raddr and rport are included
|
|
foundRaddr := false
|
|
foundRport := false
|
|
seenFields := make(map[collector.SortField]bool)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
m.cycleSort()
|
|
seenFields[m.sortField] = true
|
|
|
|
if m.sortField == collector.SortByRaddr {
|
|
foundRaddr = true
|
|
}
|
|
if m.sortField == collector.SortByRport {
|
|
foundRport = true
|
|
}
|
|
|
|
if foundRaddr && foundRport {
|
|
break
|
|
}
|
|
}
|
|
|
|
if !foundRaddr {
|
|
t.Error("expected sort cycle to include SortByRaddr")
|
|
}
|
|
if !foundRport {
|
|
t.Error("expected sort cycle to include SortByRport")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportModal(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.width = 120
|
|
m.height = 40
|
|
|
|
// initially export modal should not be shown
|
|
if m.showExportModal {
|
|
t.Fatal("expected showExportModal to be false initially")
|
|
}
|
|
|
|
// press 'x' to open export modal
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
|
m = newModel.(model)
|
|
|
|
if !m.showExportModal {
|
|
t.Error("expected showExportModal to be true after pressing 'x'")
|
|
}
|
|
|
|
// type filename
|
|
for _, c := range "test.csv" {
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}})
|
|
m = newModel.(model)
|
|
}
|
|
|
|
if m.exportFilename != "test.csv" {
|
|
t.Errorf("expected exportFilename to be 'test.csv', got '%s'", m.exportFilename)
|
|
}
|
|
|
|
// escape should close modal
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
m = newModel.(model)
|
|
|
|
if m.showExportModal {
|
|
t.Error("expected showExportModal to be false after escape")
|
|
}
|
|
if m.exportFilename != "" {
|
|
t.Error("expected exportFilename to be cleared after escape")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportModalDefaultFilename(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.width = 120
|
|
m.height = 40
|
|
|
|
// add test data
|
|
m.connections = []collector.Connection{
|
|
{PID: 1234, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
|
}
|
|
|
|
// open export modal
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
|
m = newModel.(model)
|
|
|
|
// render export modal should show default filename hint
|
|
view := m.View()
|
|
if view == "" {
|
|
t.Error("expected non-empty view with export modal")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportModalBackspace(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.width = 120
|
|
m.height = 40
|
|
|
|
// open export modal
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
|
m = newModel.(model)
|
|
|
|
// type filename
|
|
for _, c := range "test.csv" {
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}})
|
|
m = newModel.(model)
|
|
}
|
|
|
|
// backspace should remove last character
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
|
m = newModel.(model)
|
|
|
|
if m.exportFilename != "test.cs" {
|
|
t.Errorf("expected 'test.cs' after backspace, got '%s'", m.exportFilename)
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportConnectionsCSV(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
m.connections = []collector.Connection{
|
|
{PID: 1234, Process: "nginx", User: "www-data", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80, Raddr: "*", Rport: 0},
|
|
{PID: 5678, Process: "node", User: "node", Proto: "tcp", State: "ESTABLISHED", Laddr: "192.168.1.1", Lport: 3000, Raddr: "10.0.0.1", Rport: 443},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
csvPath := filepath.Join(tmpDir, "test_export.csv")
|
|
m.exportFilename = csvPath
|
|
|
|
err := m.exportConnections()
|
|
if err != nil {
|
|
t.Fatalf("exportConnections() failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(csvPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read exported file: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
|
if len(lines) != 3 {
|
|
t.Errorf("expected 3 lines (header + 2 data), got %d", len(lines))
|
|
}
|
|
|
|
if !strings.Contains(lines[0], "PID") || !strings.Contains(lines[0], "PROCESS") {
|
|
t.Error("header line should contain PID and PROCESS")
|
|
}
|
|
|
|
if !strings.Contains(lines[1], "nginx") || !strings.Contains(lines[1], "1234") {
|
|
t.Error("first data line should contain nginx and 1234")
|
|
}
|
|
|
|
if !strings.Contains(lines[2], "node") || !strings.Contains(lines[2], "5678") {
|
|
t.Error("second data line should contain node and 5678")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportConnectionsTSV(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
|
|
m.connections = []collector.Connection{
|
|
{PID: 1234, Process: "nginx", User: "www-data", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80, Raddr: "*", Rport: 0},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
tsvPath := filepath.Join(tmpDir, "test_export.tsv")
|
|
m.exportFilename = tsvPath
|
|
|
|
err := m.exportConnections()
|
|
if err != nil {
|
|
t.Fatalf("exportConnections() failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(tsvPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read exported file: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
|
|
|
// TSV should use tabs
|
|
if !strings.Contains(lines[0], "\t") {
|
|
t.Error("TSV file should use tabs as delimiters")
|
|
}
|
|
|
|
// CSV delimiter should not be present between fields
|
|
fields := strings.Split(lines[1], "\t")
|
|
if len(fields) < 9 {
|
|
t.Errorf("expected at least 9 tab-separated fields, got %d", len(fields))
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportWithFilters(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.showTCP = true
|
|
m.showUDP = false
|
|
|
|
m.connections = []collector.Connection{
|
|
{PID: 1, Process: "tcp_proc", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
|
{PID: 2, Process: "udp_proc", Proto: "udp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 53},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
csvPath := filepath.Join(tmpDir, "filtered_export.csv")
|
|
m.exportFilename = csvPath
|
|
|
|
err := m.exportConnections()
|
|
if err != nil {
|
|
t.Fatalf("exportConnections() failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(csvPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read exported file: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
|
|
|
// should only have header + 1 TCP connection (UDP filtered out)
|
|
if len(lines) != 2 {
|
|
t.Errorf("expected 2 lines (header + 1 TCP), got %d", len(lines))
|
|
}
|
|
|
|
if strings.Contains(string(content), "udp_proc") {
|
|
t.Error("UDP connection should not be exported when UDP filter is off")
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportFormatToggle(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.width = 120
|
|
m.height = 40
|
|
|
|
// open export modal
|
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
|
m = newModel.(model)
|
|
|
|
// default format should be csv
|
|
if m.exportFormat != "csv" {
|
|
t.Errorf("expected default format 'csv', got '%s'", m.exportFormat)
|
|
}
|
|
|
|
// tab should toggle to tsv
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
m = newModel.(model)
|
|
|
|
if m.exportFormat != "tsv" {
|
|
t.Errorf("expected format 'tsv' after tab, got '%s'", m.exportFormat)
|
|
}
|
|
|
|
// tab again should toggle back to csv
|
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
m = newModel.(model)
|
|
|
|
if m.exportFormat != "csv" {
|
|
t.Errorf("expected format 'csv' after second tab, got '%s'", m.exportFormat)
|
|
}
|
|
}
|
|
|
|
func TestTUI_ExportModalRenderWithStats(t *testing.T) {
|
|
m := New(Options{Theme: "dark", Interval: time.Hour})
|
|
m.width = 120
|
|
m.height = 40
|
|
|
|
m.connections = []collector.Connection{
|
|
{PID: 1, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
|
{PID: 2, Process: "postgres", Proto: "tcp", State: "LISTEN", Laddr: "127.0.0.1", Lport: 5432},
|
|
{PID: 3, Process: "node", Proto: "tcp", State: "ESTABLISHED", Laddr: "192.168.1.1", Lport: 3000},
|
|
}
|
|
|
|
m.showExportModal = true
|
|
m.exportFormat = "csv"
|
|
|
|
view := m.View()
|
|
|
|
// modal should contain summary info
|
|
if !strings.Contains(view, "3") {
|
|
t.Error("modal should show connection count")
|
|
}
|
|
|
|
// modal should show format options
|
|
if !strings.Contains(view, "CSV") || !strings.Contains(view, "TSV") {
|
|
t.Error("modal should show format options")
|
|
}
|
|
}
|
|
|