Files
ja4sentinel/internal/output/writers_test.go
Jacquin Antoine c7e8fe874f
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
fix: renforcer limites TLS, timeouts socket et validation config
Co-authored-by: aider (openrouter/openai/gpt-5.3-codex) <aider@aider.chat>
2026-02-28 20:01:39 +01:00

468 lines
9.6 KiB
Go

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