package output import ( "bytes" "encoding/json" "net" "os" "path/filepath" "testing" "time" "sync" "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, }, { name: "unix socket with custom AsyncBuffer", config: api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ { Type: "unix_socket", Enabled: true, AsyncBuffer: 5000, Params: map[string]string{"socket_path": "test.sock"}, }, }, }, wantErr: false, }, } 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", // New fields per architecture.yml ConnID: "flow-abc123", SensorID: "sensor-01", TLSVersion: "1.3", SNI: "example.com", ALPN: "h2", // Fingerprints - note: JA4Hash is NOT in LogRecord per architecture JA4: "t13d1516h2_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) } // Verify JA4Hash is NOT present (architecture decision) // JA4Hash field doesn't exist in LogRecord anymore // Verify new fields if got.ConnID != rec.ConnID { t.Errorf("ConnID = %v, want %v", got.ConnID, rec.ConnID) } if got.SNI != rec.SNI { t.Errorf("SNI = %v, want %v", got.SNI, rec.SNI) } } // 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)) } // TestUnixSocketWriter_ErrorCallback tests that errors are reported via callback func TestUnixSocketWriter_ErrorCallback(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "nonexistent.sock") // Track callback invocations var errorCalls []struct { path string err error attempt int } callback := func(path string, err error, attempt int) { errorCalls = append(errorCalls, struct { path string err error attempt int }{path, err, attempt}) } w, err := NewUnixSocketWriterWithConfig( socketPath, 100*time.Millisecond, 100*time.Millisecond, 10, WithErrorCallback(callback), ) if err != nil { t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err) } defer w.Close() rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, JA4: "test", } // Write should queue the message err = w.Write(rec) if err != nil { t.Errorf("Write() unexpected error = %v", err) } // Wait for queue processor to attempt write and trigger callback time.Sleep(500 * time.Millisecond) // Callback should have been invoked at least once if len(errorCalls) == 0 { t.Error("ErrorCallback was not invoked") } else { // Verify callback parameters lastCall := errorCalls[len(errorCalls)-1] if lastCall.path != socketPath { t.Errorf("Callback path = %v, want %v", lastCall.path, socketPath) } if lastCall.err == nil { t.Error("Callback err should not be nil") } if lastCall.attempt < 1 { t.Errorf("Callback attempt = %d, want >= 1", lastCall.attempt) } } } // TestBuilder_WithErrorCallback tests that the builder propagates error callbacks func TestBuilder_WithErrorCallback(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") callback := func(path string, err error, attempt int) { // Callback tracked for verification } builder := NewBuilder().WithErrorCallback(callback) config := api.AppConfig{ Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, }, Outputs: []api.OutputConfig{ { Type: "unix_socket", Enabled: true, AsyncBuffer: 100, Params: map[string]string{"socket_path": socketPath}, }, }, } writer, err := builder.NewFromConfig(config) if err != nil { t.Fatalf("NewFromConfig() error = %v", err) } // Verify writer is a MultiWriter mw, ok := writer.(*MultiWriter) if !ok { t.Fatal("Writer is not a MultiWriter") } // Verify the UnixSocketWriter has the callback set if len(mw.writers) != 1 { t.Fatalf("Expected 1 writer, got %d", len(mw.writers)) } unixWriter, ok := mw.writers[0].(*UnixSocketWriter) if !ok { t.Fatal("Writer is not a UnixSocketWriter") } if unixWriter.errorCallback == nil { t.Error("UnixSocketWriter.errorCallback is nil") } _ = writer } // TestUnixSocketWriter_NoCallback tests that writer works without callback func TestUnixSocketWriter_NoCallback(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "nonexistent.sock") // Create writer without callback 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 not panic even without callback err = w.Write(rec) if err != nil { t.Logf("Write() error (expected) = %v", err) } // Give queue processor time to run time.Sleep(100 * time.Millisecond) // Should not panic } // TestUnixSocketWriter_CallbackResetOnSuccess tests that failure counter resets on success func TestUnixSocketWriter_CallbackResetOnSuccess(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") // Create a real DGRAM (unixgram) socket addr, err := net.ResolveUnixAddr("unixgram", socketPath) if err != nil { t.Fatalf("Failed to resolve unixgram address: %v", err) } listener, err := net.ListenUnixgram("unixgram", addr) if err != nil { t.Fatalf("Failed to create unixgram socket: %v", err) } defer listener.Close() // Start a goroutine to read datagrams done := make(chan struct{}) go func() { buf := make([]byte, 65536) for { select { case <-done: return default: } listener.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) _, _, _ = listener.ReadFrom(buf) } }() defer close(done) var errorCalls int callback := func(path string, err error, attempt int) { errorCalls++ } w, err := NewUnixSocketWriterWithConfig( socketPath, 100*time.Millisecond, 100*time.Millisecond, 10, WithErrorCallback(callback), ) if err != nil { t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err) } defer w.Close() // Write successfully rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, JA4: "test", } err = w.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Wait for write to complete time.Sleep(200 * time.Millisecond) // Callback should not have been called since connection succeeded if errorCalls > 0 { t.Errorf("ErrorCallback called %d times, want 0 for successful connection", errorCalls) } } // TestFileWriter_Reopen tests the Reopen method for logrotate support func TestFileWriter_Reopen(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) } // Write initial data rec1 := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, JA4: "test1", } err = w.Write(rec1) if err != nil { t.Errorf("Write() error = %v", err) } // Reopen the file (for logrotate - file is typically moved externally) err = w.Reopen() if err != nil { t.Errorf("Reopen() error = %v", err) } // Write more data after reopen rec2 := api.LogRecord{ SrcIP: "192.168.1.2", SrcPort: 54321, JA4: "test2", } err = w.Write(rec2) if err != nil { t.Errorf("Write() after reopen error = %v", err) } // Close and verify if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } // Read the file - should contain both records (Reopen uses O_APPEND) data, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read file: %v", err) } // Parse JSON lines lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) if len(lines) != 2 { t.Fatalf("Expected 2 lines, got %d", len(lines)) } // Verify second record var got api.LogRecord if err := json.Unmarshal(lines[1], &got); err != nil { t.Errorf("Invalid JSON on line 2: %v", err) } if got.SrcIP != rec2.SrcIP { t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec2.SrcIP) } } // TestFileWriter_Rotate tests the log rotation functionality func TestFileWriter_Rotate(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.log") // Create writer with very small max size to trigger rotation // Minimum useful size is ~100 bytes for a log record w, err := NewFileWriterWithConfig(testFile, 200, 3) if err != nil { t.Fatalf("NewFileWriterWithConfig() error = %v", err) } // Write multiple records to trigger rotation records := []api.LogRecord{ {SrcIP: "192.168.1.1", SrcPort: 1111, JA4: "record1"}, {SrcIP: "192.168.1.2", SrcPort: 2222, JA4: "record2"}, {SrcIP: "192.168.1.3", SrcPort: 3333, JA4: "record3"}, {SrcIP: "192.168.1.4", SrcPort: 4444, JA4: "record4"}, } for i, rec := range records { err = w.Write(rec) if err != nil { t.Errorf("Write() record %d error = %v", i, err) } } if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } // Check that rotation occurred (backup file should exist) backupFile := testFile + ".1" if _, err := os.Stat(backupFile); os.IsNotExist(err) { t.Log("Note: Rotation may not have occurred if total data < maxSize") } // Verify main file exists and has content if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Errorf("Main file %s does not exist", testFile) } } // TestFileWriter_Rotate_MaxBackups tests that old backups are cleaned up func TestFileWriter_Rotate_MaxBackups(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.log") // Create writer with small max size and only 2 backups w, err := NewFileWriterWithConfig(testFile, 150, 2) if err != nil { t.Fatalf("NewFileWriterWithConfig() error = %v", err) } // Write enough records to trigger multiple rotations for i := 0; i < 10; i++ { rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: uint16(1000 + i), JA4: "test", } err = w.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } } if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } // Count backup files backupCount := 0 for i := 1; i <= 5; i++ { backupPath := testFile + "." + string(rune('0'+i)) if _, err := os.Stat(backupPath); err == nil { backupCount++ } } // Should have at most 2 backups if backupCount > 2 { t.Errorf("Too many backup files: %d, want <= 2", backupCount) } } // TestFileWriter_Reopen_Error tests Reopen after external file removal func TestFileWriter_Reopen_Error(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) } // Write initial data rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, JA4: "test", } err = w.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Remove the file externally (simulating logrotate move) os.Remove(testFile) // Reopen should succeed - it will create a new file err = w.Reopen() if err != nil { t.Errorf("Reopen() should succeed after file removal, error = %v", err) } if err := w.Close(); err != nil { t.Errorf("Close() error = %v", err) } } // TestFileWriter_NewFileWriterWithConfig tests custom configuration func TestFileWriter_NewFileWriterWithConfig(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.log") // Test with custom max size and backups w, err := NewFileWriterWithConfig(testFile, 50*1024*1024, 5) if err != nil { t.Fatalf("NewFileWriterWithConfig() error = %v", err) } defer w.Close() if w.maxSize != 50*1024*1024 { t.Errorf("maxSize = %d, want %d", w.maxSize, 50*1024*1024) } if w.maxBackups != 5 { t.Errorf("maxBackups = %d, want 5", w.maxBackups) } } // TestFileWriter_NewFileWriterWithConfig_InvalidPath tests error handling func TestFileWriter_NewFileWriterWithConfig_InvalidPath(t *testing.T) { // Try to create file in a path that should fail (e.g., /proc which is read-only) _, err := NewFileWriterWithConfig("/proc/test/test.log", 1024, 3) if err == nil { t.Error("NewFileWriterWithConfig() with invalid path should return error") } } // TestMultiWriter_Reopen tests Reopen on MultiWriter func TestMultiWriter_Reopen(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.log") mw := NewMultiWriter() // Add a FileWriter (which is Reopenable) fw, err := NewFileWriter(testFile) if err != nil { t.Fatalf("NewFileWriter() error = %v", err) } mw.Add(fw) // Add a StdoutWriter (which is NOT Reopenable) mw.Add(NewStdoutWriter()) // Write initial data rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: 12345, JA4: "test", } err = mw.Write(rec) if err != nil { t.Errorf("Write() error = %v", err) } // Reopen should work (FileWriter is reopenable, StdoutWriter is skipped) err = mw.Reopen() if err != nil { t.Errorf("Reopen() error = %v", err) } // Write after reopen rec2 := api.LogRecord{ SrcIP: "192.168.1.2", SrcPort: 54321, JA4: "test2", } err = mw.Write(rec2) if err != nil { t.Errorf("Write() after reopen error = %v", err) } if err := mw.CloseAll(); err != nil { t.Errorf("CloseAll() error = %v", err) } } // TestUnixSocketWriter_QueueFull tests behavior when queue is full func TestUnixSocketWriter_QueueFull(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "test.sock") // Create writer with very small queue w, err := NewUnixSocketWriterWithConfig(socketPath, 10*time.Millisecond, 10*time.Millisecond, 2) if err != nil { t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err) } defer w.Close() // Fill the queue with records for i := 0; i < 10; i++ { rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: uint16(1000 + i), JA4: "test", } _ = w.Write(rec) // May succeed or fail depending on queue state } // Should not panic - queue full messages are dropped } // TestUnixSocketWriter_ReconnectBackoff tests exponential backoff behavior func TestUnixSocketWriter_ReconnectBackoff(t *testing.T) { tmpDir := t.TempDir() socketPath := filepath.Join(tmpDir, "nonexistent.sock") var errorCount int callback := func(path string, err error, attempt int) { errorCount++ } w, err := NewUnixSocketWriterWithConfig( socketPath, 10*time.Millisecond, 10*time.Millisecond, 5, WithErrorCallback(callback), ) if err != nil { t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err) } defer w.Close() // Write multiple records to trigger reconnection attempts for i := 0; i < 3; i++ { rec := api.LogRecord{ SrcIP: "192.168.1.1", SrcPort: uint16(1000 + i), JA4: "test", } _ = w.Write(rec) } // Wait for queue processor to attempt writes time.Sleep(500 * time.Millisecond) // Should have attempted reconnection if errorCount == 0 { t.Error("Expected at least one error callback for nonexistent socket") } } // TestUnixSocketWriter_WriteConcurrentClose_NoPanic verifies the Bug 12 fix: // concurrent Write() and Close() must not panic with "send on closed channel". // The test spins many goroutines calling Write() while a Close() races against them. func TestUnixSocketWriter_WriteConcurrentClose_NoPanic(t *testing.T) { const workers = 20 const iterations = 100 for trial := 0; trial < 5; trial++ { w, err := NewUnixSocketWriterWithConfig( "/tmp/bug12_test.sock", time.Second, time.Second, 128, ) if err != nil { t.Fatalf("NewUnixSocketWriterWithConfig: %v", err) } rec := api.LogRecord{SrcIP: "1.2.3.4", JA4: "t13d_test"} var wg sync.WaitGroup wg.Add(workers) for i := 0; i < workers; i++ { go func() { defer wg.Done() for j := 0; j < iterations; j++ { _ = w.Write(rec) // may return error but must not panic } }() } // Close races with the writes. time.Sleep(time.Millisecond) if err := w.Close(); err != nil { t.Errorf("Close() error: %v", err) } wg.Wait() } }