package audit import ( "encoding/json" "log/slog" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/enmanuel/agents/internal/config" ) func testLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) } func TestEmit_WritesToFile(t *testing.T) { dir := t.TempDir() logFile := filepath.Join(dir, "audit.jsonl") cfg := config.AuditCfg{ Enabled: true, LogFile: logFile, } w, err := New(cfg, nil, testLogger()) if err != nil { t.Fatalf("New: %v", err) } defer w.Close() w.Emit(Event{ Time: time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC), AgentID: "test-bot", EventType: EventCommandExec, SenderID: "@user:example.com", RoomID: "!room:example.com", Detail: "command=help", }) data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("ReadFile: %v", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 1 { t.Fatalf("expected 1 line, got %d", len(lines)) } var evt Event if err := json.Unmarshal([]byte(lines[0]), &evt); err != nil { t.Fatalf("Unmarshal: %v", err) } if evt.AgentID != "test-bot" { t.Errorf("AgentID = %q, want %q", evt.AgentID, "test-bot") } if evt.EventType != EventCommandExec { t.Errorf("EventType = %q, want %q", evt.EventType, EventCommandExec) } if evt.SenderID != "@user:example.com" { t.Errorf("SenderID = %q, want %q", evt.SenderID, "@user:example.com") } if evt.Detail != "command=help" { t.Errorf("Detail = %q, want %q", evt.Detail, "command=help") } } func TestEmit_IncludeFilter(t *testing.T) { dir := t.TempDir() logFile := filepath.Join(dir, "audit.jsonl") cfg := config.AuditCfg{ Enabled: true, LogFile: logFile, Include: []string{EventCommandExec, EventToolExec}, } w, err := New(cfg, nil, testLogger()) if err != nil { t.Fatalf("New: %v", err) } defer w.Close() // Should be written (in include list) w.Emit(Event{AgentID: "bot", EventType: EventCommandExec, Detail: "included"}) // Should NOT be written (not in include list) w.Emit(Event{AgentID: "bot", EventType: EventLLMRequest, Detail: "excluded"}) // Should be written w.Emit(Event{AgentID: "bot", EventType: EventToolExec, Detail: "also-included"}) data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("ReadFile: %v", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 2 { t.Fatalf("expected 2 lines (filtered), got %d: %s", len(lines), string(data)) } // Verify content var evt0, evt1 Event json.Unmarshal([]byte(lines[0]), &evt0) json.Unmarshal([]byte(lines[1]), &evt1) if evt0.EventType != EventCommandExec { t.Errorf("line 0 EventType = %q, want %q", evt0.EventType, EventCommandExec) } if evt1.EventType != EventToolExec { t.Errorf("line 1 EventType = %q, want %q", evt1.EventType, EventToolExec) } } func TestEmit_NoLogFile_OnlyRoom(t *testing.T) { var sent []string var mu sync.Mutex sender := func(roomID, msg string) { mu.Lock() sent = append(sent, roomID+"|"+msg) mu.Unlock() } cfg := config.AuditCfg{ Enabled: true, LogToRoom: "!audit:example.com", // No LogFile } w, err := New(cfg, sender, testLogger()) if err != nil { t.Fatalf("New: %v", err) } defer w.Close() w.Emit(Event{AgentID: "bot", EventType: EventMessageReceived, Detail: "test"}) mu.Lock() defer mu.Unlock() if len(sent) != 1 { t.Fatalf("expected 1 message sent, got %d", len(sent)) } if !strings.HasPrefix(sent[0], "!audit:example.com|") { t.Errorf("message sent to wrong room: %s", sent[0]) } } func TestEmit_NoRoom_OnlyFile(t *testing.T) { dir := t.TempDir() logFile := filepath.Join(dir, "audit.jsonl") senderCalled := false sender := func(roomID, msg string) { senderCalled = true } cfg := config.AuditCfg{ Enabled: true, LogFile: logFile, // No LogToRoom } w, err := New(cfg, sender, testLogger()) if err != nil { t.Fatalf("New: %v", err) } defer w.Close() w.Emit(Event{AgentID: "bot", EventType: EventCommandExec, Detail: "test"}) if senderCalled { t.Error("sender was called despite no LogToRoom configured") } data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("ReadFile: %v", err) } if len(strings.TrimSpace(string(data))) == 0 { t.Error("expected data in log file, got empty") } } func TestEmit_SetsTimeIfZero(t *testing.T) { dir := t.TempDir() logFile := filepath.Join(dir, "audit.jsonl") cfg := config.AuditCfg{ Enabled: true, LogFile: logFile, } w, err := New(cfg, nil, testLogger()) if err != nil { t.Fatalf("New: %v", err) } defer w.Close() before := time.Now().UTC() w.Emit(Event{AgentID: "bot", EventType: EventCommandExec}) after := time.Now().UTC() data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("ReadFile: %v", err) } var evt Event json.Unmarshal([]byte(strings.TrimSpace(string(data))), &evt) if evt.Time.Before(before) || evt.Time.After(after) { t.Errorf("Time %v not in range [%v, %v]", evt.Time, before, after) } } func TestEmit_EmptyInclude_PassesAll(t *testing.T) { dir := t.TempDir() logFile := filepath.Join(dir, "audit.jsonl") cfg := config.AuditCfg{ Enabled: true, LogFile: logFile, Include: []string{}, // empty = pass all } w, err := New(cfg, nil, testLogger()) if err != nil { t.Fatalf("New: %v", err) } defer w.Close() w.Emit(Event{AgentID: "bot", EventType: EventCommandExec}) w.Emit(Event{AgentID: "bot", EventType: EventLLMRequest}) w.Emit(Event{AgentID: "bot", EventType: EventToolExec}) data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("ReadFile: %v", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 3 { t.Errorf("expected 3 lines (all passed), got %d", len(lines)) } } func TestNew_CreatesDirectory(t *testing.T) { dir := t.TempDir() logFile := filepath.Join(dir, "subdir", "nested", "audit.jsonl") cfg := config.AuditCfg{ Enabled: true, LogFile: logFile, } w, err := New(cfg, nil, testLogger()) if err != nil { t.Fatalf("New: %v", err) } w.Close() if _, err := os.Stat(logFile); os.IsNotExist(err) { t.Error("log file was not created") } }