Files
wecom_it_smart_desk/backend/tests/test_auth_qrcode.py
T

422 lines
16 KiB
Python
Raw Normal View History

# =============================================================================
# 企微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