Files

926 lines
35 KiB
Python
Raw Permalink Normal View History

# =============================================================================
# 企微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"] == "缓存流程用户"