import pytest from app.core.contracts import ( AgentDefinition, AgentRequest, AgentResult, CoreResult, RecalledMemory, RequestContext, ResolvedAgentConfig, SourceReference, ) from app.core.errors import ForbiddenAgentError, RecoverableAgentError from app.core.memory_scope import REQUIRED_EMPLOYEE_PERMISSION from app.service.agent import governance as governance_module from app.service.agent.base import BaseAgent from app.service.agent.customer_service_agent import CustomerServiceAgent from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance, review_output @pytest.mark.parametrize("name", ["recall_memory", "resolve_config", "check_compliance", "bind_governance", "_execute_governed", "validate_input"]) def test_all_governance_hooks_protected(name): with pytest.raises(TypeError): type("Bypass", (BaseAgent,), {name: lambda *args: None}) @pytest.mark.asyncio async def test_visitor_does_not_recall_customer_memory() -> None: """访客不能以匿名主体标识读取任何客户记忆。""" class Demo(BaseAgent): async def handle(self, request, context): return CoreResult(text="unused") class FailingGovernance: async def recall(self, context): raise AssertionError("visitor memory recall is forbidden") definition = AgentDefinition( agent_type="demo", version="1", allowed_roles=("visitor",), allowed_portals=("api",) ) agent = Demo(definition) agent.bind_governance(FailingGovernance()) request = AgentRequest( agent_type="demo", message="公开问题", session_id="visitor-session", idempotency_key="visitor-memory-request-0001", ) context = RequestContext( user_id="visitor-id", trace_id="visitor-trace", roles=("visitor",), permissions=("agent:run",), data_scope="public", ) await agent.recall_memory(request, context) assert agent.memories == () @pytest.mark.asyncio async def test_authenticated_customer_service_does_not_recall_customer_memory() -> None: """客服即使面对已登录用户,也不得触发通用长期或画像记忆读取。""" class FailingGovernance: async def recall(self, context): raise AssertionError("customer service memory recall is forbidden") agent = CustomerServiceAgent() agent.bind_governance(FailingGovernance()) request = AgentRequest( agent_type="customer_service", message="查询我的持仓", session_id="customer-session", idempotency_key="customer-memory-request-0001", ) context = RequestContext( user_id="1", trace_id="customer-trace", roles=("customer",), permissions=("agent:run",), data_scope="self", ) await agent.recall_memory(request, context) assert agent.memories == () async def test_resolve_recall_handle_review_order_and_snapshot(governance): calls = [] config = ResolvedAgentConfig(config_version="released", prompt_version="p", model_endpoint="m") memory = RecalledMemory(memory_uuid="m1", customer_id="1", content="偏好低风险") class Governance: async def resolve(self, definition, context): calls.append("resolve") return config async def recall(self, context): calls.append("recall") return (memory,) async def review(self, result, context, resolved, memories, *, agent_type: str = ""): calls.append("review") assert resolved is config assert memories == (memory,) return review_output(result, context, resolved, memories) class Demo(BaseAgent): async def handle(self, request, context): calls.append("handle") assert self.memories == (memory,) self.config = None self.memories = () self._governance = governance return CoreResult(text="保证收益") definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("customer",), allowed_portals=("api",)) factory = AgentFactory(Governance()) factory.register(definition, lambda _: Demo(definition)) context = RequestContext(user_id="1", trace_id="t", roles=("customer",), permissions=("agent:run",)) agent = factory.create("demo", context) events = [event async for event in agent.execute( AgentRequest(agent_type="demo", session_id="s", message="test", idempotency_key="1234567890123456"), context, "r")] assert calls == ["resolve", "recall", "handle", "review"] assert "保证收益" not in events[-1].payload["result"]["result"]["text"] def test_unissued_reference_rejected_and_sensitive_numbers_redacted(): context = RequestContext(user_id="1", trace_id="t") config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m") result = AgentResult(run_id="r", result=CoreResult(text="手机号13812345678")) assert "13812345678" not in review_output(result, context, config, ()).result.text forged = result.model_copy(update={"result": CoreResult(text="test", source_references=( SourceReference(source_type="memory", source_id="other-customer"),))}) with pytest.raises(ForbiddenAgentError): review_output(forged, context, config, ()) async def test_missing_factory_dependencies_fail_closed(): class Demo(BaseAgent): async def handle(self, request, context): pytest.fail("must not execute") definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("customer",), allowed_portals=("api",)) context = RequestContext(user_id="1", trace_id="t", roles=("customer",), permissions=("agent:run",)) with pytest.raises(RecoverableAgentError): _ = [e async for e in Demo(definition).execute( AgentRequest(agent_type="demo", message="test", session_id="s", idempotency_key="1234567890123456"), context, "r")] # --- 记忆可读范围:员工按 sys_customer_assignment 归属(2026-09-14 拍板口径) ------------- # # 修复前的行为:`recall()` 把 `int(context.user_id)` 当客户号,于是员工身份**恒空**, # 且员工号与客户号同号段时会读到陌生客户的记忆并注入提示词。下面这些用例把 # "只能读到该读的"钉死。 class _StubItem: def __init__(self, memory_uuid: str, content: str, confidence: float) -> None: self.memory_uuid = memory_uuid self.content = content self.confidence = confidence class _StubResult: def __init__(self, items: list[_StubItem]) -> None: self.items = tuple(items) self.degraded = False self.degraded_reasons: tuple[str, ...] = () self.from_cache = False class _StubRecallService: """按客户号返回记忆的召回服务替身;记录被查过哪些客户。""" def __init__(self, by_customer: dict[int, list[_StubItem]]) -> None: self.by_customer = by_customer self.queried: list[int] = [] async def recall(self, customer_id: int) -> _StubResult: self.queried.append(customer_id) return _StubResult(self.by_customer.get(customer_id, [])) class _DummySession: """`PlatformGovernance.recall` 会 `async with SessionFactory() as session`; 召回服务是替身、不碰这个 session,所以给个空壳即可,测试不连库。""" async def __aenter__(self) -> None: return None async def __aexit__(self, *exc: object) -> bool: return False def _governance_with( monkeypatch: pytest.MonkeyPatch, service: _StubRecallService, ) -> PlatformGovernance: monkeypatch.setattr(governance_module, "SessionFactory", lambda: _DummySession()) return PlatformGovernance(recall_factory=lambda _session: service) # type: ignore[arg-type,return-value] def _employee_context(user_id: str, customer_ids: tuple[str, ...]) -> RequestContext: """员工身份上下文:带 `memory:read:customer` 能力码(读他人客户记忆的授权码)。""" return RequestContext( user_id=user_id, trace_id="t", roles=("advisor",), customer_ids=customer_ids, permissions=(REQUIRED_EMPLOYEE_PERMISSION,), ) async def test_employee_recall_reads_assigned_customers( monkeypatch: pytest.MonkeyPatch, ) -> None: """员工(投顾/风控)按归属读到客户记忆,且每条记忆标注其真实归属客户号。""" service = _StubRecallService({ 9001: [_StubItem("uuid-a", "偏好进取型", 0.9)], 9102: [_StubItem("uuid-b", "持有约三年", 0.5)], }) governance = _governance_with(monkeypatch, service) context = _employee_context("9020", ("9102", "9001")) memories = await governance.recall(context) assert service.queried == [9001, 9102] # 升序,确定性 # 置信度降序:uuid-a(0.9) 在前;归属客户号必须逐条正确,否则提示词会张冠李戴。 assert [(memory.memory_uuid, memory.customer_id) for memory in memories] == [ ("uuid-a", "9001"), ("uuid-b", "9102"), ] async def test_employee_without_assignment_reads_nothing_and_skips_database( monkeypatch: pytest.MonkeyPatch, ) -> None: """归属未维护 ⇒ 失败关闭,且**连召回服务都不该被调用**(不做无谓的库/向量查询)。""" service = _StubRecallService({9020: [_StubItem("uuid-self", "不该被读到", 0.9)]}) governance = _governance_with(monkeypatch, service) context = _employee_context("9020", ()) memories = await governance.recall(context) assert memories == () # 关键:员工号 9020 没有被当成客户号 —— 否则这里会读到"同号客户"的记忆。 assert service.queried == [] async def test_employee_without_capability_reads_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: """有归属行但缺 `memory:read:customer` 能力码 ⇒ 同样失败关闭,且不查库。""" service = _StubRecallService({9001: [_StubItem("uuid-a", "不该被读到", 0.9)]}) governance = _governance_with(monkeypatch, service) context = RequestContext( user_id="9005", trace_id="t", roles=("operator",), customer_ids=("9001",), permissions=("offsite:write",), ) memories = await governance.recall(context) assert memories == () assert service.queried == [] async def test_customer_recall_ignores_assignments( monkeypatch: pytest.MonkeyPatch, ) -> None: """客户身份只读自己:分配表里有别行也不能顺带读别人。""" service = _StubRecallService({ 9001: [_StubItem("uuid-self", "自己的偏好", 0.9)], 9102: [_StubItem("uuid-other", "别人的偏好", 0.9)], }) governance = _governance_with(monkeypatch, service) context = RequestContext(user_id="9001", trace_id="t", roles=("customer",), customer_ids=("9102",)) memories = await governance.recall(context) assert service.queried == [9001] assert [memory.memory_uuid for memory in memories] == ["uuid-self"] def test_review_output_accepts_assigned_customer_memory_reference() -> None: """员工引用**归属客户**的记忆不算伪造引用;引用非归属客户的记忆必须被拦。""" config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m") context = _employee_context("9020", ("9001",)) recalled = (RecalledMemory(memory_uuid="uuid-a", customer_id="9001", content="偏好进取型"),) def _result(memory_uuid: str) -> AgentResult: return AgentResult(run_id="r", result=CoreResult( text="分析如下", source_references=(SourceReference(source_type="memory", source_id=memory_uuid),), )) # 归属客户:放行(修复前 known 恒空,这里会抛 ForbiddenAgentError)。 assert review_output(_result("uuid-a"), context, config, recalled).result.text == "分析如下" # 非归属客户:即便被塞进 memories 也必须被拦 —— 范围判定与召回共用同一口径。 forged = (RecalledMemory(memory_uuid="uuid-x", customer_id="9999", content="他人记忆"),) with pytest.raises(ForbiddenAgentError): review_output(_result("uuid-x"), context, config, forged) async def test_recall_scope_guard_rejects_memory_outside_assignments() -> None: """`BaseAgent` 的范围守卫:越界记忆必须让整条运行失败关闭。""" class StubGovernance: async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]: return (RecalledMemory(memory_uuid="uuid-x", customer_id="9999", content="他人记忆"),) class Demo(BaseAgent): async def handle(self, request, context): pytest.fail("越界记忆不得进入 handle") definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("advisor",), allowed_portals=("api",)) agent = Demo(definition) agent.bind_governance(StubGovernance()) # type: ignore[arg-type] request = AgentRequest(agent_type="demo", message="看看客户", session_id="s", idempotency_key="recall-scope-guard-0001") with pytest.raises(RecoverableAgentError, match="越过客户范围"): await agent.recall_memory(request, _employee_context("9020", ("9001",)))