"""AuditEventSink / CompositeEventSink 单元测试。 审计 sink 把沙箱 tool.completed / tool.failed 转写为 AuditEvent:completed → tool.executed / allow,failed → tool.failed / deny;argv(来自 started)作为 input_summary 交 record_audit_event 脱敏。record_audit_event 的数据访问已被 mock, 本测试聚焦 sink 的「事件 → 审计字段」映射与脱敏入口。 """ from __future__ import annotations from contextlib import asynccontextmanager from unittest.mock import AsyncMock, patch from app.integrations.sandbox.audit_sink import AuditEventSink from app.integrations.sandbox.events import ( CompositeEventSink, SandboxEvent, completed_event, failed_event, started_event, ) @asynccontextmanager async def _fake_session(): yield AsyncMock() def _sink() -> AuditEventSink: return AuditEventSink(_fake_session, run_id="run_1", character_id="char_1") async def test_completed_writes_executed_allow() -> None: sink = _sink() await sink.emit( started_event( tool="shell", argv=["ls", "-la"], risk_level="L2", trace_id="t1", run_id="run_1" ) ) with patch( "app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock ) as rec: await sink.emit( completed_event( tool="shell", trace_id="t1", run_id="run_1", exit_code=0, duration_ms=42, timeout=False, truncated=False, stdout_len=10, stderr_len=0, ) ) rec.assert_awaited_once() kw = rec.call_args.kwargs assert kw["action"] == "tool.executed" assert kw["decision"] == "allow" assert kw["pivot"] == "tool_runtime" assert kw["risk_level"] == "L2" assert kw["input_summary"] == "ls -la" assert kw["resource_id"] == "shell" assert kw["trace_id"] == "t1" assert kw["character_id"] == "char_1" assert "exit=0" in kw["output_summary"] async def test_failed_writes_failed_deny() -> None: sink = _sink() await sink.emit( started_event( tool="shell", argv=["sh", "-c", "bad"], risk_level="L3", trace_id="t1", run_id="run_1" ) ) with patch( "app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock ) as rec: await sink.emit( failed_event( tool="shell", trace_id="t1", run_id="run_1", error_code="exec_timeout", error_message="执行超时", ) ) rec.assert_awaited_once() kw = rec.call_args.kwargs assert kw["action"] == "tool.failed" assert kw["decision"] == "deny" assert kw["risk_level"] == "L3" assert kw["reason"] == "exec_timeout" assert kw["output_summary"] == "执行超时" async def test_started_does_not_write_audit() -> None: """started 只缓存上下文,不写审计。""" sink = _sink() with patch( "app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock ) as rec: await sink.emit( started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1") ) rec.assert_not_awaited() async def test_completed_summary_marks_timeout_and_truncation() -> None: sink = _sink() await sink.emit( started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1") ) with patch( "app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock ) as rec: await sink.emit( completed_event( tool="shell", trace_id="t1", run_id="run_1", exit_code=None, duration_ms=5000, timeout=True, truncated=True, stdout_len=0, stderr_len=0, ) ) summary = rec.call_args.kwargs["output_summary"] assert "timeout" in summary assert "truncated" in summary async def test_empty_argv_yields_none_input_summary() -> None: """executor 路径(fetch_url 等)started argv 为空,input_summary 应为 None。""" sink = _sink() await sink.emit( started_event(tool="fetch_url", argv=[], risk_level="L2", trace_id="t1", run_id="run_1") ) with patch( "app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock ) as rec: await sink.emit( completed_event( tool="fetch_url", trace_id="t1", run_id="run_1", exit_code=0, duration_ms=100, timeout=False, truncated=False, stdout_len=5, stderr_len=0, ) ) assert rec.call_args.kwargs["input_summary"] is None async def test_unknown_event_type_ignored() -> None: """非 started/completed/failed 的事件不触发审计写入。""" sink = _sink() with patch( "app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock ) as rec: await sink.emit(SandboxEvent(type="tool.stdout", payload={"tool": "shell"})) rec.assert_not_awaited() async def test_composite_sink_fans_out_to_all() -> None: a = AsyncMock() b = AsyncMock() composite = CompositeEventSink([a, b]) ev = SandboxEvent(type="tool.started", payload={"tool": "x"}) await composite.emit(ev) a.emit.assert_awaited_once_with(ev) b.emit.assert_awaited_once_with(ev)