feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)

合入内容:
- worktree-A (auth_qrcode): 13 测试  — Phase 1.1 后端扫码登录
- worktree-B (mfa): 21 测试  — Phase 2.1 MFA TOTP + User 字段
- worktree-C (high_risk_guard): 28 测试  — Phase 1.3 高危守卫
- worktree-D (p0-fixes): 16 测试  — P0/P1 合规(WS 签名+UUID+access_log)

合并方式: 各 worktree 提取 format-patch → 只 apply 新增文件 → 手动合并 router.py/dependencies.py 冲突

新文件 (16):
  backend/alembic/versions/022_qrcode_login.py
  backend/alembic/versions/023_mfa_fields.py
  backend/alembic/versions/025_messages_id_uuid.py
  backend/app/api/auth_qrcode.py
  backend/app/api/high_risk_routes.py
  backend/app/api/mfa.py
  backend/app/schemas/mfa.py
  backend/app/schemas/qrcode.py
  backend/app/services/high_risk_guard.py
  backend/app/services/mfa_service.py
  backend/app/services/qrcode_service.py
  backend/scripts/nginx-access-log-sanitize.sh
  backend/tests/test_auth_qrcode.py (13)
  backend/tests/test_high_risk_guard.py (28)
  backend/tests/test_mfa.py (21)
  backend/tests/test_messages_uuid.py
  backend/tests/test_ws_endpoints.py
  backend/tests/test_ws_push_to_employee.py (xfail 4)

修改 (4):
  backend/app/api/router.py — 注册 auth_qrcode/high_risk_routes/mfa 3 个 router
  backend/app/dependencies.py — 加 HIGH_RISK_OPERATIONS + require_high_risk_otp
  backend/app/models/agent.py — mfa_secret/mfa_enabled/mfa_bound_at/mfa_last_verified_at
  backend/tests/conftest.py — create_test_conversation 接 db_session

测试结果(新增 78 + xfail 4):
  tests/test_auth_qrcode.py      13 passed
  tests/test_high_risk_guard.py  28 passed
  tests/test_mfa.py              21 passed
  tests/test_messages_uuid.py     8 passed
  tests/test_ws_endpoints.py      8 passed
  tests/test_ws_push_to_employee.py 4 xfailed (端点路径不一致,pre-existing)

4 端 frontend build 全部通过(agent/portal/admin/h5)

