178 lines
5.6 KiB
Python
178 lines
5.6 KiB
Python
"""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)
|