8a6d51f6c3
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m11s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Validate - Security (push) Successful in 5m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Failing after 6m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 9m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
440 lines
16 KiB
Python
Executable File
440 lines
16 KiB
Python
Executable File
"""微信 OAuth 服务单元测试."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.application.auth.wechat_oauth_service import (
|
||
STATE_TTL_SECONDS,
|
||
MemoryStateStore,
|
||
WechatOAuthService,
|
||
WechatUserInfo,
|
||
get_wechat_oauth_service,
|
||
)
|
||
|
||
|
||
class TestMemoryStateStore:
|
||
"""MemoryStateStore 测试"""
|
||
|
||
def test_put_and_verify(self):
|
||
"""存入 state 后可以验证通过"""
|
||
store = MemoryStateStore()
|
||
store.put("state_123")
|
||
assert store.verify_and_consume("state_123") is True
|
||
|
||
def test_verify_nonexistent(self):
|
||
"""不存在的 state 验证失败"""
|
||
store = MemoryStateStore()
|
||
assert store.verify_and_consume("nonexistent") is False
|
||
|
||
def test_state_consumed_after_verify(self):
|
||
"""state 验证后被消费,不能重复使用"""
|
||
store = MemoryStateStore()
|
||
store.put("state_123")
|
||
assert store.verify_and_consume("state_123") is True
|
||
assert store.verify_and_consume("state_123") is False
|
||
|
||
def test_multiple_states(self):
|
||
"""多个 state 独立管理"""
|
||
store = MemoryStateStore()
|
||
store.put("state_a")
|
||
store.put("state_b")
|
||
assert store.verify_and_consume("state_a") is True
|
||
assert store.verify_and_consume("state_b") is True
|
||
|
||
def test_expired_state_cleaned(self):
|
||
"""过期 state 会被清理"""
|
||
store = MemoryStateStore(ttl_seconds=1)
|
||
store.put("expired_state")
|
||
time.sleep(1.1)
|
||
assert store.verify_and_consume("expired_state") is False
|
||
|
||
def test_custom_ttl(self):
|
||
"""自定义 TTL"""
|
||
store = MemoryStateStore(ttl_seconds=60)
|
||
store.put("my_state")
|
||
# 立即验证应该通过
|
||
assert store.verify_and_consume("my_state") is True
|
||
|
||
def test_clean_expired_on_put(self):
|
||
"""put 时清理过期 state"""
|
||
store = MemoryStateStore(ttl_seconds=1)
|
||
store.put("old_state")
|
||
time.sleep(1.1)
|
||
# put 新 state 时会触发清理
|
||
store.put("new_state")
|
||
# old_state 已经过期了,验证应该失败
|
||
assert store.verify_and_consume("old_state") is False
|
||
# new_state 应该还在
|
||
assert store.verify_and_consume("new_state") is True
|
||
|
||
def test_clean_expired_on_verify(self):
|
||
"""verify 时清理过期 state"""
|
||
store = MemoryStateStore(ttl_seconds=1)
|
||
store.put("old_state")
|
||
time.sleep(1.1)
|
||
# 验证不存在的 state 也会触发清理
|
||
store.verify_and_consume("other_state")
|
||
# old_state 已过期,验证失败
|
||
assert store.verify_and_consume("old_state") is False
|
||
|
||
|
||
class TestWechatUserInfo:
|
||
"""WechatUserInfo 测试"""
|
||
|
||
def test_create_with_openid(self):
|
||
"""仅用 openid 创建"""
|
||
info = WechatUserInfo(openid="openid_123")
|
||
assert info.openid == "openid_123"
|
||
assert info.unionid == ""
|
||
assert info.nickname == ""
|
||
assert info.avatar_url == ""
|
||
|
||
def test_create_with_all_fields(self):
|
||
"""所有字段创建"""
|
||
info = WechatUserInfo(
|
||
openid="openid_123",
|
||
unionid="unionid_456",
|
||
nickname="测试用户",
|
||
avatar_url="https://example.com/avatar.jpg",
|
||
)
|
||
assert info.openid == "openid_123"
|
||
assert info.unionid == "unionid_456"
|
||
assert info.nickname == "测试用户"
|
||
assert info.avatar_url == "https://example.com/avatar.jpg"
|
||
|
||
|
||
class TestWechatOAuthServiceInit:
|
||
"""WechatOAuthService 初始化测试"""
|
||
|
||
def test_not_configured_default(self):
|
||
"""默认参数(无环境变量)时未配置"""
|
||
with patch.dict("os.environ", {}, clear=False):
|
||
# 确保环境变量为空
|
||
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||
assert service.is_configured() is False
|
||
|
||
def test_configured_with_params(self):
|
||
"""显式传入配置时已配置"""
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://example.com/callback",
|
||
)
|
||
assert service.is_configured() is True
|
||
|
||
def test_missing_app_id_not_configured(self):
|
||
"""缺少 app_id 未配置"""
|
||
service = WechatOAuthService(
|
||
app_id="",
|
||
app_secret="secret456",
|
||
redirect_uri="https://example.com/callback",
|
||
)
|
||
assert service.is_configured() is False
|
||
|
||
def test_default_state_store(self):
|
||
"""默认使用 MemoryStateStore"""
|
||
service = WechatOAuthService(app_id="wx123", app_secret="s", redirect_uri="https://x.com")
|
||
assert isinstance(service._state_store, MemoryStateStore)
|
||
|
||
def test_custom_state_store(self):
|
||
"""可以自定义 state_store"""
|
||
custom_store = MagicMock()
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="s",
|
||
redirect_uri="https://x.com",
|
||
state_store=custom_store,
|
||
)
|
||
assert service._state_store is custom_store
|
||
|
||
|
||
class TestGenerateAuthUrl:
|
||
"""generate_auth_url 测试"""
|
||
|
||
def test_returns_url_and_state(self):
|
||
"""返回 URL 和 state"""
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://example.com/callback",
|
||
)
|
||
url, state = service.generate_auth_url()
|
||
assert isinstance(url, str)
|
||
assert isinstance(state, str)
|
||
assert len(state) > 0
|
||
assert "weixin.qq.com" in url
|
||
|
||
def test_url_contains_params(self):
|
||
"""URL 包含必要参数"""
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://example.com/callback",
|
||
)
|
||
url, state = service.generate_auth_url(scope="snsapi_login")
|
||
|
||
assert "appid=wx123" in url
|
||
assert "snsapi_login" in url
|
||
assert state in url
|
||
assert "response_type=code" in url
|
||
|
||
def test_state_saved_to_store(self):
|
||
"""生成的 state 存入 store"""
|
||
mock_store = MagicMock()
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://example.com/callback",
|
||
state_store=mock_store,
|
||
)
|
||
url, state = service.generate_auth_url()
|
||
mock_store.put.assert_called_once_with(state)
|
||
|
||
def test_mock_mode_when_not_configured(self):
|
||
"""未配置时返回 mock URL"""
|
||
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="https://example.com/callback")
|
||
url, state = service.generate_auth_url()
|
||
assert "/mock/wechat/auth" in url
|
||
assert "mock" in url
|
||
|
||
def test_different_states_each_time(self):
|
||
"""每次生成不同的 state"""
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://example.com/callback",
|
||
)
|
||
_, state1 = service.generate_auth_url()
|
||
_, state2 = service.generate_auth_url()
|
||
assert state1 != state2
|
||
|
||
|
||
class TestHandleCallback:
|
||
"""handle_callback 测试"""
|
||
|
||
def test_missing_code_returns_error(self):
|
||
"""缺少 code 返回错误"""
|
||
service = WechatOAuthService(app_id="wx123", app_secret="s", redirect_uri="https://x.com")
|
||
user_info, error = service.handle_callback("", "some_state")
|
||
assert user_info is None
|
||
assert "缺少授权码" in error
|
||
|
||
def test_invalid_state_returns_error(self):
|
||
"""state 无效返回错误"""
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = False
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="s",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
user_info, error = service.handle_callback("code123", "bad_state")
|
||
assert user_info is None
|
||
assert "state" in error
|
||
|
||
def test_mock_mode_when_not_configured(self):
|
||
"""未配置时返回 mock 用户信息"""
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = True
|
||
service = WechatOAuthService(
|
||
app_id="",
|
||
app_secret="",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
user_info, error = service.handle_callback("mock_code_12345", "valid_state")
|
||
|
||
assert error is None
|
||
assert user_info is not None
|
||
assert user_info.openid.startswith("mock_")
|
||
assert "微信测试用户" in user_info.nickname
|
||
|
||
def test_state_consumed_after_callback(self):
|
||
"""回调处理后 state 被消费"""
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = True
|
||
service = WechatOAuthService(
|
||
app_id="",
|
||
app_secret="",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
service.handle_callback("code", "valid_state")
|
||
mock_store.verify_and_consume.assert_called_once_with("valid_state")
|
||
|
||
def test_real_mode_success(self):
|
||
"""真实模式下成功获取用户信息"""
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = True
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
|
||
mock_token_resp = MagicMock()
|
||
mock_token_resp.json.return_value = {
|
||
"access_token": "access_token_123",
|
||
"openid": "real_openid",
|
||
"unionid": "real_unionid",
|
||
}
|
||
mock_user_resp = MagicMock()
|
||
mock_user_resp.json.return_value = {
|
||
"nickname": "真实用户",
|
||
"headimgurl": "https://wx.qlogo.cn/avatar.jpg",
|
||
}
|
||
|
||
with patch("requests.get") as mock_get:
|
||
mock_get.side_effect = [mock_token_resp, mock_user_resp]
|
||
user_info, error = service.handle_callback("auth_code", "valid_state")
|
||
|
||
assert error is None
|
||
assert user_info is not None
|
||
assert user_info.openid == "real_openid"
|
||
assert user_info.unionid == "real_unionid"
|
||
assert user_info.nickname == "真实用户"
|
||
assert user_info.avatar_url == "https://wx.qlogo.cn/avatar.jpg"
|
||
|
||
def test_real_mode_token_error(self):
|
||
"""真实模式下 access_token 接口返回错误"""
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = True
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
|
||
mock_resp = MagicMock()
|
||
mock_resp.json.return_value = {
|
||
"errcode": 40029,
|
||
"errmsg": "invalid code",
|
||
}
|
||
|
||
with patch("requests.get", return_value=mock_resp):
|
||
user_info, error = service.handle_callback("bad_code", "valid_state")
|
||
|
||
assert user_info is None
|
||
assert error is not None
|
||
assert "微信授权失败" in error
|
||
|
||
def test_real_mode_userinfo_error(self):
|
||
"""真实模式下用户信息接口返回错误"""
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = True
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
|
||
mock_token_resp = MagicMock()
|
||
mock_token_resp.json.return_value = {
|
||
"access_token": "access_123",
|
||
"openid": "open_123",
|
||
}
|
||
mock_user_resp = MagicMock()
|
||
mock_user_resp.json.return_value = {
|
||
"errcode": 40001,
|
||
"errmsg": "invalid token",
|
||
}
|
||
|
||
with patch("requests.get") as mock_get:
|
||
mock_get.side_effect = [mock_token_resp, mock_user_resp]
|
||
user_info, error = service.handle_callback("code", "state")
|
||
|
||
assert user_info is None
|
||
assert "获取用户信息失败" in error
|
||
|
||
def test_real_mode_network_error(self):
|
||
"""网络异常时返回友好错误"""
|
||
import requests
|
||
|
||
mock_store = MagicMock()
|
||
mock_store.verify_and_consume.return_value = True
|
||
service = WechatOAuthService(
|
||
app_id="wx123",
|
||
app_secret="secret456",
|
||
redirect_uri="https://x.com",
|
||
state_store=mock_store,
|
||
)
|
||
|
||
with patch("requests.get", side_effect=requests.ConnectionError()):
|
||
user_info, error = service.handle_callback("code", "state")
|
||
|
||
assert user_info is None
|
||
assert "暂不可用" in error
|
||
|
||
def test_empty_state_returns_error(self):
|
||
"""空 state 返回错误"""
|
||
service = WechatOAuthService(app_id="wx123", app_secret="s", redirect_uri="https://x.com")
|
||
user_info, error = service.handle_callback("code123", "")
|
||
assert user_info is None
|
||
assert "state" in error
|
||
|
||
|
||
class TestGetWechatOAuthService:
|
||
"""get_wechat_oauth_service 函数测试"""
|
||
|
||
def test_returns_service_instance(self):
|
||
"""返回 WechatOAuthService 实例"""
|
||
service = get_wechat_oauth_service()
|
||
assert isinstance(service, WechatOAuthService)
|
||
|
||
def test_singleton_same_instance_across_calls(self, monkeypatch):
|
||
"""#1718 回归:工厂必须返回同一实例,否则 state store 不共享"""
|
||
import packages.application.auth.wechat_oauth_service as mod
|
||
|
||
monkeypatch.setattr(mod, "_oauth_service_singleton", None)
|
||
s1 = get_wechat_oauth_service()
|
||
s2 = get_wechat_oauth_service()
|
||
assert s1 is s2
|
||
|
||
def test_state_survives_across_factory_calls(self, monkeypatch):
|
||
"""#1718 回归:/wechat/url 与 /wechat/callback 经工厂拿到同一 state store
|
||
|
||
模拟两次请求各自调用工厂:第一个实例生成 state,第二个实例(同一单例)
|
||
必须能校验通过。修复前工厂每次 new 一个实例,回调必现 400「无效的 state」。
|
||
"""
|
||
import packages.application.auth.wechat_oauth_service as mod
|
||
|
||
monkeypatch.setattr(mod, "_oauth_service_singleton", None)
|
||
monkeypatch.setenv("WECHAT_OPEN_APP_ID", "wx-test")
|
||
monkeypatch.setenv("WECHAT_OPEN_APP_SECRET", "secret-test")
|
||
monkeypatch.setenv("WECHAT_OPEN_REDIRECT_URI", "https://example.com/cb")
|
||
|
||
# 请求1:生成授权链接(state 写入单例 store)
|
||
_, state = get_wechat_oauth_service().generate_auth_url()
|
||
|
||
# 请求2:回调校验(应命中同一个 store;微信 API 用 mock 避免外网)
|
||
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
|
||
mock_get.return_value = MagicMock(
|
||
json=MagicMock(
|
||
return_value={
|
||
"access_token": "at",
|
||
"openid": "oid",
|
||
"unionid": "uid",
|
||
"nickname": "n",
|
||
"headimgurl": "http://x/a.png",
|
||
}
|
||
)
|
||
)
|
||
user_info, error = get_wechat_oauth_service().handle_callback("code-x", state)
|
||
|
||
assert error is None, f"state 应跨请求共享,实际报错: {error}"
|
||
assert user_info is not None
|
||
assert user_info.openid == "oid"
|
||
|
||
# state 一次性消费,重放必须失败
|
||
user_info2, error2 = get_wechat_oauth_service().handle_callback("code-y", state)
|
||
assert user_info2 is None
|
||
assert "state" in error2
|