Files
2026-08-21 16:00:13 +08:00

178 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)