package output import ( "bytes" "encoding/json" "os" "path/filepath" "testing" "time" "ja4sentinel/api" ) func TestStdoutWriter(t *testing.T) { w := NewStdoutWriter() if w == nil { t.Fatal("NewStdoutWriter() returned nil") } rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, JA4: "t13d1516h2_test", } // Write should not fail (but we can't easily test stdout output) err := w.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Close should be no-op if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } } func TestFileWriter(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.log") w, err := NewFileWriter(testFile) if err != nil { t.Fatalf("NewFileWriter() error = %v", err) } defer w.Close() rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, JA4: "t13d1516h2_test", } err = w.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Close the writer to flush if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } // Verify file was created and contains data data, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read test file: %v", err) } if len(data) == 0 { t.Error("File is empty") } // Verify it's valid JSON var got api.LogRecord if err := json.Unmarshal(data, &got); err != nil { t.Errorf("Output is not valid JSON: %v", err) } if got.SrcIP != rec.SrcIP { t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec.SrcIP) } } func TestFileWriter_CreatesDirectory(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "subdir", "nested", "test.log") w, err := NewFileWriter(testFile) if err != nil { t.Fatalf("NewFileWriter() error = %v", err) } defer w.Close() rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, JA4: "test", } err = w.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Verify file exists if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Error("File was not created") } } func TestMultiWriter(t *testing.T) { mw := NewMultiWriter() if mw == nil { t.Fatal("NewMultiWriter() returned nil") } // Create a test writer that tracks writes var writeCount int testWriter := &testWriter{ writeFunc: func(rec api.LogRecord) error { writeCount++ return nil }, } mw.Add(testWriter) mw.Add(NewStdoutWriter()) rec := api.LogRecord{ SrcIP: "192.168.1.1", JA4: "test", } err := mw.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } if writeCount != 1 { t.Errorf("writeCount = %d, want 1", writeCount) } // CloseAll should not fail if err := mw.CloseAll(); err != nil { t.Errorf("CloseAll() error = %v", err) } } func TestMultiWriter_WriteError(t *testing.T) { mw := NewMultiWriter() // Create a writer that always fails failWriter := &testWriter{ writeFunc: func(rec api.LogRecord) error { return os.ErrPermission }, } mw.Add(failWriter) rec := api.LogRecord{SrcIP: "192.168.1.1"} err := mw.Write(rec) // Should return the last error if err != os.ErrPermission { t.Errorf("Write() error = %v, want %v", err, os.ErrPermission) } } func TestBuilder_NewFromConfig(t *testing.T) { builder := NewBuilder() tests := []struct { name string config api.AppConfig wantErr bool }{ { name: "empty config defaults to stdout", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{}, }, wantErr: false, }, { name: "stdout output", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: true}, }, }, wantErr: false, }, { name: "disabled output ignored", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ {Type: "stdout", Enabled: false}, }, }, wantErr: false, }, { name: "file output without path fails", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ {Type: "file", Enabled: true, Params: map[string]string{}}, }, }, wantErr: true, }, { name: "unix socket without socket_path fails", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ {Type: "unix_socket", Enabled: true, Params: map[string]string{}}, }, }, wantErr: true, }, { name: "unknown output type fails", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ {Type: "unknown", Enabled: true}, }, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() // Set up paths for tests that need them (only for valid configs) if !tt.wantErr { for i := range tt.config.Outputs { if tt.config.Outputs[i].Type == "file" { if tt.config.Outputs[i].Params == nil { tt.config.Outputs[i].Params = make(map[string]string) } tt.config.Outputs[i].Params["path"] = filepath.Join(tmpDir, "test.log") } if tt.config.Outputs[i].Type == "unix_socket" { if tt.config.Outputs[i].Params == nil { tt.config.Outputs[i].Params = make(map[string]string) } tt.config.Outputs[i].Params["socket_path"] = filepath.Join(tmpDir, "test.sock") } } } _, err := builder.NewFromConfig(tt.config) if (err != nil) != tt.wantErr { t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestUnixSocketWriter(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") // Create writer (socket doesn't need to exist yet) w, err := NewUnixSocketWriter(socketPath) if err != nil { t.Fatalf("NewUnixSocketWriter() error = %v", err) } defer w.Close() rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, JA4: "test", } // Write should queue the message (won't fail if socket doesn't exist) err = w.Write(rec) if err != nil { t.Logf("Write() error (expected if socket doesn't exist) = %v", err) } // Close should clean up properly if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } } func TestUnixSocketWriterWithConfig(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") w, err := NewUnixSocketWriterWithConfig(socketPath, 1*time.Second, 1*time.Second, 100) if err != nil { t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err) } defer w.Close() if w.dialTimeout != 1*time.Second { t.Errorf("dialTimeout = %v, want 1s", w.dialTimeout) } if w.writeTimeout != 1*time.Second { t.Errorf("writeTimeout = %v, want 1s", w.writeTimeout) } } func TestUnixSocketWriter_CloseTwice(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") w, err := NewUnixSocketWriter(socketPath) if err != nil { t.Fatalf("NewUnixSocketWriter() error = %v", err) } // First close if err := w.Close(); err != nil { t.Errorf("Close() first error = %v", err) } // Second close should be safe (no-op) if err := w.Close(); err != nil { t.Errorf("Close() second error = %v", err) } } func TestUnixSocketWriter_WriteAfterClose(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") w, err := NewUnixSocketWriter(socketPath) if err != nil { t.Fatalf("NewUnixSocketWriter() error = %v", err) } if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } rec := api.LogRecord{SrcIP: "192.168.1.1"} err = w.Write(rec) if err == nil { t.Error("Write() after Close() should return error") } } // testWriter is a mock writer for testing type testWriter struct { writeFunc func(api.LogRecord) error closeFunc func() error } func (w *testWriter) Write(rec api.LogRecord) error { if w.writeFunc != nil { return w.writeFunc(rec) } return nil } func (w *testWriter) Close() error { if w.closeFunc != nil { return w.closeFunc() } return nil } // Test to verify LogRecord JSON serialization func TestLogRecordJSONSerialization(t *testing.T) { rec := api.LogRecord{ SrcIP: "192.168.1.100", SrcPort: 54321, DstIP: "10.0.0.1", DstPort: 443, IPTTL: 64, IPTotalLen: 512, IPID: 12345, IPDF: true, TCPWindow: 65535, TCPOptions: "MSS,WS,SACK,TS", JA4: "t13d1516h2_8daaf6152771_02cb136f2775", JA4Hash: "8daaf6152771_02cb136f2775", JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0", JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d", Timestamp: time.Now().UnixNano(), } data, err := json.Marshal(rec) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } // Verify it can be unmarshaled var got api.LogRecord if err := json.Unmarshal(data, &got); err != nil { t.Errorf("json.Unmarshal() error = %v", err) } // Verify key fields if got.SrcIP != rec.SrcIP { t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec.SrcIP) } if got.JA4 != rec.JA4 { t.Errorf("JA4 = %v, want %v", got.JA4, rec.JA4) } } // Test to verify optional fields are omitted when empty func TestLogRecordOptionalFieldsOmitted(t *testing.T) { rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, DstIP: "10.0.0.1", DstPort: 443, // Optional fields not set TCPMSS: nil, TCPWScale: nil, JA3: "", JA3Hash: "", } data, err := json.Marshal(rec) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } // Check that optional fields are not present in JSON jsonStr := string(data) if contains(jsonStr, `"tcp_meta_mss"`) { t.Error("tcp_meta_mss should be omitted when nil") } if contains(jsonStr, `"tcp_meta_window_scale"`) { t.Error("tcp_meta_window_scale should be omitted when nil") } } func contains(s, substr string) bool { return bytes.Contains([]byte(s), []byte(substr)) }