- Updated `RiskListAccess` and `ThresholdWriteAccess` to enforce access control in the risk repository and threshold repository, ensuring only authorized roles can perform sensitive operations. - Introduced new methods in `RiskRepository` for counting pending alerts and listing alerts with access checks, improving data security and compliance. - Enhanced the `chat.py` and `deps.py` files to integrate compliance roles into the risk management matrix, allowing for more granular access control. - Updated documentation to reflect the new testing baseline of 825 passed tests, indicating improved stability and functionality across the application. This update significantly strengthens the risk management capabilities, ensuring robust access control and compliance with organizational policies.
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),
|
||
)
|
||
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"
|