package engine import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/security" ) // ─── LocalAuditSink ─────────────────────────────────────────────────────────── func TestLocalAuditSink_WriteAndRead(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.jsonl") sink, err := NewLocalAuditSink(path) if err != nil { t.Fatalf("create sink: %v", err) } defer sink.Close() entry := security.AuditEntry{ SessionID: "sess-001", TurnNumber: 3, Timestamp: time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC), ToolName: "Write", Operation: "write", Resource: "/tmp/test.go", Outcome: "allowed", } if err := sink.Write(entry); err != nil { t.Fatalf("write entry: %v", err) } if err := sink.Close(); err != nil { t.Fatalf("close sink: %v", err) } // 读取并验证 JSONL 内容 data, err := os.ReadFile(path) if err != nil { t.Fatalf("read file: %v", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 1 { t.Fatalf("expected 1 line, got %d", len(lines)) } var got security.AuditEntry if err := json.Unmarshal([]byte(lines[0]), &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.SessionID != "sess-001" { t.Errorf("session_id: got %q, want %q", got.SessionID, "sess-001") } if got.TurnNumber != 3 { t.Errorf("turn_number: got %d, want 3", got.TurnNumber) } if got.ToolName != "Write" { t.Errorf("tool_name: got %q, want 'Write'", got.ToolName) } if got.Outcome != "allowed" { t.Errorf("outcome: got %q, want 'allowed'", got.Outcome) } } func TestLocalAuditSink_MultipleEntries(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.jsonl") sink, err := NewLocalAuditSink(path) if err != nil { t.Fatalf("create sink: %v", err) } defer sink.Close() for i := 0; i < 3; i++ { if err := sink.Write(security.AuditEntry{ ToolName: "Write", Operation: "write", Resource: "/file", Outcome: "allowed", Timestamp: time.Now().UTC(), }); err != nil { t.Fatalf("write entry %d: %v", i, err) } } sink.Close() data, _ := os.ReadFile(path) lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 3 { t.Errorf("expected 3 lines, got %d", len(lines)) } } func TestLocalAuditSink_CreatesParentDir(t *testing.T) { dir := t.TempDir() // 写入不存在的子目录 path := filepath.Join(dir, "subdir", "nested", "audit.jsonl") sink, err := NewLocalAuditSink(path) if err != nil { t.Fatalf("should auto-create parent dirs: %v", err) } sink.Close() if _, err := os.Stat(path); os.IsNotExist(err) { t.Error("file should exist after sink creation") } } func TestLocalAuditSink_AppendMode(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.jsonl") // 第一次写入 sink1, _ := NewLocalAuditSink(path) sink1.Write(security.AuditEntry{ToolName: "Write", Outcome: "allowed", Timestamp: time.Now().UTC()}) //nolint:errcheck sink1.Close() // 第二次写入(追加,不覆盖) sink2, _ := NewLocalAuditSink(path) sink2.Write(security.AuditEntry{ToolName: "Edit", Outcome: "blocked", Timestamp: time.Now().UTC()}) //nolint:errcheck sink2.Close() data, _ := os.ReadFile(path) lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 2 { t.Errorf("expected 2 lines (append mode), got %d", len(lines)) } } func TestLocalAuditSink_ExtraFields(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.jsonl") sink, _ := NewLocalAuditSink(path) defer sink.Close() entry := security.AuditEntry{ ToolName: "Write", Operation: "write", Resource: "/warehouse/inventory.csv", Outcome: "allowed", Timestamp: time.Now().UTC(), Extra: map[string]string{ "sku": "SKU-001", "qty_delta": "-20", }, } sink.Write(entry) //nolint:errcheck sink.Close() data, _ := os.ReadFile(path) var got security.AuditEntry json.Unmarshal([]byte(strings.TrimSpace(string(data))), &got) //nolint:errcheck if got.Extra["sku"] != "SKU-001" { t.Errorf("extra.sku: got %q, want SKU-001", got.Extra["sku"]) } } // ─── AuditObserver ──────────────────────────────────────────────────────────── // captureSink 是测试用的 AuditSink,捕获所有写入的记录. type captureSink struct { entries []security.AuditEntry } func (c *captureSink) Write(e security.AuditEntry) error { c.entries = append(c.entries, e) return nil } func (c *captureSink) Close() error { return nil } func TestAuditObserver_OperationRecorded_Allowed(t *testing.T) { sink := &captureSink{} obs := NewAuditObserver(sink, "sess-test") obs.Event("operation_recorded", map[string]any{ "tool": "Write", "status": "success", "message_id": "msg-001", "resource": "/some/file.go", }) if len(sink.entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(sink.entries)) } e := sink.entries[0] if e.Outcome != "allowed" { t.Errorf("outcome: got %q, want allowed", e.Outcome) } if e.ToolName != "Write" { t.Errorf("tool_name: got %q, want Write", e.ToolName) } if e.Operation != "write" { t.Errorf("operation: got %q, want write", e.Operation) } } func TestAuditObserver_OperationRecorded_Failed(t *testing.T) { sink := &captureSink{} obs := NewAuditObserver(sink, "") obs.Event("operation_recorded", map[string]any{ "tool": "Write", "status": "failed", "reason": "secret_detected:github-pat", }) if len(sink.entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(sink.entries)) } if sink.entries[0].Outcome != "blocked" { t.Errorf("outcome: got %q, want blocked", sink.entries[0].Outcome) } } func TestAuditObserver_SecretScanBlocked(t *testing.T) { sink := &captureSink{} obs := NewAuditObserver(sink, "sess-123") obs.Event("secret_scan_blocked", map[string]any{ "tool": "Write", "path": "/secret/config.yml", "count": 2, // rule_ids 不落盘,只记录命中数量(安全审计够用) }) if len(sink.entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(sink.entries)) } e := sink.entries[0] if e.Outcome != "blocked" { t.Errorf("outcome: got %q, want blocked", e.Outcome) } if !strings.Contains(e.Reason, "count=2") { t.Errorf("reason should contain count: %s", e.Reason) } if e.Resource != "/secret/config.yml" { t.Errorf("resource: got %q, want /secret/config.yml", e.Resource) } } func TestAuditObserver_UnknownEventIgnored(t *testing.T) { sink := &captureSink{} obs := NewAuditObserver(sink, "") obs.Event("some_other_event", map[string]any{"foo": "bar"}) if len(sink.entries) != 0 { t.Errorf("unknown events should not produce audit entries") } } func TestAuditObserver_NilSink(t *testing.T) { // nil sink 应退化为 NoopAuditSink,不 panic obs := NewAuditObserver(nil, "") obs.Event("operation_recorded", map[string]any{ "tool": "Write", "status": "success", }) // 不 panic 即通过 } // ─── CompositeAuditSink ─────────────────────────────────────────────────────── func TestCompositeAuditSink_WritesAll(t *testing.T) { sink1 := &captureSink{} sink2 := &captureSink{} composite := security.NewCompositeAuditSink(sink1, sink2) composite.Write(security.AuditEntry{ToolName: "Write", Outcome: "allowed", Timestamp: time.Now().UTC()}) //nolint:errcheck if len(sink1.entries) != 1 || len(sink2.entries) != 1 { t.Errorf("composite should write to all sinks: sink1=%d sink2=%d", len(sink1.entries), len(sink2.entries)) } } // ─── fallbackOperationFromTool ──────────────────────────────────────────────── // TestFallbackOperationFromTool 验证启发式兜底的命名约定识别. // L1191 重构后 operationFromTool 改为 AuditObserver 方法, 启发式部分保留 // 为 fallbackOperationFromTool 包级函数 (零回归 + trip-wire). func TestFallbackOperationFromTool(t *testing.T) { cases := []struct{ tool, want string }{ {"Write", "write"}, {"FileWrite", "write"}, {"Edit", "edit"}, {"FileEdit", "edit"}, {"Read", "read"}, {"Glob", "read"}, {"Grep", "read"}, {"Bash", "execute"}, {"CustomTool", "invoke"}, } for _, tc := range cases { got := fallbackOperationFromTool(tc.tool) if got != tc.want { t.Errorf("fallbackOperationFromTool(%q) = %q, want %q", tc.tool, got, tc.want) } } }