"""#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)