422 lines
16 KiB
Python
422 lines
16 KiB
Python
|
|
# =============================================================================
|
||
|
|
# 企微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
|