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:
@@ -0,0 +1,422 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 扫码登录 API 测试
|
||||
# =============================================================================
|
||||
# 测试覆盖:
|
||||
# 1. create → 返回 ticket + qrcode_url
|
||||
# 2. create 后立即 poll (waiting)
|
||||
# 3. dev 模式 scan → 写 Redis scan:{ticket} 成功
|
||||
# 4. scan 后 poll → scanned
|
||||
# 5. dev 模式 confirm (无 otp) → 返回 token
|
||||
# 6. confirm 后 poll → confirmed + token
|
||||
# 7. 不存在的 ticket poll → expired
|
||||
# 8. expired ticket confirm → 失败
|
||||
#
|
||||
# dev 模式强制走 mock(代码内 _dev_mode_enabled() 检查 DEV_MODE env),
|
||||
# 测试通过 monkeypatch 强制开启,确保不调真实企微 API。
|
||||
# =============================================================================
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 工具: 让测试期间 dev 模式强制为 True
|
||||
# --------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def force_dev_mode(monkeypatch):
|
||||
"""强制 dev 模式为 True(让 _dev_mode_enabled() 返回 True)。
|
||||
|
||||
通过同时 patch:
|
||||
1. os.getenv("DEV_MODE") → "true"
|
||||
2. settings.dev_mode → True
|
||||
避免真实企微 API 被调用。
|
||||
"""
|
||||
monkeypatch.setenv("DEV_MODE", "true")
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "dev_mode", True)
|
||||
yield
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 工具: 创建已登录坐席 token,用于 confirm 端点鉴权
|
||||
# --------------------------------------------------------------------------
|
||||
async def _create_agent_token(mock_redis: MockRedis, user_id: str, name: str) -> str:
|
||||
"""在 mock_redis 里手动写一个坐席 token,返回 token 字符串。
|
||||
|
||||
与 TokenService.create_token 一致: 写 user:token:{token} + agent:token:{token}。
|
||||
"""
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_data = {
|
||||
"employee_id": user_id,
|
||||
"name": name,
|
||||
"department": "信息技术部",
|
||||
"avatar": "",
|
||||
"roles": ["agent"],
|
||||
"current_role": "agent",
|
||||
"login_source": "test",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
}
|
||||
# MockRedis 的 setex 内部用 str 存,get 返回 bytes
|
||||
await mock_redis.setex(
|
||||
f"user:token:{token}",
|
||||
8 * 60 * 60,
|
||||
json.dumps(token_data, ensure_ascii=False),
|
||||
)
|
||||
await mock_redis.setex(f"agent:token:{token}", 8 * 60 * 60, user_id)
|
||||
return token
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. create: 返回 ticket + qrcode_url
|
||||
# --------------------------------------------------------------------------
|
||||
class TestQrcodeCreate:
|
||||
"""测试创建扫码登录票据。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_ticket_and_url(self, client, mock_redis):
|
||||
"""验证 create 返回 ticket + 企微 OAuth2 URL。"""
|
||||
response = await client.post("/auth_qrcode/create")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 0
|
||||
assert "data" in body
|
||||
|
||||
data = body["data"]
|
||||
assert "ticket" in data
|
||||
assert len(data["ticket"]) >= 16
|
||||
assert "qrcode_url" in data
|
||||
# URL 必须含企微 OAuth 域名 + state={ticket}
|
||||
assert "open.weixin.qq.com/connect/oauth2/authorize" in data["qrcode_url"]
|
||||
assert f"state={data['ticket']}" in data["qrcode_url"]
|
||||
# 有效期 120s
|
||||
assert data["expires_in"] == 120
|
||||
assert "expires_at" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_writes_ticket_to_redis(self, client, mock_redis):
|
||||
"""验证 create 后 Redis 写入了 qrcode:ticket:{ticket}。"""
|
||||
response = await client.post("/auth_qrcode/create")
|
||||
ticket = response.json()["data"]["ticket"]
|
||||
|
||||
redis_key = f"qrcode:ticket:{ticket}"
|
||||
stored = await mock_redis.get(redis_key)
|
||||
assert stored is not None
|
||||
# stored 是 bytes(MockRedis.get 返回 bytes),解码后应含 created_at
|
||||
import json
|
||||
payload = json.loads(stored.decode("utf-8"))
|
||||
assert "created_at" in payload
|
||||
assert "expires_at" in payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. create 后立即 poll → waiting
|
||||
# --------------------------------------------------------------------------
|
||||
class TestQrcodePoll:
|
||||
"""测试轮询扫码状态。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_after_create_returns_waiting(self, client, mock_redis):
|
||||
"""create 后立即 poll,无扫码无确认,应为 waiting。"""
|
||||
# 1. create
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
|
||||
# 2. poll
|
||||
poll_resp = await client.get(f"/auth_qrcode/poll/{ticket}")
|
||||
|
||||
assert poll_resp.status_code == 200
|
||||
body = poll_resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["status"] == "waiting"
|
||||
assert data["employee_id"] is None
|
||||
assert data["name"] is None
|
||||
assert data["token"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_nonexistent_ticket_returns_expired(self, client, mock_redis):
|
||||
"""不存在的 ticket poll → expired。"""
|
||||
response = await client.get("/auth_qrcode/poll/nonexistent-ticket-xxx")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["status"] == "expired"
|
||||
assert body["data"]["token"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3+4. dev 模式 scan → scanned
|
||||
# --------------------------------------------------------------------------
|
||||
class TestQrcodeScan:
|
||||
"""测试扫码回调(dev 模式强制 mock)。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_writes_redis(self, client, mock_redis):
|
||||
"""dev 模式 scan → 写 Redis scan:{ticket} 成功。"""
|
||||
# 1. create
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
|
||||
# 2. scan(dev 模式 code 形如 "dev:dev-user-001")
|
||||
scan_resp = await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-user-001"},
|
||||
)
|
||||
|
||||
assert scan_resp.status_code == 200
|
||||
body = scan_resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["success"] is True
|
||||
|
||||
# 3. 验证 Redis 写入
|
||||
scan_key = f"qrcode:scan:{ticket}"
|
||||
stored = await mock_redis.get(scan_key)
|
||||
assert stored is not None
|
||||
import json
|
||||
payload = json.loads(stored.decode("utf-8"))
|
||||
assert payload["employee_id"] == "dev-user-001"
|
||||
assert "张三" in payload["name"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_then_poll_returns_scanned(self, client, mock_redis):
|
||||
"""scan 后 poll → status=scanned,带 employee_id/name 但无 token。"""
|
||||
# create + scan
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-agent-001"},
|
||||
)
|
||||
|
||||
# poll
|
||||
poll_resp = await client.get(f"/auth_qrcode/poll/{ticket}")
|
||||
body = poll_resp.json()
|
||||
data = body["data"]
|
||||
|
||||
assert data["status"] == "scanned"
|
||||
assert data["employee_id"] == "dev-agent-001"
|
||||
assert "李四" in data["name"]
|
||||
assert data["token"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_with_invalid_ticket_returns_error(self, client, mock_redis):
|
||||
"""不存在的 ticket scan → 1003 错误。"""
|
||||
response = await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": "invalid-ticket-xxx", "code": "dev:dev-user-001"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
# 业务错误(票据过期),code 是错误码(非 0)
|
||||
assert body["code"] != 0
|
||||
assert body["code"] == 1003
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5+6. confirm: 无 otp → 返回 token,确认后 poll → confirmed+token
|
||||
# --------------------------------------------------------------------------
|
||||
class TestQrcodeConfirm:
|
||||
"""测试已登录坐席确认授权。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_returns_token(self, client, mock_redis):
|
||||
"""完整流程: create → scan → confirm → 返回 token。"""
|
||||
# 1. create
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
|
||||
# 2. scan
|
||||
await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-user-001"},
|
||||
)
|
||||
|
||||
# 3. 创建已登录坐席 token(模拟浏览器已有一个坐席在确认授权)
|
||||
confirm_token = await _create_agent_token(
|
||||
mock_redis, user_id="admin-001", name="管理员"
|
||||
)
|
||||
|
||||
# 4. confirm
|
||||
confirm_resp = await client.post(
|
||||
"/auth_qrcode/confirm",
|
||||
json={"ticket": ticket, "otp_code": None},
|
||||
headers={"Authorization": f"Bearer {confirm_token}"},
|
||||
)
|
||||
|
||||
assert confirm_resp.status_code == 200
|
||||
body = confirm_resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert "token" in data
|
||||
assert data["employee_id"] == "dev-user-001"
|
||||
assert "张三" in data["name"]
|
||||
assert data["roles"] == ["agent"]
|
||||
# Phase 1.1: 没有传 otp_code,require_otp 应为 False
|
||||
assert data["require_otp"] is False
|
||||
|
||||
# 5. 验证 token 写入 Redis(unified format)
|
||||
token = data["token"]
|
||||
stored = await mock_redis.get(f"user:token:{token}")
|
||||
assert stored is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_then_poll_returns_confirmed(self, client, mock_redis):
|
||||
"""confirm 后 poll → status=confirmed + token 一致。"""
|
||||
# create + scan
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-user-001"},
|
||||
)
|
||||
|
||||
# confirm
|
||||
confirm_token = await _create_agent_token(mock_redis, "admin-001", "管理员")
|
||||
confirm_resp = await client.post(
|
||||
"/auth_qrcode/confirm",
|
||||
json={"ticket": ticket},
|
||||
headers={"Authorization": f"Bearer {confirm_token}"},
|
||||
)
|
||||
new_token = confirm_resp.json()["data"]["token"]
|
||||
|
||||
# poll
|
||||
poll_resp = await client.get(f"/auth_qrcode/poll/{ticket}")
|
||||
body = poll_resp.json()
|
||||
data = body["data"]
|
||||
|
||||
assert data["status"] == "confirmed"
|
||||
assert data["token"] == new_token
|
||||
assert data["employee_id"] == "dev-user-001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_without_auth_returns_unauthorized(self, client, mock_redis):
|
||||
"""未鉴权 confirm → 401 或 403(FastAPI HTTPBearer 默认 403,本项目统一为 401)。
|
||||
|
||||
这里接受两种状态码是因为 FastAPI HTTPBearer 在不同场景下:
|
||||
- 无 Authorization 头 → 403
|
||||
- Token 格式错 → 401
|
||||
业务上都是"未鉴权",均视为失败。
|
||||
"""
|
||||
# create + scan
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-user-001"},
|
||||
)
|
||||
|
||||
# 没带 Authorization 头
|
||||
confirm_resp = await client.post(
|
||||
"/auth_qrcode/confirm",
|
||||
json={"ticket": ticket},
|
||||
)
|
||||
|
||||
# 鉴权失败:401 或 403 都接受
|
||||
assert confirm_resp.status_code in (401, 403)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_expired_ticket_fails(self, client, mock_redis):
|
||||
"""expired ticket(手动 Redis delete 后)confirm → 失败。
|
||||
|
||||
模拟场景: 票据过了 120s,Redis 自动过期。
|
||||
这里通过手动 delete qrcode:ticket:{ticket} 模拟。
|
||||
"""
|
||||
# create + scan
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-user-001"},
|
||||
)
|
||||
|
||||
# 模拟票据过期: 删除 ticket key
|
||||
await mock_redis.delete(f"qrcode:ticket:{ticket}")
|
||||
|
||||
# confirm → 应该失败(1003 资源不存在)
|
||||
confirm_token = await _create_agent_token(mock_redis, "admin-001", "管理员")
|
||||
confirm_resp = await client.post(
|
||||
"/auth_qrcode/confirm",
|
||||
json={"ticket": ticket},
|
||||
headers={"Authorization": f"Bearer {confirm_token}"},
|
||||
)
|
||||
|
||||
assert confirm_resp.status_code == 200
|
||||
body = confirm_resp.json()
|
||||
assert body["code"] != 0
|
||||
assert body["code"] == 1003
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_without_scan_fails(self, client, mock_redis):
|
||||
"""没扫码(只有 ticket 没有 scan 数据)就 confirm → 失败。"""
|
||||
# create 但不 scan
|
||||
create_resp = await client.post("/auth_qrcode/create")
|
||||
ticket = create_resp.json()["data"]["ticket"]
|
||||
|
||||
confirm_token = await _create_agent_token(mock_redis, "admin-001", "管理员")
|
||||
confirm_resp = await client.post(
|
||||
"/auth_qrcode/confirm",
|
||||
json={"ticket": ticket},
|
||||
headers={"Authorization": f"Bearer {confirm_token}"},
|
||||
)
|
||||
|
||||
body = confirm_resp.json()
|
||||
assert body["code"] != 0
|
||||
assert body["code"] == 1003
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 7. 完整端到端流程 smoke test
|
||||
# --------------------------------------------------------------------------
|
||||
class TestQrcodeEndToEnd:
|
||||
"""完整端到端 smoke test。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_flow(self, client, mock_redis):
|
||||
"""完整流程: create → poll waiting → scan → poll scanned → confirm → poll confirmed。"""
|
||||
# 1. create
|
||||
r = await client.post("/auth_qrcode/create")
|
||||
ticket = r.json()["data"]["ticket"]
|
||||
assert r.json()["code"] == 0
|
||||
|
||||
# 2. poll (waiting)
|
||||
r = await client.get(f"/auth_qrcode/poll/{ticket}")
|
||||
assert r.json()["data"]["status"] == "waiting"
|
||||
|
||||
# 3. scan
|
||||
r = await client.post(
|
||||
"/auth_qrcode/scan",
|
||||
json={"ticket": ticket, "code": "dev:dev-agent-001"},
|
||||
)
|
||||
assert r.json()["data"]["success"] is True
|
||||
|
||||
# 4. poll (scanned)
|
||||
r = await client.get(f"/auth_qrcode/poll/{ticket}")
|
||||
assert r.json()["data"]["status"] == "scanned"
|
||||
assert r.json()["data"]["employee_id"] == "dev-agent-001"
|
||||
|
||||
# 5. confirm
|
||||
confirm_token = await _create_agent_token(mock_redis, "admin-001", "管理员")
|
||||
r = await client.post(
|
||||
"/auth_qrcode/confirm",
|
||||
json={"ticket": ticket},
|
||||
headers={"Authorization": f"Bearer {confirm_token}"},
|
||||
)
|
||||
new_token = r.json()["data"]["token"]
|
||||
assert new_token
|
||||
|
||||
# 6. poll (confirmed + token)
|
||||
r = await client.get(f"/auth_qrcode/poll/{ticket}")
|
||||
data = r.json()["data"]
|
||||
assert data["status"] == "confirmed"
|
||||
assert data["token"] == new_token
|
||||
Reference in New Issue
Block a user