Files
wecom_it_smart_desk/backend/tests/test_h5_oauth.py
T

926 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微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 存入 Rediskey 格式为 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 TokenX-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"] == "缓存流程用户"