package output import ( "bufio" "bytes" "encoding/json" "errors" "net" "os" "path/filepath" "strings" "sync" "testing" "time" "ja4sentinel/api" ) func TestStdoutWriter(t *testing.T) { // Capture stdout by replacing it temporarily oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w writer := NewStdoutWriter() rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, JA4: "t12s0102ab_1234567890ab", } err := writer.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } w.Close() os.Stdout = oldStdout var buf bytes.Buffer buf.ReadFrom(r) output := buf.String() if output == "" { t.Error("Write() produced no output") } // Verify it's valid JSON var result api.LogRecord if err := json.Unmarshal([]byte(output), &result); err != nil { t.Errorf("Output is not valid JSON: %v", err) } } func TestFileWriter(t *testing.T) { // Create a temporary file tmpFile := "/tmp/ja4sentinel_test.log" defer os.Remove(tmpFile) writer, err := NewFileWriter(tmpFile) if err != nil { t.Fatalf("NewFileWriter() error = %v", err) } defer writer.Close() rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, JA4: "t12s0102ab_1234567890ab", } err = writer.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Read the file and verify data, err := os.ReadFile(tmpFile) if err != nil { t.Fatalf("Failed to read file: %v", err) } if len(data) == 0 { t.Error("Write() produced no output") } // Verify it's valid JSON var result api.LogRecord if err := json.Unmarshal(data, &result); err != nil { t.Errorf("Output is not valid JSON: %v", err) } } func TestMultiWriter(t *testing.T) { multiWriter := NewMultiWriter() // Create a temporary file writer tmpFile := "/tmp/ja4sentinel_multi_test.log" defer os.Remove(tmpFile) fileWriter, err := NewFileWriter(tmpFile) if err != nil { t.Fatalf("NewFileWriter() error = %v", err) } defer fileWriter.Close() multiWriter.Add(fileWriter) rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, JA4: "t12s0102ab_1234567890ab", } err = multiWriter.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Verify file output data, err := os.ReadFile(tmpFile) if err != nil { t.Fatalf("Failed to read file: %v", err) } if len(data) == 0 { t.Error("MultiWriter.Write() produced no file output") } } func TestBuilderNewFromConfig(t *testing.T) { builder := NewBuilder() tests := []struct { name string cfg api.AppConfig wantErr bool }{ { name: "stdout output", cfg: api.AppConfig{ Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true}, }, }, wantErr: false, }, { name: "file output", cfg: api.AppConfig{ Outputs: []api.OutputConfig{ { Type: "file", Enabled: true, Params: map[string]string{"path": "/tmp/ja4sentinel_builder_test.log"}, }, }, }, wantErr: false, }, { name: "file output without path", cfg: api.AppConfig{ Outputs: []api.OutputConfig{ {Type: "file", Enabled: true}, }, }, wantErr: true, }, { name: "unix socket output", cfg: api.AppConfig{ Outputs: []api.OutputConfig{ { Type: "unix_socket", Enabled: true, Params: map[string]string{"socket_path": "/tmp/ja4sentinel_test.sock"}, }, }, }, wantErr: false, }, { name: "unknown output type", cfg: api.AppConfig{ Outputs: []api.OutputConfig{ {Type: "unknown", Enabled: true}, }, }, wantErr: true, }, { name: "no outputs (should default to stdout)", cfg: api.AppConfig{}, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { writer, err := builder.NewFromConfig(tt.cfg) if (err != nil) != tt.wantErr { t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && writer == nil { t.Error("NewFromConfig() returned nil writer") } }) } } func TestUnixSocketWriter(t *testing.T) { // Test creation without socket (should not fail) socketPath := "/tmp/ja4sentinel_nonexistent.sock" writer, err := NewUnixSocketWriter(socketPath) if err != nil { t.Fatalf("NewUnixSocketWriter() error = %v", err) } // Write should fail since socket doesn't exist rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, } err = writer.Write(rec) if err == nil { t.Error("Write() should fail for non-existent socket") } writer.Close() } func TestUnixSocketWriter_Write_NonexistentSocket_ReturnsQuickly(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "ja4sentinel_missing.sock") writer, err := NewUnixSocketWriter(socketPath) if err != nil { t.Fatalf("NewUnixSocketWriter() error = %v", err) } defer writer.Close() start := time.Now() err = writer.Write(api.LogRecord{ SrcIP: "192.168.1.10", SrcPort: 44444, DstIP: "10.0.0.10", DstPort: 443, }) elapsed := time.Since(start) if err == nil { t.Fatal("Write() should fail for non-existent socket") } if elapsed >= 3*time.Second { t.Fatalf("Write() took too long: %v (expected < 3s)", elapsed) } } type timeoutError struct{} func (timeoutError) Error() string { return "i/o timeout" } func (timeoutError) Timeout() bool { return true } func (timeoutError) Temporary() bool { return true } type mockAddr string func (a mockAddr) Network() string { return "unix" } func (a mockAddr) String() string { return string(a) } type mockConn struct { writeCalls int closeCalled bool setWriteDeadlineCalled bool setReadDeadlineCalled bool setAnyDeadlineWasCalled bool } func (m *mockConn) Read(_ []byte) (int, error) { return 0, errors.New("not implemented") } func (m *mockConn) Write(_ []byte) (int, error) { m.writeCalls++ return 0, timeoutError{} } func (m *mockConn) Close() error { m.closeCalled = true return nil } func (m *mockConn) LocalAddr() net.Addr { return mockAddr("local") } func (m *mockConn) RemoteAddr() net.Addr { return mockAddr("remote") } func (m *mockConn) SetDeadline(_ time.Time) error { m.setAnyDeadlineWasCalled = true return nil } func (m *mockConn) SetReadDeadline(_ time.Time) error { m.setReadDeadlineCalled = true return nil } func (m *mockConn) SetWriteDeadline(_ time.Time) error { m.setWriteDeadlineCalled = true return nil } func TestUnixSocketWriter_Write_UsesWriteDeadline(t *testing.T) { mc := &mockConn{} writer := &UnixSocketWriter{ socketPath: filepath.Join(t.TempDir(), "missing.sock"), conn: mc, dialTimeout: 100 * time.Millisecond, writeTimeout: 100 * time.Millisecond, } err := writer.Write(api.LogRecord{ SrcIP: "192.168.1.20", SrcPort: 55555, DstIP: "10.0.0.20", DstPort: 443, }) if err == nil { t.Fatal("Write() should fail because reconnect target does not exist") } if !mc.setWriteDeadlineCalled { t.Fatal("expected SetWriteDeadline to be called before write") } if !mc.closeCalled { t.Fatal("expected connection to be closed after first write failure") } if mc.writeCalls != 1 { t.Fatalf("expected exactly 1 write on initial conn, got %d", mc.writeCalls) } if !strings.Contains(err.Error(), "reconnect failed") { t.Fatalf("expected reconnect failure error, got: %v", err) } } type unixTestServer struct { listener net.Listener received chan string mu sync.Mutex conns map[net.Conn]struct{} } func newUnixTestServer(path string) (*unixTestServer, error) { _ = os.Remove(path) ln, err := net.Listen("unix", path) if err != nil { return nil, err } s := &unixTestServer{ listener: ln, received: make(chan string, 10), conns: make(map[net.Conn]struct{}), } go s.serve() return s, nil } func (s *unixTestServer) serve() { for { conn, err := s.listener.Accept() if err != nil { return } s.mu.Lock() s.conns[conn] = struct{}{} s.mu.Unlock() go func(c net.Conn) { defer func() { s.mu.Lock() delete(s.conns, c) s.mu.Unlock() _ = c.Close() }() scanner := bufio.NewScanner(c) for scanner.Scan() { s.received <- scanner.Text() } }(conn) } } func (s *unixTestServer) close(path string) { _ = s.listener.Close() s.mu.Lock() for c := range s.conns { _ = c.Close() } s.mu.Unlock() _ = os.Remove(path) } func TestUnixSocketWriter_ReconnectAndWrite(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "ja4sentinel.sock") server1, err := newUnixTestServer(socketPath) if err != nil { t.Fatalf("failed to start first unix test server: %v", err) } writer, err := NewUnixSocketWriter(socketPath) if err != nil { t.Fatalf("NewUnixSocketWriter() error = %v", err) } defer writer.Close() rec1 := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 11111, DstIP: "10.0.0.1", DstPort: 443, JA4: "first", } if err := writer.Write(rec1); err != nil { t.Fatalf("first Write() error = %v", err) } select { case <-server1.received: case <-time.After(2 * time.Second): t.Fatal("timeout waiting first message on unix socket") } server1.close(socketPath) server2, err := newUnixTestServer(socketPath) if err != nil { t.Fatalf("failed to restart unix test server: %v", err) } defer server2.close(socketPath) rec2 := api.LogRecord{ SrcIP: "192.168.1.2", SrcPort: 22222, DstIP: "10.0.0.2", DstPort: 443, JA4: "second", } if err := writer.Write(rec2); err != nil { t.Fatalf("second Write() after reconnect error = %v", err) } select { case <-server2.received: case <-time.After(2 * time.Second): t.Fatal("timeout waiting second message after reconnect") } }