# ============================================================================= # 企微IT智能服务台 — H5 OAuth2 认证流程测试 # ============================================================================= # 测试覆盖: # 1. OAuth2 授权 URL 接口(GET /api/h5/oauth/authorize) # 2. OAuth2 回调接口(POST /api/h5/oauth/callback) # 3. Token 验证依赖函数 _get_current_employee # 4. 获取当前员工信息(GET /api/h5/me) # 5. 向后兼容(X-Employee-Id 头降级) # 6. 错误处理(WecomService 失败、Redis 不可用) # ============================================================================= import json from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.database import Base, get_db from app.models.conversation import Conversation from app.models.funny_phrase import FunnyPhrase from tests.conftest import MockRedis, create_test_conversation, test_engine, test_session_factory # --------------------------------------------------------------------------- # 专用 fixtures:带 h5 API Redis mock 的测试客户端 # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def h5_client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncClient: """提供针对 H5 OAuth2 API 的异步测试客户端。 与 conftest.py 的 client fixture 类似,但额外 mock 了 app.api.h5 模块中的 _get_redis,确保 OAuth2 流程中 Redis 操作使用内存模拟。 """ async def _override_get_db(): yield db_session from app.main import create_app app = create_app() app.dependency_overrides[get_db] = _override_get_db with patch("app.api.h5._get_redis", return_value=mock_redis): with patch("redis.asyncio.from_url", return_value=mock_redis): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac app.dependency_overrides.clear() @pytest.fixture def mock_redis_fresh() -> MockRedis: """提供干净的模拟 Redis(每个测试独立)。""" return MockRedis() # =========================================================================== # 1. OAuth2 授权 URL 接口 # =========================================================================== class TestOAuthAuthorizeURL: """测试 GET /api/h5/oauth/authorize — 获取企微 OAuth2 授权 URL。""" @pytest.mark.asyncio async def test_authorize_url_returns_correct_structure(self, h5_client): """验证返回结构包含 authorize_url 字段。""" response = await h5_client.get("/h5/oauth/authorize") assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert "authorize_url" in data["data"] @pytest.mark.asyncio async def test_authorize_url_contains_correct_base(self, h5_client): """验证授权 URL 以企微 OAuth2 基础地址开头。""" response = await h5_client.get("/h5/oauth/authorize") data = response.json() url = data["data"]["authorize_url"] assert url.startswith("https://open.weixin.qq.com/connect/oauth2/authorize") @pytest.mark.asyncio async def test_authorize_url_contains_appid(self, h5_client): """验证授权 URL 包含 appid 参数(企微 CorpID)。""" from app.config import settings response = await h5_client.get("/h5/oauth/authorize") data = response.json() url = data["data"]["authorize_url"] # corp_id 来自实际配置(可能是 .env 覆盖后的值) assert f"appid={settings.wecom_corp_id}" in url @pytest.mark.asyncio async def test_authorize_url_contains_scope_snsapi_base(self, h5_client): """验证授权 URL 使用 snsapi_base 作用域(静默授权)。""" response = await h5_client.get("/h5/oauth/authorize") data = response.json() url = data["data"]["authorize_url"] assert "scope=snsapi_base" in url @pytest.mark.asyncio async def test_authorize_url_contains_response_type_code(self, h5_client): """验证授权 URL 包含 response_type=code。""" response = await h5_client.get("/h5/oauth/authorize") data = response.json() url = data["data"]["authorize_url"] assert "response_type=code" in url @pytest.mark.asyncio async def test_authorize_url_contains_wechat_redirect(self, h5_client): """验证授权 URL 末尾包含 #wechat_redirect。""" response = await h5_client.get("/h5/oauth/authorize") data = response.json() url = data["data"]["authorize_url"] assert url.endswith("#wechat_redirect") @pytest.mark.asyncio async def test_authorize_url_with_redirect_uri_param(self, h5_client): """验证传入 redirect_uri 参数时 URL 包含自定义回调地址。""" custom_uri = "https://myapp.example.com/h5/" response = await h5_client.get( "/h5/oauth/authorize", params={"redirect_uri": custom_uri}, ) data = response.json() url = data["data"]["authorize_url"] # redirect_uri 需要经过 URL 编码 from urllib.parse import quote encoded = quote(custom_uri, safe="") assert f"redirect_uri={encoded}" in url @pytest.mark.asyncio async def test_authorize_url_with_host_header(self, h5_client): """验证使用 Host 头构造默认回调地址。""" response = await h5_client.get( "/h5/oauth/authorize", headers={"Host": "myapp.example.com"}, ) data = response.json() url = data["data"]["authorize_url"] # Host 头构造的 URL 应使用 https 协议 from urllib.parse import quote expected_redirect = quote("https://myapp.example.com/h5/", safe="") assert f"redirect_uri={expected_redirect}" in url @pytest.mark.asyncio async def test_authorize_url_without_redirect_uri_uses_default(self, h5_client): """验证不带 redirect_uri 且无 Host 头时使用配置默认值。""" response = await h5_client.get("/h5/oauth/authorize") data = response.json() url = data["data"]["authorize_url"] # 应该仍然返回有效的 URL(使用默认 origin) assert "redirect_uri=" in url # =========================================================================== # 2. OAuth2 回调接口 # =========================================================================== class TestOAuthCallback: """测试 POST /api/h5/oauth/callback — OAuth2 回调处理。""" @pytest.mark.asyncio async def test_callback_returns_token_and_employee_info(self, h5_client, mock_redis): """验证 OAuth2 回调返回 token 和员工信息。""" # Mock WecomService mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "test_user_001", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "测试员工", "department": [1, 2], "position": "工程师", "avatar": "https://avatar.example.com/test.jpg", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "valid_auth_code"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 # 验证返回字段 assert "token" in data["data"] assert data["data"]["employee_id"] == "test_user_001" assert data["data"]["employee_name"] == "测试员工" assert data["data"]["department"] == "1,2" assert data["data"]["position"] == "工程师" assert data["data"]["avatar"] == "https://avatar.example.com/test.jpg" @pytest.mark.asyncio async def test_callback_stores_token_in_redis(self, h5_client, mock_redis): """验证 token 存入 Redis,key 格式为 employee:token:{token}。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "redis_test_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "Redis测试", "department": [], "position": "", "avatar": "", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "valid_auth_code"}, ) data = response.json() token = data["data"]["token"] # 验证 Redis 中存在对应的 key stored = await mock_redis.get(f"employee:token:{token}") assert stored is not None assert stored == b"redis_test_user" @pytest.mark.asyncio async def test_callback_caches_employee_info_in_redis(self, h5_client, mock_redis): """验证员工信息缓存到 Redis。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "cache_test_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "缓存测试", "department": [3], "position": "经理", "avatar": "https://avatar.example.com/cache.jpg", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "valid_auth_code"}, ) # 验证 Redis 中存在员工信息缓存 cached = await mock_redis.get("employee:info:cache_test_user") assert cached is not None cached_info = json.loads(cached) assert cached_info["employee_id"] == "cache_test_user" assert cached_info["employee_name"] == "缓存测试" assert cached_info["department"] == "3" @pytest.mark.asyncio async def test_callback_with_empty_userid_returns_error(self, h5_client, mock_redis): """验证 OAuth2 返回空 UserID 时报错。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "", "user_ticket": ""}) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "bad_code"}, ) data = response.json() assert data["code"] != 0 @pytest.mark.asyncio async def test_callback_wecom_service_failure(self, h5_client, mock_redis): """验证 WecomService 调用失败时的错误处理。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(side_effect=Exception("企微API不可用")) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "will_fail"}, ) data = response.json() assert data["code"] != 0 @pytest.mark.asyncio async def test_callback_detail_fetch_failure_still_returns_token(self, h5_client, mock_redis): """验证获取员工详细信息失败时仍返回 token(降级处理)。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "degrade_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(side_effect=Exception("通讯录API失败")) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "valid_code"}, ) data = response.json() # 应该仍然返回成功,token 和 employee_id assert data["code"] == 0 assert "token" in data["data"] assert data["data"]["employee_id"] == "degrade_user" # 详细信息为空降级 assert data["data"]["employee_name"] == "" assert data["data"]["department"] == "" @pytest.mark.asyncio async def test_callback_missing_code_field(self, h5_client, mock_redis): """验证缺少 code 字段时返回参数错误。""" response = await h5_client.post( "/h5/oauth/callback", json={}, ) # Pydantic 验证失败 assert response.status_code == 422 @pytest.mark.asyncio async def test_callback_empty_code_field(self, h5_client, mock_redis): """验证空 code 字段时返回参数错误。""" response = await h5_client.post( "/h5/oauth/callback", json={"code": ""}, ) # Pydantic min_length=1 验证失败 assert response.status_code == 422 # =========================================================================== # 3. Token 验证依赖函数 _get_current_employee # =========================================================================== class TestGetCurrentEmployee: """测试 _get_current_employee 依赖注入函数。""" @pytest.mark.asyncio async def test_valid_bearer_token(self, h5_client, mock_redis): """验证有效 Bearer token 返回对应 employee_id。""" # 预设 Redis 中的 token 和员工信息缓存 await mock_redis.setex("employee:token:test_valid_token", 28800, "authed_user_001") employee_info = { "employee_id": "authed_user_001", "employee_name": "认证测试用户", "department": "IT部", "position": "工程师", "mobile": "", "email": "", "avatar": "", } await mock_redis.setex( "employee:info:authed_user_001", 28800, json.dumps(employee_info, ensure_ascii=False), ) # 调用需要认证的 /api/h5/me 接口 response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer test_valid_token"}, ) assert response.status_code == 200 data = response.json() # 接口成功返回,说明认证通过 assert data["code"] == 0 @pytest.mark.asyncio async def test_invalid_token_returns_unauthorized(self, h5_client, mock_redis): """验证无效 token 返回 401(业务码 1002)。""" response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer non_existent_token"}, ) data = response.json() assert data["code"] == 1002 assert "未授权" in data["message"] @pytest.mark.asyncio async def test_missing_authorization_header(self, h5_client, mock_redis): """验证缺少 Authorization 头返回未授权。""" response = await h5_client.get("/h5/me") data = response.json() assert data["code"] == 1002 @pytest.mark.asyncio async def test_empty_authorization_header(self, h5_client, mock_redis): """验证空的 Authorization 头返回未授权。""" response = await h5_client.get( "/h5/me", headers={"Authorization": ""}, ) data = response.json() assert data["code"] == 1002 @pytest.mark.asyncio async def test_bearer_prefix_extraction(self, h5_client, mock_redis): """验证 Bearer 前缀正确提取 token。""" # 设置 Redis token 和员工信息缓存 await mock_redis.setex("employee:token:my_token_123", 28800, "prefix_test_user") employee_info = { "employee_id": "prefix_test_user", "employee_name": "前缀测试", "department": "", "position": "", "mobile": "", "email": "", "avatar": "", } await mock_redis.setex( "employee:info:prefix_test_user", 28800, json.dumps(employee_info, ensure_ascii=False), ) response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer my_token_123"}, ) data = response.json() # 认证通过,接口返回成功 assert data["code"] == 0 @pytest.mark.asyncio async def test_token_without_bearer_prefix(self, h5_client, mock_redis): """验证不带 Bearer 前缀的 token 也能被识别(兼容)。""" await mock_redis.setex("employee:token:raw_token_456", 28800, "raw_token_user") employee_info = { "employee_id": "raw_token_user", "employee_name": "原始Token测试", "department": "", "position": "", "mobile": "", "email": "", "avatar": "", } await mock_redis.setex( "employee:info:raw_token_user", 28800, json.dumps(employee_info, ensure_ascii=False), ) response = await h5_client.get( "/h5/me", headers={"Authorization": "raw_token_456"}, ) data = response.json() # 源码中:如果 token 不以 "Bearer " 开头,直接使用整个值 assert data["code"] == 0 @pytest.mark.asyncio async def test_expired_token_returns_unauthorized(self, h5_client, mock_redis): """验证过期 token(Redis 中不存在)返回未授权。""" # 不在 Redis 中设置任何 token,模拟过期 response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer expired_token_xyz"}, ) data = response.json() assert data["code"] == 1002 # =========================================================================== # 4. GET /api/h5/me 接口 # =========================================================================== class TestGetCurrentEmployeeInfo: """测试 GET /api/h5/me — 获取当前员工详细信息。""" @pytest.mark.asyncio async def test_me_returns_employee_info_from_cache(self, h5_client, mock_redis): """验证从 Redis 缓存读取员工信息。""" # 预设 token 和缓存信息 await mock_redis.setex("employee:token:cache_me_token", 28800, "me_cache_user") employee_info = { "employee_id": "me_cache_user", "employee_name": "缓存用户", "department": "技术部", "position": "开发", "mobile": "13800138000", "email": "cache@test.com", "avatar": "https://avatar.example.com/me.jpg", } await mock_redis.setex( "employee:info:me_cache_user", 28800, json.dumps(employee_info, ensure_ascii=False), ) response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer cache_me_token"}, ) data = response.json() assert data["code"] == 0 assert data["data"]["employee_id"] == "me_cache_user" assert data["data"]["employee_name"] == "缓存用户" # is_vip 由接口补充 assert data["data"]["is_vip"] is False @pytest.mark.asyncio async def test_me_falls_back_to_wecom_api(self, h5_client, mock_redis): """验证缓存不存在时从企微 API 获取员工信息。""" # 预设 token 但不设缓存 await mock_redis.setex("employee:token:nocache_me_token", 28800, "me_nocache_user") mock_wecom = AsyncMock() mock_wecom.get_user_info = AsyncMock(return_value={ "name": "API用户", "department": [5], "position": "测试", "avatar": "https://avatar.example.com/api.jpg", "mobile": "13900139000", "email": "api@test.com", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer nocache_me_token"}, ) data = response.json() assert data["code"] == 0 assert data["data"]["employee_id"] == "me_nocache_user" assert data["data"]["employee_name"] == "API用户" assert data["data"]["department"] == "5" assert data["data"]["mobile"] == "13900139000" assert data["data"]["is_vip"] is False @pytest.mark.asyncio async def test_me_unauthenticated_returns_401(self, h5_client, mock_redis): """验证未认证时 /me 返回 401。""" response = await h5_client.get("/h5/me") data = response.json() assert data["code"] == 1002 # =========================================================================== # 5. 向后兼容 # =========================================================================== class TestBackwardCompatibility: """测试向后兼容:X-Employee-Id 头降级模式。""" @pytest.mark.asyncio async def test_x_employee_id_header_still_works_for_old_endpoints(self, h5_client, db_session, mock_redis): """验证旧版 X-Employee-Id 头仍可用于兼容旧接口。 注意:新接口(/h5/me, /h5/oauth/*)使用 Bearer Token, 但旧端点(如 /h5/conversations/current)使用 _get_current_employee 也支持旧方式需要看具体端点实现。此处验证旧方式在 _get_employee_id 中仍然工作。 """ # /h5/user 接口使用 _get_current_employee(需要 Bearer Token) # 但 /h5/conversations/current 也用 _get_current_employee # 旧版 _get_employee_id 只在特定端点使用 # 测试通过 Bearer Token 方式访问 /h5/conversations/current await mock_redis.setex("employee:token:compat_token", 28800, "compat_user") # 先创建一个会话 conv = create_test_conversation( employee_id="compat_user", status="queued", ) db_session.add(conv) await db_session.flush() response = await h5_client.get( "/h5/conversations/current", headers={"Authorization": "Bearer compat_token"}, ) data = response.json() assert data["code"] == 0 assert data["data"] is not None @pytest.mark.asyncio async def test_old_x_employee_id_header_not_accepted_by_new_auth(self, h5_client, mock_redis): """验证仅用 X-Employee-Id 头(无 Bearer Token)访问新接口返回未授权。 新的 _get_current_employee 只认 Bearer Token, 不认 X-Employee-Id。这是正确的安全行为。 """ response = await h5_client.get( "/h5/me", headers={"X-Employee-Id": "old_style_user"}, ) data = response.json() # 新接口只认 Bearer Token,X-Employee-Id 不应通过认证 assert data["code"] == 1002 # =========================================================================== # 6. 错误处理 # =========================================================================== class TestErrorHandling: """测试错误处理场景。""" @pytest.mark.asyncio async def test_redis_unavailable_during_token_validation(self, h5_client, mock_redis): """验证 Redis 不可用时 token 验证降级返回未授权。""" # 模拟 Redis get 抛出异常 original_get = mock_redis.get async def broken_get(key): raise Exception("Redis connection refused") mock_redis.get = broken_get response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer some_token"}, ) data = response.json() # Redis 不可用时应返回未授权 assert data["code"] == 1002 # 恢复 mock_redis.get = original_get @pytest.mark.asyncio async def test_redis_write_failure_during_callback(self, h5_client, mock_redis): """验证 Redis 写入失败时 OAuth2 回调仍能完成(降级处理)。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "redis_fail_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "Redis故障测试", "department": [], "position": "", "avatar": "", }) mock_wecom.close = AsyncMock() # Mock Redis setex to fail original_setex = mock_redis.setex async def broken_setex(name, time, value): raise Exception("Redis write failed") mock_redis.setex = broken_setex with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "valid_code"}, ) data = response.json() # Redis 写入失败不应阻塞 OAuth2 回调流程 # token 仍然返回(虽然不会被持久化) assert data["code"] == 0 assert "token" in data["data"] assert data["data"]["employee_id"] == "redis_fail_user" # 恢复 mock_redis.setex = original_setex @pytest.mark.asyncio async def test_wecom_oauth_failure_returns_error(self, h5_client, mock_redis): """验证企微 OAuth2 服务失败时返回错误。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock( side_effect=Exception("企微API超时") ) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "timeout_code"}, ) data = response.json() assert data["code"] != 0 @pytest.mark.asyncio async def test_me_wecom_api_failure(self, h5_client, mock_redis): """验证 /me 接口企微 API 失败时返回错误。""" # 预设 token 但不设缓存 await mock_redis.setex("employee:token:wecom_fail_token", 28800, "wecom_fail_user") mock_wecom = AsyncMock() mock_wecom.get_user_info = AsyncMock( side_effect=Exception("通讯录API失败") ) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.get( "/h5/me", headers={"Authorization": "Bearer wecom_fail_token"}, ) data = response.json() # 缓存不存在 + 企微API失败,应返回错误 assert data["code"] != 0 # =========================================================================== # 7. Token TTL 与格式 # =========================================================================== class TestTokenTTLAndFormat: """测试 Token TTL 和格式。""" @pytest.mark.asyncio async def test_token_stored_with_correct_ttl(self, h5_client, mock_redis): """验证 Token 存入 Redis 时设置了正确的 TTL(8小时=28800秒)。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "ttl_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "TTL测试", "department": [], "position": "", "avatar": "", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "ttl_test_code"}, ) data = response.json() token = data["data"]["token"] # 验证 TTL ttl = mock_redis._ttl.get(f"employee:token:{token}") assert ttl == 28800 # 8小时 = 28800 秒 @pytest.mark.asyncio async def test_employee_info_cache_has_same_ttl(self, h5_client, mock_redis): """验证员工信息缓存与 Token 使用相同的 TTL。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "info_ttl_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "InfoTTL测试", "department": [], "position": "", "avatar": "", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "info_ttl_code"}, ) # 验证员工信息缓存的 TTL info_ttl = mock_redis._ttl.get("employee:info:info_ttl_user") assert info_ttl == 28800 @pytest.mark.asyncio async def test_token_is_urlsafe(self, h5_client, mock_redis): """验证生成的 Token 是 URL-safe 格式(secrets.token_urlsafe)。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "fmt_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "格式测试", "department": [], "position": "", "avatar": "", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "fmt_test_code"}, ) data = response.json() token = data["data"]["token"] # token 应该是非空字符串 assert isinstance(token, str) assert len(token) > 0 # URL-safe base64 字符集:A-Z, a-z, 0-9, -, _ import re assert re.match(r'^[A-Za-z0-9_-]+$', token), f"Token '{token}' is not URL-safe" # =========================================================================== # 8. Schema 验证 # =========================================================================== class TestSchemaValidation: """测试 Pydantic Schema 验证。""" @pytest.mark.asyncio async def test_oauth_callback_request_requires_code(self, h5_client, mock_redis): """验证 OAuthCallbackRequest 必须包含 code 字段。""" response = await h5_client.post( "/h5/oauth/callback", json={}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_oauth_callback_request_code_min_length(self, h5_client, mock_redis): """验证 code 字段最小长度为 1。""" response = await h5_client.post( "/h5/oauth/callback", json={"code": ""}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_oauth_callback_request_valid_code(self, h5_client, mock_redis): """验证有效的 code 字段格式被接受。""" mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "schema_user", "user_ticket": ""}) mock_wecom.get_user_info = AsyncMock(return_value={"name": "", "department": [], "position": "", "avatar": ""}) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): response = await h5_client.post( "/h5/oauth/callback", json={"code": "valid_code_here"}, ) # 请求格式正确,应返回 200(非 422) assert response.status_code == 200 # =========================================================================== # 9. 端到端 OAuth2 流程 # =========================================================================== class TestOAuth2EndToEnd: """测试完整的 OAuth2 认证流程。""" @pytest.mark.asyncio async def test_full_oauth2_flow(self, h5_client, mock_redis): """验证完整 OAuth2 流程:获取授权URL → 回调获取token → 用token访问/me。""" # Step 1: 获取授权 URL auth_response = await h5_client.get("/h5/oauth/authorize") assert auth_response.json()["code"] == 0 auth_url = auth_response.json()["data"]["authorize_url"] assert "snsapi_base" in auth_url # Step 2: 模拟回调获取 token mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={ "userid": "e2e_user", "user_ticket": "", }) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "端到端用户", "department": [1, 2], "position": "架构师", "avatar": "https://avatar.example.com/e2e.jpg", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): callback_response = await h5_client.post( "/h5/oauth/callback", json={"code": "e2e_auth_code"}, ) callback_data = callback_response.json() assert callback_data["code"] == 0 token = callback_data["data"]["token"] assert token # token 非空 # Step 3: 使用 token 访问 /me me_response = await h5_client.get( "/h5/me", headers={"Authorization": f"Bearer {token}"}, ) me_data = me_response.json() assert me_data["code"] == 0 assert me_data["data"]["employee_id"] == "e2e_user" assert me_data["data"]["employee_name"] == "端到端用户" assert me_data["data"]["is_vip"] is False @pytest.mark.asyncio async def test_full_flow_with_cached_info(self, h5_client, mock_redis): """验证 OAuth2 流程完成后,后续 /me 请求从缓存读取。""" # Step 1: 模拟回调 mock_wecom = AsyncMock() mock_wecom.get_oauth_user_info = AsyncMock(return_value={ "userid": "cached_flow_user", "user_ticket": "", }) mock_wecom.get_user_info = AsyncMock(return_value={ "name": "缓存流程用户", "department": [10], "position": "产品", "avatar": "", }) mock_wecom.close = AsyncMock() with patch("app.api.h5.WecomService", return_value=mock_wecom): callback_response = await h5_client.post( "/h5/oauth/callback", json={"code": "cached_flow_code"}, ) token = callback_response.json()["data"]["token"] # Step 2: 第一次访问 /me(应从缓存读取,不再调用 WecomService) with patch("app.api.h5.WecomService") as MockWecomClass: me_response = await h5_client.get( "/h5/me", headers={"Authorization": f"Bearer {token}"}, ) # WecomService 不应被实例化(因为缓存命中) MockWecomClass.assert_not_called() me_data = me_response.json() assert me_data["code"] == 0 assert me_data["data"]["employee_name"] == "缓存流程用户"