test_market_support_api.py 80 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471
  1. import asyncio
  2. from decimal import Decimal
  3. from io import BytesIO
  4. from types import SimpleNamespace
  5. from zipfile import ZipFile
  6. import pytest
  7. from sqlalchemy import create_engine
  8. from sqlalchemy.orm import sessionmaker
  9. from app.models.base import Base
  10. from app.models.models import (
  11. BizAttachment,
  12. BizCellSampleType,
  13. BizCollectionHospital,
  14. BizCollectionProtocol,
  15. BizCollectionRegion,
  16. BizCollectionRegionStaff,
  17. BizItemUnit,
  18. BizPresignOrder,
  19. BizPresignTransfer,
  20. BizProtocolCollectionInfo,
  21. BizProtocolFullmoonTodo,
  22. BizProtocolPayment,
  23. BizProtocolPaymentDetail,
  24. BizProtocolSample,
  25. BizProtocolStorageCert,
  26. BizSampleCode,
  27. SysPost,
  28. SysPostRole,
  29. SysRole,
  30. SysRoleDataScopeDetail,
  31. SysDictData,
  32. SysEthnic,
  33. SysUser,
  34. SysUserPost,
  35. SysUserRole,
  36. )
  37. from app.models.research_donation import (
  38. BizResearchDonation,
  39. BizResearchDonationCollection,
  40. BizResearchDonationSample,
  41. )
  42. from app.services import market_support
  43. from app.services.market_field_scope import MarketFieldContext
  44. def _session():
  45. engine = create_engine('sqlite:///:memory:')
  46. Base.metadata.create_all(
  47. engine,
  48. tables=[
  49. BizAttachment.__table__,
  50. BizCellSampleType.__table__,
  51. BizCollectionHospital.__table__,
  52. BizCollectionProtocol.__table__,
  53. BizCollectionRegion.__table__,
  54. BizCollectionRegionStaff.__table__,
  55. BizItemUnit.__table__,
  56. BizProtocolSample.__table__,
  57. BizResearchDonationSample.__table__,
  58. BizSampleCode.__table__,
  59. SysDictData.__table__,
  60. SysEthnic.__table__,
  61. SysUser.__table__,
  62. ],
  63. )
  64. return sessionmaker(bind=engine)()
  65. def _ctx(scope='specialist', user_id='user-a'):
  66. return MarketFieldContext('tenant-a', user_id, scope)
  67. def _user(user_id='user-a'):
  68. return {
  69. 'user_id': user_id,
  70. 'tenant_no': 'tenant-a',
  71. 'region_id': 'dept-a',
  72. }
  73. def _seed_options(db):
  74. db.add_all(
  75. [
  76. BizCollectionRegion(id='ra', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  77. BizCollectionRegion(id='rb', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  78. BizCollectionRegion(id='rx', region_name='跨租户秘密区', tenant_no='tenant-b', create_dept_id='', del_flag=0),
  79. BizCollectionHospital(id='ha', region_id='ra', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  80. BizCollectionHospital(id='hb', region_id='rb', hospital_name='B院', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  81. BizCollectionHospital(id='hx', region_id='rx', hospital_name='跨租户秘密院', tenant_no='tenant-b', create_dept_id='', del_flag=0),
  82. BizCollectionRegionStaff(id='rsa', region_id='ra', user_id='user-a', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  83. BizCollectionRegionStaff(id='rsb', region_id='rb', user_id='user-b', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  84. BizCollectionRegionStaff(id='rsa2', region_id='ra', user_id='staff-a', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  85. BizCollectionRegionStaff(id='rsa3', region_id='ra', user_id='leader-a', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  86. BizCollectionRegionStaff(id='rsx', region_id='rx', user_id='cross-user', is_leader=0, tenant_no='tenant-b', create_dept_id='', del_flag=0),
  87. SysUser(id='user-a', user_no='ua', nick_name='本人', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  88. SysUser(id='user-b', user_no='ub', nick_name='B经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  89. SysUser(id='staff-a', user_no='sa', nick_name='A员工', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  90. SysUser(id='leader-a', user_no='la', nick_name='A负责人', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  91. SysUser(id='disabled', user_no='off', nick_name='停用秘密', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  92. SysUser(id='cross-user', user_no='xx', nick_name='跨租户秘密用户', status='1', tenant_no='tenant-b', create_dept_id='', del_flag=0),
  93. BizCellSampleType(
  94. id='type-a',
  95. type_code='A',
  96. type_name='样本A',
  97. storage_price=Decimal('25880.00'),
  98. storage_years=20,
  99. tenant_no='tenant-a',
  100. create_dept_id='',
  101. del_flag=0,
  102. ),
  103. BizCellSampleType(
  104. id='type-x',
  105. type_code='X',
  106. type_name='跨租户秘密样本',
  107. storage_price=Decimal('9999999999999999.99'),
  108. storage_years=99,
  109. tenant_no='tenant-b',
  110. create_dept_id='',
  111. del_flag=0,
  112. ),
  113. BizItemUnit(id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  114. BizSampleCode(id='sample-code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  115. SysEthnic(id='ethnic-han', ethnic_name='汉族', ethnic_code='01', tenant_no='', create_dept_id='', del_flag=0),
  116. SysDictData(id='dict-pay', dict_label='微信', dict_value='wx', dict_type='zzfs', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  117. SysDictData(id='dict-freight', dict_label='专线车', dict_value='zxc', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  118. SysDictData(id='dict-secret', dict_label='不应泄漏', dict_value='secret', dict_type='unrelated', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  119. ]
  120. )
  121. db.commit()
  122. def test_specialist_form_options_list_all_tenant_regions_like_web(monkeypatch):
  123. """市场专员采集地区选项应与 Web 一致:本租户全部地区,不依赖人员绑定。"""
  124. from app.services.market_support import get_form_options
  125. db = _session()
  126. _seed_options(db)
  127. # 未绑定任何采集地区的专员仍应能选到租户内地区(对齐 Web 预签表单)
  128. unbound = get_form_options(
  129. db,
  130. module='presign',
  131. ctx=_ctx('specialist', 'staff-unbound'),
  132. user_info=_user('staff-unbound'),
  133. )
  134. assert unbound['regions'] == [
  135. {'value': 'ra', 'label': 'A区'},
  136. {'value': 'rb', 'label': 'B区'},
  137. ]
  138. assert '跨租户' not in str(unbound)
  139. out = get_form_options(db, module='presign', ctx=_ctx(), user_info=_user())
  140. assert out['regions'] == [
  141. {'value': 'ra', 'label': 'A区'},
  142. {'value': 'rb', 'label': 'B区'},
  143. ]
  144. assert out['hospitals'] == [
  145. {'value': 'ha', 'label': 'A院'},
  146. {'value': 'hb', 'label': 'B院'},
  147. ]
  148. assert {row['value'] for row in out['staff']} >= {
  149. 'leader-a',
  150. 'staff-a',
  151. 'user-a',
  152. 'user-b',
  153. }
  154. assert out['sampleTypes'] == [{'value': 'type-a', 'label': '样本A'}]
  155. assert out['units'] == [{'value': 'unit-a', 'label': '毫升'}]
  156. assert out['payModes'] == [
  157. {'label': '趸交', 'value': 'dj'},
  158. {'label': '分期', 'value': 'fq'},
  159. ]
  160. assert out['orderStatuses'] == [
  161. {'label': '草稿', 'value': 'draft'},
  162. {'label': '进行中', 'value': 'active'},
  163. {'label': '已转正式', 'value': 'converted'},
  164. {'label': '已作废', 'value': 'void'},
  165. ]
  166. assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}]
  167. assert out['currentUser'] == {'value': 'user-a', 'label': '本人'}
  168. # 当前登录人关联采集地区(新增协议单默认采集地区用)
  169. assert out['currentUserRegions'] == [{'value': 'ra', 'label': 'A区'}]
  170. assert '跨租户' not in str(out)
  171. scoped = get_form_options(
  172. db,
  173. module='presign',
  174. ctx=_ctx(),
  175. user_info=_user(),
  176. region_id='ra',
  177. )
  178. assert scoped['regions'] == [
  179. {'value': 'ra', 'label': 'A区'},
  180. {'value': 'rb', 'label': 'B区'},
  181. ]
  182. assert scoped['hospitals'] == [{'value': 'ha', 'label': 'A院'}]
  183. assert scoped['staff'] == [
  184. {'value': 'leader-a', 'label': 'A负责人', 'isLeader': True},
  185. {'value': 'staff-a', 'label': 'A员工', 'isLeader': False},
  186. {'value': 'user-a', 'label': '本人', 'isLeader': False},
  187. ]
  188. monkeypatch.setattr(
  189. 'app.services.market_support._business_region_id',
  190. lambda *args, **kwargs: 'rb',
  191. )
  192. supplemented = get_form_options(
  193. db,
  194. module='presign',
  195. ctx=_ctx(),
  196. user_info=_user(),
  197. business_id='own-document',
  198. )
  199. assert supplemented['regions'] == [
  200. {'value': 'ra', 'label': 'A区'},
  201. {'value': 'rb', 'label': 'B区'},
  202. ]
  203. def test_form_options_current_user_regions_keep_staff_binding_order():
  204. """多地区绑定时 currentUserRegions 按人员地区表顺序返回,供前端默认第一个。"""
  205. from app.services.market_support import get_form_options
  206. db = _session()
  207. _seed_options(db)
  208. db.add(
  209. BizCollectionRegionStaff(
  210. id='rsa-b',
  211. region_id='rb',
  212. user_id='user-a',
  213. is_leader=0,
  214. order_num=20,
  215. tenant_no='tenant-a',
  216. create_dept_id='',
  217. del_flag=0,
  218. )
  219. )
  220. # 已有 rsa 绑定 ra;显式调高 order_num 保证 ra 在前
  221. rsa = db.query(BizCollectionRegionStaff).filter_by(id='rsa').one()
  222. rsa.order_num = 10
  223. db.commit()
  224. out = get_form_options(db, module='protocol', ctx=_ctx(), user_info=_user())
  225. assert out['currentUserRegions'] == [
  226. {'value': 'ra', 'label': 'A区'},
  227. {'value': 'rb', 'label': 'B区'},
  228. ]
  229. unbound = get_form_options(
  230. db,
  231. module='protocol',
  232. ctx=_ctx('specialist', 'staff-unbound'),
  233. user_info=_user('staff-unbound'),
  234. )
  235. assert unbound['currentUserRegions'] == []
  236. def test_manager_form_options_list_all_tenant_regions_like_web():
  237. from app.services.market_support import get_form_options
  238. db = _session()
  239. _seed_options(db)
  240. out = get_form_options(
  241. db,
  242. module='protocol',
  243. ctx=_ctx('manager', 'user-b'),
  244. user_info=_user('user-b'),
  245. region_id='rb',
  246. )
  247. assert out['regions'] == [
  248. {'value': 'ra', 'label': 'A区'},
  249. {'value': 'rb', 'label': 'B区'},
  250. ]
  251. assert out['hospitals'] == [{'value': 'hb', 'label': 'B院'}]
  252. assert out['ethnicities'] == [{'value': '汉族', 'label': '汉族'}]
  253. assert out['sampleCodes'] == [
  254. {'value': 'sample-code-a', 'label': 'S-001', 'sampleCode': 'S-001'}
  255. ]
  256. # 级联医院可按任意本租户地区加载;跨租户地区仍拒绝
  257. other = get_form_options(
  258. db,
  259. module='protocol',
  260. ctx=_ctx('manager', 'user-b'),
  261. user_info=_user('user-b'),
  262. region_id='ra',
  263. )
  264. assert other['hospitals'] == [{'value': 'ha', 'label': 'A院'}]
  265. with pytest.raises(PermissionError, match='地区'):
  266. get_form_options(
  267. db,
  268. module='protocol',
  269. ctx=_ctx('manager', 'user-b'),
  270. user_info=_user('user-b'),
  271. region_id='rx',
  272. )
  273. def test_protocol_sample_type_options_keep_exact_defaults_and_tenant_scope():
  274. from app.services.market_support import get_form_options
  275. db = _session()
  276. _seed_options(db)
  277. max_type = BizCellSampleType(
  278. id='type-max',
  279. type_code='MAX',
  280. type_name='最大金额样本',
  281. storage_price=Decimal('9999999999999999.99'),
  282. storage_years=30,
  283. tenant_no='tenant-a',
  284. create_dept_id='',
  285. del_flag=0,
  286. )
  287. null_type = BizCellSampleType(
  288. id='type-null',
  289. type_code='NULL',
  290. type_name='未配置样本',
  291. storage_price=None,
  292. storage_years=None,
  293. tenant_no='tenant-a',
  294. create_dept_id='',
  295. del_flag=0,
  296. )
  297. db.add_all([max_type, null_type])
  298. # SQLite 会把超出双精度安全范围的 NUMERIC 值以 REAL 回读;保持本次
  299. # flush 后的 Decimal 实例在 identity map 中,测试生产 MySQL 返回 Decimal
  300. # 时的实际 API 序列化路径,而不是 SQLite 方言自身的精度损失。
  301. db.flush()
  302. out = get_form_options(
  303. db,
  304. module='protocol',
  305. ctx=_ctx('manager', 'user-b'),
  306. user_info=_user('user-b'),
  307. region_id='rb',
  308. )
  309. sample_types = {item['value']: item for item in out['sampleTypes']}
  310. assert sample_types == {
  311. 'type-a': {
  312. 'value': 'type-a',
  313. 'label': '样本A',
  314. 'storagePrice': '25880.00',
  315. 'storageYears': 20,
  316. 'sampleCodeFilter': '',
  317. },
  318. 'type-max': {
  319. 'value': 'type-max',
  320. 'label': '最大金额样本',
  321. 'storagePrice': '9999999999999999.99',
  322. 'storageYears': 30,
  323. 'sampleCodeFilter': '',
  324. },
  325. 'type-null': {
  326. 'value': 'type-null',
  327. 'label': '未配置样本',
  328. 'storagePrice': None,
  329. 'storageYears': None,
  330. 'sampleCodeFilter': '',
  331. },
  332. }
  333. assert 'type-x' not in sample_types
  334. def test_protocol_sample_type_options_expose_sample_code_filter():
  335. from app.services.market_support import get_form_options
  336. db = _session()
  337. _seed_options(db)
  338. db.query(BizCellSampleType).filter(BizCellSampleType.id == 'type-a').update(
  339. {'sample_code_filter': 'ZKZX'}
  340. )
  341. db.commit()
  342. out = get_form_options(
  343. db,
  344. module='protocol',
  345. ctx=_ctx('manager', 'user-b'),
  346. user_info=_user('user-b'),
  347. region_id='rb',
  348. )
  349. type_a = next(item for item in out['sampleTypes'] if item['value'] == 'type-a')
  350. assert type_a['sampleCodeFilter'] == 'ZKZX'
  351. def test_protocol_form_options_filter_sample_codes_by_code_filter():
  352. from app.services.market_support import get_form_options
  353. db = _session()
  354. _seed_options(db)
  355. db.add_all(
  356. [
  357. BizSampleCode(
  358. id='sample-code-zk',
  359. sample_code='ZKZX-001',
  360. tenant_no='tenant-a',
  361. create_dept_id='',
  362. del_flag=0,
  363. ),
  364. BizSampleCode(
  365. id='sample-code-other',
  366. sample_code='OTHER-002',
  367. tenant_no='tenant-a',
  368. create_dept_id='',
  369. del_flag=0,
  370. ),
  371. ]
  372. )
  373. db.commit()
  374. filtered = get_form_options(
  375. db,
  376. module='protocol',
  377. ctx=_ctx('manager', 'user-b'),
  378. user_info=_user('user-b'),
  379. region_id='rb',
  380. code_filter='ZKZX',
  381. )
  382. assert filtered['sampleCodes'] == [
  383. {
  384. 'value': 'sample-code-zk',
  385. 'label': 'ZKZX-001',
  386. 'sampleCode': 'ZKZX-001',
  387. }
  388. ]
  389. unfiltered = get_form_options(
  390. db,
  391. module='protocol',
  392. ctx=_ctx('manager', 'user-b'),
  393. user_info=_user('user-b'),
  394. region_id='rb',
  395. code_filter='',
  396. )
  397. codes = {item['sampleCode'] for item in unfiltered['sampleCodes']}
  398. assert 'S-001' in codes
  399. assert 'ZKZX-001' in codes
  400. assert 'OTHER-002' in codes
  401. def test_protocol_form_options_search_sample_codes_by_keyword():
  402. """弹窗输入的编码走 keyword,并仍受样本类型 codeFilter 约束,不受最新 100 条截断。"""
  403. from datetime import datetime, timedelta
  404. from app.services.market_support import get_form_options
  405. db = _session()
  406. _seed_options(db)
  407. base = datetime(2020, 1, 1)
  408. db.add(
  409. BizSampleCode(
  410. id='sample-code-target',
  411. sample_code='ZKZX34000987',
  412. tenant_no='tenant-a',
  413. create_dept_id='',
  414. del_flag=0,
  415. create_time=base,
  416. )
  417. )
  418. db.add(
  419. BizSampleCode(
  420. id='sample-code-other-hit',
  421. sample_code='OTHER34000987',
  422. tenant_no='tenant-a',
  423. create_dept_id='',
  424. del_flag=0,
  425. create_time=base + timedelta(days=400),
  426. )
  427. )
  428. db.add_all(
  429. [
  430. BizSampleCode(
  431. id=f'sample-code-new-{index}',
  432. sample_code=f'ZKZXN{index:05d}',
  433. tenant_no='tenant-a',
  434. create_dept_id='',
  435. del_flag=0,
  436. create_time=base + timedelta(days=index + 1),
  437. )
  438. for index in range(201)
  439. ]
  440. )
  441. db.commit()
  442. hidden = get_form_options(
  443. db,
  444. module='protocol',
  445. ctx=_ctx('manager', 'user-b'),
  446. user_info=_user('user-b'),
  447. region_id='rb',
  448. code_filter='ZKZX',
  449. )
  450. hidden_codes = {item['sampleCode'] for item in hidden['sampleCodes']}
  451. assert 'ZKZX34000987' not in hidden_codes
  452. found = get_form_options(
  453. db,
  454. module='protocol',
  455. ctx=_ctx('manager', 'user-b'),
  456. user_info=_user('user-b'),
  457. region_id='rb',
  458. code_filter='ZKZX',
  459. sample_code_keyword='34000987',
  460. )
  461. assert found['sampleCodes'] == [
  462. {
  463. 'value': 'sample-code-target',
  464. 'label': 'ZKZX34000987',
  465. 'sampleCode': 'ZKZX34000987',
  466. }
  467. ]
  468. def test_protocol_form_options_hide_occupied_sample_codes():
  469. from app.services.market_support import get_form_options
  470. db = _session()
  471. _seed_options(db)
  472. db.add(BizSampleCode(id='sample-code-b', sample_code='S-002', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  473. db.add(
  474. BizCollectionProtocol(
  475. id='proto-used',
  476. protocol_no='CP-USED',
  477. tenant_no='tenant-a',
  478. create_dept_id='',
  479. del_flag=0,
  480. )
  481. )
  482. db.add(
  483. BizProtocolSample(
  484. id='ps-used',
  485. collection_protocol_id='proto-used',
  486. sample_code='S-001',
  487. tenant_no='tenant-a',
  488. create_dept_id='',
  489. del_flag=0,
  490. )
  491. )
  492. db.commit()
  493. out = get_form_options(
  494. db,
  495. module='protocol',
  496. ctx=_ctx('manager', 'user-b'),
  497. user_info=_user('user-b'),
  498. region_id='rb',
  499. )
  500. assert out['sampleCodes'] == [
  501. {'value': 'sample-code-b', 'label': 'S-002', 'sampleCode': 'S-002'}
  502. ]
  503. assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}]
  504. assert out['transportModes'] == [{'value': 'zxc', 'label': '专线车'}]
  505. def test_presign_form_options_search_active_tenant_users_without_pii():
  506. from app.services.market_support import get_form_options
  507. db = _session()
  508. _seed_options(db)
  509. out = get_form_options(
  510. db,
  511. module='presign',
  512. ctx=_ctx(),
  513. user_info=_user(),
  514. user_keyword='员工',
  515. )
  516. assert out['users'] == [{'value': 'staff-a', 'label': 'A员工'}]
  517. assert '跨租户' not in str(out['users'])
  518. assert '停用' not in str(out['users'])
  519. assert 'userNo' not in str(out['users'])
  520. def test_payment_and_freight_options_use_fixed_dictionary_allowlist():
  521. from app.services.market_support import get_form_options
  522. db = _session()
  523. _seed_options(db)
  524. payment = get_form_options(db, module='payment', ctx=_ctx(), user_info=_user())
  525. freight = get_form_options(db, module='freight', ctx=_ctx(), user_info=_user())
  526. assert payment == {'transferTypes': [{'value': 'wx', 'label': '微信'}]}
  527. assert freight == {'transportModes': [{'value': 'zxc', 'label': '专线车'}]}
  528. assert '不应泄漏' not in str(payment) + str(freight)
  529. class _Storage:
  530. def __init__(self):
  531. self.uploaded = []
  532. self.deleted = []
  533. def upload_file(self, content, filename, folder):
  534. self.uploaded.append((content, filename, folder))
  535. return f'https://files.test/{folder}/generated.pdf'
  536. def delete_file(self, path):
  537. self.deleted.append(path)
  538. return True
  539. def test_upload_uses_fixed_folder_and_records_current_tenant_and_uploader():
  540. from app.services.market_support import save_market_attachment
  541. db = _session()
  542. backend = _Storage()
  543. result = save_market_attachment(
  544. db,
  545. content=b'%PDF-1.7 safe',
  546. filename='proof.pdf',
  547. content_type='application/pdf',
  548. module='payment',
  549. ctx=_ctx(),
  550. user_info=_user(),
  551. storage_backend=backend,
  552. )
  553. assert backend.uploaded[0][2] == 'market/specialist/payment'
  554. row = db.query(BizAttachment).one()
  555. assert (row.tenant_no, row.create_by, row.create_dept_id) == ('tenant-a', 'user-a', 'dept-a')
  556. assert row.business_type == 'market:specialist:payment:staging'
  557. assert result['id'] == row.id
  558. assert result['fileUrl'] == row.file_url
  559. def test_presign_and_protocol_upload_allow_20_mib_while_payment_remains_at_10_mib():
  560. from app.services.market_support import save_market_attachment
  561. content = b'%PDF-' + b'a' * (10 * 1024 * 1024)
  562. backend = _Storage()
  563. result = save_market_attachment(
  564. _session(),
  565. content=content,
  566. filename='presign-proof.pdf',
  567. content_type='application/pdf',
  568. module='presign',
  569. ctx=_ctx(),
  570. user_info=_user(),
  571. storage_backend=backend,
  572. )
  573. assert result['fileSize'] == len(content)
  574. protocol_result = save_market_attachment(
  575. _session(),
  576. content=content,
  577. filename='protocol-proof.pdf',
  578. content_type='application/pdf',
  579. module='protocol',
  580. ctx=_ctx(),
  581. user_info=_user(),
  582. storage_backend=_Storage(),
  583. )
  584. assert protocol_result['fileSize'] == len(content)
  585. with pytest.raises(ValueError, match='10MiB'):
  586. save_market_attachment(
  587. _session(),
  588. content=content,
  589. filename='payment-proof.pdf',
  590. content_type='application/pdf',
  591. module='payment',
  592. ctx=_ctx(),
  593. user_info=_user(),
  594. storage_backend=_Storage(),
  595. )
  596. def _zip_payload(path):
  597. output = BytesIO()
  598. with ZipFile(output, 'w') as archive:
  599. archive.writestr('[Content_Types].xml', '<Types/>')
  600. archive.writestr(path, 'payload')
  601. return output.getvalue()
  602. @pytest.mark.parametrize(
  603. ('content', 'filename', 'content_type'),
  604. [
  605. (_zip_payload('ppt/presentation.xml'), 'slides.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'),
  606. (b'plain text', 'notes.txt', 'text/plain'),
  607. (_zip_payload('folder/readme.txt'), 'archive.zip', 'application/zip'),
  608. (b'Rar!\x1a\x07\x01\x00' + b'a' * 8, 'archive.rar', 'application/vnd.rar'),
  609. (b'\x00\x00\x00\x18ftypmp42' + b'a' * 12, 'video.mp4', 'video/mp4'),
  610. ],
  611. )
  612. def test_protocol_upload_accepts_web_protocol_attachment_formats(content, filename, content_type):
  613. from app.services.market_support import save_market_attachment
  614. result = save_market_attachment(
  615. _session(),
  616. content=content,
  617. filename=filename,
  618. content_type=content_type,
  619. module='protocol',
  620. ctx=_ctx(),
  621. user_info=_user(),
  622. storage_backend=_Storage(),
  623. )
  624. assert result['fileName'] == filename
  625. def test_protocol_detail_payment_capabilities_require_payment_permission():
  626. from app.services.market_support import decorate_allowed_actions
  627. detail = {
  628. 'source': 'cell_storage',
  629. 'basic': {'protocolStatus': 'sxz', 'paymentModelVersion': 2},
  630. 'samplePayments': [{
  631. 'id': 'pay-1',
  632. 'schedules': [{
  633. 'id': 'schedule-1',
  634. 'scheduleStatus': 'open',
  635. 'arrearsAmount': 100,
  636. 'transfers': [
  637. {'id': 'draft', 'payStatus': 'dfj'},
  638. {'id': 'submitted', 'payStatus': 'jfqrz'},
  639. {'id': 'confirmed', 'payStatus': 'yjf'},
  640. ],
  641. }],
  642. }],
  643. }
  644. readonly = decorate_allowed_actions(
  645. 'protocol', detail, scope_kind='specialist', has_payment_permission=False
  646. )
  647. assert readonly['samplePayments'][0]['schedules'][0]['allowedActions'] == []
  648. assert [row['allowedActions'] for row in readonly['samplePayments'][0]['schedules'][0]['transfers']] == [[], [], []]
  649. writable = decorate_allowed_actions(
  650. 'protocol', detail, scope_kind='specialist', has_payment_permission=True
  651. )
  652. schedule = writable['samplePayments'][0]['schedules'][0]
  653. assert schedule['allowedActions'] == ['register-transfer']
  654. assert [row['allowedActions'] for row in schedule['transfers']] == [
  655. ['edit-transfer', 'delete-payment-transfer', 'submit-finance'],
  656. ['withdraw-transfer'],
  657. [],
  658. ]
  659. @pytest.mark.parametrize(
  660. ('content', 'filename', 'content_type'),
  661. [
  662. (b'', 'empty.pdf', 'application/pdf'),
  663. (b'<svg/>', 'bad.svg', 'image/svg+xml'),
  664. (b'MZ', 'bad.exe', 'application/octet-stream'),
  665. (b'not an image', 'fake.jpg', 'image/jpeg'),
  666. (b'%PDF-1.7', 'fake.jpg', 'image/jpeg'),
  667. (b'\x00\x00\x00\x18junk-ftyp-heic-data', 'fake.heic', 'image/heic'),
  668. (b'\x00\x00\x00\x18ftypzzzz\x00\x00\x00\x00zzzz', 'fake.heic', 'image/heic'),
  669. (b'\x00\x00\x00\xffftypheic\x00\x00\x00\x00', 'fake.heic', 'image/heic'),
  670. (b'\x00\x00\x00\x0cftypheic', 'short.heic', 'image/heic'),
  671. (b'\x00\x00\x00\x0fftypheicabc', 'short.heic', 'image/heic'),
  672. (b'\x00\x00\x00\x15ftypheic\x00\x00\x00\x00mif1x', 'misaligned.heic', 'image/heic'),
  673. (b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00', 'generic-only.heic', 'image/heic'),
  674. (b'PK\x03\x04not-a-docx', 'fake.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
  675. (b'a' * (10 * 1024 * 1024 + 1), 'huge.pdf', 'application/pdf'),
  676. ],
  677. )
  678. def test_upload_rejects_empty_oversized_or_unsafe_files(content, filename, content_type):
  679. from app.services.market_support import save_market_attachment
  680. db = _session()
  681. backend = _Storage()
  682. with pytest.raises(ValueError):
  683. save_market_attachment(
  684. db,
  685. content=content,
  686. filename=filename,
  687. content_type=content_type,
  688. module='payment',
  689. ctx=_ctx(),
  690. user_info=_user(),
  691. storage_backend=backend,
  692. )
  693. assert backend.uploaded == []
  694. def test_upload_deletes_storage_object_when_database_commit_fails(monkeypatch):
  695. from app.services.market_support import save_market_attachment
  696. db = _session()
  697. backend = _Storage()
  698. monkeypatch.setattr(db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down')))
  699. with pytest.raises(RuntimeError, match='db down'):
  700. save_market_attachment(
  701. db,
  702. content=b'%PDF-1.7 safe',
  703. filename='proof.pdf',
  704. content_type='application/pdf',
  705. module='protocol',
  706. ctx=_ctx(),
  707. user_info=_user(),
  708. storage_backend=backend,
  709. )
  710. assert backend.deleted == ['https://files.test/market/specialist/protocol/generated.pdf']
  711. def test_upload_never_refreshes_after_commit_or_deletes_committed_object(monkeypatch):
  712. from app.services.market_support import save_market_attachment
  713. db = _session()
  714. backend = _Storage()
  715. committed = False
  716. original_commit = db.commit
  717. original_refresh = db.refresh
  718. def tracked_commit():
  719. nonlocal committed
  720. original_commit()
  721. committed = True
  722. def refresh_only_before_commit(row):
  723. if committed:
  724. raise RuntimeError('refresh after commit is unsafe')
  725. return original_refresh(row)
  726. monkeypatch.setattr(db, 'commit', tracked_commit)
  727. monkeypatch.setattr(db, 'refresh', refresh_only_before_commit)
  728. result = save_market_attachment(
  729. db,
  730. content=b'%PDF-1.7 safe',
  731. filename='proof.pdf',
  732. content_type='application/pdf',
  733. module='protocol',
  734. ctx=_ctx(),
  735. user_info=_user(),
  736. storage_backend=backend,
  737. )
  738. assert result['id']
  739. assert backend.deleted == []
  740. def test_upload_accepts_well_formed_heif_ftyp_box_with_allowed_brand():
  741. from app.services.market_support import save_market_attachment
  742. db = _session()
  743. backend = _Storage()
  744. content = b'\x00\x00\x00\x14ftypheic\x00\x00\x00\x00mif1'
  745. result = save_market_attachment(
  746. db,
  747. content=content,
  748. filename='photo.heic',
  749. content_type='image/heic',
  750. module='sample',
  751. ctx=_ctx(),
  752. user_info=_user(),
  753. storage_backend=backend,
  754. )
  755. assert result['fileType'] == 'image/heic'
  756. assert backend.uploaded[0][0] == content
  757. def test_heif_extension_accepts_generic_heif_brand():
  758. from app.services.market_support import save_market_attachment
  759. db = _session()
  760. backend = _Storage()
  761. content = b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00'
  762. result = save_market_attachment(
  763. db,
  764. content=content,
  765. filename='photo.heif',
  766. content_type='image/heif',
  767. module='sample',
  768. ctx=_ctx(),
  769. user_info=_user(),
  770. storage_backend=backend,
  771. )
  772. assert result['fileType'] == 'image/heif'
  773. def _attachment(**overrides):
  774. values = dict(
  775. id='att-a',
  776. business_type='market:specialist:payment:staging',
  777. business_id=None,
  778. original_name='proof.pdf',
  779. file_ext='pdf',
  780. file_url='market/specialist/payment/a.pdf',
  781. file_size=12,
  782. file_type='application/pdf',
  783. tenant_no='tenant-a',
  784. create_by='user-a',
  785. create_dept_id='',
  786. del_flag=0,
  787. )
  788. values.update(overrides)
  789. return BizAttachment(**values)
  790. class _DownloadStorage:
  791. def __init__(self):
  792. self.requests = []
  793. def presigned_download_url(self, file_url, file_name):
  794. self.requests.append((file_url, file_name))
  795. return 'https://files.test/signed-download'
  796. def _protocol_attachment_session():
  797. engine = create_engine('sqlite:///:memory:')
  798. Base.metadata.create_all(
  799. engine,
  800. tables=[
  801. BizAttachment.__table__,
  802. BizCollectionProtocol.__table__,
  803. BizCollectionRegionStaff.__table__,
  804. BizPresignOrder.__table__,
  805. BizPresignTransfer.__table__,
  806. BizProtocolCollectionInfo.__table__,
  807. BizProtocolFullmoonTodo.__table__,
  808. BizProtocolPayment.__table__,
  809. BizProtocolPaymentDetail.__table__,
  810. BizProtocolStorageCert.__table__,
  811. BizResearchDonation.__table__,
  812. BizResearchDonationCollection.__table__,
  813. SysPost.__table__,
  814. SysPostRole.__table__,
  815. SysRole.__table__,
  816. SysRoleDataScopeDetail.__table__,
  817. SysUserPost.__table__,
  818. SysUserRole.__table__,
  819. ],
  820. )
  821. db = sessionmaker(bind=engine)()
  822. db.add(
  823. SysRole(
  824. id='market-role',
  825. role_name='市场移动端',
  826. data_scope='1',
  827. status='1',
  828. tenant_no='tenant-a',
  829. create_dept_id='',
  830. del_flag=0,
  831. )
  832. )
  833. for user_id in ('sales-a', 'sales-b', 'manager-a'):
  834. db.add(
  835. SysUserRole(
  836. id=f'user-role-{user_id}',
  837. user_id=user_id,
  838. role_id='market-role',
  839. tenant_no='tenant-a',
  840. create_dept_id='',
  841. del_flag=0,
  842. )
  843. )
  844. db.add(
  845. BizCollectionRegionStaff(
  846. id='manager-a-region-a',
  847. region_id='region-a',
  848. user_id='manager-a',
  849. is_leader=1,
  850. tenant_no='tenant-a',
  851. create_dept_id='',
  852. del_flag=0,
  853. )
  854. )
  855. db.commit()
  856. return db
  857. def _market_user(user_id):
  858. return {
  859. 'user_id': user_id,
  860. 'tenant_no': 'tenant-a',
  861. 'role': 'market_user',
  862. 'dept_id': 'dept-a',
  863. }
  864. def _add_protocol_attachment_reference(
  865. db,
  866. protocol_id,
  867. *,
  868. field,
  869. value,
  870. sales_user_id='sales-a',
  871. region_id='region-a',
  872. manager_user_id='manager-b',
  873. parent_tenant_no='tenant-a',
  874. parent_del_flag=0,
  875. collection_tenant_no=None,
  876. collection_del_flag=0,
  877. child_tenant_no='tenant-a',
  878. child_del_flag=0,
  879. ):
  880. db.add_all(
  881. [
  882. BizCollectionProtocol(
  883. id=protocol_id,
  884. protocol_no=f'P-{protocol_id}',
  885. tenant_no=parent_tenant_no,
  886. create_by=sales_user_id,
  887. create_dept_id='dept-a',
  888. del_flag=parent_del_flag,
  889. ),
  890. BizProtocolCollectionInfo(
  891. id=f'collection-{protocol_id}',
  892. collection_protocol_id=protocol_id,
  893. region_id=region_id,
  894. sales_user_id=sales_user_id,
  895. region_manager_user_id=manager_user_id,
  896. tenant_no=(
  897. collection_tenant_no
  898. if collection_tenant_no is not None
  899. else parent_tenant_no
  900. ),
  901. create_dept_id='dept-a',
  902. del_flag=collection_del_flag,
  903. ),
  904. ]
  905. )
  906. model = (
  907. BizProtocolStorageCert
  908. if field == 'cert_image_urls'
  909. else BizProtocolFullmoonTodo
  910. )
  911. db.add(
  912. model(
  913. id=f'child-{protocol_id}',
  914. collection_protocol_id=protocol_id,
  915. tenant_no=child_tenant_no,
  916. create_dept_id='dept-a',
  917. del_flag=child_del_flag,
  918. **{field: value},
  919. )
  920. )
  921. db.commit()
  922. def _protocol_download_ctx(scope_kind, user_id):
  923. return MarketFieldContext('tenant-a', user_id, scope_kind)
  924. def test_protocol_cert_and_insurance_references_use_real_scope_and_exact_urls():
  925. db = _protocol_attachment_session()
  926. _add_protocol_attachment_reference(
  927. db,
  928. 'own-cert',
  929. field='cert_image_urls',
  930. value=' files/cert.pdf.evil , files/cert-exact.pdf ',
  931. )
  932. _add_protocol_attachment_reference(
  933. db,
  934. 'own-insurance',
  935. field='insurance_attachments',
  936. value='files/insurance.pdf',
  937. )
  938. assert market_support._has_accessible_market_attachment_reference(
  939. db,
  940. module='protocol',
  941. candidates=['files/cert-exact.pdf'],
  942. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  943. user_info=_market_user('sales-a'),
  944. )
  945. assert market_support._has_accessible_market_attachment_reference(
  946. db,
  947. module='protocol',
  948. candidates=['files/insurance.pdf'],
  949. ctx=_protocol_download_ctx('manager', 'manager-a'),
  950. user_info=_market_user('manager-a'),
  951. )
  952. assert not market_support._has_accessible_market_attachment_reference(
  953. db,
  954. module='protocol',
  955. candidates=['files/cert.pdf'],
  956. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  957. user_info=_market_user('sales-a'),
  958. )
  959. assert not market_support._has_accessible_market_attachment_reference(
  960. db,
  961. module='protocol',
  962. candidates=['files/cert-exact.pdf.evil'],
  963. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  964. user_info=_market_user('sales-a'),
  965. )
  966. assert not market_support._has_accessible_market_attachment_reference(
  967. db,
  968. module='protocol',
  969. candidates=['files/cert-exact.pdf'],
  970. ctx=_protocol_download_ctx('specialist', 'sales-b'),
  971. user_info=_market_user('sales-b'),
  972. )
  973. db.add_all(
  974. [
  975. _attachment(
  976. id='short-url',
  977. file_url='files/cert.pdf',
  978. business_type='market:specialist:protocol:bound',
  979. ),
  980. _attachment(
  981. id='long-url',
  982. file_url='files/cert-exact.pdf.evil',
  983. business_type='market:specialist:protocol:bound',
  984. ),
  985. _attachment(
  986. id='other-specialist-scope',
  987. file_url='files/cert-exact.pdf',
  988. business_type='market:specialist:protocol:bound',
  989. ),
  990. ]
  991. )
  992. db.commit()
  993. for file_url in ('files/cert.pdf', 'files/cert-exact.pdf.evil'):
  994. with pytest.raises(PermissionError, match='附件'):
  995. market_support.get_market_attachment_download_url(
  996. db,
  997. file_url=file_url,
  998. file_name=None,
  999. module='protocol',
  1000. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1001. user_info=_market_user('sales-a'),
  1002. storage_backend=_DownloadStorage(),
  1003. )
  1004. with pytest.raises(PermissionError, match='附件'):
  1005. market_support.get_market_attachment_download_url(
  1006. db,
  1007. file_url='files/cert-exact.pdf',
  1008. file_name=None,
  1009. module='protocol',
  1010. ctx=_protocol_download_ctx('specialist', 'sales-b'),
  1011. user_info=_market_user('sales-b'),
  1012. storage_backend=_DownloadStorage(),
  1013. )
  1014. @pytest.mark.parametrize(
  1015. ('attachment_id', 'file_url', 'business_type', 'field', 'scope_kind', 'user_id'),
  1016. [
  1017. ('legacy-cert', 'files/legacy-cert.pdf', '', 'cert_image_urls', 'specialist', 'sales-a'),
  1018. (
  1019. 'protocol-cert',
  1020. 'files/protocol-cert.pdf',
  1021. 'market:specialist:protocol:bound',
  1022. 'cert_image_urls',
  1023. 'specialist',
  1024. 'sales-a',
  1025. ),
  1026. (
  1027. 'protocol-insurance',
  1028. 'files/protocol-insurance.pdf',
  1029. 'market:manager:protocol:bound',
  1030. 'insurance_attachments',
  1031. 'manager',
  1032. 'manager-a',
  1033. ),
  1034. (
  1035. 'sample-cert',
  1036. 'files/sample-cert.pdf',
  1037. 'market:specialist:sample:bound',
  1038. 'cert_image_urls',
  1039. 'specialist',
  1040. 'sales-a',
  1041. ),
  1042. (
  1043. 'sample-insurance',
  1044. 'files/sample-insurance.pdf',
  1045. 'market:manager:sample:bound',
  1046. 'insurance_attachments',
  1047. 'manager',
  1048. 'manager-a',
  1049. ),
  1050. ],
  1051. )
  1052. def test_protocol_download_allows_accessible_cert_and_insurance_attachment_history(
  1053. attachment_id,
  1054. file_url,
  1055. business_type,
  1056. field,
  1057. scope_kind,
  1058. user_id,
  1059. ):
  1060. db = _protocol_attachment_session()
  1061. _add_protocol_attachment_reference(
  1062. db,
  1063. f'protocol-{attachment_id}',
  1064. field=field,
  1065. value=file_url,
  1066. )
  1067. db.add(
  1068. _attachment(
  1069. id=attachment_id,
  1070. file_url=file_url,
  1071. business_type=business_type,
  1072. business_id=f'protocol-{attachment_id}',
  1073. create_by='historical-uploader',
  1074. )
  1075. )
  1076. db.commit()
  1077. result = market_support.get_market_attachment_download_url(
  1078. db,
  1079. file_url=file_url,
  1080. file_name=None,
  1081. module='protocol',
  1082. ctx=_protocol_download_ctx(scope_kind, user_id),
  1083. user_info=_market_user(user_id),
  1084. storage_backend=_DownloadStorage(),
  1085. )
  1086. assert result == {'downloadUrl': 'https://files.test/signed-download'}
  1087. @pytest.mark.parametrize(
  1088. ('attachment_id', 'file_url', 'business_type'),
  1089. [
  1090. ('legacy-deposit', 'files/deposit-legacy.png', ''),
  1091. (
  1092. 'presign-bound-deposit',
  1093. 'files/deposit-presign.png',
  1094. 'market:specialist:presign:bound',
  1095. ),
  1096. (
  1097. 'manager-presign-deposit',
  1098. 'files/deposit-manager.png',
  1099. 'market:manager:presign:bound',
  1100. ),
  1101. ],
  1102. )
  1103. def test_protocol_download_allows_linked_presign_deposit_attachments(
  1104. attachment_id,
  1105. file_url,
  1106. business_type,
  1107. ):
  1108. """正式协议详情/编辑预览意向金凭证:附件归属预签转账,须经 source_presign 关联授权。"""
  1109. db = _protocol_attachment_session()
  1110. protocol = BizCollectionProtocol(
  1111. id=f'protocol-{attachment_id}',
  1112. protocol_no=f'P-{attachment_id}',
  1113. source_presign_order_id=f'presign-{attachment_id}',
  1114. tenant_no='tenant-a',
  1115. create_by='sales-a',
  1116. create_dept_id='dept-a',
  1117. del_flag=0,
  1118. )
  1119. db.add_all(
  1120. [
  1121. protocol,
  1122. BizProtocolCollectionInfo(
  1123. id=f'collection-{attachment_id}',
  1124. collection_protocol_id=protocol.id,
  1125. region_id='region-a',
  1126. sales_user_id='sales-a',
  1127. region_manager_user_id='manager-a',
  1128. tenant_no='tenant-a',
  1129. create_dept_id='dept-a',
  1130. del_flag=0,
  1131. ),
  1132. BizPresignOrder(
  1133. id=f'presign-{attachment_id}',
  1134. order_status='converted',
  1135. sales_user_id='sales-a',
  1136. tenant_no='tenant-a',
  1137. create_by='sales-a',
  1138. create_dept_id='dept-a',
  1139. del_flag=0,
  1140. ),
  1141. BizPresignTransfer(
  1142. id=f'transfer-{attachment_id}',
  1143. presign_order_id=f'presign-{attachment_id}',
  1144. transfer_amount=Decimal('1000.00'),
  1145. transfer_attachments=file_url,
  1146. pay_status='yjf',
  1147. tenant_no='tenant-a',
  1148. create_by='sales-a',
  1149. create_dept_id='dept-a',
  1150. del_flag=0,
  1151. ),
  1152. _attachment(
  1153. id=attachment_id,
  1154. file_url=file_url,
  1155. business_type=business_type,
  1156. business_id=f'presign-{attachment_id}',
  1157. create_by='historical-uploader',
  1158. original_name='抄送我的.png',
  1159. ),
  1160. ]
  1161. )
  1162. db.commit()
  1163. result = market_support.get_market_attachment_download_url(
  1164. db,
  1165. file_url=file_url,
  1166. file_name=None,
  1167. module='protocol',
  1168. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1169. user_info=_market_user('sales-a'),
  1170. storage_backend=_DownloadStorage(),
  1171. )
  1172. assert result == {'downloadUrl': 'https://files.test/signed-download'}
  1173. def test_protocol_download_rejects_presign_deposit_attachment_outside_scope():
  1174. db = _protocol_attachment_session()
  1175. db.add_all(
  1176. [
  1177. BizCollectionProtocol(
  1178. id='protocol-other',
  1179. protocol_no='P-OTHER',
  1180. source_presign_order_id='presign-other',
  1181. tenant_no='tenant-a',
  1182. create_by='sales-b',
  1183. create_dept_id='dept-a',
  1184. del_flag=0,
  1185. ),
  1186. BizProtocolCollectionInfo(
  1187. id='collection-other',
  1188. collection_protocol_id='protocol-other',
  1189. region_id='region-b',
  1190. sales_user_id='sales-b',
  1191. region_manager_user_id='manager-b',
  1192. tenant_no='tenant-a',
  1193. create_dept_id='dept-a',
  1194. del_flag=0,
  1195. ),
  1196. BizPresignOrder(
  1197. id='presign-other',
  1198. order_status='converted',
  1199. sales_user_id='sales-b',
  1200. tenant_no='tenant-a',
  1201. create_by='sales-b',
  1202. create_dept_id='dept-a',
  1203. del_flag=0,
  1204. ),
  1205. BizPresignTransfer(
  1206. id='transfer-other',
  1207. presign_order_id='presign-other',
  1208. transfer_amount=Decimal('500.00'),
  1209. transfer_attachments='files/deposit-other.png',
  1210. pay_status='yjf',
  1211. tenant_no='tenant-a',
  1212. create_by='sales-b',
  1213. create_dept_id='dept-a',
  1214. del_flag=0,
  1215. ),
  1216. _attachment(
  1217. id='deposit-other',
  1218. file_url='files/deposit-other.png',
  1219. business_type='market:specialist:presign:bound',
  1220. business_id='presign-other',
  1221. create_by='sales-b',
  1222. ),
  1223. ]
  1224. )
  1225. db.commit()
  1226. with pytest.raises(PermissionError, match='附件'):
  1227. market_support.get_market_attachment_download_url(
  1228. db,
  1229. file_url='files/deposit-other.png',
  1230. file_name=None,
  1231. module='protocol',
  1232. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1233. user_info=_market_user('sales-a'),
  1234. storage_backend=_DownloadStorage(),
  1235. )
  1236. def test_protocol_download_does_not_use_plain_protocol_reference_for_sample_bound():
  1237. db = _protocol_attachment_session()
  1238. _add_protocol_attachment_reference(
  1239. db,
  1240. 'plain-protocol-reference',
  1241. field='cert_image_urls',
  1242. value='files/different-cert.pdf',
  1243. )
  1244. protocol = db.get(BizCollectionProtocol, 'plain-protocol-reference')
  1245. protocol.protocol_attachments = 'files/plain-protocol.pdf'
  1246. db.add(
  1247. _attachment(
  1248. id='plain-protocol-sample-bound',
  1249. file_url='files/plain-protocol.pdf',
  1250. business_type='market:specialist:sample:bound',
  1251. business_id='plain-protocol-reference',
  1252. )
  1253. )
  1254. db.commit()
  1255. with pytest.raises(PermissionError, match='附件'):
  1256. market_support.get_market_attachment_download_url(
  1257. db,
  1258. file_url='files/plain-protocol.pdf',
  1259. file_name=None,
  1260. module='protocol',
  1261. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1262. user_info=_market_user('sales-a'),
  1263. storage_backend=_DownloadStorage(),
  1264. )
  1265. @pytest.mark.parametrize(
  1266. (
  1267. 'case',
  1268. 'parent_tenant_no',
  1269. 'parent_del_flag',
  1270. 'collection_tenant_no',
  1271. 'collection_del_flag',
  1272. 'child_tenant_no',
  1273. 'child_del_flag',
  1274. ),
  1275. [
  1276. ('cross-parent-tenant', 'tenant-b', 0, 'tenant-b', 0, 'tenant-a', 0),
  1277. ('deleted-parent', 'tenant-a', 1, 'tenant-a', 0, 'tenant-a', 0),
  1278. ('cross-collection-tenant', 'tenant-a', 0, 'tenant-b', 0, 'tenant-a', 0),
  1279. ('deleted-collection', 'tenant-a', 0, 'tenant-a', 1, 'tenant-a', 0),
  1280. ('cross-child-tenant', 'tenant-a', 0, 'tenant-a', 0, 'tenant-b', 0),
  1281. ('deleted-child', 'tenant-a', 0, 'tenant-a', 0, 'tenant-a', 1),
  1282. ],
  1283. )
  1284. def test_protocol_cert_reference_rejects_tenant_scope_and_soft_delete_boundaries(
  1285. case,
  1286. parent_tenant_no,
  1287. parent_del_flag,
  1288. collection_tenant_no,
  1289. collection_del_flag,
  1290. child_tenant_no,
  1291. child_del_flag,
  1292. ):
  1293. db = _protocol_attachment_session()
  1294. file_url = f'files/{case}.pdf'
  1295. _add_protocol_attachment_reference(
  1296. db,
  1297. case,
  1298. field='cert_image_urls',
  1299. value=file_url,
  1300. parent_tenant_no=parent_tenant_no,
  1301. parent_del_flag=parent_del_flag,
  1302. collection_tenant_no=collection_tenant_no,
  1303. collection_del_flag=collection_del_flag,
  1304. child_tenant_no=child_tenant_no,
  1305. child_del_flag=child_del_flag,
  1306. )
  1307. assert not market_support._has_accessible_market_attachment_reference(
  1308. db,
  1309. module='protocol',
  1310. candidates=[file_url],
  1311. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1312. user_info=_market_user('sales-a'),
  1313. )
  1314. db.add(
  1315. _attachment(
  1316. id=f'attachment-{case}',
  1317. file_url=file_url,
  1318. business_type='market:specialist:protocol:bound',
  1319. )
  1320. )
  1321. db.commit()
  1322. with pytest.raises(PermissionError, match='附件'):
  1323. market_support.get_market_attachment_download_url(
  1324. db,
  1325. file_url=file_url,
  1326. file_name=None,
  1327. module='protocol',
  1328. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1329. user_info=_market_user('sales-a'),
  1330. storage_backend=_DownloadStorage(),
  1331. )
  1332. @pytest.mark.parametrize(
  1333. ('attachment_id', 'file_url', 'business_type', 'attachment_tenant', 'attachment_del_flag'),
  1334. [
  1335. ('sample-staging', 'files/sample-staging.pdf', 'market:specialist:sample:staging', 'tenant-a', 0),
  1336. ('other-staging', 'files/other-staging.pdf', 'market:manager:protocol:staging', 'tenant-a', 0),
  1337. ('cross-tenant', 'files/cross-tenant.pdf', '', 'tenant-b', 0),
  1338. ('deleted-attachment', 'files/deleted-attachment.pdf', '', 'tenant-a', 1),
  1339. ('unreferenced', 'files/unreferenced.pdf', '', 'tenant-a', 0),
  1340. ],
  1341. )
  1342. def test_protocol_download_rejects_staging_cross_tenant_deleted_and_unreferenced_rows(
  1343. attachment_id,
  1344. file_url,
  1345. business_type,
  1346. attachment_tenant,
  1347. attachment_del_flag,
  1348. ):
  1349. db = _protocol_attachment_session()
  1350. referenced_url = file_url if attachment_id != 'unreferenced' else 'files/other.pdf'
  1351. _add_protocol_attachment_reference(
  1352. db,
  1353. f'protocol-{attachment_id}',
  1354. field='cert_image_urls',
  1355. value=referenced_url,
  1356. )
  1357. db.add(
  1358. _attachment(
  1359. id=attachment_id,
  1360. file_url=file_url,
  1361. business_type=business_type,
  1362. tenant_no=attachment_tenant,
  1363. del_flag=attachment_del_flag,
  1364. )
  1365. )
  1366. db.commit()
  1367. with pytest.raises(PermissionError, match='附件'):
  1368. market_support.get_market_attachment_download_url(
  1369. db,
  1370. file_url=file_url,
  1371. file_name=None,
  1372. module='protocol',
  1373. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1374. user_info=_market_user('sales-a'),
  1375. storage_backend=_DownloadStorage(),
  1376. )
  1377. def test_market_attachment_download_uses_registered_original_name():
  1378. from app.services.market_support import get_market_attachment_download_url
  1379. db = _session()
  1380. db.add(_attachment())
  1381. db.commit()
  1382. backend = _DownloadStorage()
  1383. result = get_market_attachment_download_url(
  1384. db,
  1385. file_url='market/specialist/payment/a.pdf',
  1386. file_name='spoofed.exe',
  1387. module='payment',
  1388. ctx=_ctx(),
  1389. user_info=_user(),
  1390. storage_backend=backend,
  1391. )
  1392. assert result == {'downloadUrl': 'https://files.test/signed-download'}
  1393. assert backend.requests == [
  1394. ('market/specialist/payment/a.pdf', 'proof.pdf')
  1395. ]
  1396. def test_market_attachment_download_rejects_cross_tenant_module_and_other_users_staging():
  1397. from app.services.market_support import get_market_attachment_download_url
  1398. db = _session()
  1399. db.add_all(
  1400. [
  1401. _attachment(id='cross', file_url='cross.pdf', tenant_no='tenant-b'),
  1402. _attachment(
  1403. id='wrong-module',
  1404. file_url='wrong-module.pdf',
  1405. business_type='market:specialist:sample:bound',
  1406. business_id='sample-a',
  1407. ),
  1408. _attachment(
  1409. id='other-staging',
  1410. file_url='other-staging.pdf',
  1411. create_by='user-b',
  1412. ),
  1413. ]
  1414. )
  1415. db.commit()
  1416. for file_url in ('cross.pdf', 'wrong-module.pdf', 'other-staging.pdf'):
  1417. with pytest.raises(PermissionError, match='附件'):
  1418. get_market_attachment_download_url(
  1419. db,
  1420. file_url=file_url,
  1421. file_name=None,
  1422. module='payment',
  1423. ctx=_ctx(),
  1424. user_info=_user(),
  1425. storage_backend=_DownloadStorage(),
  1426. )
  1427. def test_market_attachment_download_rejects_unreferenced_bound_and_legacy_rows(
  1428. monkeypatch,
  1429. ):
  1430. from app.services import market_support
  1431. db = _session()
  1432. db.add_all(
  1433. [
  1434. _attachment(
  1435. id='manager-bound',
  1436. file_url='manager-bound.pdf',
  1437. business_type='market:manager:payment:bound',
  1438. business_id='transfer-a',
  1439. ),
  1440. _attachment(
  1441. id='legacy',
  1442. file_url='legacy.pdf',
  1443. business_type=None,
  1444. create_by='admin',
  1445. ),
  1446. ]
  1447. )
  1448. db.commit()
  1449. monkeypatch.setattr(
  1450. market_support,
  1451. '_has_accessible_market_attachment_reference',
  1452. lambda *args, **kwargs: False,
  1453. )
  1454. for file_url in ('manager-bound.pdf', 'legacy.pdf'):
  1455. with pytest.raises(PermissionError, match='附件'):
  1456. market_support.get_market_attachment_download_url(
  1457. db,
  1458. file_url=file_url,
  1459. file_name=None,
  1460. module='payment',
  1461. ctx=_ctx(),
  1462. user_info=_user(),
  1463. storage_backend=_DownloadStorage(),
  1464. )
  1465. def test_market_attachment_download_allows_accessible_referenced_legacy_row(monkeypatch):
  1466. from app.services import market_support
  1467. db = _session()
  1468. db.add(
  1469. _attachment(
  1470. id='legacy',
  1471. file_url='legacy.pdf',
  1472. business_type=None,
  1473. create_by='admin',
  1474. )
  1475. )
  1476. db.commit()
  1477. checked = []
  1478. def allow_reference(_db, *, module, candidates, ctx, user_info):
  1479. checked.append((module, candidates, ctx.user_id, user_info['user_id']))
  1480. return True
  1481. monkeypatch.setattr(
  1482. market_support,
  1483. '_has_accessible_market_attachment_reference',
  1484. allow_reference,
  1485. )
  1486. result = market_support.get_market_attachment_download_url(
  1487. db,
  1488. file_url='legacy.pdf',
  1489. file_name=None,
  1490. module='payment',
  1491. ctx=_ctx(),
  1492. user_info=_user(),
  1493. storage_backend=_DownloadStorage(),
  1494. )
  1495. assert result['downloadUrl'] == 'https://files.test/signed-download'
  1496. assert checked == [('payment', ['legacy.pdf'], 'user-a', 'user-a')]
  1497. def test_protocol_attachment_reference_respects_specialist_donation_scope():
  1498. from app.models.models import (
  1499. BizCollectionProtocol,
  1500. BizProtocolCollectionInfo,
  1501. BizProtocolPayment,
  1502. BizProtocolPaymentDetail,
  1503. )
  1504. from app.models.research_donation import (
  1505. BizResearchDonation,
  1506. BizResearchDonationCollection,
  1507. )
  1508. from app.services import market_support
  1509. engine = create_engine('sqlite:///:memory:')
  1510. Base.metadata.create_all(
  1511. engine,
  1512. tables=[
  1513. BizCollectionProtocol.__table__,
  1514. BizProtocolCollectionInfo.__table__,
  1515. BizProtocolStorageCert.__table__,
  1516. BizProtocolFullmoonTodo.__table__,
  1517. BizProtocolPayment.__table__,
  1518. BizProtocolPaymentDetail.__table__,
  1519. BizResearchDonation.__table__,
  1520. BizResearchDonationCollection.__table__,
  1521. ],
  1522. )
  1523. db = sessionmaker(bind=engine)()
  1524. db.add_all(
  1525. [
  1526. BizResearchDonation(
  1527. id='donation-a',
  1528. donation_no='D-1',
  1529. donation_status='sxz',
  1530. source_type='direct',
  1531. attachment_urls='legacy.pdf',
  1532. tenant_no='tenant-a',
  1533. create_dept_id='',
  1534. del_flag=0,
  1535. ),
  1536. BizResearchDonationCollection(
  1537. id='collection-a',
  1538. research_donation_id='donation-a',
  1539. sales_user_id='user-a',
  1540. tenant_no='tenant-a',
  1541. create_dept_id='',
  1542. del_flag=0,
  1543. ),
  1544. ]
  1545. )
  1546. db.commit()
  1547. super_admin_a = {**_user('user-a'), 'role': 'super_admin'}
  1548. super_admin_b = {**_user('user-b'), 'role': 'super_admin'}
  1549. assert market_support._has_accessible_market_attachment_reference(
  1550. db,
  1551. module='protocol',
  1552. candidates=['legacy.pdf'],
  1553. ctx=_ctx(user_id='user-a'),
  1554. user_info=super_admin_a,
  1555. )
  1556. assert not market_support._has_accessible_market_attachment_reference(
  1557. db,
  1558. module='protocol',
  1559. candidates=['legacy.pdf'],
  1560. ctx=_ctx(user_id='user-b'),
  1561. user_info=super_admin_b,
  1562. )
  1563. def test_create_attachment_refs_require_current_uploader_staging_exact_match():
  1564. from app.services.market_support import validate_attachment_refs
  1565. db = _session()
  1566. db.add_all(
  1567. [
  1568. _attachment(),
  1569. _attachment(id='att-other', file_url='market/specialist/payment/other.pdf', create_by='other'),
  1570. _attachment(id='att-cross', file_url='market/specialist/payment/cross.pdf', tenant_no='tenant-b'),
  1571. ]
  1572. )
  1573. db.commit()
  1574. assert validate_attachment_refs(
  1575. db,
  1576. 'att-a,market/specialist/payment/a.pdf',
  1577. module='payment',
  1578. ctx=_ctx(),
  1579. creating=True,
  1580. ) == 'market/specialist/payment/a.pdf'
  1581. for invalid in ('att-other', 'att-cross', 'https://evil.test/a.pdf', 'a.pdf'):
  1582. with pytest.raises(ValueError, match='附件'):
  1583. validate_attachment_refs(db, invalid, module='payment', ctx=_ctx(), creating=True)
  1584. def test_staging_attachment_must_match_current_scope_role_exactly():
  1585. from app.services.market_support import validate_attachment_refs
  1586. db = _session()
  1587. db.add(
  1588. _attachment(
  1589. id='manager-staging',
  1590. business_type='market:manager:payment:staging',
  1591. file_url='market/manager/payment/proof.pdf',
  1592. )
  1593. )
  1594. db.commit()
  1595. with pytest.raises(ValueError, match='附件'):
  1596. validate_attachment_refs(
  1597. db,
  1598. 'manager-staging',
  1599. module='payment',
  1600. ctx=_ctx('specialist'),
  1601. creating=True,
  1602. business_id='pay-a',
  1603. )
  1604. def test_attachment_refs_bind_once_to_exact_scope_module_and_business():
  1605. from app.services.market_support import validate_attachment_refs
  1606. db = _session()
  1607. row = _attachment()
  1608. db.add(row)
  1609. db.commit()
  1610. result = validate_attachment_refs(
  1611. db,
  1612. 'att-a,market/specialist/payment/a.pdf',
  1613. module='payment',
  1614. ctx=_ctx(),
  1615. creating=True,
  1616. business_id='transfer-a',
  1617. )
  1618. assert result == 'market/specialist/payment/a.pdf'
  1619. assert (row.business_type, row.business_id) == (
  1620. 'market:specialist:payment:bound',
  1621. 'transfer-a',
  1622. )
  1623. assert validate_attachment_refs(
  1624. db,
  1625. 'att-a',
  1626. module='payment',
  1627. ctx=_ctx(),
  1628. creating=False,
  1629. business_id='transfer-a',
  1630. ) == 'market/specialist/payment/a.pdf'
  1631. with pytest.raises(ValueError, match='附件'):
  1632. validate_attachment_refs(
  1633. db,
  1634. 'att-a',
  1635. module='payment',
  1636. ctx=_ctx(),
  1637. creating=False,
  1638. business_id='transfer-b',
  1639. )
  1640. with pytest.raises(ValueError, match='附件'):
  1641. validate_attachment_refs(
  1642. db,
  1643. 'att-a',
  1644. module='payment',
  1645. ctx=_ctx('manager'),
  1646. creating=False,
  1647. business_id='transfer-a',
  1648. )
  1649. def test_attachment_ref_resolution_uses_one_batched_query_and_deduplicates_aliases():
  1650. from sqlalchemy import event
  1651. from app.services.market_support import validate_attachment_refs
  1652. db = _session()
  1653. db.add_all(
  1654. [
  1655. _attachment(id='att-1', file_url='market/specialist/payment/1.pdf'),
  1656. _attachment(id='att-2', file_url='market/specialist/payment/2.pdf'),
  1657. ]
  1658. )
  1659. db.commit()
  1660. statements = []
  1661. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  1662. if 'biz_attachment' in statement.lower() and statement.lstrip().lower().startswith('select'):
  1663. statements.append(statement)
  1664. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  1665. try:
  1666. result = validate_attachment_refs(
  1667. db,
  1668. 'att-1,market/specialist/payment/1.pdf,att-2',
  1669. module='payment',
  1670. ctx=_ctx(),
  1671. creating=True,
  1672. business_id='transfer-batch',
  1673. )
  1674. finally:
  1675. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  1676. assert result == 'market/specialist/payment/1.pdf,market/specialist/payment/2.pdf'
  1677. assert len(statements) == 1
  1678. @pytest.mark.parametrize(
  1679. 'refs',
  1680. [','.join(f'att-{index}' for index in range(21)), 'x' * 4001],
  1681. )
  1682. def test_attachment_refs_reject_excessive_count_or_total_length(refs):
  1683. from app.services.market_support import validate_attachment_refs
  1684. with pytest.raises(ValueError, match='附件'):
  1685. validate_attachment_refs(
  1686. _session(),
  1687. refs,
  1688. module='payment',
  1689. ctx=_ctx(),
  1690. creating=True,
  1691. business_id='transfer-limit',
  1692. )
  1693. def test_all_mobile_attachment_schemas_cap_total_reference_length():
  1694. from pydantic import ValidationError
  1695. from app.schemas.market_mobile import (
  1696. MarketPaymentTransferBody,
  1697. MarketPresignTransferBody,
  1698. MarketProtocolAttachmentsBody,
  1699. MarketSampleStorageCertBody,
  1700. )
  1701. too_long = 'x' * 4001
  1702. payloads = (
  1703. (MarketPresignTransferBody, {'transferAttachments': too_long}),
  1704. (MarketProtocolAttachmentsBody, {'protocolAttachments': too_long}),
  1705. (MarketPaymentTransferBody, {'transferAttachments': too_long}),
  1706. (MarketSampleStorageCertBody, {'attachments': too_long}),
  1707. )
  1708. for schema, payload in payloads:
  1709. with pytest.raises(ValidationError):
  1710. schema.model_validate(payload)
  1711. def test_update_attachment_refs_allow_exact_existing_refs_and_current_user_new_only():
  1712. from app.services.market_support import validate_attachment_refs
  1713. db = _session()
  1714. db.add_all(
  1715. [
  1716. _attachment(
  1717. id='existing',
  1718. file_url='market/specialist/payment/existing.pdf',
  1719. create_by='legacy',
  1720. business_type='market:specialist:payment:bound',
  1721. business_id='transfer-existing',
  1722. ),
  1723. _attachment(id='new', file_url='market/specialist/payment/new.pdf'),
  1724. ]
  1725. )
  1726. db.commit()
  1727. out = validate_attachment_refs(
  1728. db,
  1729. 'existing,new',
  1730. module='payment',
  1731. ctx=_ctx(),
  1732. creating=False,
  1733. existing_refs='market/specialist/payment/existing.pdf',
  1734. business_id='transfer-existing',
  1735. )
  1736. assert out == 'market/specialist/payment/existing.pdf,market/specialist/payment/new.pdf'
  1737. @pytest.mark.parametrize('metadata_kind', ['missing', 'legacy-unbound', 'other-role-bound'])
  1738. def test_update_may_retain_exact_server_supplied_existing_url_without_current_metadata(
  1739. metadata_kind,
  1740. ):
  1741. from app.services.market_support import validate_attachment_refs
  1742. db = _session()
  1743. existing_url = 'legacy/payment/existing.pdf'
  1744. if metadata_kind == 'legacy-unbound':
  1745. db.add(
  1746. _attachment(
  1747. id='legacy-unbound',
  1748. file_url=existing_url,
  1749. business_type='protocol',
  1750. create_by='legacy-user',
  1751. )
  1752. )
  1753. elif metadata_kind == 'other-role-bound':
  1754. db.add(
  1755. _attachment(
  1756. id='specialist-bound',
  1757. file_url=existing_url,
  1758. business_type='market:specialist:payment:bound',
  1759. business_id='transfer-existing',
  1760. create_by='specialist-user',
  1761. )
  1762. )
  1763. db.commit()
  1764. assert validate_attachment_refs(
  1765. db,
  1766. existing_url,
  1767. module='payment',
  1768. ctx=_ctx('manager'),
  1769. creating=False,
  1770. existing_refs=existing_url,
  1771. business_id='transfer-existing',
  1772. ) == existing_url
  1773. def test_existing_ref_exception_allows_removal_but_not_arbitrary_url_smuggling():
  1774. from app.services.market_support import validate_attachment_refs
  1775. db = _session()
  1776. assert validate_attachment_refs(
  1777. db,
  1778. '',
  1779. module='payment',
  1780. ctx=_ctx(),
  1781. creating=False,
  1782. existing_refs='legacy/payment/existing.pdf',
  1783. business_id='transfer-existing',
  1784. ) == ''
  1785. with pytest.raises(ValueError, match='附件'):
  1786. validate_attachment_refs(
  1787. db,
  1788. 'https://evil.test/smuggled.pdf',
  1789. module='payment',
  1790. ctx=_ctx(),
  1791. creating=False,
  1792. existing_refs='legacy/payment/existing.pdf',
  1793. business_id='transfer-existing',
  1794. )
  1795. def test_allowed_actions_are_state_scope_and_permission_aware():
  1796. from app.services.market_support import allowed_actions
  1797. assert allowed_actions('presign', status='active') == ['edit', 'convert', 'delete']
  1798. assert allowed_actions('presign', status='void') == ['edit', 'delete']
  1799. assert allowed_actions('presign', status='converted') == ['edit']
  1800. assert allowed_actions('presign-transfer', status='dfj') == ['edit', 'delete', 'submit']
  1801. assert allowed_actions('presign-transfer', status='jfqrz') == ['withdraw']
  1802. assert allowed_actions('presign-transfer', status='yjf') == []
  1803. assert allowed_actions('protocol', status='dsx') == ['edit', 'delete']
  1804. assert allowed_actions('protocol', status='yzz') == ['edit', 'delete']
  1805. assert allowed_actions('protocol', status='yzf') == ['edit', 'delete']
  1806. assert allowed_actions('payment-transfer', status='dfj') == [
  1807. 'edit-transfer',
  1808. 'delete-payment-transfer',
  1809. 'submit-finance',
  1810. ]
  1811. assert allowed_actions('payment-transfer', status='jfqrz') == ['withdraw-transfer']
  1812. assert allowed_actions('payment-chase', status='', has_payment_permission=False) == []
  1813. assert allowed_actions('payment-chase', status='', has_payment_permission=True) == []
  1814. assert allowed_actions('sample', status='', source='research_donation', has_sample_code=True) == []
  1815. assert allowed_actions('sample', status='', source='cell_storage', has_sample_code=True) == []
  1816. assert allowed_actions('freight', status='', scope_kind='specialist') == []
  1817. assert allowed_actions('freight', status='', scope_kind='manager') == ['edit']
  1818. assert allowed_actions('presign-list', status='') == ['create']
  1819. assert allowed_actions('protocol-list', status='') == ['create']
  1820. assert allowed_actions('freight-list', status='', scope_kind='specialist') == ['create']
  1821. def test_legacy_v1_protocol_detail_keeps_status_scoped_edit_and_delete():
  1822. from app.services.market_support import decorate_allowed_actions
  1823. result = decorate_allowed_actions(
  1824. 'protocol',
  1825. {
  1826. 'source': 'cell_storage',
  1827. 'basic': {
  1828. 'protocolStatus': 'sxz',
  1829. 'paymentModelVersion': 1,
  1830. },
  1831. },
  1832. scope_kind='specialist',
  1833. detail=True,
  1834. )
  1835. assert result['allowedActions'] == ['edit', 'delete']
  1836. def test_presign_actions_include_delete_and_nested_transfer_actions():
  1837. from app.services.market_support import decorate_allowed_actions
  1838. active = decorate_allowed_actions(
  1839. 'presign',
  1840. {
  1841. 'orderStatus': 'active',
  1842. 'transfers': [
  1843. {'id': 't1', 'payStatus': 'dfj'},
  1844. {'id': 't2', 'payStatus': 'jfqrz'},
  1845. {'id': 't3', 'payStatus': 'yjf'},
  1846. ],
  1847. },
  1848. scope_kind='specialist',
  1849. detail=True,
  1850. )
  1851. assert active['allowedActions'] == ['edit', 'convert', 'delete']
  1852. assert [row['allowedActions'] for row in active['transfers']] == [
  1853. ['edit', 'delete', 'submit'],
  1854. ['withdraw'],
  1855. [],
  1856. ]
  1857. for parent_status, parent_actions in (
  1858. ('converted', ['edit']),
  1859. ('void', ['edit', 'delete']),
  1860. ):
  1861. locked = decorate_allowed_actions(
  1862. 'presign',
  1863. {
  1864. 'orderStatus': parent_status,
  1865. 'transfers': [
  1866. {'id': 't1', 'payStatus': 'dfj'},
  1867. {'id': 't2', 'payStatus': 'jfqrz'},
  1868. ],
  1869. },
  1870. scope_kind='specialist',
  1871. detail=True,
  1872. )
  1873. assert locked['allowedActions'] == parent_actions
  1874. assert [row['allowedActions'] for row in locked['transfers']] == [[], []]
  1875. unknown = decorate_allowed_actions(
  1876. 'presign',
  1877. {
  1878. 'orderStatus': 'legacy-locked',
  1879. 'transfers': [{'id': 't1', 'payStatus': 'dfj'}],
  1880. },
  1881. scope_kind='specialist',
  1882. detail=True,
  1883. )
  1884. assert unknown['transfers'][0]['allowedActions'] == []
  1885. def test_form_options_do_not_leak_children_of_soft_deleted_regions():
  1886. from app.services.market_support import get_form_options
  1887. db = _session()
  1888. _seed_options(db)
  1889. db.add_all(
  1890. [
  1891. BizCollectionRegion(
  1892. id='deleted-region',
  1893. region_name='已删除地区',
  1894. tenant_no='tenant-a',
  1895. create_dept_id='',
  1896. del_flag=1,
  1897. ),
  1898. BizCollectionHospital(
  1899. id='deleted-region-hospital',
  1900. region_id='deleted-region',
  1901. hospital_name='不应泄漏医院',
  1902. tenant_no='tenant-a',
  1903. create_dept_id='',
  1904. del_flag=0,
  1905. ),
  1906. BizCollectionRegionStaff(
  1907. id='deleted-region-staff',
  1908. region_id='deleted-region',
  1909. user_id='user-a',
  1910. is_leader=0,
  1911. tenant_no='tenant-a',
  1912. create_dept_id='',
  1913. del_flag=0,
  1914. ),
  1915. ]
  1916. )
  1917. db.commit()
  1918. out = get_form_options(db, module='presign', ctx=_ctx(), user_info=_user())
  1919. assert '已删除地区' not in str(out)
  1920. assert '不应泄漏医院' not in str(out)
  1921. with pytest.raises(PermissionError, match='地区'):
  1922. get_form_options(
  1923. db,
  1924. module='presign',
  1925. ctx=_ctx(),
  1926. user_info=_user(),
  1927. region_id='deleted-region',
  1928. )
  1929. def test_decorate_allowed_actions_covers_nested_payment_transfers():
  1930. from app.services.market_support import decorate_allowed_actions
  1931. detail = {
  1932. 'id': 'pay-a',
  1933. 'schedules': [
  1934. {
  1935. 'id': 'schedule-a',
  1936. 'scheduleStatus': 'open',
  1937. 'arrearsAmount': '80.00',
  1938. 'transfers': [
  1939. {'id': 'draft', 'payStatus': 'dfj'},
  1940. {'id': 'submitted', 'payStatus': 'jfqrz'},
  1941. {'id': 'done', 'payStatus': 'yjf'},
  1942. ],
  1943. }
  1944. ],
  1945. }
  1946. out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
  1947. assert out['allowedActions'] == ['register-transfer']
  1948. assert out['schedules'][0]['allowedActions'] == ['register-transfer']
  1949. assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [
  1950. ['edit-transfer', 'delete-payment-transfer', 'submit-finance'],
  1951. ['withdraw-transfer'],
  1952. [],
  1953. ]
  1954. @pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
  1955. def test_payment_terminal_protocol_keeps_transfer_actions_when_arrears_exist(protocol_status):
  1956. from app.services.market_support import decorate_allowed_actions
  1957. detail = {
  1958. 'id': 'pay-terminal',
  1959. 'protocolStatus': protocol_status,
  1960. 'schedules': [
  1961. {
  1962. 'id': 'schedule-open',
  1963. 'scheduleStatus': 'open',
  1964. 'arrearsAmount': '80.00',
  1965. 'transfers': [
  1966. {'id': 'draft', 'payStatus': 'dfj'},
  1967. {'id': 'submitted', 'payStatus': 'jfqrz'},
  1968. ],
  1969. }
  1970. ],
  1971. }
  1972. out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
  1973. assert out['allowedActions'] == ['register-transfer']
  1974. assert out['schedules'][0]['allowedActions'] == ['register-transfer']
  1975. assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [
  1976. ['edit-transfer', 'delete-payment-transfer', 'submit-finance'],
  1977. ['withdraw-transfer'],
  1978. ]
  1979. @pytest.mark.parametrize(
  1980. ('schedule_status', 'arrears_amount', 'expected_schedule_actions'),
  1981. [('cleared', '80.00', ['register-transfer']), ('open', '0.00', []), ('void', '80.00', [])],
  1982. )
  1983. def test_payment_schedule_actions_require_non_void_status_and_positive_arrears(
  1984. schedule_status, arrears_amount, expected_schedule_actions
  1985. ):
  1986. from app.services.market_support import decorate_allowed_actions
  1987. detail = {
  1988. 'id': 'pay-closed',
  1989. 'protocolStatus': 'sxz',
  1990. 'schedules': [
  1991. {
  1992. 'id': 'schedule-closed',
  1993. 'scheduleStatus': schedule_status,
  1994. 'arrearsAmount': arrears_amount,
  1995. 'transfers': [{'id': 'draft', 'payStatus': 'dfj'}],
  1996. }
  1997. ],
  1998. }
  1999. out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
  2000. assert out['allowedActions'] == expected_schedule_actions
  2001. assert out['schedules'][0]['allowedActions'] == expected_schedule_actions
  2002. assert out['schedules'][0]['transfers'][0]['allowedActions'] == [
  2003. 'edit-transfer',
  2004. 'delete-payment-transfer',
  2005. 'submit-finance',
  2006. ]
  2007. def test_freight_detail_exposes_only_scope_safe_link_actions():
  2008. from app.services.market_support import decorate_allowed_actions
  2009. specialist = decorate_allowed_actions(
  2010. 'freight',
  2011. {'id': 'f1', 'allowedActions': ['edit', 'delete', 'link', 'unlink']},
  2012. scope_kind='specialist',
  2013. detail=True,
  2014. )
  2015. manager = decorate_allowed_actions(
  2016. 'freight',
  2017. {'id': 'f1', 'allowedActions': ['link', 'unlink']},
  2018. scope_kind='manager',
  2019. detail=True,
  2020. )
  2021. assert specialist['allowedActions'] == ['edit', 'delete', 'link', 'unlink']
  2022. assert manager['allowedActions'] == ['link', 'unlink']
  2023. def test_protocol_detail_action_reads_nested_basic_status():
  2024. from app.services.market_support import decorate_allowed_actions
  2025. active = decorate_allowed_actions(
  2026. 'protocol', {'basic': {'protocolStatus': 'dsx'}}, scope_kind='specialist'
  2027. )
  2028. terminal = decorate_allowed_actions(
  2029. 'protocol', {'basic': {'protocolStatus': 'yzf'}}, scope_kind='specialist'
  2030. )
  2031. assert active['allowedActions'] == ['edit', 'delete']
  2032. assert terminal['allowedActions'] == ['edit', 'delete']
  2033. def test_market_support_routes_are_fixed_and_registered_before_dynamic_details():
  2034. from app.api.market_mobile import router
  2035. paths = [route.path for route in router.routes]
  2036. for scope in ('specialist', 'manager'):
  2037. for path in (
  2038. f'/mini/market/{scope}/presign-orders/form-options',
  2039. f'/mini/market/{scope}/protocols/form-options',
  2040. f'/mini/market/{scope}/payments/form-options',
  2041. f'/mini/market/{scope}/freights/form-options',
  2042. ):
  2043. assert path in paths
  2044. for module in ('presign', 'protocol', 'payment'):
  2045. upload_path = f'/mini/market/{scope}/{module}/attachments'
  2046. download_path = f'{upload_path}/download-url'
  2047. assert upload_path in paths
  2048. assert download_path in paths
  2049. upload_route = next(route for route in router.routes if route.path == upload_path)
  2050. download_route = next(route for route in router.routes if route.path == download_path)
  2051. assert [param.name for param in upload_route.dependant.query_params] == []
  2052. assert download_route.methods == {'POST'}
  2053. assert f'/mini/market/{scope}/sample/attachments' not in paths
  2054. assert paths.index(f'/mini/market/{scope}/presign-orders/form-options') < paths.index(
  2055. f'/mini/market/{scope}/presign-orders/{{order_id}}'
  2056. )
  2057. assert paths.index(f'/mini/market/{scope}/protocols/form-options') < paths.index(
  2058. f'/mini/market/{scope}/protocols/{{protocol_id}}'
  2059. )
  2060. assert f'/mini/market/{scope}/samples/form-options' not in paths
  2061. def test_presign_write_routes_use_fixed_paths_and_methods():
  2062. from app.api.market_mobile import router
  2063. for scope in ('specialist', 'manager'):
  2064. expected = {
  2065. f'/mini/market/{scope}/presign-orders/{{order_id}}': {'GET', 'PUT', 'DELETE'},
  2066. f'/mini/market/{scope}/presign-orders/{{order_id}}/transfers': {'POST'},
  2067. f'/mini/market/{scope}/presign-transfers/{{transfer_id}}': {'PUT', 'DELETE'},
  2068. f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/submit': {'POST'},
  2069. f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/withdraw': {'POST'},
  2070. }
  2071. for path, methods in expected.items():
  2072. registered = {
  2073. method
  2074. for route in router.routes
  2075. if route.path == path
  2076. for method in route.methods
  2077. }
  2078. assert registered == methods
  2079. def test_presign_write_routes_map_permission_and_validation_errors(monkeypatch):
  2080. from app.api import market_mobile
  2081. from app.schemas.market_mobile import MarketPresignTransferBody
  2082. delete_route = next(
  2083. route
  2084. for route in market_mobile.router.routes
  2085. if route.path == '/mini/market/specialist/presign-orders/{order_id}'
  2086. and route.methods == {'DELETE'}
  2087. )
  2088. create_transfer_route = next(
  2089. route
  2090. for route in market_mobile.router.routes
  2091. if route.path
  2092. == '/mini/market/specialist/presign-orders/{order_id}/transfers'
  2093. )
  2094. monkeypatch.setattr(
  2095. market_mobile.market_presign,
  2096. 'delete_presign_order',
  2097. lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError('forbidden')),
  2098. )
  2099. permission_result = asyncio.run(
  2100. delete_route.endpoint(order_id='o1', user=_user(), db=SimpleNamespace())
  2101. )
  2102. assert permission_result['code'] == 403
  2103. monkeypatch.setattr(
  2104. market_mobile.market_presign,
  2105. 'create_presign_transfer',
  2106. lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('invalid')),
  2107. )
  2108. validation_result = asyncio.run(
  2109. create_transfer_route.endpoint(
  2110. order_id='o1',
  2111. body=MarketPresignTransferBody(transferAmount=100),
  2112. user=_user(),
  2113. db=SimpleNamespace(),
  2114. )
  2115. )
  2116. assert validation_result['code'] == 400
  2117. def test_market_list_routes_map_invalid_filters_to_validation_response(monkeypatch):
  2118. from app.api import market_mobile
  2119. route_paths = {
  2120. '/mini/market/specialist/protocols': market_mobile.market_protocol,
  2121. '/mini/market/specialist/payments': market_mobile.market_payment,
  2122. '/mini/market/specialist/freights': market_mobile.market_freight,
  2123. }
  2124. routes = {
  2125. route.path: route
  2126. for route in market_mobile.router.routes
  2127. if route.path in route_paths and route.methods == {'GET'}
  2128. }
  2129. for path, service in route_paths.items():
  2130. method_name = {
  2131. '/mini/market/specialist/protocols': 'list_protocols',
  2132. '/mini/market/specialist/payments': 'list_payments',
  2133. '/mini/market/specialist/freights': 'list_freights',
  2134. }[path]
  2135. monkeypatch.setattr(
  2136. service,
  2137. method_name,
  2138. lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('invalid filter')),
  2139. )
  2140. result = asyncio.run(
  2141. routes[path].endpoint(user=_user(), db=SimpleNamespace())
  2142. )
  2143. assert result['code'] == 400
  2144. assert result['message'] == 'invalid filter'
  2145. def test_attachment_routes_read_only_their_module_upload_limit(monkeypatch):
  2146. from app.api import market_mobile
  2147. class TrackingUpload:
  2148. filename = 'proof.pdf'
  2149. content_type = 'application/pdf'
  2150. def __init__(self):
  2151. self.read_size = None
  2152. async def read(self, size):
  2153. self.read_size = size
  2154. return b'%PDF-safe'
  2155. async def close(self):
  2156. return None
  2157. monkeypatch.setattr(
  2158. market_mobile.market_support,
  2159. 'save_market_attachment',
  2160. lambda *args, **kwargs: {'id': 'attachment'},
  2161. )
  2162. routes = {
  2163. route.path: route
  2164. for route in market_mobile.router.routes
  2165. if route.path in {
  2166. '/mini/market/specialist/presign/attachments',
  2167. '/mini/market/specialist/payment/attachments',
  2168. }
  2169. }
  2170. presign_file = TrackingUpload()
  2171. payment_file = TrackingUpload()
  2172. asyncio.run(
  2173. routes['/mini/market/specialist/presign/attachments'].endpoint(
  2174. file=presign_file, user=_user(), db=SimpleNamespace()
  2175. )
  2176. )
  2177. asyncio.run(
  2178. routes['/mini/market/specialist/payment/attachments'].endpoint(
  2179. file=payment_file, user=_user(), db=SimpleNamespace()
  2180. )
  2181. )
  2182. assert presign_file.read_size == 20 * 1024 * 1024 + 1
  2183. assert payment_file.read_size == 10 * 1024 * 1024 + 1
  2184. def test_payment_transfer_response_carries_server_authorized_capabilities():
  2185. draft = market_support.decorate_allowed_actions(
  2186. 'payment-transfer',
  2187. {'id': 't1', 'payStatus': 'dfj'},
  2188. scope_kind='specialist',
  2189. )
  2190. pending = market_support.decorate_allowed_actions(
  2191. 'payment-transfer',
  2192. {'id': 't2', 'payStatus': 'jfqrz'},
  2193. scope_kind='specialist',
  2194. )
  2195. assert draft['allowedActions'] == [
  2196. 'edit-transfer',
  2197. 'delete-payment-transfer',
  2198. 'submit-finance',
  2199. ]
  2200. assert pending['allowedActions'] == ['withdraw-transfer']