- Implemented `_merged_items` and `_merged_memory_text` functions to consolidate consult and chitchat memories, improving context awareness in intent classification and response generation. - Updated intent prompts to include recent dialogue history, aiding in the resolution of ambiguous user queries. - Enhanced `search_knowledge` tool to utilize context window for better query understanding, addressing issues with omitted references in user inputs. - Fixed existing test cases to reflect changes in intent constants and ensure accurate context handling during tests. This update significantly improves the handling of multi-turn dialogues, ensuring a more coherent and contextually aware interaction for users.
644 lines
28 KiB
Python
644 lines
28 KiB
Python
"""T-06 chat 最小闭环(会话/归属/SessionGuard/窗口/落盘)。
|
||
|
||
走 main app 真路由栈(audit+trace 中间件);仓储注入 sqlite(含
|
||
agent_session/agent_message);Redis 注入 FakeRedis(窗口读写可断言,
|
||
异常路径验证降级);agent_service 走无 key 降级路径(不依赖外网)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
from app.api import audit_middleware as audit_mod
|
||
from app.api import chat as chat_mod
|
||
from app.api import deps as deps_mod
|
||
from app.api import risk as risk_api
|
||
from app.main import app
|
||
from app.config.settings import settings
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.repository.session_repository import SessionRepository
|
||
from app.service import agent_service, customer_service as cs, memory_service, tool_service
|
||
from app.service.auth_service import issue_dev_token as _issue # noqa: F401
|
||
from app.service import auth_service
|
||
from app.service.profile_service import CustomerMemoryService
|
||
from app.service.risk import redis_gateway
|
||
|
||
|
||
class FakeCSMsg:
|
||
def __init__(self, content: str) -> None:
|
||
self.content = content
|
||
|
||
|
||
class FakeCSLLM:
|
||
"""客服 LangGraph 用 LLM 替身(按 system prompt 关键词应答)。"""
|
||
|
||
def invoke(self, messages):
|
||
system = messages[0]["content"] if messages else ""
|
||
user = messages[-1]["content"] if messages else ""
|
||
if "意图分类" in system:
|
||
return FakeCSMsg("chit_chat")
|
||
if "系统查询结果" in user or "事实数据" in user:
|
||
return FakeCSMsg("您当前没有持仓记录。")
|
||
return FakeCSMsg("您好,有什么可以帮您?")
|
||
|
||
|
||
class _FakeHotCache:
|
||
def get_style_tags(self, cid):
|
||
return {}
|
||
|
||
def get_style_tags_lazy(self, cid):
|
||
return {}
|
||
|
||
def invalidate(self, cid):
|
||
pass
|
||
|
||
|
||
class _FakeVisitorRag:
|
||
def retrieve(self, intent, query, top_k=5):
|
||
return "", []
|
||
|
||
|
||
class FakeRedis:
|
||
"""窗口 + 客服记忆 Redis 替身(可注入故障)。"""
|
||
|
||
def __init__(self):
|
||
self.strings: dict[str, str] = {}
|
||
self.lists: dict[str, list[str]] = {}
|
||
self.hashes: dict[str, dict] = {}
|
||
self.ttls: dict[str, int] = {}
|
||
self.fail = False
|
||
|
||
def _maybe_fail(self):
|
||
if self.fail:
|
||
raise ConnectionError("redis down")
|
||
|
||
def get(self, key):
|
||
return self.strings.get(key)
|
||
|
||
def setex(self, key, ttl, value):
|
||
self._maybe_fail()
|
||
self.strings[key] = value
|
||
self.ttls[key] = ttl
|
||
|
||
def incr(self, key):
|
||
self._maybe_fail()
|
||
value = int(self.strings.get(key, 0)) + 1
|
||
self.strings[key] = str(value)
|
||
return value
|
||
|
||
def hincrby(self, key, field, delta=1):
|
||
h = self.hashes.setdefault(key, {})
|
||
h[field] = int(h.get(field, 0)) + delta
|
||
return h[field]
|
||
|
||
def hgetall(self, key):
|
||
return dict(self.hashes.get(key, {}))
|
||
|
||
def rpush(self, key, *vals):
|
||
self._maybe_fail()
|
||
self.lists.setdefault(key, []).extend(vals)
|
||
|
||
def lrange(self, key, start, end):
|
||
self._maybe_fail()
|
||
lst = self.lists.get(key, [])
|
||
return lst[start:] if end == -1 else lst[start : end + 1]
|
||
|
||
def ltrim(self, key, start, end):
|
||
lst = self.lists.get(key, [])
|
||
self.lists[key] = lst[start:] if end == -1 else lst[start : end + 1]
|
||
|
||
def expire(self, key, ttl):
|
||
self._maybe_fail()
|
||
self.ttls[key] = ttl
|
||
|
||
def publish(self, *a, **k):
|
||
pass
|
||
|
||
def delete(self, *keys):
|
||
for key in keys:
|
||
self.strings.pop(key, None)
|
||
self.lists.pop(key, None)
|
||
self.hashes.pop(key, None)
|
||
|
||
def exists(self, key):
|
||
return False
|
||
|
||
def set_ex(self, *a, **k):
|
||
pass
|
||
|
||
|
||
CUSTOMER = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527", "X-Agent-Type": "customer"}
|
||
ADVISOR = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10086", "X-Agent-Type": "advisor"}
|
||
|
||
|
||
@pytest.fixture()
|
||
def env(monkeypatch):
|
||
engine = create_sqlite_engine()
|
||
repo = RiskRepository(engine=engine)
|
||
session_repo = SessionRepository(engine=engine)
|
||
core_ro = CoreReadOnlyRepository(engine=engine)
|
||
with engine.begin() as conn: # 归属种子(rbac-seed-reference 口径)
|
||
conn.execute(
|
||
text("INSERT INTO core_customer_advisor (advisor_id, customer_id, rel_status)"
|
||
" VALUES ('STAFF-10086', 'CUST-9527', 'active'),"
|
||
" ('STAFF-10087', 'CUST-1010', 'active')")
|
||
)
|
||
fake_redis = FakeRedis()
|
||
cs_llm = FakeCSLLM()
|
||
monkeypatch.setattr(settings, "deepseek_api_key", "")
|
||
monkeypatch.setattr(chat_mod, "_repo", lambda: repo)
|
||
monkeypatch.setattr(chat_mod, "_session_repo", lambda: session_repo)
|
||
monkeypatch.setattr(chat_mod, "_core_ro", lambda: core_ro)
|
||
monkeypatch.setattr(memory_service, "_session_repo", lambda: session_repo)
|
||
# T-04:Tool 节点经 tool_service 落库/查库,同一 sqlite 注入
|
||
monkeypatch.setattr(tool_service, "_session_repo", lambda: session_repo)
|
||
monkeypatch.setattr(tool_service, "_core_ro", lambda: core_ro)
|
||
# T-04 复审:越权双写共用同一 sqlite 仓储——漏打桩会写脏本机演示 MySQL
|
||
monkeypatch.setattr(tool_service, "_risk_repo", lambda: repo)
|
||
monkeypatch.setattr(risk_api, "_repo", lambda: repo)
|
||
monkeypatch.setattr(audit_mod, "_repo", lambda: repo)
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(redis_gateway, "_gateway", fake_redis)
|
||
# S2 接缝:customer 分流走 customer_service(Redis/LLM/归档替身)
|
||
monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis)
|
||
monkeypatch.setattr(cs, "_build_llm", lambda: cs_llm)
|
||
monkeypatch.setattr(cs, "CustomerMemoryService", lambda: CustomerMemoryService(redis_client=fake_redis))
|
||
monkeypatch.setattr(cs, "ProfileHotCache", lambda repo=None: _FakeHotCache())
|
||
monkeypatch.setattr(cs, "VisitorRagService", _FakeVisitorRag)
|
||
monkeypatch.setattr(cs, "_spawn", lambda fn, *a: fn(*a))
|
||
monkeypatch.setattr(cs, "archive_session", lambda *a, **k: True)
|
||
monkeypatch.setattr(cs, "archive_idle_sessions", lambda *a, **k: [])
|
||
monkeypatch.setattr(cs, "extract_profile", lambda *a, **k: [])
|
||
monkeypatch.setattr(cs, "record_intent", lambda *a, **k: None)
|
||
monkeypatch.setattr("app.tool.core_ro_tool.CoreReadOnlyRepository", lambda: core_ro)
|
||
yield {
|
||
"client": TestClient(app),
|
||
"repo": repo,
|
||
"engine": engine,
|
||
"redis": fake_redis,
|
||
"cs_llm": cs_llm,
|
||
"core_ro": core_ro,
|
||
}
|
||
engine.dispose()
|
||
|
||
|
||
def _rows(engine, sql, **params):
|
||
with engine.connect() as conn:
|
||
return [dict(r) for r in conn.execute(text(sql), params).mappings().all()]
|
||
|
||
|
||
def test_chat_new_session_creates_session_and_messages(env):
|
||
"""customer 分流(S2):会话/落盘/持仓工具链;免责声明口径随 customer_service。"""
|
||
r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["session_id"].startswith("sess-")
|
||
assert body["agent_type"] == "customer"
|
||
assert body["customer_id"] == "CUST-9527"
|
||
assert body["intent"] == "holding_query"
|
||
assert body["has_disclaimer"] is False
|
||
assert body["transfer_to_human"] is False
|
||
assert body["trace_id"] == r.headers["X-Trace-Id"]
|
||
|
||
sessions = _rows(env["engine"], "SELECT * FROM agent_session")
|
||
assert len(sessions) == 1
|
||
s = sessions[0]
|
||
assert (s["actor_id"], s["agent_type"], s["actor_role"], s["customer_id"]) == (
|
||
"CUST-9527", "customer", "customer", "CUST-9527"
|
||
)
|
||
assert s["trace_id"] == body["trace_id"] # 手册 §9:会话绑定 trace_id
|
||
msgs = _rows(
|
||
env["engine"],
|
||
"SELECT role, has_disclaimer FROM agent_message ORDER BY seq_no",
|
||
)
|
||
assert [(m["role"], m["has_disclaimer"]) for m in msgs] == [("user", 0), ("assistant", 0)]
|
||
stored = _rows(
|
||
env["engine"],
|
||
"SELECT content FROM agent_message WHERE role = 'assistant' ORDER BY seq_no LIMIT 1",
|
||
)[0]["content"]
|
||
assert body["reply"] == stored # HTTP 响应与落库正文同口径
|
||
|
||
|
||
def test_chat_sync_persist_disclaimer_when_guard_skips_append(env, monkeypatch):
|
||
"""落库在 API 层显式拼免责声明:customer 分流同样经 _assistant_content_for_persist。"""
|
||
monkeypatch.setattr(
|
||
chat_mod,
|
||
"run_customer_chat",
|
||
lambda *a, **k: ("仅正文", True, "chit_chat", False, None),
|
||
)
|
||
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
expected = f"仅正文\n\n{agent_service.CHAT_DISCLAIMER}"
|
||
assert r.json()["reply"] == expected
|
||
stored = _rows(
|
||
env["engine"],
|
||
"SELECT content, has_disclaimer FROM agent_message WHERE role = 'assistant'",
|
||
)[0]
|
||
assert stored["content"] == expected and stored["has_disclaimer"] == 1
|
||
|
||
|
||
def test_chat_resume_appends_messages(env):
|
||
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
|
||
r = env["client"].post("/api/chat", json={"message": "第二句", "session_id": sid}, headers=CUSTOMER)
|
||
assert r.status_code == 200 and r.json()["session_id"] == sid
|
||
msgs = _rows(env["engine"], "SELECT seq_no, role FROM agent_message ORDER BY seq_no")
|
||
assert [m["role"] for m in msgs] == ["user", "assistant", "user", "assistant"]
|
||
assert [m["seq_no"] for m in msgs] == [1, 2, 3, 4]
|
||
|
||
|
||
def test_chat_redis_window_written(env):
|
||
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
|
||
sid = r.json()["session_id"]
|
||
key = f"sess:customer:{sid}:msgs"
|
||
items = [json.loads(x) for x in env["redis"].lists[key]]
|
||
assert [(m["role"], m["content"]) for m in items] == [("user", "hi"), ("assistant", items[1]["content"])]
|
||
assert env["redis"].ttls[key] == 2 * 3600
|
||
|
||
|
||
def test_chat_redis_down_degrades(env, monkeypatch):
|
||
"""Redis 异常降级:窗口读写失败不阻塞对话(MySQL 为权威)。"""
|
||
env["redis"].fail = True
|
||
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_chat_session_of_other_actor_denied(env):
|
||
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
|
||
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
|
||
r = env["client"].post("/api/chat", json={"message": "续聊", "session_id": sid}, headers=other)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
|
||
assert _rows(env["engine"], "SELECT 1 FROM audit_log WHERE decision = 'forbidden'")
|
||
|
||
|
||
def test_chat_session_of_other_agent_type_denied(env):
|
||
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
|
||
r = env["client"].post(
|
||
"/api/chat",
|
||
json={"message": "续聊", "session_id": sid},
|
||
headers={**ADVISOR, "X-Agent-Type": "advisor"},
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
|
||
|
||
|
||
def test_chat_customer_forged_customer_id_denied(env):
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "hi", "customer_id": "CUST-1001"}, headers=CUSTOMER
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_OWNER"
|
||
|
||
|
||
def test_chat_advisor_assigned_customer_ok(env):
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "客户情况如何", "customer_id": "CUST-9527"}, headers=ADVISOR
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["customer_id"] == "CUST-9527"
|
||
assert body["has_disclaimer"] is False # 内部角色无免责声明
|
||
|
||
|
||
def test_chat_advisor_not_assigned_denied(env):
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "hi", "customer_id": "CUST-1010"}, headers=ADVISOR
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED"
|
||
|
||
|
||
def test_chat_agent_type_missing_or_invalid(env):
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "hi"}, headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527"}
|
||
)
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_MISSING_AGENT_TYPE"
|
||
r2 = env["client"].post("/api/chat", json={"message": "hi"}, headers={**CUSTOMER, "X-Agent-Type": "ops"})
|
||
assert r2.status_code == 400
|
||
|
||
|
||
def test_chat_agent_mismatch_debug_channel(env):
|
||
"""手册 §5.4:advisor 角色不能进 customer agent(debug 通道同样强制)。"""
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "hi"}, headers={**ADVISOR, "X-Agent-Type": "customer"}
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"
|
||
|
||
|
||
def test_chat_session_not_found_and_closed(env):
|
||
r = env["client"].post("/api/chat", json={"message": "hi", "session_id": "sess-nope"}, headers=CUSTOMER)
|
||
assert r.status_code == 404
|
||
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
with env["engine"].begin() as conn:
|
||
conn.execute(text("UPDATE agent_session SET status = 'closed' WHERE session_id = :s"), {"s": sid})
|
||
r2 = env["client"].post("/api/chat", json={"message": "hi", "session_id": sid}, headers=CUSTOMER)
|
||
assert r2.status_code == 409 and r2.json()["error_code"] == "STATE_CONFLICT"
|
||
|
||
|
||
# ---------- T-04 Tool 全链路(经真路由栈触发 Tool 节点) ----------
|
||
|
||
|
||
def test_chat_tool_holdings_full_link(env):
|
||
"""持仓关键词 → customer_service Core RO 工具链(不经 agent_tool_call 表)。"""
|
||
r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["intent"] == "holding_query"
|
||
assert "持仓" in body["reply"]
|
||
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
|
||
|
||
|
||
def test_chat_tool_no_intent_no_call(env):
|
||
r = env["client"].post("/api/chat", json={"message": "今天天气如何"}, headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
|
||
|
||
|
||
def test_chat_tool_advisor_profile_query(env):
|
||
"""advisor(已分配)测评关键词 → L0 查询 success(A-01 归属链路)。"""
|
||
with env["engine"].begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active)"
|
||
" VALUES ('CUST-9527', '张三', 45, 1)"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)"
|
||
" VALUES ('CUST-9527', 'B', '2026-08-01 10:00:00')"
|
||
)
|
||
)
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "客户的风险测评等级", "customer_id": "CUST-9527"}, headers=ADVISOR
|
||
)
|
||
assert r.status_code == 200
|
||
rows = _rows(env["engine"], "SELECT tool_name, status FROM agent_tool_call")
|
||
assert [(x["tool_name"], x["status"]) for x in rows] == [("query_customer_profile", "success")]
|
||
|
||
|
||
def test_chat_tool_risk_agent_no_intent_rule(env):
|
||
"""risk 分支无意图规则(C1/C2 接风控 Tool),不触发 Core RO 查询。"""
|
||
r = env["client"].post(
|
||
"/api/chat",
|
||
json={"message": "查一下我的持仓", "customer_id": "CUST-9527"},
|
||
headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"},
|
||
)
|
||
assert r.status_code == 200
|
||
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
|
||
|
||
|
||
def test_chat_risk_manager_entry_denied(env):
|
||
"""C5 前置(PRD 4A.1):对话线不放行 risk_manager——矩阵放行 HTTP 通道,
|
||
chat 层显式 deny 保住 FR-6 冻结口径;返回 403 + AUTH_403_ROLE,且不得建会话。"""
|
||
r = env["client"].post(
|
||
"/api/chat",
|
||
json={"message": "查一下预警台账", "customer_id": "CUST-9527"},
|
||
headers={"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"},
|
||
)
|
||
assert r.status_code == 403
|
||
assert r.json()["error_code"] == "AUTH_403_ROLE"
|
||
assert _rows(env["engine"], "SELECT 1 FROM agent_session") == []
|
||
|
||
|
||
def test_chat_compliance_entry_denied_on_risk_line(env):
|
||
"""F12:compliance 可经 risk 矩阵访问 HTTP aml 台账,但对话线仍仅 risk_officer。"""
|
||
r = env["client"].post(
|
||
"/api/chat",
|
||
json={"message": "你好"},
|
||
headers={"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"},
|
||
)
|
||
assert r.status_code == 403
|
||
assert r.json()["error_code"] == "AUTH_403_ROLE"
|
||
assert _rows(env["engine"], "SELECT 1 FROM agent_session") == []
|
||
|
||
|
||
# ---------- JWT 通道(生产主链路 · 评审 P2-3) ----------
|
||
|
||
|
||
def test_chat_jwt_customer_new_session(env):
|
||
tok = auth_service.issue_dev_token(
|
||
sub="CUST-9527", roles=["customer"], token_type="customer", customer_id="CUST-9527"
|
||
)
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "查持仓"},
|
||
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["customer_id"] == "CUST-9527"
|
||
assert body["intent"] == "holding_query"
|
||
assert body["has_disclaimer"] is False
|
||
s = _rows(env["engine"], "SELECT actor_id, actor_role FROM agent_session")[0]
|
||
assert (s["actor_id"], s["actor_role"]) == ("CUST-9527", "customer")
|
||
|
||
|
||
def test_chat_jwt_advisor_not_assigned_denied(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-10086", roles=["advisor"])
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "hi", "customer_id": "CUST-1010"},
|
||
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "advisor"},
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED"
|
||
|
||
|
||
def test_chat_jwt_agent_mismatch_denied(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
r = env["client"].post(
|
||
"/api/chat", json={"message": "hi"},
|
||
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"},
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"
|
||
|
||
|
||
# ---------- 方案 B:前端拉侧(会话列表 / 历史消息 / 关闭会话) ----------
|
||
|
||
RISK_OFFICER = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"}
|
||
RISK_MANAGER = {"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"}
|
||
COMPLIANCE_RISK = {"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"}
|
||
|
||
|
||
def test_sessions_list_only_own_and_paged(env):
|
||
"""会话列表:仅本人 + 本 Agent 线;created_at 倒序;limit/offset 分页 + total。"""
|
||
sid1 = env["client"].post("/api/chat", json={"message": "第一条"}, headers=CUSTOMER).json()["session_id"]
|
||
sid2 = env["client"].post("/api/chat", json={"message": "第二条"}, headers=CUSTOMER).json()["session_id"]
|
||
# 另一 actor 的会话不应出现在我的列表里
|
||
env["client"].post(
|
||
"/api/chat", json={"message": "别人的"},
|
||
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001", "X-Agent-Type": "customer"},
|
||
)
|
||
|
||
r = env["client"].get("/api/chat/sessions", headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["total"] == 2
|
||
assert [s["session_id"] for s in body["items"]] == [sid2, sid1] # 倒序
|
||
assert all(s["agent_type"] == "customer" and s["status"] == "active" for s in body["items"])
|
||
|
||
r2 = env["client"].get("/api/chat/sessions?limit=1&offset=1", headers=CUSTOMER)
|
||
body2 = r2.json()
|
||
assert body2["total"] == 2 and len(body2["items"]) == 1
|
||
assert body2["items"][0]["session_id"] == sid1
|
||
|
||
|
||
def test_sessions_list_risk_line_isolated(env):
|
||
"""agent_type 隔离:risk 线会话不出现在 customer 线列表(反之亦然)。"""
|
||
env["client"].post("/api/chat", json={"message": "客户线"}, headers=CUSTOMER)
|
||
r = env["client"].get("/api/chat/sessions", headers=RISK_OFFICER)
|
||
assert r.status_code == 200 and r.json()["total"] == 0
|
||
|
||
|
||
def test_messages_page_ascending_and_paged(env):
|
||
"""历史消息:seq_no 升序全量分页;has_disclaimer 透出;closed 后仍可读。"""
|
||
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
|
||
env["client"].post("/api/chat", json={"message": "第二句", "session_id": sid}, headers=CUSTOMER)
|
||
|
||
r = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["total"] == 4
|
||
assert [m["role"] for m in body["items"]] == ["user", "assistant", "user", "assistant"]
|
||
assert [m["seq_no"] for m in body["items"]] == [1, 2, 3, 4]
|
||
assert body["items"][0]["content"] == "第一句"
|
||
assert body["items"][1]["has_disclaimer"] is False # customer 线 chitchat 默认无 guard 免责声明
|
||
|
||
r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages?limit=2&offset=2", headers=CUSTOMER)
|
||
body2 = r2.json()
|
||
assert body2["total"] == 4 and [m["seq_no"] for m in body2["items"]] == [3, 4]
|
||
|
||
|
||
def test_messages_of_other_actor_denied(env):
|
||
"""SessionGuard:他人会话历史 403 + 审计留痕(与 POST 同口径)。"""
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
|
||
r = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=other)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
|
||
assert _rows(env["engine"], "SELECT 1 FROM audit_log WHERE decision = 'forbidden'")
|
||
|
||
|
||
def test_messages_not_found_and_agent_type_mismatch(env):
|
||
r = env["client"].get("/api/chat/sessions/sess-nope/messages", headers=CUSTOMER)
|
||
assert r.status_code == 404
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
# 会话在 customer 线,advisor 头查 → agent_type 不一致 403
|
||
r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=ADVISOR)
|
||
assert r2.status_code == 403 and r2.json()["error_code"] == "AUTH_403_SESSION_AGENT"
|
||
|
||
|
||
def test_close_session_then_read_only(env):
|
||
"""关闭会话:200 + closed_at 落库;续聊 409;重复关闭 409;历史仍可读。"""
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
|
||
r = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
|
||
assert r.status_code == 200 and r.json() == {"session_id": sid, "status": "closed"}
|
||
row = _rows(env["engine"], "SELECT status, closed_at FROM agent_session WHERE session_id = :s", s=sid)[0]
|
||
assert row["status"] == "closed" and row["closed_at"] is not None
|
||
|
||
r2 = env["client"].post("/api/chat", json={"message": "续聊", "session_id": sid}, headers=CUSTOMER)
|
||
assert r2.status_code == 409 and r2.json()["error_code"] == "STATE_CONFLICT"
|
||
|
||
r3 = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
|
||
assert r3.status_code == 409 and r3.json()["error_code"] == "STATE_CONFLICT"
|
||
|
||
r4 = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER)
|
||
assert r4.status_code == 200 and r4.json()["total"] == 2
|
||
|
||
|
||
def test_close_other_actor_denied(env):
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
|
||
r = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=other)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
|
||
# 未关闭成功
|
||
row = _rows(env["engine"], "SELECT status FROM agent_session WHERE session_id = :s", s=sid)[0]
|
||
assert row["status"] == "active"
|
||
|
||
|
||
def test_close_not_found_and_agent_type_mismatch(env):
|
||
"""close 端点:会话不存在 404;会话在 customer 线、advisor 头关 → 403(评审 P2)。"""
|
||
r = env["client"].post("/api/chat/sessions/sess-nope/close", headers=CUSTOMER)
|
||
assert r.status_code == 404
|
||
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
r2 = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=ADVISOR)
|
||
assert r2.status_code == 403 and r2.json()["error_code"] == "AUTH_403_SESSION_AGENT"
|
||
row = _rows(env["engine"], "SELECT status FROM agent_session WHERE session_id = :s", s=sid)[0]
|
||
assert row["status"] == "active"
|
||
|
||
|
||
def test_close_all_sessions(env):
|
||
"""批量关闭:仅 active;关闭后 status=active 列表为空。"""
|
||
sid1 = env["client"].post("/api/chat", json={"message": "a"}, headers=CUSTOMER).json()["session_id"]
|
||
sid2 = env["client"].post("/api/chat", json={"message": "b"}, headers=CUSTOMER).json()["session_id"]
|
||
r = env["client"].post("/api/chat/sessions/close-all", headers=CUSTOMER)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["closed_count"] == 2
|
||
r2 = env["client"].get("/api/chat/sessions?status=active", headers=CUSTOMER)
|
||
assert r2.json()["total"] == 0
|
||
r3 = env["client"].get("/api/chat/sessions?status=closed", headers=CUSTOMER)
|
||
assert r3.json()["total"] >= 2
|
||
closed_ids = {s["session_id"] for s in r3.json()["items"]}
|
||
assert {sid1, sid2}.issubset(closed_ids)
|
||
|
||
|
||
def test_sessions_list_status_filter(env):
|
||
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
|
||
env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
|
||
assert env["client"].get("/api/chat/sessions?status=active", headers=CUSTOMER).json()["total"] == 0
|
||
assert env["client"].get("/api/chat/sessions?status=closed", headers=CUSTOMER).json()["total"] == 1
|
||
|
||
|
||
def test_query_endpoints_limit_clamped(env):
|
||
"""分页参数钳制:limit 超上限(sessions 100 / messages 200)由 Query 拦 422(评审 P2)。"""
|
||
assert env["client"].get("/api/chat/sessions?limit=101", headers=CUSTOMER).status_code == 422
|
||
assert env["client"].get("/api/chat/sessions?offset=-1", headers=CUSTOMER).status_code == 422
|
||
r = env["client"].get("/api/chat/sessions?limit=100", headers=CUSTOMER)
|
||
assert r.status_code == 200 and r.json()["limit"] == 100
|
||
|
||
|
||
def test_query_endpoints_risk_manager_denied(env):
|
||
"""方案 B 拍板:查询/关闭端点与对话线同口径——risk_manager 一律 403 AUTH_403_ROLE。"""
|
||
for method, url in (
|
||
("get", "/api/chat/sessions"),
|
||
("get", "/api/chat/sessions/sess-x/messages"),
|
||
("post", "/api/chat/sessions/sess-x/close"),
|
||
("post", "/api/chat/sessions/close-all"),
|
||
):
|
||
r = getattr(env["client"], method)(url, headers=RISK_MANAGER)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
|
||
|
||
|
||
def test_query_endpoints_compliance_denied_on_risk_line(env):
|
||
"""F12:compliance 与 risk_manager 同口径,不得进 risk 对话线数据面。"""
|
||
for method, url in (
|
||
("get", "/api/chat/sessions"),
|
||
("post", "/api/chat/sessions/close-all"),
|
||
):
|
||
r = getattr(env["client"], method)(url, headers=COMPLIANCE_RISK)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
|
||
|
||
|
||
def test_query_endpoints_missing_agent_type(env):
|
||
r = env["client"].get(
|
||
"/api/chat/sessions",
|
||
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527"},
|
||
)
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_MISSING_AGENT_TYPE"
|
||
|
||
|
||
def test_sessions_list_jwt_channel(env):
|
||
"""JWT 通道:risk_officer 拉自己的 risk 线会话列表。"""
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
headers = {"Authorization": f"Bearer {tok}", "X-Agent-Type": "risk"}
|
||
env["client"].post("/api/chat", json={"message": "看下预警"}, headers=headers)
|
||
r = env["client"].get("/api/chat/sessions", headers=headers)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["total"] == 1 and body["items"][0]["agent_type"] == "risk"
|
||
assert body["items"][0]["actor_id"] == "STAFF-30001"
|