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