test_market_support_api.py 78 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398
  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_hide_occupied_sample_codes():
  402. from app.services.market_support import get_form_options
  403. db = _session()
  404. _seed_options(db)
  405. db.add(BizSampleCode(id='sample-code-b', sample_code='S-002', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  406. db.add(
  407. BizCollectionProtocol(
  408. id='proto-used',
  409. protocol_no='CP-USED',
  410. tenant_no='tenant-a',
  411. create_dept_id='',
  412. del_flag=0,
  413. )
  414. )
  415. db.add(
  416. BizProtocolSample(
  417. id='ps-used',
  418. collection_protocol_id='proto-used',
  419. sample_code='S-001',
  420. tenant_no='tenant-a',
  421. create_dept_id='',
  422. del_flag=0,
  423. )
  424. )
  425. db.commit()
  426. out = get_form_options(
  427. db,
  428. module='protocol',
  429. ctx=_ctx('manager', 'user-b'),
  430. user_info=_user('user-b'),
  431. region_id='rb',
  432. )
  433. assert out['sampleCodes'] == [
  434. {'value': 'sample-code-b', 'label': 'S-002', 'sampleCode': 'S-002'}
  435. ]
  436. assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}]
  437. assert out['transportModes'] == [{'value': 'zxc', 'label': '专线车'}]
  438. def test_presign_form_options_search_active_tenant_users_without_pii():
  439. from app.services.market_support import get_form_options
  440. db = _session()
  441. _seed_options(db)
  442. out = get_form_options(
  443. db,
  444. module='presign',
  445. ctx=_ctx(),
  446. user_info=_user(),
  447. user_keyword='员工',
  448. )
  449. assert out['users'] == [{'value': 'staff-a', 'label': 'A员工'}]
  450. assert '跨租户' not in str(out['users'])
  451. assert '停用' not in str(out['users'])
  452. assert 'userNo' not in str(out['users'])
  453. def test_payment_and_freight_options_use_fixed_dictionary_allowlist():
  454. from app.services.market_support import get_form_options
  455. db = _session()
  456. _seed_options(db)
  457. payment = get_form_options(db, module='payment', ctx=_ctx(), user_info=_user())
  458. freight = get_form_options(db, module='freight', ctx=_ctx(), user_info=_user())
  459. assert payment == {'transferTypes': [{'value': 'wx', 'label': '微信'}]}
  460. assert freight == {'transportModes': [{'value': 'zxc', 'label': '专线车'}]}
  461. assert '不应泄漏' not in str(payment) + str(freight)
  462. class _Storage:
  463. def __init__(self):
  464. self.uploaded = []
  465. self.deleted = []
  466. def upload_file(self, content, filename, folder):
  467. self.uploaded.append((content, filename, folder))
  468. return f'https://files.test/{folder}/generated.pdf'
  469. def delete_file(self, path):
  470. self.deleted.append(path)
  471. return True
  472. def test_upload_uses_fixed_folder_and_records_current_tenant_and_uploader():
  473. from app.services.market_support import save_market_attachment
  474. db = _session()
  475. backend = _Storage()
  476. result = save_market_attachment(
  477. db,
  478. content=b'%PDF-1.7 safe',
  479. filename='proof.pdf',
  480. content_type='application/pdf',
  481. module='payment',
  482. ctx=_ctx(),
  483. user_info=_user(),
  484. storage_backend=backend,
  485. )
  486. assert backend.uploaded[0][2] == 'market/specialist/payment'
  487. row = db.query(BizAttachment).one()
  488. assert (row.tenant_no, row.create_by, row.create_dept_id) == ('tenant-a', 'user-a', 'dept-a')
  489. assert row.business_type == 'market:specialist:payment:staging'
  490. assert result['id'] == row.id
  491. assert result['fileUrl'] == row.file_url
  492. def test_presign_and_protocol_upload_allow_20_mib_while_payment_remains_at_10_mib():
  493. from app.services.market_support import save_market_attachment
  494. content = b'%PDF-' + b'a' * (10 * 1024 * 1024)
  495. backend = _Storage()
  496. result = save_market_attachment(
  497. _session(),
  498. content=content,
  499. filename='presign-proof.pdf',
  500. content_type='application/pdf',
  501. module='presign',
  502. ctx=_ctx(),
  503. user_info=_user(),
  504. storage_backend=backend,
  505. )
  506. assert result['fileSize'] == len(content)
  507. protocol_result = save_market_attachment(
  508. _session(),
  509. content=content,
  510. filename='protocol-proof.pdf',
  511. content_type='application/pdf',
  512. module='protocol',
  513. ctx=_ctx(),
  514. user_info=_user(),
  515. storage_backend=_Storage(),
  516. )
  517. assert protocol_result['fileSize'] == len(content)
  518. with pytest.raises(ValueError, match='10MiB'):
  519. save_market_attachment(
  520. _session(),
  521. content=content,
  522. filename='payment-proof.pdf',
  523. content_type='application/pdf',
  524. module='payment',
  525. ctx=_ctx(),
  526. user_info=_user(),
  527. storage_backend=_Storage(),
  528. )
  529. def _zip_payload(path):
  530. output = BytesIO()
  531. with ZipFile(output, 'w') as archive:
  532. archive.writestr('[Content_Types].xml', '<Types/>')
  533. archive.writestr(path, 'payload')
  534. return output.getvalue()
  535. @pytest.mark.parametrize(
  536. ('content', 'filename', 'content_type'),
  537. [
  538. (_zip_payload('ppt/presentation.xml'), 'slides.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'),
  539. (b'plain text', 'notes.txt', 'text/plain'),
  540. (_zip_payload('folder/readme.txt'), 'archive.zip', 'application/zip'),
  541. (b'Rar!\x1a\x07\x01\x00' + b'a' * 8, 'archive.rar', 'application/vnd.rar'),
  542. (b'\x00\x00\x00\x18ftypmp42' + b'a' * 12, 'video.mp4', 'video/mp4'),
  543. ],
  544. )
  545. def test_protocol_upload_accepts_web_protocol_attachment_formats(content, filename, content_type):
  546. from app.services.market_support import save_market_attachment
  547. result = save_market_attachment(
  548. _session(),
  549. content=content,
  550. filename=filename,
  551. content_type=content_type,
  552. module='protocol',
  553. ctx=_ctx(),
  554. user_info=_user(),
  555. storage_backend=_Storage(),
  556. )
  557. assert result['fileName'] == filename
  558. def test_protocol_detail_payment_capabilities_require_payment_permission():
  559. from app.services.market_support import decorate_allowed_actions
  560. detail = {
  561. 'source': 'cell_storage',
  562. 'basic': {'protocolStatus': 'sxz', 'paymentModelVersion': 2},
  563. 'samplePayments': [{
  564. 'id': 'pay-1',
  565. 'schedules': [{
  566. 'id': 'schedule-1',
  567. 'scheduleStatus': 'open',
  568. 'arrearsAmount': 100,
  569. 'transfers': [
  570. {'id': 'draft', 'payStatus': 'dfj'},
  571. {'id': 'submitted', 'payStatus': 'jfqrz'},
  572. {'id': 'confirmed', 'payStatus': 'yjf'},
  573. ],
  574. }],
  575. }],
  576. }
  577. readonly = decorate_allowed_actions(
  578. 'protocol', detail, scope_kind='specialist', has_payment_permission=False
  579. )
  580. assert readonly['samplePayments'][0]['schedules'][0]['allowedActions'] == []
  581. assert [row['allowedActions'] for row in readonly['samplePayments'][0]['schedules'][0]['transfers']] == [[], [], []]
  582. writable = decorate_allowed_actions(
  583. 'protocol', detail, scope_kind='specialist', has_payment_permission=True
  584. )
  585. schedule = writable['samplePayments'][0]['schedules'][0]
  586. assert schedule['allowedActions'] == ['register-transfer']
  587. assert [row['allowedActions'] for row in schedule['transfers']] == [
  588. ['edit-transfer', 'delete-payment-transfer', 'submit-finance'],
  589. ['withdraw-transfer'],
  590. [],
  591. ]
  592. @pytest.mark.parametrize(
  593. ('content', 'filename', 'content_type'),
  594. [
  595. (b'', 'empty.pdf', 'application/pdf'),
  596. (b'<svg/>', 'bad.svg', 'image/svg+xml'),
  597. (b'MZ', 'bad.exe', 'application/octet-stream'),
  598. (b'not an image', 'fake.jpg', 'image/jpeg'),
  599. (b'%PDF-1.7', 'fake.jpg', 'image/jpeg'),
  600. (b'\x00\x00\x00\x18junk-ftyp-heic-data', 'fake.heic', 'image/heic'),
  601. (b'\x00\x00\x00\x18ftypzzzz\x00\x00\x00\x00zzzz', 'fake.heic', 'image/heic'),
  602. (b'\x00\x00\x00\xffftypheic\x00\x00\x00\x00', 'fake.heic', 'image/heic'),
  603. (b'\x00\x00\x00\x0cftypheic', 'short.heic', 'image/heic'),
  604. (b'\x00\x00\x00\x0fftypheicabc', 'short.heic', 'image/heic'),
  605. (b'\x00\x00\x00\x15ftypheic\x00\x00\x00\x00mif1x', 'misaligned.heic', 'image/heic'),
  606. (b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00', 'generic-only.heic', 'image/heic'),
  607. (b'PK\x03\x04not-a-docx', 'fake.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
  608. (b'a' * (10 * 1024 * 1024 + 1), 'huge.pdf', 'application/pdf'),
  609. ],
  610. )
  611. def test_upload_rejects_empty_oversized_or_unsafe_files(content, filename, content_type):
  612. from app.services.market_support import save_market_attachment
  613. db = _session()
  614. backend = _Storage()
  615. with pytest.raises(ValueError):
  616. save_market_attachment(
  617. db,
  618. content=content,
  619. filename=filename,
  620. content_type=content_type,
  621. module='payment',
  622. ctx=_ctx(),
  623. user_info=_user(),
  624. storage_backend=backend,
  625. )
  626. assert backend.uploaded == []
  627. def test_upload_deletes_storage_object_when_database_commit_fails(monkeypatch):
  628. from app.services.market_support import save_market_attachment
  629. db = _session()
  630. backend = _Storage()
  631. monkeypatch.setattr(db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down')))
  632. with pytest.raises(RuntimeError, match='db down'):
  633. save_market_attachment(
  634. db,
  635. content=b'%PDF-1.7 safe',
  636. filename='proof.pdf',
  637. content_type='application/pdf',
  638. module='protocol',
  639. ctx=_ctx(),
  640. user_info=_user(),
  641. storage_backend=backend,
  642. )
  643. assert backend.deleted == ['https://files.test/market/specialist/protocol/generated.pdf']
  644. def test_upload_never_refreshes_after_commit_or_deletes_committed_object(monkeypatch):
  645. from app.services.market_support import save_market_attachment
  646. db = _session()
  647. backend = _Storage()
  648. committed = False
  649. original_commit = db.commit
  650. original_refresh = db.refresh
  651. def tracked_commit():
  652. nonlocal committed
  653. original_commit()
  654. committed = True
  655. def refresh_only_before_commit(row):
  656. if committed:
  657. raise RuntimeError('refresh after commit is unsafe')
  658. return original_refresh(row)
  659. monkeypatch.setattr(db, 'commit', tracked_commit)
  660. monkeypatch.setattr(db, 'refresh', refresh_only_before_commit)
  661. result = save_market_attachment(
  662. db,
  663. content=b'%PDF-1.7 safe',
  664. filename='proof.pdf',
  665. content_type='application/pdf',
  666. module='protocol',
  667. ctx=_ctx(),
  668. user_info=_user(),
  669. storage_backend=backend,
  670. )
  671. assert result['id']
  672. assert backend.deleted == []
  673. def test_upload_accepts_well_formed_heif_ftyp_box_with_allowed_brand():
  674. from app.services.market_support import save_market_attachment
  675. db = _session()
  676. backend = _Storage()
  677. content = b'\x00\x00\x00\x14ftypheic\x00\x00\x00\x00mif1'
  678. result = save_market_attachment(
  679. db,
  680. content=content,
  681. filename='photo.heic',
  682. content_type='image/heic',
  683. module='sample',
  684. ctx=_ctx(),
  685. user_info=_user(),
  686. storage_backend=backend,
  687. )
  688. assert result['fileType'] == 'image/heic'
  689. assert backend.uploaded[0][0] == content
  690. def test_heif_extension_accepts_generic_heif_brand():
  691. from app.services.market_support import save_market_attachment
  692. db = _session()
  693. backend = _Storage()
  694. content = b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00'
  695. result = save_market_attachment(
  696. db,
  697. content=content,
  698. filename='photo.heif',
  699. content_type='image/heif',
  700. module='sample',
  701. ctx=_ctx(),
  702. user_info=_user(),
  703. storage_backend=backend,
  704. )
  705. assert result['fileType'] == 'image/heif'
  706. def _attachment(**overrides):
  707. values = dict(
  708. id='att-a',
  709. business_type='market:specialist:payment:staging',
  710. business_id=None,
  711. original_name='proof.pdf',
  712. file_ext='pdf',
  713. file_url='market/specialist/payment/a.pdf',
  714. file_size=12,
  715. file_type='application/pdf',
  716. tenant_no='tenant-a',
  717. create_by='user-a',
  718. create_dept_id='',
  719. del_flag=0,
  720. )
  721. values.update(overrides)
  722. return BizAttachment(**values)
  723. class _DownloadStorage:
  724. def __init__(self):
  725. self.requests = []
  726. def presigned_download_url(self, file_url, file_name):
  727. self.requests.append((file_url, file_name))
  728. return 'https://files.test/signed-download'
  729. def _protocol_attachment_session():
  730. engine = create_engine('sqlite:///:memory:')
  731. Base.metadata.create_all(
  732. engine,
  733. tables=[
  734. BizAttachment.__table__,
  735. BizCollectionProtocol.__table__,
  736. BizCollectionRegionStaff.__table__,
  737. BizPresignOrder.__table__,
  738. BizPresignTransfer.__table__,
  739. BizProtocolCollectionInfo.__table__,
  740. BizProtocolFullmoonTodo.__table__,
  741. BizProtocolPayment.__table__,
  742. BizProtocolPaymentDetail.__table__,
  743. BizProtocolStorageCert.__table__,
  744. BizResearchDonation.__table__,
  745. BizResearchDonationCollection.__table__,
  746. SysPost.__table__,
  747. SysPostRole.__table__,
  748. SysRole.__table__,
  749. SysRoleDataScopeDetail.__table__,
  750. SysUserPost.__table__,
  751. SysUserRole.__table__,
  752. ],
  753. )
  754. db = sessionmaker(bind=engine)()
  755. db.add(
  756. SysRole(
  757. id='market-role',
  758. role_name='市场移动端',
  759. data_scope='1',
  760. status='1',
  761. tenant_no='tenant-a',
  762. create_dept_id='',
  763. del_flag=0,
  764. )
  765. )
  766. for user_id in ('sales-a', 'sales-b', 'manager-a'):
  767. db.add(
  768. SysUserRole(
  769. id=f'user-role-{user_id}',
  770. user_id=user_id,
  771. role_id='market-role',
  772. tenant_no='tenant-a',
  773. create_dept_id='',
  774. del_flag=0,
  775. )
  776. )
  777. db.add(
  778. BizCollectionRegionStaff(
  779. id='manager-a-region-a',
  780. region_id='region-a',
  781. user_id='manager-a',
  782. is_leader=1,
  783. tenant_no='tenant-a',
  784. create_dept_id='',
  785. del_flag=0,
  786. )
  787. )
  788. db.commit()
  789. return db
  790. def _market_user(user_id):
  791. return {
  792. 'user_id': user_id,
  793. 'tenant_no': 'tenant-a',
  794. 'role': 'market_user',
  795. 'dept_id': 'dept-a',
  796. }
  797. def _add_protocol_attachment_reference(
  798. db,
  799. protocol_id,
  800. *,
  801. field,
  802. value,
  803. sales_user_id='sales-a',
  804. region_id='region-a',
  805. manager_user_id='manager-b',
  806. parent_tenant_no='tenant-a',
  807. parent_del_flag=0,
  808. collection_tenant_no=None,
  809. collection_del_flag=0,
  810. child_tenant_no='tenant-a',
  811. child_del_flag=0,
  812. ):
  813. db.add_all(
  814. [
  815. BizCollectionProtocol(
  816. id=protocol_id,
  817. protocol_no=f'P-{protocol_id}',
  818. tenant_no=parent_tenant_no,
  819. create_by=sales_user_id,
  820. create_dept_id='dept-a',
  821. del_flag=parent_del_flag,
  822. ),
  823. BizProtocolCollectionInfo(
  824. id=f'collection-{protocol_id}',
  825. collection_protocol_id=protocol_id,
  826. region_id=region_id,
  827. sales_user_id=sales_user_id,
  828. region_manager_user_id=manager_user_id,
  829. tenant_no=(
  830. collection_tenant_no
  831. if collection_tenant_no is not None
  832. else parent_tenant_no
  833. ),
  834. create_dept_id='dept-a',
  835. del_flag=collection_del_flag,
  836. ),
  837. ]
  838. )
  839. model = (
  840. BizProtocolStorageCert
  841. if field == 'cert_image_urls'
  842. else BizProtocolFullmoonTodo
  843. )
  844. db.add(
  845. model(
  846. id=f'child-{protocol_id}',
  847. collection_protocol_id=protocol_id,
  848. tenant_no=child_tenant_no,
  849. create_dept_id='dept-a',
  850. del_flag=child_del_flag,
  851. **{field: value},
  852. )
  853. )
  854. db.commit()
  855. def _protocol_download_ctx(scope_kind, user_id):
  856. return MarketFieldContext('tenant-a', user_id, scope_kind)
  857. def test_protocol_cert_and_insurance_references_use_real_scope_and_exact_urls():
  858. db = _protocol_attachment_session()
  859. _add_protocol_attachment_reference(
  860. db,
  861. 'own-cert',
  862. field='cert_image_urls',
  863. value=' files/cert.pdf.evil , files/cert-exact.pdf ',
  864. )
  865. _add_protocol_attachment_reference(
  866. db,
  867. 'own-insurance',
  868. field='insurance_attachments',
  869. value='files/insurance.pdf',
  870. )
  871. assert market_support._has_accessible_market_attachment_reference(
  872. db,
  873. module='protocol',
  874. candidates=['files/cert-exact.pdf'],
  875. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  876. user_info=_market_user('sales-a'),
  877. )
  878. assert market_support._has_accessible_market_attachment_reference(
  879. db,
  880. module='protocol',
  881. candidates=['files/insurance.pdf'],
  882. ctx=_protocol_download_ctx('manager', 'manager-a'),
  883. user_info=_market_user('manager-a'),
  884. )
  885. assert not market_support._has_accessible_market_attachment_reference(
  886. db,
  887. module='protocol',
  888. candidates=['files/cert.pdf'],
  889. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  890. user_info=_market_user('sales-a'),
  891. )
  892. assert not market_support._has_accessible_market_attachment_reference(
  893. db,
  894. module='protocol',
  895. candidates=['files/cert-exact.pdf.evil'],
  896. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  897. user_info=_market_user('sales-a'),
  898. )
  899. assert not market_support._has_accessible_market_attachment_reference(
  900. db,
  901. module='protocol',
  902. candidates=['files/cert-exact.pdf'],
  903. ctx=_protocol_download_ctx('specialist', 'sales-b'),
  904. user_info=_market_user('sales-b'),
  905. )
  906. db.add_all(
  907. [
  908. _attachment(
  909. id='short-url',
  910. file_url='files/cert.pdf',
  911. business_type='market:specialist:protocol:bound',
  912. ),
  913. _attachment(
  914. id='long-url',
  915. file_url='files/cert-exact.pdf.evil',
  916. business_type='market:specialist:protocol:bound',
  917. ),
  918. _attachment(
  919. id='other-specialist-scope',
  920. file_url='files/cert-exact.pdf',
  921. business_type='market:specialist:protocol:bound',
  922. ),
  923. ]
  924. )
  925. db.commit()
  926. for file_url in ('files/cert.pdf', 'files/cert-exact.pdf.evil'):
  927. with pytest.raises(PermissionError, match='附件'):
  928. market_support.get_market_attachment_download_url(
  929. db,
  930. file_url=file_url,
  931. file_name=None,
  932. module='protocol',
  933. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  934. user_info=_market_user('sales-a'),
  935. storage_backend=_DownloadStorage(),
  936. )
  937. with pytest.raises(PermissionError, match='附件'):
  938. market_support.get_market_attachment_download_url(
  939. db,
  940. file_url='files/cert-exact.pdf',
  941. file_name=None,
  942. module='protocol',
  943. ctx=_protocol_download_ctx('specialist', 'sales-b'),
  944. user_info=_market_user('sales-b'),
  945. storage_backend=_DownloadStorage(),
  946. )
  947. @pytest.mark.parametrize(
  948. ('attachment_id', 'file_url', 'business_type', 'field', 'scope_kind', 'user_id'),
  949. [
  950. ('legacy-cert', 'files/legacy-cert.pdf', '', 'cert_image_urls', 'specialist', 'sales-a'),
  951. (
  952. 'protocol-cert',
  953. 'files/protocol-cert.pdf',
  954. 'market:specialist:protocol:bound',
  955. 'cert_image_urls',
  956. 'specialist',
  957. 'sales-a',
  958. ),
  959. (
  960. 'protocol-insurance',
  961. 'files/protocol-insurance.pdf',
  962. 'market:manager:protocol:bound',
  963. 'insurance_attachments',
  964. 'manager',
  965. 'manager-a',
  966. ),
  967. (
  968. 'sample-cert',
  969. 'files/sample-cert.pdf',
  970. 'market:specialist:sample:bound',
  971. 'cert_image_urls',
  972. 'specialist',
  973. 'sales-a',
  974. ),
  975. (
  976. 'sample-insurance',
  977. 'files/sample-insurance.pdf',
  978. 'market:manager:sample:bound',
  979. 'insurance_attachments',
  980. 'manager',
  981. 'manager-a',
  982. ),
  983. ],
  984. )
  985. def test_protocol_download_allows_accessible_cert_and_insurance_attachment_history(
  986. attachment_id,
  987. file_url,
  988. business_type,
  989. field,
  990. scope_kind,
  991. user_id,
  992. ):
  993. db = _protocol_attachment_session()
  994. _add_protocol_attachment_reference(
  995. db,
  996. f'protocol-{attachment_id}',
  997. field=field,
  998. value=file_url,
  999. )
  1000. db.add(
  1001. _attachment(
  1002. id=attachment_id,
  1003. file_url=file_url,
  1004. business_type=business_type,
  1005. business_id=f'protocol-{attachment_id}',
  1006. create_by='historical-uploader',
  1007. )
  1008. )
  1009. db.commit()
  1010. result = market_support.get_market_attachment_download_url(
  1011. db,
  1012. file_url=file_url,
  1013. file_name=None,
  1014. module='protocol',
  1015. ctx=_protocol_download_ctx(scope_kind, user_id),
  1016. user_info=_market_user(user_id),
  1017. storage_backend=_DownloadStorage(),
  1018. )
  1019. assert result == {'downloadUrl': 'https://files.test/signed-download'}
  1020. @pytest.mark.parametrize(
  1021. ('attachment_id', 'file_url', 'business_type'),
  1022. [
  1023. ('legacy-deposit', 'files/deposit-legacy.png', ''),
  1024. (
  1025. 'presign-bound-deposit',
  1026. 'files/deposit-presign.png',
  1027. 'market:specialist:presign:bound',
  1028. ),
  1029. (
  1030. 'manager-presign-deposit',
  1031. 'files/deposit-manager.png',
  1032. 'market:manager:presign:bound',
  1033. ),
  1034. ],
  1035. )
  1036. def test_protocol_download_allows_linked_presign_deposit_attachments(
  1037. attachment_id,
  1038. file_url,
  1039. business_type,
  1040. ):
  1041. """正式协议详情/编辑预览意向金凭证:附件归属预签转账,须经 source_presign 关联授权。"""
  1042. db = _protocol_attachment_session()
  1043. protocol = BizCollectionProtocol(
  1044. id=f'protocol-{attachment_id}',
  1045. protocol_no=f'P-{attachment_id}',
  1046. source_presign_order_id=f'presign-{attachment_id}',
  1047. tenant_no='tenant-a',
  1048. create_by='sales-a',
  1049. create_dept_id='dept-a',
  1050. del_flag=0,
  1051. )
  1052. db.add_all(
  1053. [
  1054. protocol,
  1055. BizProtocolCollectionInfo(
  1056. id=f'collection-{attachment_id}',
  1057. collection_protocol_id=protocol.id,
  1058. region_id='region-a',
  1059. sales_user_id='sales-a',
  1060. region_manager_user_id='manager-a',
  1061. tenant_no='tenant-a',
  1062. create_dept_id='dept-a',
  1063. del_flag=0,
  1064. ),
  1065. BizPresignOrder(
  1066. id=f'presign-{attachment_id}',
  1067. order_status='converted',
  1068. sales_user_id='sales-a',
  1069. tenant_no='tenant-a',
  1070. create_by='sales-a',
  1071. create_dept_id='dept-a',
  1072. del_flag=0,
  1073. ),
  1074. BizPresignTransfer(
  1075. id=f'transfer-{attachment_id}',
  1076. presign_order_id=f'presign-{attachment_id}',
  1077. transfer_amount=Decimal('1000.00'),
  1078. transfer_attachments=file_url,
  1079. pay_status='yjf',
  1080. tenant_no='tenant-a',
  1081. create_by='sales-a',
  1082. create_dept_id='dept-a',
  1083. del_flag=0,
  1084. ),
  1085. _attachment(
  1086. id=attachment_id,
  1087. file_url=file_url,
  1088. business_type=business_type,
  1089. business_id=f'presign-{attachment_id}',
  1090. create_by='historical-uploader',
  1091. original_name='抄送我的.png',
  1092. ),
  1093. ]
  1094. )
  1095. db.commit()
  1096. result = market_support.get_market_attachment_download_url(
  1097. db,
  1098. file_url=file_url,
  1099. file_name=None,
  1100. module='protocol',
  1101. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1102. user_info=_market_user('sales-a'),
  1103. storage_backend=_DownloadStorage(),
  1104. )
  1105. assert result == {'downloadUrl': 'https://files.test/signed-download'}
  1106. def test_protocol_download_rejects_presign_deposit_attachment_outside_scope():
  1107. db = _protocol_attachment_session()
  1108. db.add_all(
  1109. [
  1110. BizCollectionProtocol(
  1111. id='protocol-other',
  1112. protocol_no='P-OTHER',
  1113. source_presign_order_id='presign-other',
  1114. tenant_no='tenant-a',
  1115. create_by='sales-b',
  1116. create_dept_id='dept-a',
  1117. del_flag=0,
  1118. ),
  1119. BizProtocolCollectionInfo(
  1120. id='collection-other',
  1121. collection_protocol_id='protocol-other',
  1122. region_id='region-b',
  1123. sales_user_id='sales-b',
  1124. region_manager_user_id='manager-b',
  1125. tenant_no='tenant-a',
  1126. create_dept_id='dept-a',
  1127. del_flag=0,
  1128. ),
  1129. BizPresignOrder(
  1130. id='presign-other',
  1131. order_status='converted',
  1132. sales_user_id='sales-b',
  1133. tenant_no='tenant-a',
  1134. create_by='sales-b',
  1135. create_dept_id='dept-a',
  1136. del_flag=0,
  1137. ),
  1138. BizPresignTransfer(
  1139. id='transfer-other',
  1140. presign_order_id='presign-other',
  1141. transfer_amount=Decimal('500.00'),
  1142. transfer_attachments='files/deposit-other.png',
  1143. pay_status='yjf',
  1144. tenant_no='tenant-a',
  1145. create_by='sales-b',
  1146. create_dept_id='dept-a',
  1147. del_flag=0,
  1148. ),
  1149. _attachment(
  1150. id='deposit-other',
  1151. file_url='files/deposit-other.png',
  1152. business_type='market:specialist:presign:bound',
  1153. business_id='presign-other',
  1154. create_by='sales-b',
  1155. ),
  1156. ]
  1157. )
  1158. db.commit()
  1159. with pytest.raises(PermissionError, match='附件'):
  1160. market_support.get_market_attachment_download_url(
  1161. db,
  1162. file_url='files/deposit-other.png',
  1163. file_name=None,
  1164. module='protocol',
  1165. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1166. user_info=_market_user('sales-a'),
  1167. storage_backend=_DownloadStorage(),
  1168. )
  1169. def test_protocol_download_does_not_use_plain_protocol_reference_for_sample_bound():
  1170. db = _protocol_attachment_session()
  1171. _add_protocol_attachment_reference(
  1172. db,
  1173. 'plain-protocol-reference',
  1174. field='cert_image_urls',
  1175. value='files/different-cert.pdf',
  1176. )
  1177. protocol = db.get(BizCollectionProtocol, 'plain-protocol-reference')
  1178. protocol.protocol_attachments = 'files/plain-protocol.pdf'
  1179. db.add(
  1180. _attachment(
  1181. id='plain-protocol-sample-bound',
  1182. file_url='files/plain-protocol.pdf',
  1183. business_type='market:specialist:sample:bound',
  1184. business_id='plain-protocol-reference',
  1185. )
  1186. )
  1187. db.commit()
  1188. with pytest.raises(PermissionError, match='附件'):
  1189. market_support.get_market_attachment_download_url(
  1190. db,
  1191. file_url='files/plain-protocol.pdf',
  1192. file_name=None,
  1193. module='protocol',
  1194. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1195. user_info=_market_user('sales-a'),
  1196. storage_backend=_DownloadStorage(),
  1197. )
  1198. @pytest.mark.parametrize(
  1199. (
  1200. 'case',
  1201. 'parent_tenant_no',
  1202. 'parent_del_flag',
  1203. 'collection_tenant_no',
  1204. 'collection_del_flag',
  1205. 'child_tenant_no',
  1206. 'child_del_flag',
  1207. ),
  1208. [
  1209. ('cross-parent-tenant', 'tenant-b', 0, 'tenant-b', 0, 'tenant-a', 0),
  1210. ('deleted-parent', 'tenant-a', 1, 'tenant-a', 0, 'tenant-a', 0),
  1211. ('cross-collection-tenant', 'tenant-a', 0, 'tenant-b', 0, 'tenant-a', 0),
  1212. ('deleted-collection', 'tenant-a', 0, 'tenant-a', 1, 'tenant-a', 0),
  1213. ('cross-child-tenant', 'tenant-a', 0, 'tenant-a', 0, 'tenant-b', 0),
  1214. ('deleted-child', 'tenant-a', 0, 'tenant-a', 0, 'tenant-a', 1),
  1215. ],
  1216. )
  1217. def test_protocol_cert_reference_rejects_tenant_scope_and_soft_delete_boundaries(
  1218. case,
  1219. parent_tenant_no,
  1220. parent_del_flag,
  1221. collection_tenant_no,
  1222. collection_del_flag,
  1223. child_tenant_no,
  1224. child_del_flag,
  1225. ):
  1226. db = _protocol_attachment_session()
  1227. file_url = f'files/{case}.pdf'
  1228. _add_protocol_attachment_reference(
  1229. db,
  1230. case,
  1231. field='cert_image_urls',
  1232. value=file_url,
  1233. parent_tenant_no=parent_tenant_no,
  1234. parent_del_flag=parent_del_flag,
  1235. collection_tenant_no=collection_tenant_no,
  1236. collection_del_flag=collection_del_flag,
  1237. child_tenant_no=child_tenant_no,
  1238. child_del_flag=child_del_flag,
  1239. )
  1240. assert not market_support._has_accessible_market_attachment_reference(
  1241. db,
  1242. module='protocol',
  1243. candidates=[file_url],
  1244. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1245. user_info=_market_user('sales-a'),
  1246. )
  1247. db.add(
  1248. _attachment(
  1249. id=f'attachment-{case}',
  1250. file_url=file_url,
  1251. business_type='market:specialist:protocol:bound',
  1252. )
  1253. )
  1254. db.commit()
  1255. with pytest.raises(PermissionError, match='附件'):
  1256. market_support.get_market_attachment_download_url(
  1257. db,
  1258. file_url=file_url,
  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. ('attachment_id', 'file_url', 'business_type', 'attachment_tenant', 'attachment_del_flag'),
  1267. [
  1268. ('sample-staging', 'files/sample-staging.pdf', 'market:specialist:sample:staging', 'tenant-a', 0),
  1269. ('other-staging', 'files/other-staging.pdf', 'market:manager:protocol:staging', 'tenant-a', 0),
  1270. ('cross-tenant', 'files/cross-tenant.pdf', '', 'tenant-b', 0),
  1271. ('deleted-attachment', 'files/deleted-attachment.pdf', '', 'tenant-a', 1),
  1272. ('unreferenced', 'files/unreferenced.pdf', '', 'tenant-a', 0),
  1273. ],
  1274. )
  1275. def test_protocol_download_rejects_staging_cross_tenant_deleted_and_unreferenced_rows(
  1276. attachment_id,
  1277. file_url,
  1278. business_type,
  1279. attachment_tenant,
  1280. attachment_del_flag,
  1281. ):
  1282. db = _protocol_attachment_session()
  1283. referenced_url = file_url if attachment_id != 'unreferenced' else 'files/other.pdf'
  1284. _add_protocol_attachment_reference(
  1285. db,
  1286. f'protocol-{attachment_id}',
  1287. field='cert_image_urls',
  1288. value=referenced_url,
  1289. )
  1290. db.add(
  1291. _attachment(
  1292. id=attachment_id,
  1293. file_url=file_url,
  1294. business_type=business_type,
  1295. tenant_no=attachment_tenant,
  1296. del_flag=attachment_del_flag,
  1297. )
  1298. )
  1299. db.commit()
  1300. with pytest.raises(PermissionError, match='附件'):
  1301. market_support.get_market_attachment_download_url(
  1302. db,
  1303. file_url=file_url,
  1304. file_name=None,
  1305. module='protocol',
  1306. ctx=_protocol_download_ctx('specialist', 'sales-a'),
  1307. user_info=_market_user('sales-a'),
  1308. storage_backend=_DownloadStorage(),
  1309. )
  1310. def test_market_attachment_download_uses_registered_original_name():
  1311. from app.services.market_support import get_market_attachment_download_url
  1312. db = _session()
  1313. db.add(_attachment())
  1314. db.commit()
  1315. backend = _DownloadStorage()
  1316. result = get_market_attachment_download_url(
  1317. db,
  1318. file_url='market/specialist/payment/a.pdf',
  1319. file_name='spoofed.exe',
  1320. module='payment',
  1321. ctx=_ctx(),
  1322. user_info=_user(),
  1323. storage_backend=backend,
  1324. )
  1325. assert result == {'downloadUrl': 'https://files.test/signed-download'}
  1326. assert backend.requests == [
  1327. ('market/specialist/payment/a.pdf', 'proof.pdf')
  1328. ]
  1329. def test_market_attachment_download_rejects_cross_tenant_module_and_other_users_staging():
  1330. from app.services.market_support import get_market_attachment_download_url
  1331. db = _session()
  1332. db.add_all(
  1333. [
  1334. _attachment(id='cross', file_url='cross.pdf', tenant_no='tenant-b'),
  1335. _attachment(
  1336. id='wrong-module',
  1337. file_url='wrong-module.pdf',
  1338. business_type='market:specialist:sample:bound',
  1339. business_id='sample-a',
  1340. ),
  1341. _attachment(
  1342. id='other-staging',
  1343. file_url='other-staging.pdf',
  1344. create_by='user-b',
  1345. ),
  1346. ]
  1347. )
  1348. db.commit()
  1349. for file_url in ('cross.pdf', 'wrong-module.pdf', 'other-staging.pdf'):
  1350. with pytest.raises(PermissionError, match='附件'):
  1351. get_market_attachment_download_url(
  1352. db,
  1353. file_url=file_url,
  1354. file_name=None,
  1355. module='payment',
  1356. ctx=_ctx(),
  1357. user_info=_user(),
  1358. storage_backend=_DownloadStorage(),
  1359. )
  1360. def test_market_attachment_download_rejects_unreferenced_bound_and_legacy_rows(
  1361. monkeypatch,
  1362. ):
  1363. from app.services import market_support
  1364. db = _session()
  1365. db.add_all(
  1366. [
  1367. _attachment(
  1368. id='manager-bound',
  1369. file_url='manager-bound.pdf',
  1370. business_type='market:manager:payment:bound',
  1371. business_id='transfer-a',
  1372. ),
  1373. _attachment(
  1374. id='legacy',
  1375. file_url='legacy.pdf',
  1376. business_type=None,
  1377. create_by='admin',
  1378. ),
  1379. ]
  1380. )
  1381. db.commit()
  1382. monkeypatch.setattr(
  1383. market_support,
  1384. '_has_accessible_market_attachment_reference',
  1385. lambda *args, **kwargs: False,
  1386. )
  1387. for file_url in ('manager-bound.pdf', 'legacy.pdf'):
  1388. with pytest.raises(PermissionError, match='附件'):
  1389. market_support.get_market_attachment_download_url(
  1390. db,
  1391. file_url=file_url,
  1392. file_name=None,
  1393. module='payment',
  1394. ctx=_ctx(),
  1395. user_info=_user(),
  1396. storage_backend=_DownloadStorage(),
  1397. )
  1398. def test_market_attachment_download_allows_accessible_referenced_legacy_row(monkeypatch):
  1399. from app.services import market_support
  1400. db = _session()
  1401. db.add(
  1402. _attachment(
  1403. id='legacy',
  1404. file_url='legacy.pdf',
  1405. business_type=None,
  1406. create_by='admin',
  1407. )
  1408. )
  1409. db.commit()
  1410. checked = []
  1411. def allow_reference(_db, *, module, candidates, ctx, user_info):
  1412. checked.append((module, candidates, ctx.user_id, user_info['user_id']))
  1413. return True
  1414. monkeypatch.setattr(
  1415. market_support,
  1416. '_has_accessible_market_attachment_reference',
  1417. allow_reference,
  1418. )
  1419. result = market_support.get_market_attachment_download_url(
  1420. db,
  1421. file_url='legacy.pdf',
  1422. file_name=None,
  1423. module='payment',
  1424. ctx=_ctx(),
  1425. user_info=_user(),
  1426. storage_backend=_DownloadStorage(),
  1427. )
  1428. assert result['downloadUrl'] == 'https://files.test/signed-download'
  1429. assert checked == [('payment', ['legacy.pdf'], 'user-a', 'user-a')]
  1430. def test_protocol_attachment_reference_respects_specialist_donation_scope():
  1431. from app.models.models import (
  1432. BizCollectionProtocol,
  1433. BizProtocolCollectionInfo,
  1434. BizProtocolPayment,
  1435. BizProtocolPaymentDetail,
  1436. )
  1437. from app.models.research_donation import (
  1438. BizResearchDonation,
  1439. BizResearchDonationCollection,
  1440. )
  1441. from app.services import market_support
  1442. engine = create_engine('sqlite:///:memory:')
  1443. Base.metadata.create_all(
  1444. engine,
  1445. tables=[
  1446. BizCollectionProtocol.__table__,
  1447. BizProtocolCollectionInfo.__table__,
  1448. BizProtocolStorageCert.__table__,
  1449. BizProtocolFullmoonTodo.__table__,
  1450. BizProtocolPayment.__table__,
  1451. BizProtocolPaymentDetail.__table__,
  1452. BizResearchDonation.__table__,
  1453. BizResearchDonationCollection.__table__,
  1454. ],
  1455. )
  1456. db = sessionmaker(bind=engine)()
  1457. db.add_all(
  1458. [
  1459. BizResearchDonation(
  1460. id='donation-a',
  1461. donation_no='D-1',
  1462. donation_status='sxz',
  1463. source_type='direct',
  1464. attachment_urls='legacy.pdf',
  1465. tenant_no='tenant-a',
  1466. create_dept_id='',
  1467. del_flag=0,
  1468. ),
  1469. BizResearchDonationCollection(
  1470. id='collection-a',
  1471. research_donation_id='donation-a',
  1472. sales_user_id='user-a',
  1473. tenant_no='tenant-a',
  1474. create_dept_id='',
  1475. del_flag=0,
  1476. ),
  1477. ]
  1478. )
  1479. db.commit()
  1480. super_admin_a = {**_user('user-a'), 'role': 'super_admin'}
  1481. super_admin_b = {**_user('user-b'), 'role': 'super_admin'}
  1482. assert market_support._has_accessible_market_attachment_reference(
  1483. db,
  1484. module='protocol',
  1485. candidates=['legacy.pdf'],
  1486. ctx=_ctx(user_id='user-a'),
  1487. user_info=super_admin_a,
  1488. )
  1489. assert not market_support._has_accessible_market_attachment_reference(
  1490. db,
  1491. module='protocol',
  1492. candidates=['legacy.pdf'],
  1493. ctx=_ctx(user_id='user-b'),
  1494. user_info=super_admin_b,
  1495. )
  1496. def test_create_attachment_refs_require_current_uploader_staging_exact_match():
  1497. from app.services.market_support import validate_attachment_refs
  1498. db = _session()
  1499. db.add_all(
  1500. [
  1501. _attachment(),
  1502. _attachment(id='att-other', file_url='market/specialist/payment/other.pdf', create_by='other'),
  1503. _attachment(id='att-cross', file_url='market/specialist/payment/cross.pdf', tenant_no='tenant-b'),
  1504. ]
  1505. )
  1506. db.commit()
  1507. assert validate_attachment_refs(
  1508. db,
  1509. 'att-a,market/specialist/payment/a.pdf',
  1510. module='payment',
  1511. ctx=_ctx(),
  1512. creating=True,
  1513. ) == 'market/specialist/payment/a.pdf'
  1514. for invalid in ('att-other', 'att-cross', 'https://evil.test/a.pdf', 'a.pdf'):
  1515. with pytest.raises(ValueError, match='附件'):
  1516. validate_attachment_refs(db, invalid, module='payment', ctx=_ctx(), creating=True)
  1517. def test_staging_attachment_must_match_current_scope_role_exactly():
  1518. from app.services.market_support import validate_attachment_refs
  1519. db = _session()
  1520. db.add(
  1521. _attachment(
  1522. id='manager-staging',
  1523. business_type='market:manager:payment:staging',
  1524. file_url='market/manager/payment/proof.pdf',
  1525. )
  1526. )
  1527. db.commit()
  1528. with pytest.raises(ValueError, match='附件'):
  1529. validate_attachment_refs(
  1530. db,
  1531. 'manager-staging',
  1532. module='payment',
  1533. ctx=_ctx('specialist'),
  1534. creating=True,
  1535. business_id='pay-a',
  1536. )
  1537. def test_attachment_refs_bind_once_to_exact_scope_module_and_business():
  1538. from app.services.market_support import validate_attachment_refs
  1539. db = _session()
  1540. row = _attachment()
  1541. db.add(row)
  1542. db.commit()
  1543. result = validate_attachment_refs(
  1544. db,
  1545. 'att-a,market/specialist/payment/a.pdf',
  1546. module='payment',
  1547. ctx=_ctx(),
  1548. creating=True,
  1549. business_id='transfer-a',
  1550. )
  1551. assert result == 'market/specialist/payment/a.pdf'
  1552. assert (row.business_type, row.business_id) == (
  1553. 'market:specialist:payment:bound',
  1554. 'transfer-a',
  1555. )
  1556. assert validate_attachment_refs(
  1557. db,
  1558. 'att-a',
  1559. module='payment',
  1560. ctx=_ctx(),
  1561. creating=False,
  1562. business_id='transfer-a',
  1563. ) == 'market/specialist/payment/a.pdf'
  1564. with pytest.raises(ValueError, match='附件'):
  1565. validate_attachment_refs(
  1566. db,
  1567. 'att-a',
  1568. module='payment',
  1569. ctx=_ctx(),
  1570. creating=False,
  1571. business_id='transfer-b',
  1572. )
  1573. with pytest.raises(ValueError, match='附件'):
  1574. validate_attachment_refs(
  1575. db,
  1576. 'att-a',
  1577. module='payment',
  1578. ctx=_ctx('manager'),
  1579. creating=False,
  1580. business_id='transfer-a',
  1581. )
  1582. def test_attachment_ref_resolution_uses_one_batched_query_and_deduplicates_aliases():
  1583. from sqlalchemy import event
  1584. from app.services.market_support import validate_attachment_refs
  1585. db = _session()
  1586. db.add_all(
  1587. [
  1588. _attachment(id='att-1', file_url='market/specialist/payment/1.pdf'),
  1589. _attachment(id='att-2', file_url='market/specialist/payment/2.pdf'),
  1590. ]
  1591. )
  1592. db.commit()
  1593. statements = []
  1594. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  1595. if 'biz_attachment' in statement.lower() and statement.lstrip().lower().startswith('select'):
  1596. statements.append(statement)
  1597. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  1598. try:
  1599. result = validate_attachment_refs(
  1600. db,
  1601. 'att-1,market/specialist/payment/1.pdf,att-2',
  1602. module='payment',
  1603. ctx=_ctx(),
  1604. creating=True,
  1605. business_id='transfer-batch',
  1606. )
  1607. finally:
  1608. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  1609. assert result == 'market/specialist/payment/1.pdf,market/specialist/payment/2.pdf'
  1610. assert len(statements) == 1
  1611. @pytest.mark.parametrize(
  1612. 'refs',
  1613. [','.join(f'att-{index}' for index in range(21)), 'x' * 4001],
  1614. )
  1615. def test_attachment_refs_reject_excessive_count_or_total_length(refs):
  1616. from app.services.market_support import validate_attachment_refs
  1617. with pytest.raises(ValueError, match='附件'):
  1618. validate_attachment_refs(
  1619. _session(),
  1620. refs,
  1621. module='payment',
  1622. ctx=_ctx(),
  1623. creating=True,
  1624. business_id='transfer-limit',
  1625. )
  1626. def test_all_mobile_attachment_schemas_cap_total_reference_length():
  1627. from pydantic import ValidationError
  1628. from app.schemas.market_mobile import (
  1629. MarketPaymentTransferBody,
  1630. MarketPresignTransferBody,
  1631. MarketProtocolAttachmentsBody,
  1632. MarketSampleStorageCertBody,
  1633. )
  1634. too_long = 'x' * 4001
  1635. payloads = (
  1636. (MarketPresignTransferBody, {'transferAttachments': too_long}),
  1637. (MarketProtocolAttachmentsBody, {'protocolAttachments': too_long}),
  1638. (MarketPaymentTransferBody, {'transferAttachments': too_long}),
  1639. (MarketSampleStorageCertBody, {'attachments': too_long}),
  1640. )
  1641. for schema, payload in payloads:
  1642. with pytest.raises(ValidationError):
  1643. schema.model_validate(payload)
  1644. def test_update_attachment_refs_allow_exact_existing_refs_and_current_user_new_only():
  1645. from app.services.market_support import validate_attachment_refs
  1646. db = _session()
  1647. db.add_all(
  1648. [
  1649. _attachment(
  1650. id='existing',
  1651. file_url='market/specialist/payment/existing.pdf',
  1652. create_by='legacy',
  1653. business_type='market:specialist:payment:bound',
  1654. business_id='transfer-existing',
  1655. ),
  1656. _attachment(id='new', file_url='market/specialist/payment/new.pdf'),
  1657. ]
  1658. )
  1659. db.commit()
  1660. out = validate_attachment_refs(
  1661. db,
  1662. 'existing,new',
  1663. module='payment',
  1664. ctx=_ctx(),
  1665. creating=False,
  1666. existing_refs='market/specialist/payment/existing.pdf',
  1667. business_id='transfer-existing',
  1668. )
  1669. assert out == 'market/specialist/payment/existing.pdf,market/specialist/payment/new.pdf'
  1670. @pytest.mark.parametrize('metadata_kind', ['missing', 'legacy-unbound', 'other-role-bound'])
  1671. def test_update_may_retain_exact_server_supplied_existing_url_without_current_metadata(
  1672. metadata_kind,
  1673. ):
  1674. from app.services.market_support import validate_attachment_refs
  1675. db = _session()
  1676. existing_url = 'legacy/payment/existing.pdf'
  1677. if metadata_kind == 'legacy-unbound':
  1678. db.add(
  1679. _attachment(
  1680. id='legacy-unbound',
  1681. file_url=existing_url,
  1682. business_type='protocol',
  1683. create_by='legacy-user',
  1684. )
  1685. )
  1686. elif metadata_kind == 'other-role-bound':
  1687. db.add(
  1688. _attachment(
  1689. id='specialist-bound',
  1690. file_url=existing_url,
  1691. business_type='market:specialist:payment:bound',
  1692. business_id='transfer-existing',
  1693. create_by='specialist-user',
  1694. )
  1695. )
  1696. db.commit()
  1697. assert validate_attachment_refs(
  1698. db,
  1699. existing_url,
  1700. module='payment',
  1701. ctx=_ctx('manager'),
  1702. creating=False,
  1703. existing_refs=existing_url,
  1704. business_id='transfer-existing',
  1705. ) == existing_url
  1706. def test_existing_ref_exception_allows_removal_but_not_arbitrary_url_smuggling():
  1707. from app.services.market_support import validate_attachment_refs
  1708. db = _session()
  1709. assert validate_attachment_refs(
  1710. db,
  1711. '',
  1712. module='payment',
  1713. ctx=_ctx(),
  1714. creating=False,
  1715. existing_refs='legacy/payment/existing.pdf',
  1716. business_id='transfer-existing',
  1717. ) == ''
  1718. with pytest.raises(ValueError, match='附件'):
  1719. validate_attachment_refs(
  1720. db,
  1721. 'https://evil.test/smuggled.pdf',
  1722. module='payment',
  1723. ctx=_ctx(),
  1724. creating=False,
  1725. existing_refs='legacy/payment/existing.pdf',
  1726. business_id='transfer-existing',
  1727. )
  1728. def test_allowed_actions_are_state_scope_and_permission_aware():
  1729. from app.services.market_support import allowed_actions
  1730. assert allowed_actions('presign', status='active') == ['edit', 'convert', 'delete']
  1731. assert allowed_actions('presign', status='void') == ['edit', 'delete']
  1732. assert allowed_actions('presign', status='converted') == ['edit']
  1733. assert allowed_actions('presign-transfer', status='dfj') == ['edit', 'delete', 'submit']
  1734. assert allowed_actions('presign-transfer', status='jfqrz') == ['withdraw']
  1735. assert allowed_actions('presign-transfer', status='yjf') == []
  1736. assert allowed_actions('protocol', status='dsx') == ['edit', 'delete']
  1737. assert allowed_actions('protocol', status='yzz') == ['edit', 'delete']
  1738. assert allowed_actions('protocol', status='yzf') == ['edit', 'delete']
  1739. assert allowed_actions('payment-transfer', status='dfj') == [
  1740. 'edit-transfer',
  1741. 'delete-payment-transfer',
  1742. 'submit-finance',
  1743. ]
  1744. assert allowed_actions('payment-transfer', status='jfqrz') == ['withdraw-transfer']
  1745. assert allowed_actions('payment-chase', status='', has_payment_permission=False) == []
  1746. assert allowed_actions('payment-chase', status='', has_payment_permission=True) == []
  1747. assert allowed_actions('sample', status='', source='research_donation', has_sample_code=True) == []
  1748. assert allowed_actions('sample', status='', source='cell_storage', has_sample_code=True) == []
  1749. assert allowed_actions('freight', status='', scope_kind='specialist') == []
  1750. assert allowed_actions('freight', status='', scope_kind='manager') == ['edit']
  1751. assert allowed_actions('presign-list', status='') == ['create']
  1752. assert allowed_actions('protocol-list', status='') == ['create']
  1753. assert allowed_actions('freight-list', status='', scope_kind='specialist') == ['create']
  1754. def test_legacy_v1_protocol_detail_keeps_status_scoped_edit_and_delete():
  1755. from app.services.market_support import decorate_allowed_actions
  1756. result = decorate_allowed_actions(
  1757. 'protocol',
  1758. {
  1759. 'source': 'cell_storage',
  1760. 'basic': {
  1761. 'protocolStatus': 'sxz',
  1762. 'paymentModelVersion': 1,
  1763. },
  1764. },
  1765. scope_kind='specialist',
  1766. detail=True,
  1767. )
  1768. assert result['allowedActions'] == ['edit', 'delete']
  1769. def test_presign_actions_include_delete_and_nested_transfer_actions():
  1770. from app.services.market_support import decorate_allowed_actions
  1771. active = decorate_allowed_actions(
  1772. 'presign',
  1773. {
  1774. 'orderStatus': 'active',
  1775. 'transfers': [
  1776. {'id': 't1', 'payStatus': 'dfj'},
  1777. {'id': 't2', 'payStatus': 'jfqrz'},
  1778. {'id': 't3', 'payStatus': 'yjf'},
  1779. ],
  1780. },
  1781. scope_kind='specialist',
  1782. detail=True,
  1783. )
  1784. assert active['allowedActions'] == ['edit', 'convert', 'delete']
  1785. assert [row['allowedActions'] for row in active['transfers']] == [
  1786. ['edit', 'delete', 'submit'],
  1787. ['withdraw'],
  1788. [],
  1789. ]
  1790. for parent_status, parent_actions in (
  1791. ('converted', ['edit']),
  1792. ('void', ['edit', 'delete']),
  1793. ):
  1794. locked = decorate_allowed_actions(
  1795. 'presign',
  1796. {
  1797. 'orderStatus': parent_status,
  1798. 'transfers': [
  1799. {'id': 't1', 'payStatus': 'dfj'},
  1800. {'id': 't2', 'payStatus': 'jfqrz'},
  1801. ],
  1802. },
  1803. scope_kind='specialist',
  1804. detail=True,
  1805. )
  1806. assert locked['allowedActions'] == parent_actions
  1807. assert [row['allowedActions'] for row in locked['transfers']] == [[], []]
  1808. unknown = decorate_allowed_actions(
  1809. 'presign',
  1810. {
  1811. 'orderStatus': 'legacy-locked',
  1812. 'transfers': [{'id': 't1', 'payStatus': 'dfj'}],
  1813. },
  1814. scope_kind='specialist',
  1815. detail=True,
  1816. )
  1817. assert unknown['transfers'][0]['allowedActions'] == []
  1818. def test_form_options_do_not_leak_children_of_soft_deleted_regions():
  1819. from app.services.market_support import get_form_options
  1820. db = _session()
  1821. _seed_options(db)
  1822. db.add_all(
  1823. [
  1824. BizCollectionRegion(
  1825. id='deleted-region',
  1826. region_name='已删除地区',
  1827. tenant_no='tenant-a',
  1828. create_dept_id='',
  1829. del_flag=1,
  1830. ),
  1831. BizCollectionHospital(
  1832. id='deleted-region-hospital',
  1833. region_id='deleted-region',
  1834. hospital_name='不应泄漏医院',
  1835. tenant_no='tenant-a',
  1836. create_dept_id='',
  1837. del_flag=0,
  1838. ),
  1839. BizCollectionRegionStaff(
  1840. id='deleted-region-staff',
  1841. region_id='deleted-region',
  1842. user_id='user-a',
  1843. is_leader=0,
  1844. tenant_no='tenant-a',
  1845. create_dept_id='',
  1846. del_flag=0,
  1847. ),
  1848. ]
  1849. )
  1850. db.commit()
  1851. out = get_form_options(db, module='presign', ctx=_ctx(), user_info=_user())
  1852. assert '已删除地区' not in str(out)
  1853. assert '不应泄漏医院' not in str(out)
  1854. with pytest.raises(PermissionError, match='地区'):
  1855. get_form_options(
  1856. db,
  1857. module='presign',
  1858. ctx=_ctx(),
  1859. user_info=_user(),
  1860. region_id='deleted-region',
  1861. )
  1862. def test_decorate_allowed_actions_covers_nested_payment_transfers():
  1863. from app.services.market_support import decorate_allowed_actions
  1864. detail = {
  1865. 'id': 'pay-a',
  1866. 'schedules': [
  1867. {
  1868. 'id': 'schedule-a',
  1869. 'scheduleStatus': 'open',
  1870. 'arrearsAmount': '80.00',
  1871. 'transfers': [
  1872. {'id': 'draft', 'payStatus': 'dfj'},
  1873. {'id': 'submitted', 'payStatus': 'jfqrz'},
  1874. {'id': 'done', 'payStatus': 'yjf'},
  1875. ],
  1876. }
  1877. ],
  1878. }
  1879. out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
  1880. assert out['allowedActions'] == ['register-transfer']
  1881. assert out['schedules'][0]['allowedActions'] == ['register-transfer']
  1882. assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [
  1883. ['edit-transfer', 'delete-payment-transfer', 'submit-finance'],
  1884. ['withdraw-transfer'],
  1885. [],
  1886. ]
  1887. @pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
  1888. def test_payment_terminal_protocol_keeps_transfer_actions_when_arrears_exist(protocol_status):
  1889. from app.services.market_support import decorate_allowed_actions
  1890. detail = {
  1891. 'id': 'pay-terminal',
  1892. 'protocolStatus': protocol_status,
  1893. 'schedules': [
  1894. {
  1895. 'id': 'schedule-open',
  1896. 'scheduleStatus': 'open',
  1897. 'arrearsAmount': '80.00',
  1898. 'transfers': [
  1899. {'id': 'draft', 'payStatus': 'dfj'},
  1900. {'id': 'submitted', 'payStatus': 'jfqrz'},
  1901. ],
  1902. }
  1903. ],
  1904. }
  1905. out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
  1906. assert out['allowedActions'] == ['register-transfer']
  1907. assert out['schedules'][0]['allowedActions'] == ['register-transfer']
  1908. assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [
  1909. ['edit-transfer', 'delete-payment-transfer', 'submit-finance'],
  1910. ['withdraw-transfer'],
  1911. ]
  1912. @pytest.mark.parametrize(
  1913. ('schedule_status', 'arrears_amount', 'expected_schedule_actions'),
  1914. [('cleared', '80.00', ['register-transfer']), ('open', '0.00', []), ('void', '80.00', [])],
  1915. )
  1916. def test_payment_schedule_actions_require_non_void_status_and_positive_arrears(
  1917. schedule_status, arrears_amount, expected_schedule_actions
  1918. ):
  1919. from app.services.market_support import decorate_allowed_actions
  1920. detail = {
  1921. 'id': 'pay-closed',
  1922. 'protocolStatus': 'sxz',
  1923. 'schedules': [
  1924. {
  1925. 'id': 'schedule-closed',
  1926. 'scheduleStatus': schedule_status,
  1927. 'arrearsAmount': arrears_amount,
  1928. 'transfers': [{'id': 'draft', 'payStatus': 'dfj'}],
  1929. }
  1930. ],
  1931. }
  1932. out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
  1933. assert out['allowedActions'] == expected_schedule_actions
  1934. assert out['schedules'][0]['allowedActions'] == expected_schedule_actions
  1935. assert out['schedules'][0]['transfers'][0]['allowedActions'] == [
  1936. 'edit-transfer',
  1937. 'delete-payment-transfer',
  1938. 'submit-finance',
  1939. ]
  1940. def test_freight_detail_exposes_only_scope_safe_link_actions():
  1941. from app.services.market_support import decorate_allowed_actions
  1942. specialist = decorate_allowed_actions(
  1943. 'freight',
  1944. {'id': 'f1', 'allowedActions': ['edit', 'delete', 'link', 'unlink']},
  1945. scope_kind='specialist',
  1946. detail=True,
  1947. )
  1948. manager = decorate_allowed_actions(
  1949. 'freight',
  1950. {'id': 'f1', 'allowedActions': ['link', 'unlink']},
  1951. scope_kind='manager',
  1952. detail=True,
  1953. )
  1954. assert specialist['allowedActions'] == ['edit', 'delete', 'link', 'unlink']
  1955. assert manager['allowedActions'] == ['link', 'unlink']
  1956. def test_protocol_detail_action_reads_nested_basic_status():
  1957. from app.services.market_support import decorate_allowed_actions
  1958. active = decorate_allowed_actions(
  1959. 'protocol', {'basic': {'protocolStatus': 'dsx'}}, scope_kind='specialist'
  1960. )
  1961. terminal = decorate_allowed_actions(
  1962. 'protocol', {'basic': {'protocolStatus': 'yzf'}}, scope_kind='specialist'
  1963. )
  1964. assert active['allowedActions'] == ['edit', 'delete']
  1965. assert terminal['allowedActions'] == ['edit', 'delete']
  1966. def test_market_support_routes_are_fixed_and_registered_before_dynamic_details():
  1967. from app.api.market_mobile import router
  1968. paths = [route.path for route in router.routes]
  1969. for scope in ('specialist', 'manager'):
  1970. for path in (
  1971. f'/mini/market/{scope}/presign-orders/form-options',
  1972. f'/mini/market/{scope}/protocols/form-options',
  1973. f'/mini/market/{scope}/payments/form-options',
  1974. f'/mini/market/{scope}/freights/form-options',
  1975. ):
  1976. assert path in paths
  1977. for module in ('presign', 'protocol', 'payment'):
  1978. upload_path = f'/mini/market/{scope}/{module}/attachments'
  1979. download_path = f'{upload_path}/download-url'
  1980. assert upload_path in paths
  1981. assert download_path in paths
  1982. upload_route = next(route for route in router.routes if route.path == upload_path)
  1983. download_route = next(route for route in router.routes if route.path == download_path)
  1984. assert [param.name for param in upload_route.dependant.query_params] == []
  1985. assert download_route.methods == {'POST'}
  1986. assert f'/mini/market/{scope}/sample/attachments' not in paths
  1987. assert paths.index(f'/mini/market/{scope}/presign-orders/form-options') < paths.index(
  1988. f'/mini/market/{scope}/presign-orders/{{order_id}}'
  1989. )
  1990. assert paths.index(f'/mini/market/{scope}/protocols/form-options') < paths.index(
  1991. f'/mini/market/{scope}/protocols/{{protocol_id}}'
  1992. )
  1993. assert f'/mini/market/{scope}/samples/form-options' not in paths
  1994. def test_presign_write_routes_use_fixed_paths_and_methods():
  1995. from app.api.market_mobile import router
  1996. for scope in ('specialist', 'manager'):
  1997. expected = {
  1998. f'/mini/market/{scope}/presign-orders/{{order_id}}': {'GET', 'PUT', 'DELETE'},
  1999. f'/mini/market/{scope}/presign-orders/{{order_id}}/transfers': {'POST'},
  2000. f'/mini/market/{scope}/presign-transfers/{{transfer_id}}': {'PUT', 'DELETE'},
  2001. f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/submit': {'POST'},
  2002. f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/withdraw': {'POST'},
  2003. }
  2004. for path, methods in expected.items():
  2005. registered = {
  2006. method
  2007. for route in router.routes
  2008. if route.path == path
  2009. for method in route.methods
  2010. }
  2011. assert registered == methods
  2012. def test_presign_write_routes_map_permission_and_validation_errors(monkeypatch):
  2013. from app.api import market_mobile
  2014. from app.schemas.market_mobile import MarketPresignTransferBody
  2015. delete_route = next(
  2016. route
  2017. for route in market_mobile.router.routes
  2018. if route.path == '/mini/market/specialist/presign-orders/{order_id}'
  2019. and route.methods == {'DELETE'}
  2020. )
  2021. create_transfer_route = next(
  2022. route
  2023. for route in market_mobile.router.routes
  2024. if route.path
  2025. == '/mini/market/specialist/presign-orders/{order_id}/transfers'
  2026. )
  2027. monkeypatch.setattr(
  2028. market_mobile.market_presign,
  2029. 'delete_presign_order',
  2030. lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError('forbidden')),
  2031. )
  2032. permission_result = asyncio.run(
  2033. delete_route.endpoint(order_id='o1', user=_user(), db=SimpleNamespace())
  2034. )
  2035. assert permission_result['code'] == 403
  2036. monkeypatch.setattr(
  2037. market_mobile.market_presign,
  2038. 'create_presign_transfer',
  2039. lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('invalid')),
  2040. )
  2041. validation_result = asyncio.run(
  2042. create_transfer_route.endpoint(
  2043. order_id='o1',
  2044. body=MarketPresignTransferBody(transferAmount=100),
  2045. user=_user(),
  2046. db=SimpleNamespace(),
  2047. )
  2048. )
  2049. assert validation_result['code'] == 400
  2050. def test_market_list_routes_map_invalid_filters_to_validation_response(monkeypatch):
  2051. from app.api import market_mobile
  2052. route_paths = {
  2053. '/mini/market/specialist/protocols': market_mobile.market_protocol,
  2054. '/mini/market/specialist/payments': market_mobile.market_payment,
  2055. '/mini/market/specialist/freights': market_mobile.market_freight,
  2056. }
  2057. routes = {
  2058. route.path: route
  2059. for route in market_mobile.router.routes
  2060. if route.path in route_paths and route.methods == {'GET'}
  2061. }
  2062. for path, service in route_paths.items():
  2063. method_name = {
  2064. '/mini/market/specialist/protocols': 'list_protocols',
  2065. '/mini/market/specialist/payments': 'list_payments',
  2066. '/mini/market/specialist/freights': 'list_freights',
  2067. }[path]
  2068. monkeypatch.setattr(
  2069. service,
  2070. method_name,
  2071. lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('invalid filter')),
  2072. )
  2073. result = asyncio.run(
  2074. routes[path].endpoint(user=_user(), db=SimpleNamespace())
  2075. )
  2076. assert result['code'] == 400
  2077. assert result['message'] == 'invalid filter'
  2078. def test_attachment_routes_read_only_their_module_upload_limit(monkeypatch):
  2079. from app.api import market_mobile
  2080. class TrackingUpload:
  2081. filename = 'proof.pdf'
  2082. content_type = 'application/pdf'
  2083. def __init__(self):
  2084. self.read_size = None
  2085. async def read(self, size):
  2086. self.read_size = size
  2087. return b'%PDF-safe'
  2088. async def close(self):
  2089. return None
  2090. monkeypatch.setattr(
  2091. market_mobile.market_support,
  2092. 'save_market_attachment',
  2093. lambda *args, **kwargs: {'id': 'attachment'},
  2094. )
  2095. routes = {
  2096. route.path: route
  2097. for route in market_mobile.router.routes
  2098. if route.path in {
  2099. '/mini/market/specialist/presign/attachments',
  2100. '/mini/market/specialist/payment/attachments',
  2101. }
  2102. }
  2103. presign_file = TrackingUpload()
  2104. payment_file = TrackingUpload()
  2105. asyncio.run(
  2106. routes['/mini/market/specialist/presign/attachments'].endpoint(
  2107. file=presign_file, user=_user(), db=SimpleNamespace()
  2108. )
  2109. )
  2110. asyncio.run(
  2111. routes['/mini/market/specialist/payment/attachments'].endpoint(
  2112. file=payment_file, user=_user(), db=SimpleNamespace()
  2113. )
  2114. )
  2115. assert presign_file.read_size == 20 * 1024 * 1024 + 1
  2116. assert payment_file.read_size == 10 * 1024 * 1024 + 1
  2117. def test_payment_transfer_response_carries_server_authorized_capabilities():
  2118. draft = market_support.decorate_allowed_actions(
  2119. 'payment-transfer',
  2120. {'id': 't1', 'payStatus': 'dfj'},
  2121. scope_kind='specialist',
  2122. )
  2123. pending = market_support.decorate_allowed_actions(
  2124. 'payment-transfer',
  2125. {'id': 't2', 'payStatus': 'jfqrz'},
  2126. scope_kind='specialist',
  2127. )
  2128. assert draft['allowedActions'] == [
  2129. 'edit-transfer',
  2130. 'delete-payment-transfer',
  2131. 'submit-finance',
  2132. ]
  2133. assert pending['allowedActions'] == ['withdraw-transfer']