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
- 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 路由日志分支、工厂降级分支
263 lines
9.8 KiB
Python
263 lines
9.8 KiB
Python
"""#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)
|