Files
xiaoxia-saas/tests/unit/test_wechat_oauth_service.py
xiaoxia 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
fix(auth): 微信OAuth服务工厂改为真单例,修复回调state必校验失败 (#1718) (#1719)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 17:17:07 +08:00

440 lines
16 KiB
Python
Executable File
Raw Permalink 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.
"""微信 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