Files
shanxia2.0/backend/tests/test_wechat_notice_binding.py
wuzilongandCursor e7c1762113 fix: 小程序服务号绑定先打开门户域名再跳转微信授权。
web-view 不能直接打开 open.weixin.qq.com,移动端改为打开已配置的业务域名中转页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 01:44:40 +08:00

247 lines
9.6 KiB
Python

import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
class MemoryRedis:
def __init__(self):
self.values = {}
def set(self, key, value, ex=None, nx=False):
if nx and key in self.values:
return False
self.values[key] = value
return True
def get(self, key):
return self.values.get(key)
def delete(self, key):
return self.values.pop(key, None)
def eval(self, script, count, key, *args):
if 'ARGV[1]' in script:
if self.get(key) == args[0]:
self.delete(key)
return 1
return 0
if 'GET' in script and 'DEL' in script:
value = self.get(key)
self.delete(key)
return value
raise AssertionError('unsupported script')
@pytest.fixture
def binding(monkeypatch):
from app.services import wechat_notice_binding as svc
from app.models.wechat_notice import WechatBinding
engine = create_engine('sqlite://')
WechatBinding.__table__.create(engine)
db = sessionmaker(bind=engine)()
redis = MemoryRedis()
monkeypatch.setattr(svc, 'get_sync_redis_client', lambda: redis)
monkeypatch.setattr(svc.settings, 'WECHAT_OA_ENABLED', True)
monkeypatch.setattr(svc.settings, 'WECHAT_OA_SECRET', 'test-only-secret')
monkeypatch.setattr(svc.settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example.test')
client = SimpleNamespace(oauth_openid=lambda code: 'oa-openid', subscribed=lambda oid: True)
monkeypatch.setattr(svc, 'client', client)
user = {'user_id': 'u1', 'tenant_no': 't1', 'region_id': 'd1'}
yield svc, db, redis, user, WechatBinding
db.close()
def scan(svc, started):
from urllib.parse import urlparse, parse_qs
state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
return state, svc.oauth_callback(state, 'code')
def test_scan_requires_original_user_confirmation_and_rejects_replay(binding):
svc, db, redis, user, Model = binding
started = svc.start_binding(user)
state, scanned = scan(svc, started)
assert scanned['status'] == 'scanned'
assert db.query(Model).count() == 0
status = svc.binding_session(user, started['sessionId'])
assert 'openid' not in status
assert status['verificationCode'] == scanned['verificationCode']
with pytest.raises(HTTPException):
svc.oauth_callback(state, 'code')
svc.confirm_binding(db, user, started['sessionId'])
assert db.query(Model).one().openid == 'oa-openid'
def test_cross_user_or_tenant_cannot_read_or_confirm(binding):
svc, db, redis, user, Model = binding
started = svc.start_binding(user)
scan(svc, started)
for other in ({**user, 'user_id': 'u2'}, {**user, 'tenant_no': 't2'}):
with pytest.raises(HTTPException):
svc.binding_session(other, started['sessionId'])
with pytest.raises(HTTPException):
svc.confirm_binding(db, other, started['sessionId'])
assert db.query(Model).count() == 0
def test_unsubscribed_cannot_confirm(binding, monkeypatch):
svc, db, redis, user, Model = binding
monkeypatch.setattr(svc.client, 'subscribed', lambda oid: False)
started = svc.start_binding(user)
scan(svc, started)
with pytest.raises(HTTPException):
svc.confirm_binding(db, user, started['sessionId'])
assert db.query(Model).count() == 0
def test_expired_replaced_session_and_unbind(binding):
svc, db, redis, user, Model = binding
first = svc.start_binding(user)
second = svc.start_binding(user)
assert svc.binding_session(user, first['sessionId'])['status'] == 'expired'
scan(svc, second)
svc.confirm_binding(db, user, second['sessionId'])
svc.unbind(db, user)
row = db.query(Model).one()
assert row.del_flag == 1 and row.openid is None
with pytest.raises(HTTPException):
svc.confirm_binding(db, user, second['sessionId'])
def test_cannot_bind_same_wechat_to_another_user_in_tenant(binding):
svc, db, redis, user, Model = binding
started = svc.start_binding(user)
scan(svc, started)
svc.confirm_binding(db, user, started['sessionId'])
other = {**user, 'user_id': 'u2'}
started = svc.start_binding(other)
scan(svc, started)
with pytest.raises(HTTPException):
svc.confirm_binding(db, other, started['sessionId'])
assert db.query(Model).count() == 1
def test_status_refresh_checks_current_follow_state(binding, monkeypatch):
svc, db, redis, user, Model = binding
started = svc.start_binding(user)
scan(svc, started)
svc.confirm_binding(db, user, started['sessionId'])
monkeypatch.setattr(svc.client, 'subscribed', lambda oid: False)
assert svc.binding_status(db, user) == {'enabled': True, 'bound': True, 'subscribed': False}
def test_start_cannot_replace_session_during_confirmation(binding):
svc, db, redis, user, Model = binding
redis.set(svc._owner_key(user) + ':lock', 'confirming', ex=60)
with pytest.raises(HTTPException):
svc.start_binding(user)
def test_old_subscription_refresh_cannot_overwrite_new_binding(binding, monkeypatch):
svc, db, redis, user, Model = binding
started = svc.start_binding(user)
scan(svc, started)
svc.confirm_binding(db, user, started['sessionId'])
def old_subscription_reply(openid):
assert openid == 'oa-openid'
row = db.query(Model).one()
row.openid, row.subscribed = 'new-openid', 1
db.commit()
return False
monkeypatch.setattr(svc.client, 'subscribed', old_subscription_reply)
assert svc.binding_status(db, user)['subscribed'] is True
assert db.query(Model).one().openid == 'new-openid'
def test_callback_instructs_mobile_return_without_binding(binding, monkeypatch):
from app.api.wechat_notices import callback
svc, db, _, user, Model = binding
started = svc.start_binding(user)
from urllib.parse import urlparse, parse_qs
state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
response = callback(state, 'code')
body = response.body.decode()
assert '左上角返回' in body
assert '验证码' in body
assert '确认绑定' in body
assert 'oa-openid' not in body
assert db.query(Model).count() == 0
assert response.headers['cache-control'] == 'no-store'
assert response.headers['referrer-policy'] == 'no-referrer'
def test_mobile_web_view_opens_business_domain_before_wechat(binding):
from app.api.wechat_notices import oauth_launch
from urllib.parse import parse_qs, urlparse
svc, _, redis, user, _ = binding
started = svc.start_binding(user, mode='mobile')
launch = urlparse(started['authorizeUrl'])
assert launch.scheme == 'https'
assert launch.netloc == 'portal.example.test'
assert launch.path == '/api/public/wechat-notices/oauth/launch'
assert 'open.weixin.qq.com' not in started['authorizeUrl']
state = parse_qs(launch.query)['state'][0]
page = oauth_launch(state)
body = page.body.decode()
assert 'location.replace' in body
assert 'open.weixin.qq.com/connect/oauth2/authorize' in body
assert state in body
assert "'unsafe-inline'" not in page.headers['content-security-policy']
assert redis.get('wechat:oa:state:' + state)
missing = oauth_launch('a' * 32)
assert '过期' in missing.body.decode()
assert 'open.weixin.qq.com' not in missing.body.decode()
invalid = oauth_launch('javascript:alert(1)')
assert '无效' in invalid.body.decode()
assert 'open.weixin.qq.com' not in invalid.body.decode()
def test_mobile_callback_only_returns_to_original_authenticated_client(binding):
from app.api.wechat_notices import callback, start, StartBinding
from urllib.parse import urlparse, parse_qs
svc, db, redis, user, Model = binding
response = start(body=StartBinding(mode='mobile'), user=user)
started = response['data']
assert svc._load(started['sessionId'])['mode'] == 'mobile'
state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
page = callback(state, 'code')
body = page.body.decode()
assert '正在返回小程序' in body
assert '记下验证码' not in body
assert 'oa-openid' not in body
assert started['sessionId'] not in body
assert 'jweixin-1.6.0.js' in body
assert 'nonce-' in page.headers['content-security-policy']
assert "'unsafe-inline'" not in page.headers['content-security-policy']
assert db.query(Model).count() == 0
with pytest.raises(HTTPException):
svc.confirm_binding(db, {**user, 'user_id': 'u2'}, started['sessionId'])
assert svc.confirm_binding(db, user, started['sessionId'])['bound'] is True
def test_mobile_unsubscribed_returns_without_success_or_binding(binding, monkeypatch):
from app.api.wechat_notices import callback
from urllib.parse import urlparse, parse_qs
svc, db, _, user, Model = binding
monkeypatch.setattr(svc.client, 'subscribed', lambda oid: False)
started = svc.start_binding(user, mode='mobile')
state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
page = callback(state, 'code')
assert '请先关注' in page.body.decode()
assert '已开通' not in page.body.decode()
assert db.query(Model).count() == 0
def test_binding_start_mode_defaults_to_desktop_and_rejects_invalid_mode(binding):
from app.api.wechat_notices import StartBinding, start
from pydantic import ValidationError
svc, _, _, user, _ = binding
result = start(body=None, user=user)
assert svc._load(result['data']['sessionId'])['mode'] == 'desktop'
with pytest.raises(ValidationError):
StartBinding(mode='https://evil.test')