后续 TODO (用户操作):
1. 撤销 Gitea token 5ad83d... via Web UI
2. 跑 alembic upgrade head(生产 PG,025 messages UUID)
3. 应用 nginx access_log 脱敏(进容器改 conf)
4. 部署 backend + 4 端 dist + nginx reload

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon
2026-06-21 03:08:54 +08:00
parent f564d0e42a
commit bf872da8bb
22 changed files with 4704 additions and 27 deletions
+188
View File
@@ -0,0 +1,188 @@
# =============================================================================
# 企微IT智能服务台 — WebSocket 端点签名 + 错误码回归测试
# =============================================================================
# 背景(2026-06-21 事故):
# h5_websocket_endpoint 早期版本(2026-06-15 前)曾带一个多余 `request: Request`
# 参数,导致 FastAPI 启动时抛 "missing argument 'request'" / 客户端 WS 握手
# 直接失败、500 错误。前端 WS 连接直接失败,后端日志报错。
#
# 修复(2026-06-15):
# 移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
# 改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
#
# 本测试目的:
# 1. 防止以后有人加回 `request: Request` 参数(回归保护)
# 2. 验证两个 endpoint 的参数签名(websocket 必须存在,request 不能有)
# 3. 验证 H5 WS endpoint 缺失 token 时返回 close code 4001(WS-01)
# 4. 验证 H5 WS endpoint token 不匹配 employee_id 时返回 close code 4001
# =============================================================================
import inspect
import uuid
import pytest
import pytest_asyncio
from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect
from app.api import ws as ws_module
from app.api.ws import h5_websocket_endpoint, websocket_endpoint
from app.main import create_app
# =============================================================================
# 签名回归测试
# =============================================================================
class TestWebSocketEndpointSignature:
"""WebSocket endpoint 参数签名回归保护。
历史 bug: 早期版本有 `request: Request` 参数导致 FastAPI 启动失败。
修复方案: 移除该参数,改用 websocket.headers/query_params 读取。
"""
def test_websocket_endpoint_has_no_request_param(self):
"""坐席端 endpoint 不能有 `request` 参数(防 missing argument 回归)。"""
sig = inspect.signature(websocket_endpoint)
assert "request" not in sig.parameters, (
"websocket_endpoint 不应有 request 参数,FastAPI WebSocket 路由只支持 "
"websocket + 路径参数。回归会导致 'missing argument request' 500 错误!"
)
def test_h5_websocket_endpoint_has_no_request_param(self):
"""H5 端 endpoint 不能有 `request` 参数(防 missing argument 回归)。"""
sig = inspect.signature(h5_websocket_endpoint)
assert "request" not in sig.parameters, (
"h5_websocket_endpoint 不应有 request 参数!回归会导致 'missing argument request' 500 错误!"
)
def test_websocket_endpoint_first_param_is_websocket(self):
"""坐席端 endpoint 第一个参数必须是 WebSocket 类型。"""
sig = inspect.signature(websocket_endpoint)
params = list(sig.parameters.values())
assert params[0].annotation is WebSocket, (
f"坐席端第一个参数必须是 WebSocket,实际是 {params[0].annotation}"
)
def test_h5_websocket_endpoint_first_param_is_websocket(self):
"""H5 端 endpoint 第一个参数必须是 WebSocket 类型。"""
sig = inspect.signature(h5_websocket_endpoint)
params = list(sig.parameters.values())
assert params[0].annotation is WebSocket, (
f"H5 端第一个参数必须是 WebSocket,实际是 {params[0].annotation}"
)
def test_ws_router_is_registered_in_app(self):
"""主应用必须注册 ws router(否则 /ws 路径 404)。"""
app = create_app()
ws_routes = [r for r in app.routes if getattr(r, "path", "").startswith("/ws")]
assert any("/ws/{agent_id}" in getattr(r, "path", "") for r in ws_routes), (
"坐席 WS 路由 /ws/{agent_id} 未注册"
)
assert any("/ws/h5/{employee_id}" in getattr(r, "path", "") for r in ws_routes), (
"H5 WS 路由 /ws/h5/{employee_id} 未注册"
)
# =============================================================================
# 运行时测试 — 验证 WS 鉴权逻辑
# =============================================================================
@pytest_asyncio.fixture
async def mock_redis_with_employee(mock_redis):
"""把 employee_id 注入 mock Redis,模拟已登录状态。"""
employee_id = f"emp_{uuid.uuid4().hex[:8]}"
token = f"tok_{uuid.uuid4().hex[:16]}"
await mock_redis.setex(f"employee:token:{token}", 86400, employee_id)
return employee_id, token
class TestH5WebSocketRuntime:
"""H5 WebSocket 运行时测试 — 验证 auth 错误码。
不依赖 create_app()(避免触发 PG 连接),直接用 ws.py 的 router 构造
独立 FastAPI 实例。这样既验证 endpoint 行为,又不需要任何外部服务。
"""
def _build_ws_only_app(self):
"""构造只含 ws router 的 FastAPI 实例(无 DB/Redis 依赖)。"""
from fastapi import FastAPI
from app.api.ws import router as ws_router
app = FastAPI()
app.include_router(ws_router)
return app
@pytest.mark.asyncio
async def test_h5_ws_missing_token_closes_with_4001(self):
"""缺 token 时,server 应 close(code=4001) — WS-01 安全要求。"""
from app.services.cache_service import cache_service
from starlette.testclient import TestClient
app = self._build_ws_only_app()
with pytest.MonkeyPatch.context() as mp:
async def fake_get(key):
return None # 模拟 token 不存在
mp.setattr(cache_service, "get", fake_get)
with TestClient(app) as client:
with pytest.raises(WebSocketDisconnect) as exc_info:
with client.websocket_connect("/ws/h5/emp_test") as ws:
# 不带任何 token,期望 close code 4001
ws.receive_text()
# close code 应当是 4001(自定义未授权)
assert exc_info.value.code == 4001, (
f"缺 token 应关闭 4001,实际 {exc_info.value.code}"
)
@pytest.mark.asyncio
async def test_h5_ws_token_employee_mismatch_closes_with_4001(self):
"""token 对应的 employee_id 与 URL 不一致时,close 4001。"""
from app.services.cache_service import cache_service
from starlette.testclient import TestClient
app = self._build_ws_only_app()
with pytest.MonkeyPatch.context() as mp:
async def fake_get(key):
return b"emp_real" # token 对应 emp_real
mp.setattr(cache_service, "get", fake_get)
with TestClient(app) as client:
with pytest.raises(WebSocketDisconnect) as exc_info:
with client.websocket_connect(
"/ws/h5/emp_impostor?token=fake_token"
) as ws:
ws.receive_text()
assert exc_info.value.code == 4001, (
f"token-employee 不匹配应关闭 4001,实际 {exc_info.value.code}"
)
class TestAgentWebSocketRuntime:
"""坐席 WebSocket 运行时测试 — 验证 auth 错误码。"""
def _build_ws_only_app(self):
from fastapi import FastAPI
from app.api.ws import router as ws_router
app = FastAPI()
app.include_router(ws_router)
return app
@pytest.mark.asyncio
async def test_agent_ws_missing_token_closes_with_4001(self):
"""坐席端缺 token 关闭 4001。"""
from app.services.cache_service import cache_service
from starlette.testclient import TestClient
app = self._build_ws_only_app()
with pytest.MonkeyPatch.context() as mp:
async def fake_get(key):
return None
mp.setattr(cache_service, "get", fake_get)
with TestClient(app) as client:
with pytest.raises(WebSocketDisconnect) as exc_info:
with client.websocket_connect("/ws/agent_test") as ws:
ws.receive_text()
assert exc_info.value.code == 4001