test_wechat_notice_binding.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import json
  2. from types import SimpleNamespace
  3. import pytest
  4. from fastapi import HTTPException
  5. from sqlalchemy import create_engine
  6. from sqlalchemy.orm import sessionmaker
  7. class MemoryRedis:
  8. def __init__(self):
  9. self.values = {}
  10. def set(self, key, value, ex=None, nx=False):
  11. if nx and key in self.values:
  12. return False
  13. self.values[key] = value
  14. return True
  15. def get(self, key):
  16. return self.values.get(key)
  17. def delete(self, key):
  18. return self.values.pop(key, None)
  19. def eval(self, script, count, key, *args):
  20. if 'ARGV[1]' in script:
  21. if self.get(key) == args[0]:
  22. self.delete(key)
  23. return 1
  24. return 0
  25. if 'GET' in script and 'DEL' in script:
  26. value = self.get(key)
  27. self.delete(key)
  28. return value
  29. raise AssertionError('unsupported script')
  30. @pytest.fixture
  31. def binding(monkeypatch):
  32. from app.services import wechat_notice_binding as svc
  33. from app.models.wechat_notice import WechatBinding
  34. engine = create_engine('sqlite://')
  35. WechatBinding.__table__.create(engine)
  36. db = sessionmaker(bind=engine)()
  37. redis = MemoryRedis()
  38. monkeypatch.setattr(svc, 'get_sync_redis_client', lambda: redis)
  39. monkeypatch.setattr(svc.settings, 'WECHAT_OA_ENABLED', True)
  40. monkeypatch.setattr(svc.settings, 'WECHAT_OA_SECRET', 'test-only-secret')
  41. monkeypatch.setattr(svc.settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example.test')
  42. client = SimpleNamespace(oauth_openid=lambda code: 'oa-openid', subscribed=lambda oid: True)
  43. monkeypatch.setattr(svc, 'client', client)
  44. user = {'user_id': 'u1', 'tenant_no': 't1', 'region_id': 'd1'}
  45. yield svc, db, redis, user, WechatBinding
  46. db.close()
  47. def scan(svc, started):
  48. from urllib.parse import urlparse, parse_qs
  49. state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
  50. return state, svc.oauth_callback(state, 'code')
  51. def test_scan_requires_original_user_confirmation_and_rejects_replay(binding):
  52. svc, db, redis, user, Model = binding
  53. started = svc.start_binding(user)
  54. state, scanned = scan(svc, started)
  55. assert scanned['status'] == 'scanned'
  56. assert db.query(Model).count() == 0
  57. status = svc.binding_session(user, started['sessionId'])
  58. assert 'openid' not in status
  59. assert status['verificationCode'] == scanned['verificationCode']
  60. with pytest.raises(HTTPException):
  61. svc.oauth_callback(state, 'code')
  62. svc.confirm_binding(db, user, started['sessionId'])
  63. assert db.query(Model).one().openid == 'oa-openid'
  64. def test_cross_user_or_tenant_cannot_read_or_confirm(binding):
  65. svc, db, redis, user, Model = binding
  66. started = svc.start_binding(user)
  67. scan(svc, started)
  68. for other in ({**user, 'user_id': 'u2'}, {**user, 'tenant_no': 't2'}):
  69. with pytest.raises(HTTPException):
  70. svc.binding_session(other, started['sessionId'])
  71. with pytest.raises(HTTPException):
  72. svc.confirm_binding(db, other, started['sessionId'])
  73. assert db.query(Model).count() == 0
  74. def test_unsubscribed_cannot_confirm(binding, monkeypatch):
  75. svc, db, redis, user, Model = binding
  76. monkeypatch.setattr(svc.client, 'subscribed', lambda oid: False)
  77. started = svc.start_binding(user)
  78. scan(svc, started)
  79. with pytest.raises(HTTPException):
  80. svc.confirm_binding(db, user, started['sessionId'])
  81. assert db.query(Model).count() == 0
  82. def test_expired_replaced_session_and_unbind(binding):
  83. svc, db, redis, user, Model = binding
  84. first = svc.start_binding(user)
  85. second = svc.start_binding(user)
  86. assert svc.binding_session(user, first['sessionId'])['status'] == 'expired'
  87. scan(svc, second)
  88. svc.confirm_binding(db, user, second['sessionId'])
  89. svc.unbind(db, user)
  90. row = db.query(Model).one()
  91. assert row.del_flag == 1 and row.openid is None
  92. with pytest.raises(HTTPException):
  93. svc.confirm_binding(db, user, second['sessionId'])
  94. def test_cannot_bind_same_wechat_to_another_user_in_tenant(binding):
  95. svc, db, redis, user, Model = binding
  96. started = svc.start_binding(user)
  97. scan(svc, started)
  98. svc.confirm_binding(db, user, started['sessionId'])
  99. other = {**user, 'user_id': 'u2'}
  100. started = svc.start_binding(other)
  101. scan(svc, started)
  102. with pytest.raises(HTTPException):
  103. svc.confirm_binding(db, other, started['sessionId'])
  104. assert db.query(Model).count() == 1
  105. def test_status_refresh_checks_current_follow_state(binding, monkeypatch):
  106. svc, db, redis, user, Model = binding
  107. started = svc.start_binding(user)
  108. scan(svc, started)
  109. svc.confirm_binding(db, user, started['sessionId'])
  110. monkeypatch.setattr(svc.client, 'subscribed', lambda oid: False)
  111. assert svc.binding_status(db, user) == {'enabled': True, 'bound': True, 'subscribed': False}
  112. def test_start_cannot_replace_session_during_confirmation(binding):
  113. svc, db, redis, user, Model = binding
  114. redis.set(svc._owner_key(user) + ':lock', 'confirming', ex=60)
  115. with pytest.raises(HTTPException):
  116. svc.start_binding(user)
  117. def test_old_subscription_refresh_cannot_overwrite_new_binding(binding, monkeypatch):
  118. svc, db, redis, user, Model = binding
  119. started = svc.start_binding(user)
  120. scan(svc, started)
  121. svc.confirm_binding(db, user, started['sessionId'])
  122. def old_subscription_reply(openid):
  123. assert openid == 'oa-openid'
  124. row = db.query(Model).one()
  125. row.openid, row.subscribed = 'new-openid', 1
  126. db.commit()
  127. return False
  128. monkeypatch.setattr(svc.client, 'subscribed', old_subscription_reply)
  129. assert svc.binding_status(db, user)['subscribed'] is True
  130. assert db.query(Model).one().openid == 'new-openid'
  131. def test_callback_instructs_mobile_return_without_binding(binding, monkeypatch):
  132. from app.api.wechat_notices import callback
  133. svc, db, _, user, Model = binding
  134. started = svc.start_binding(user)
  135. from urllib.parse import urlparse, parse_qs
  136. state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
  137. response = callback(state, 'code')
  138. body = response.body.decode()
  139. assert '左上角返回' in body
  140. assert '验证码' in body
  141. assert '确认绑定' in body
  142. assert 'oa-openid' not in body
  143. assert db.query(Model).count() == 0
  144. assert response.headers['cache-control'] == 'no-store'
  145. assert response.headers['referrer-policy'] == 'no-referrer'
  146. def test_mobile_web_view_opens_business_domain_before_wechat(binding):
  147. from app.api.wechat_notices import oauth_launch
  148. from urllib.parse import parse_qs, urlparse
  149. svc, _, redis, user, _ = binding
  150. started = svc.start_binding(user, mode='mobile')
  151. launch = urlparse(started['authorizeUrl'])
  152. assert launch.scheme == 'https'
  153. assert launch.netloc == 'portal.example.test'
  154. assert launch.path == '/api/public/wechat-notices/oauth/launch'
  155. assert 'open.weixin.qq.com' not in started['authorizeUrl']
  156. state = parse_qs(launch.query)['state'][0]
  157. page = oauth_launch(state)
  158. body = page.body.decode()
  159. assert 'location.replace' in body
  160. assert 'open.weixin.qq.com/connect/oauth2/authorize' in body
  161. assert state in body
  162. assert "'unsafe-inline'" not in page.headers['content-security-policy']
  163. assert redis.get('wechat:oa:state:' + state)
  164. missing = oauth_launch('a' * 32)
  165. assert '过期' in missing.body.decode()
  166. assert 'open.weixin.qq.com' not in missing.body.decode()
  167. invalid = oauth_launch('javascript:alert(1)')
  168. assert '无效' in invalid.body.decode()
  169. assert 'open.weixin.qq.com' not in invalid.body.decode()
  170. def test_mobile_callback_only_returns_to_original_authenticated_client(binding):
  171. from app.api.wechat_notices import callback, start, StartBinding
  172. from urllib.parse import urlparse, parse_qs
  173. svc, db, redis, user, Model = binding
  174. response = start(body=StartBinding(mode='mobile'), user=user)
  175. started = response['data']
  176. assert svc._load(started['sessionId'])['mode'] == 'mobile'
  177. state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
  178. page = callback(state, 'code')
  179. body = page.body.decode()
  180. assert '正在返回小程序' in body
  181. assert '记下验证码' not in body
  182. assert 'oa-openid' not in body
  183. assert started['sessionId'] not in body
  184. assert 'jweixin-1.6.0.js' in body
  185. assert 'nonce-' in page.headers['content-security-policy']
  186. assert "'unsafe-inline'" not in page.headers['content-security-policy']
  187. assert db.query(Model).count() == 0
  188. with pytest.raises(HTTPException):
  189. svc.confirm_binding(db, {**user, 'user_id': 'u2'}, started['sessionId'])
  190. assert svc.confirm_binding(db, user, started['sessionId'])['bound'] is True
  191. def test_mobile_unsubscribed_returns_without_success_or_binding(binding, monkeypatch):
  192. from app.api.wechat_notices import callback
  193. from urllib.parse import urlparse, parse_qs
  194. svc, db, _, user, Model = binding
  195. monkeypatch.setattr(svc.client, 'subscribed', lambda oid: False)
  196. started = svc.start_binding(user, mode='mobile')
  197. state = parse_qs(urlparse(started['authorizeUrl']).query)['state'][0]
  198. page = callback(state, 'code')
  199. assert '请先关注' in page.body.decode()
  200. assert '已开通' not in page.body.decode()
  201. assert db.query(Model).count() == 0
  202. def test_binding_start_mode_defaults_to_desktop_and_rejects_invalid_mode(binding):
  203. from app.api.wechat_notices import StartBinding, start
  204. from pydantic import ValidationError
  205. svc, _, _, user, _ = binding
  206. result = start(body=None, user=user)
  207. assert svc._load(result['data']['sessionId'])['mode'] == 'desktop'
  208. with pytest.raises(ValidationError):
  209. StartBinding(mode='https://evil.test')