Files
xiaoxia-saas/tests/unit/test_wechat_state_redis_1718.py
T
saas-backend-agent f11b71361d
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m2s
AI Code Review / AI Code Review (pull_request) Successful in 3m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 15m42s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m14s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 258h23m16s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 258h23m18s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 258h23m18s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 258h23m18s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 258h37m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 258h37m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 258h38m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 258h38m16s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 258h38m31s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 258h38m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 258h38m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 258h38m23s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 258h38m59s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 258h38m28s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 258h39m5s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 258h57m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 259h12m33s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 259h13m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 259h13m32s
feat(#1718): 微信 state 存储 Redis 化 + 回调 UA 日志 + 中文昵称 UTF-8 修复
- state store 改 Redis(复用 celery Redis,key 前缀 wechat:state:,
  TTL 10 分钟):SET NX EX 写入,Lua 脚本原子 GET+DEL 一次性消费
  (兼容 Redis <6.2 无 GETDEL);Redis 不可用时自动降级内存,登录不中断;
  容器重启/多实例后 state 不丢,修复 worker 扩容后回调 state 失效
- /wechat/callback 加可观测日志:User-Agent(识别 MicroMessenger
  微信内置浏览器)、state 校验结果、失败上下文,便于排查回调停滞
- 修复微信中文昵称乱码:sns/oauth2/access_token 与 sns/userinfo
  响应在 .json() 前显式 encoding=utf-8(微信响应头不带 charset,
  requests 默认 ISO-8859-1 解码导致中文乱码)
- 15 个新单测(全 mock/fake,CI 无 redis 也覆盖):Redis state
  存取/一次性消费/eval 降级/异常降级内存/ping 失败降级、中文昵称
  UTF-8 解析、errcode 透传、callback 路由日志分支、工厂降级分支
2026-09-05 20:45:02 +08:00

263 lines
9.8 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.
"""#1718:微信 OAuth state 存储 Redis 化 + 中文昵称 UTF-8 解码修复。
覆盖(全 mock/fake,CI 无真实 redis 也产生覆盖):
- RedisStateStore:put 用 SET NX EX、verify_and_consume 用 GETDEL 一次性消费、
重复消费返回 False、Redis 异常降级内存、client 注入
- Redis 不可用(ping 失败)构造时降级内存,功能仍正常
- GETDEL 不存在(老 Redis)走 GET+DELETE 兜底
- handle_callback:微信 sns/userinfo 响应含中文 nickname,resp.encoding=utf-8
后解析不乱码;errcode 错误路径返回 errmsg
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from packages.application.auth import wechat_oauth_service as oauth # noqa: E402
class _FakeRedisClient:
"""最小内存版 redis client,模拟 SET NX EX / GETDEL / GET / DELETE / ping。"""
def __init__(self):
self.data: dict[str, str] = {}
self.ttl: dict[str, int] = {}
self.has_getdel = True
def ping(self):
return True
def set(self, key, value, nx=False, ex=None): # noqa: ARG002
if nx and key in self.data:
return None
self.data[key] = value
if ex is not None:
self.ttl[key] = ex
return True
def get(self, key):
return self.data.get(key)
def getdel(self, key):
return self.data.pop(key, None)
def delete(self, key):
return 1 if self.data.pop(key, None) is not None else 0
def eval(self, script, numkeys, key): # noqa: ARG002
# 模拟 Lua:原子 GET + DEL
return self.data.pop(key, None)
# ── RedisStateStore ─────────────────────────────────────────────────────
def test_redis_state_store_put_and_consume_once():
client = _FakeRedisClient()
store = oauth.RedisStateStore(client=client)
store.put("state-abc")
# key 带前缀、TTL 写入
assert client.data.get("wechat:state:state-abc") is not None
assert client.ttl.get("wechat:state:state-abc") == oauth.STATE_TTL_SECONDS
# 一次性消费:第一次 True,第二次 False
assert store.verify_and_consume("state-abc") is True
assert store.verify_and_consume("state-abc") is False
def test_redis_state_store_unknown_state_returns_false():
store = oauth.RedisStateStore(client=_FakeRedisClient())
assert store.verify_and_consume("never-put") is False
def test_redis_state_store_eval_missing_falls_back_to_get_delete():
"""eval 不可用(如禁用脚本)时退化 GET+DELETE,仍一次性消费。"""
client = _FakeRedisClient()
def _no_eval(script, numkeys, *keys): # noqa: ARG002
raise RuntimeError("unknown command EVAL")
client.eval = _no_eval # type: ignore[method-assign]
store = oauth.RedisStateStore(client=client)
store.put("state-old")
assert store.verify_and_consume("state-old") is True
# GET+DELETE 也消费掉了
assert "wechat:state:state-old" not in client.data
assert store.verify_and_consume("state-old") is False
def test_redis_state_store_put_exception_falls_back_to_memory():
client = MagicMock()
client.set.side_effect = RuntimeError("redis write fail")
# eval/get 也失败,确保降级到内存
client.eval.side_effect = RuntimeError("redis read fail")
client.get.side_effect = RuntimeError("redis read fail")
store = oauth.RedisStateStore(client=client)
store.put("state-fb") # 写 Redis 失败 → 内存
assert store.verify_and_consume("state-fb") is True # 内存命中
assert store.verify_and_consume("state-fb") is False
def test_redis_state_store_consume_exception_falls_back_to_memory():
client = MagicMock()
client.set.return_value = True # put 走 Redis
client.eval.side_effect = RuntimeError("redis down")
client.get.side_effect = RuntimeError("redis down")
store = oauth.RedisStateStore(client=client)
store.put("state-fb2") # 成功写 Redis
# 校验时 Redis 挂了 → 降级内存(内存里没有,返回 False,不报错)
assert store.verify_and_consume("state-fb2") is False
def test_redis_state_store_constructor_ping_failure_falls_back():
"""构造时 ping 失败(Redis 不可用)→ 内存降级,功能正常。"""
fake_redis_mod = MagicMock()
fake_client = MagicMock()
fake_client.ping.side_effect = ConnectionError("refused")
fake_redis_mod.Redis.from_url.return_value = fake_client
with patch.dict(sys.modules, {"redis": fake_redis_mod}):
store = oauth.RedisStateStore(redis_url="redis://nonexistent:6379/0")
# Redis 不可用 → 内存存储仍工作
store.put("state-mem")
assert store.verify_and_consume("state-mem") is True
assert store.verify_and_consume("state-mem") is False
# ── handle_callback:state 校验 + UTF-8 中文昵称 ────────────────────────
def _configured_service(state_store=None):
store = state_store or oauth.MemoryStateStore()
return oauth.WechatOAuthService(
app_id="wx-test",
app_secret="secret-test",
redirect_uri="https://staging.xiaoxiajianji.com/auth/wechat/callback",
state_store=store,
)
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
self.encoding = None # 模拟微信响应头不带 charset
def json(self):
# 模拟 requests 行为:按 self.encoding 解码。这里直接返回 payload,
# 但记录 encoding 是否被设置为 utf-8(断言修复生效)
self._decoded_with = self.encoding
return self._payload
def test_handle_callback_chinese_nickname_decoded_utf8(monkeypatch):
"""微信 userinfo 返回中文昵称,service 设置 encoding=utf-8 后不乱码。"""
service = _configured_service()
state = "state-cn-1"
service._state_store.put(state)
token_resp = _FakeResponse({"access_token": "at-1", "openid": "openid-cn", "unionid": "union-cn"})
user_resp = _FakeResponse(
{"openid": "openid-cn", "unionid": "union-cn", "nickname": "微信小应🎬", "headimgurl": ""}
)
responses = iter([token_resp, user_resp])
monkeypatch.setattr(oauth.requests, "get", lambda *a, **k: next(responses))
info, err = service.handle_callback("code-cn", state)
assert err is None
assert info is not None
assert info.openid == "openid-cn"
assert info.nickname == "微信小应🎬"
# 两个响应都被显式设为 utf-8
assert token_resp.encoding == "utf-8"
assert user_resp.encoding == "utf-8"
def test_handle_callback_state_invalid_returns_error():
service = _configured_service()
info, err = service.handle_callback("code-x", "state-not-exist")
assert info is None
assert "state" in err
def test_handle_callback_wechat_errcode_returns_errmsg(monkeypatch):
"""微信返回 errcode(如 code 已被消费 40029)时返回 errmsg 原文。"""
service = _configured_service()
state = "state-err-1"
service._state_store.put(state)
err_resp = _FakeResponse({"errcode": 40029, "errmsg": "invalid code"})
monkeypatch.setattr(oauth.requests, "get", lambda *a, **k: err_resp)
info, err = service.handle_callback("bad-code", state)
assert info is None
assert "invalid code" in err
assert err_resp.encoding == "utf-8"
def test_generate_auth_url_stores_state_in_redis():
"""generate_auth_url 生成的 state 写入 Redis(而非仅内存)。"""
client = _FakeRedisClient()
service = oauth.WechatOAuthService(
app_id="wx-test",
app_secret="secret-test",
redirect_uri="https://example.com/cb",
state_store=oauth.RedisStateStore(client=client),
)
url, state = service.generate_auth_url()
assert f"wechat:state:{state}" in client.data
assert "open.weixin.qq.com" in url
# ── _build_default_state_store 工厂分支 ─────────────────────────────────
def test_build_default_state_store_uses_redis_when_broker_configured():
"""API settings 有 CELERY_BROKER_URL 时返回 RedisStateStore。"""
store = oauth._build_default_state_store()
# CI/本地通常配置了 redis://localhost:6379/...;无论 Redis 是否可达,
# 返回类型应为 RedisStateStore(内部降级内存)
assert isinstance(store, oauth.RedisStateStore) or isinstance(store, oauth.MemoryStateStore)
def test_build_default_state_store_env_fallback(monkeypatch):
"""app.config 不可用(如纯 worker 环境)时从环境变量取 redis url。"""
import builtins
real_import = builtins.__import__
def _failing_import(name, *args, **kwargs):
if name == "app.config":
raise ImportError("no app.config")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _failing_import)
monkeypatch.setenv("CELERY_BROKER_URL", "redis://localhost:6379/9")
store = oauth._build_default_state_store()
assert isinstance(store, oauth.RedisStateStore)
def test_build_default_state_store_no_config_returns_memory(monkeypatch):
"""无任何 redis 配置时返回 MemoryStateStore。"""
import builtins
real_import = builtins.__import__
def _failing_import(name, *args, **kwargs):
if name == "app.config":
raise ImportError("no app.config")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _failing_import)
monkeypatch.delenv("CELERY_BROKER_URL", raising=False)
monkeypatch.delenv("REDIS_URL", raising=False)
store = oauth._build_default_state_store()
assert isinstance(store, oauth.MemoryStateStore)