"""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.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.repository.session_repository import SessionRepository from app.service import memory_service from app.service import tool_service from app.service.auth_service import issue_dev_token as _issue # noqa: F401 from app.service import auth_service from app.service.risk import redis_gateway class FakeRedis: """窗口语义:rpush/lrange/ltrim/expire 最小实现 + 可注入故障。""" def __init__(self): self.lists: dict[str, list[str]] = {} self.ttls: dict[str, int] = {} self.fail = False def _maybe_fail(self): if self.fail: raise ConnectionError("redis down") 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, *a, **k): pass 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() 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) yield {"client": TestClient(app), "repo": repo, "engine": engine, "redis": fake_redis} 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): 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["has_disclaimer"] is True # 客户对外口径(降级回复同样经 guard) 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", 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): """持仓关键词触发 Tool:agent_tool_call 落库(trace/session 贯通)+ 降级回复带摘要。""" r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER) assert r.status_code == 200 body = r.json() rows = _rows(env["engine"], "SELECT * FROM agent_tool_call") assert len(rows) == 1 row = rows[0] assert (row["session_id"], row["trace_id"], row["tool_name"], row["status"]) == ( body["session_id"], body["trace_id"], "query_holdings", "success" ) assert "LLM 未配置" in body["reply"] # env 无 key 走降级 assert "持仓查询" in body["reply"] # 降级回复携带 Tool 摘要(env 无持仓种子 → 0 笔) 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") == [] # ---------- 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" and body["has_disclaimer"] is True 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"} 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 True # customer 线 assistant 带免责声明 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_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"), ): 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_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"