test_market_support_api.py 70 KB

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