Files
ja4sentinel/internal/output/writers_test.go
Jacquin Antoine fec500ba46
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
fix: correction race conditions et amélioration robustesse
- Correction race condition dans tlsparse avec mutex par ConnectionFlow
- Fix fuite mémoire buffer HelloBuffer
- Ajout rotation de fichiers logs (100MB, 3 backups)
- Implémentation queue asynchrone avec reconnexion exponentielle (socket UNIX)
- Validation BPF (caractères, longueur, parenthèses)
- Augmentation snapLen pcap de 1600 à 65535 bytes
- Permissions fichiers sécurisées (0600)
- Ajout 46 tests unitaires (capture, output, logging)
- Passage go test -race sans erreur

Tests: go test -race ./... ✓
Build: go build ./... ✓
Lint: go vet ./... ✓

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-28 21:15:45 +01:00

471 lines
10 KiB
Go

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