test_web_sensitive_permissions.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  1. import asyncio
  2. from pathlib import Path
  3. import pytest
  4. from fastapi import FastAPI, HTTPException
  5. from fastapi.testclient import TestClient
  6. from sqlalchemy import create_engine
  7. from sqlalchemy.orm import sessionmaker
  8. from sqlalchemy.pool import StaticPool
  9. from app.core import menu_guard
  10. from app.core.auth import get_current_user
  11. from app.core.database import get_db
  12. from app.models.base import Base
  13. from app.models.models import (
  14. SysMenu,
  15. SysPost,
  16. SysPostRole,
  17. SysRole,
  18. SysRoleMenu,
  19. SysUser,
  20. SysUserPost,
  21. SysUserRole,
  22. )
  23. PROTOCOL_LIST = "biz:cellStorageProtocol:list"
  24. PROTOCOL_AUDIT = "biz:cellStorageProtocolAudit:list"
  25. PAYMENT_CONFIRM = "biz:cellStoragePaymentConfirm:list"
  26. REFUND_CONFIRM = "biz:cellStorageRefundConfirm:list"
  27. STORAGE_CERT = "cell-storage:storage-cert:list"
  28. QC_MENUS = {"biz:qcWorkOrder:list", "biz:myQcTask:list"}
  29. def _session():
  30. engine = create_engine("sqlite:///:memory:")
  31. Base.metadata.create_all(
  32. engine,
  33. tables=[
  34. SysMenu.__table__,
  35. SysPost.__table__,
  36. SysPostRole.__table__,
  37. SysRole.__table__,
  38. SysRoleMenu.__table__,
  39. SysUser.__table__,
  40. SysUserPost.__table__,
  41. SysUserRole.__table__,
  42. ],
  43. )
  44. return sessionmaker(bind=engine)()
  45. def _thread_session():
  46. engine = create_engine(
  47. "sqlite://",
  48. connect_args={"check_same_thread": False},
  49. poolclass=StaticPool,
  50. )
  51. Base.metadata.create_all(
  52. engine,
  53. tables=[
  54. SysMenu.__table__,
  55. SysPost.__table__,
  56. SysPostRole.__table__,
  57. SysRole.__table__,
  58. SysRoleMenu.__table__,
  59. SysUser.__table__,
  60. SysUserPost.__table__,
  61. SysUserRole.__table__,
  62. ],
  63. )
  64. return sessionmaker(bind=engine)()
  65. def _add_user(db, *, user_id="u1", tenant_no="tenant-a"):
  66. db.add(
  67. SysUser(
  68. id=user_id,
  69. user_no=user_id,
  70. nick_name=user_id,
  71. password="unused",
  72. status="1",
  73. tenant_no=tenant_no,
  74. create_dept_id="",
  75. del_flag=0,
  76. )
  77. )
  78. def _add_role(db, *, role_id, tenant_no, role_name="普通角色"):
  79. db.add(
  80. SysRole(
  81. id=role_id,
  82. role_name=role_name,
  83. status="1",
  84. tenant_no=tenant_no,
  85. create_dept_id="",
  86. del_flag=0,
  87. )
  88. )
  89. def _add_menu_grant(db, *, role_id, tenant_no, code, suffix):
  90. menu_id = f"menu-{suffix}"
  91. db.add_all(
  92. [
  93. SysMenu(
  94. id=menu_id,
  95. menu_name=code,
  96. parent_id="root",
  97. menu_type="C",
  98. path=f"/{suffix}",
  99. perms=code,
  100. status="1",
  101. tenant_no=tenant_no,
  102. create_dept_id="",
  103. del_flag=0,
  104. ),
  105. SysRoleMenu(
  106. id=f"rm-{role_id}-{suffix}",
  107. role_id=role_id,
  108. menu_id=menu_id,
  109. tenant_no=tenant_no,
  110. create_dept_id="",
  111. del_flag=0,
  112. ),
  113. ]
  114. )
  115. def _bind_direct(db, *, user_id, role_id, tenant_no, suffix):
  116. db.add(
  117. SysUserRole(
  118. id=f"ur-{suffix}",
  119. user_id=user_id,
  120. role_id=role_id,
  121. tenant_no=tenant_no,
  122. create_dept_id="",
  123. del_flag=0,
  124. )
  125. )
  126. def _bind_post(db, *, user_id, role_id, tenant_no, suffix):
  127. post_id = f"post-{suffix}"
  128. db.add_all(
  129. [
  130. SysPost(
  131. id=post_id,
  132. post_name="授权职位",
  133. post_code=post_id,
  134. status="1",
  135. tenant_no=tenant_no,
  136. create_dept_id="",
  137. del_flag=0,
  138. ),
  139. SysUserPost(
  140. id=f"up-{suffix}",
  141. user_id=user_id,
  142. post_id=post_id,
  143. tenant_no=tenant_no,
  144. create_dept_id="",
  145. del_flag=0,
  146. ),
  147. SysPostRole(
  148. id=f"pr-{suffix}",
  149. post_id=post_id,
  150. role_id=role_id,
  151. tenant_no=tenant_no,
  152. create_dept_id="",
  153. del_flag=0,
  154. ),
  155. ]
  156. )
  157. def _strict_checker():
  158. checker = getattr(menu_guard, "strict_user_has_menu_perm", None)
  159. assert callable(checker), "menu_guard must expose strict_user_has_menu_perm"
  160. return checker
  161. def _strict_dependency(code):
  162. factory = getattr(menu_guard, "require_strict_menu_code", None)
  163. assert callable(factory), "menu_guard must expose require_strict_menu_code"
  164. return factory(code)
  165. def test_strict_permission_uses_direct_and_post_role_union():
  166. db = _session()
  167. _add_user(db)
  168. _add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
  169. _add_role(db, role_id="finance", tenant_no="tenant-a", role_name="财务人员")
  170. _add_menu_grant(
  171. db,
  172. role_id="market",
  173. tenant_no="tenant-a",
  174. code="mini:market:specialist:payment",
  175. suffix="mini",
  176. )
  177. _add_menu_grant(
  178. db,
  179. role_id="finance",
  180. tenant_no="tenant-a",
  181. code=PAYMENT_CONFIRM,
  182. suffix="finance",
  183. )
  184. _bind_direct(
  185. db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
  186. )
  187. _bind_post(
  188. db, user_id="u1", role_id="finance", tenant_no="tenant-a", suffix="finance"
  189. )
  190. db.commit()
  191. checker = _strict_checker()
  192. user = {"user_id": "u1", "tenant_no": "forged", "role": "admin"}
  193. assert checker(db, user, PAYMENT_CONFIRM)
  194. assert not checker(db, user, REFUND_CONFIRM)
  195. assert asyncio.run(_strict_dependency(PAYMENT_CONFIRM)(user=user, db=db)) is user
  196. def test_strict_permission_ignores_cross_tenant_grants():
  197. db = _session()
  198. _add_user(db, tenant_no="tenant-a")
  199. _add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
  200. _bind_direct(
  201. db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
  202. )
  203. _add_role(db, role_id="finance-b", tenant_no="tenant-b", role_name="财务人员")
  204. _add_menu_grant(
  205. db,
  206. role_id="finance-b",
  207. tenant_no="tenant-b",
  208. code=PAYMENT_CONFIRM,
  209. suffix="finance-b",
  210. )
  211. _bind_direct(
  212. db,
  213. user_id="u1",
  214. role_id="finance-b",
  215. tenant_no="tenant-b",
  216. suffix="finance-b",
  217. )
  218. db.commit()
  219. assert not _strict_checker()(
  220. db,
  221. {"user_id": "u1", "tenant_no": "tenant-b", "role": "super_admin"},
  222. PAYMENT_CONFIRM,
  223. )
  224. @pytest.mark.parametrize("role_name", ["超级市场专员", "超级管理员"])
  225. def test_strict_permission_rejects_privileged_role_name_without_menu(role_name):
  226. db = _session()
  227. _add_user(db)
  228. _add_role(
  229. db,
  230. role_id="super",
  231. tenant_no="tenant-a",
  232. role_name=role_name,
  233. )
  234. _bind_direct(
  235. db, user_id="u1", role_id="super", tenant_no="tenant-a", suffix="super"
  236. )
  237. db.commit()
  238. user = {"user_id": "u1", "tenant_no": "forged", "role": "admin"}
  239. assert not _strict_checker()(db, user, REFUND_CONFIRM)
  240. with pytest.raises(HTTPException) as exc_info:
  241. asyncio.run(_strict_dependency(REFUND_CONFIRM)(user=user, db=db))
  242. assert exc_info.value.status_code == 403
  243. def test_strict_permission_allows_super_admin_only_with_explicit_menu_grant():
  244. db = _session()
  245. _add_user(db)
  246. _add_role(db, role_id="super", tenant_no="tenant-a", role_name="超级管理员")
  247. _add_menu_grant(
  248. db,
  249. role_id="super",
  250. tenant_no="tenant-a",
  251. code=REFUND_CONFIRM,
  252. suffix="refund",
  253. )
  254. _bind_direct(
  255. db, user_id="u1", role_id="super", tenant_no="tenant-a", suffix="super"
  256. )
  257. db.commit()
  258. user = {"user_id": "u1", "tenant_no": "forged", "role": "super_admin"}
  259. assert _strict_checker()(db, user, REFUND_CONFIRM)
  260. assert asyncio.run(_strict_dependency(REFUND_CONFIRM)(user=user, db=db)) is user
  261. def test_strict_dependency_rejects_market_only_role():
  262. db = _session()
  263. _add_user(db)
  264. _add_role(db, role_id="market", tenant_no="tenant-a", role_name="地区经理")
  265. _add_menu_grant(
  266. db,
  267. role_id="market",
  268. tenant_no="tenant-a",
  269. code="mini:market:manager:protocol",
  270. suffix="mini",
  271. )
  272. _bind_direct(
  273. db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
  274. )
  275. db.commit()
  276. with pytest.raises(HTTPException) as exc_info:
  277. asyncio.run(
  278. _strict_dependency(PROTOCOL_AUDIT)(
  279. user={"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"},
  280. db=db,
  281. )
  282. )
  283. assert exc_info.value.status_code == 403
  284. def _route_strict_codes(router, endpoint_name):
  285. route = next(r for r in router.routes if r.endpoint.__name__ == endpoint_name)
  286. codes = set()
  287. for dependency in route.dependant.dependencies:
  288. codes.update(getattr(dependency.call, "strict_menu_codes", ()))
  289. return codes
  290. @pytest.mark.parametrize(
  291. ("module_name", "endpoint_names", "expected_codes"),
  292. [
  293. (
  294. "research_donation",
  295. [
  296. "create_research_donation",
  297. "update_research_donation",
  298. ],
  299. {PROTOCOL_LIST},
  300. ),
  301. (
  302. "protocol_sample_payment",
  303. ["confirm_payment_transfer", "reject_payment_transfer"],
  304. {PAYMENT_CONFIRM},
  305. ),
  306. (
  307. "presign_order",
  308. ["confirm_presign_transfer", "reject_presign_transfer"],
  309. {PAYMENT_CONFIRM},
  310. ),
  311. (
  312. "sample_cert_insurance",
  313. ["put_sample_storage_cert", "put_sample_insurance"],
  314. {STORAGE_CERT},
  315. ),
  316. (
  317. "sample_storage_cert_qc",
  318. ["put_sample_storage_cert_qc"],
  319. QC_MENUS,
  320. ),
  321. ],
  322. )
  323. def test_sensitive_write_routes_declare_strict_menu_dependencies(
  324. module_name, endpoint_names, expected_codes
  325. ):
  326. module = __import__(f"app.api.{module_name}", fromlist=["router"])
  327. for endpoint_name in endpoint_names:
  328. assert _route_strict_codes(module.router, endpoint_name) == expected_codes
  329. def test_research_donation_audit_route_has_no_strict_menu_dependency():
  330. """科研捐赠审核只保留运营端登录,不再要求审核菜单权限。"""
  331. from app.api import research_donation
  332. assert _route_strict_codes(research_donation.router, "submit_donation_audit") == set()
  333. def test_collection_protocol_write_routes_have_no_strict_menu_dependencies():
  334. """采集协议单接口仅保留运营端登录守卫,不再挂菜单权限码。"""
  335. from app.api import collection_protocol
  336. write_endpoints = [
  337. "create_collection_protocol",
  338. "update_collection_protocol",
  339. "delete_collection_protocol",
  340. "submit_collection_protocol_audit",
  341. "post_receipt_confirm",
  342. "post_receipt_reject",
  343. "post_protocol_invoice_request",
  344. "complete_protocol_invoice_request",
  345. "post_submit_refund_finance",
  346. "post_submit_refund_finance_batch",
  347. "post_refund_confirm",
  348. "post_refund_reject",
  349. "update_fullmoon_todo_insurance",
  350. "update_storage_cert_handle",
  351. "update_fullmoon_todo_handle",
  352. ]
  353. for endpoint_name in write_endpoints:
  354. assert _route_strict_codes(collection_protocol.router, endpoint_name) == set()
  355. def test_protocol_refund_record_routes_have_no_strict_menu_dependencies():
  356. """采集协议单上的退费登记只保留运营端登录,不再要求退费确认菜单。"""
  357. from app.api import protocol_refund_record
  358. for endpoint_name in (
  359. "list_protocol_refund_records",
  360. "create_protocol_refund_record",
  361. "update_protocol_refund_record",
  362. "delete_protocol_refund_record",
  363. ):
  364. assert _route_strict_codes(protocol_refund_record.router, endpoint_name) == set()
  365. def test_collection_protocol_write_paths_keep_payment_detail_guard_only():
  366. source = Path("app/api/collection_protocol.py").read_text()
  367. assert "assert_protocol_sensitive_fields" in source
  368. assert "require_strict_menu_code" not in source
  369. assert "assert_strict_menu_code" not in source
  370. def test_research_donation_write_paths_assert_sensitive_field_permissions():
  371. source = Path("app/api/research_donation.py").read_text()
  372. assert "assert_donation_sensitive_fields" in source
  373. assert PROTOCOL_AUDIT in source
  374. def _permission_user(db, *codes):
  375. _add_user(db)
  376. _add_role(db, role_id="protocol", tenant_no="tenant-a", role_name="协议录入")
  377. _add_menu_grant(
  378. db,
  379. role_id="protocol",
  380. tenant_no="tenant-a",
  381. code=PROTOCOL_LIST,
  382. suffix="protocol",
  383. )
  384. _bind_direct(
  385. db,
  386. user_id="u1",
  387. role_id="protocol",
  388. tenant_no="tenant-a",
  389. suffix="protocol",
  390. )
  391. for index, code in enumerate(codes):
  392. role_id = f"extra-{index}"
  393. _add_role(db, role_id=role_id, tenant_no="tenant-a")
  394. _add_menu_grant(
  395. db,
  396. role_id=role_id,
  397. tenant_no="tenant-a",
  398. code=code,
  399. suffix=role_id,
  400. )
  401. _bind_direct(
  402. db,
  403. user_id="u1",
  404. role_id=role_id,
  405. tenant_no="tenant-a",
  406. suffix=role_id,
  407. )
  408. db.commit()
  409. return {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
  410. def test_protocol_sensitive_fields_no_longer_require_menu_permissions():
  411. from app.api.collection_protocol import (
  412. ProtocolCreateBody,
  413. ProtocolUpdateBody,
  414. assert_protocol_sensitive_fields,
  415. )
  416. db = _session()
  417. user = _permission_user(db)
  418. assert_protocol_sensitive_fields(db, user, ProtocolCreateBody(), creating=True)
  419. assert_protocol_sensitive_fields(
  420. db,
  421. user,
  422. ProtocolCreateBody(protocolStatus="sxz"),
  423. creating=True,
  424. )
  425. assert_protocol_sensitive_fields(
  426. db,
  427. user,
  428. ProtocolUpdateBody(storageCert={}),
  429. creating=False,
  430. )
  431. def test_donation_sensitive_fields_require_audit_permission():
  432. from app.api.research_donation import (
  433. DonationCreateBody,
  434. DonationUpdateBody,
  435. assert_donation_sensitive_fields,
  436. )
  437. db = _session()
  438. user = _permission_user(db)
  439. assert_donation_sensitive_fields(db, user, DonationCreateBody(), creating=True)
  440. for body, creating in (
  441. (DonationCreateBody(donationStatus="sxz"), True),
  442. (DonationCreateBody(sourceType="cell_storage"), True),
  443. (DonationUpdateBody(sourceProtocolId="p1"), False),
  444. ):
  445. with pytest.raises(HTTPException) as exc_info:
  446. assert_donation_sensitive_fields(db, user, body, creating=creating)
  447. assert exc_info.value.status_code == 403
  448. def test_market_only_user_is_stopped_before_sensitive_crud(monkeypatch):
  449. """其它仍挂菜单码的写接口:仅有小程序市场权限时仍应 403。
  450. 采集协议单接口已按方案去掉菜单码,不在本用例断言范围内。
  451. """
  452. from app.api import (
  453. protocol_sample_payment,
  454. sample_cert_insurance,
  455. sample_storage_cert_qc,
  456. )
  457. db = _thread_session()
  458. _add_user(db)
  459. _add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
  460. _add_menu_grant(
  461. db,
  462. role_id="market",
  463. tenant_no="tenant-a",
  464. code="mini:market:specialist:protocol",
  465. suffix="mini-protocol",
  466. )
  467. _bind_direct(
  468. db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
  469. )
  470. db.commit()
  471. called = []
  472. def forbidden_call(*args, **kwargs):
  473. called.append((args, kwargs))
  474. raise AssertionError("sensitive CRUD must not be called")
  475. monkeypatch.setattr(
  476. protocol_sample_payment.psp_svc, "confirm_receipt", forbidden_call
  477. )
  478. monkeypatch.setattr(
  479. sample_cert_insurance.cert_crud, "upsert_handle", forbidden_call
  480. )
  481. monkeypatch.setattr(sample_storage_cert_qc.svc, "upsert", forbidden_call)
  482. app = FastAPI()
  483. for router in (
  484. protocol_sample_payment.router,
  485. sample_cert_insurance.router,
  486. sample_storage_cert_qc.router,
  487. ):
  488. app.include_router(router)
  489. user = {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
  490. def override_db():
  491. yield db
  492. app.dependency_overrides[get_current_user] = lambda: user
  493. app.dependency_overrides[get_db] = override_db
  494. requests = [
  495. ("post", "/web/admin/payment-transfers/t1/receipt-confirm", {}),
  496. ("put", "/web/admin/sample-storage-certs/S1", {}),
  497. ("put", "/web/admin/sample-storage-cert-qc/S1", {"payload": {}}),
  498. ]
  499. client = TestClient(app)
  500. for method, url, body in requests:
  501. response = client.request(method, url, json=body)
  502. assert response.status_code == 403, (method, url, response.text)
  503. assert called == []
  504. def test_quick_register_writes_skip_menu_and_data_scope():
  505. """客户服务七类登记不再校验菜单码,也不按数据权限拦截样本。"""
  506. api_source = Path("app/api/protocol_sample_collection.py").read_text()
  507. crud_source = Path("app/crud/sample_info_quick_register.py").read_text()
  508. assert "assert_strict_menu_code" not in api_source
  509. assert "apply_data_scope" not in crud_source
  510. @pytest.mark.parametrize("kind", ["storage_cert", "insurance", "all"])
  511. def test_quick_register_does_not_require_storage_cert_menu(monkeypatch, kind):
  512. """客户服务管理登记储存证/保险不再要求储存证菜单权限。"""
  513. from app.api import protocol_sample_collection
  514. db = _session()
  515. user = _permission_user(db)
  516. called = []
  517. def allowed_call(*args, **kwargs):
  518. called.append((args, kwargs))
  519. return {"success": 1, "fail": 0}
  520. monkeypatch.setattr(
  521. protocol_sample_collection.quick_svc,
  522. "batch_handle_storage_certs",
  523. allowed_call,
  524. )
  525. monkeypatch.setattr(
  526. protocol_sample_collection.quick_svc,
  527. "batch_handle_insurance",
  528. allowed_call,
  529. )
  530. body = protocol_sample_collection.QuickRegisterBody(
  531. kind=kind,
  532. receptionSource="cell_storage",
  533. items=[{"sampleCode": "S1", "protocolId": "P1"}],
  534. handleTime="2026-10-14 10:00:00",
  535. )
  536. result = asyncio.run(
  537. protocol_sample_collection.quick_register_sample_info(
  538. body=body,
  539. db=db,
  540. user=user,
  541. )
  542. )
  543. assert result["code"] == 200
  544. assert called
  545. @pytest.mark.parametrize(
  546. "payment_detail",
  547. [
  548. {"installmentNo": 1, "payStatus": "yjf"},
  549. {"installmentNo": 1, "PayStatus": "yjf"},
  550. {"installmentNo": 1, "financeConfirmAttachments": "secret.pdf"},
  551. {
  552. "installmentNo": 1,
  553. "financeConfirmAttachmentFiles": [{"url": "secret.pdf"}],
  554. },
  555. {"installmentNo": 1, "financeRemark": "confirmed"},
  556. {"installmentNo": 1, "refundAmount": 100},
  557. {"installmentNo": 1, "refundRemark": "refund"},
  558. {"installmentNo": 1, "refundAttachments": "refund.pdf"},
  559. {
  560. "installmentNo": 1,
  561. "refundAttachmentFiles": [{"url": "refund.pdf"}],
  562. },
  563. {
  564. "installmentNo": 1,
  565. "refundFinanceConfirmAttachments": "finance.pdf",
  566. },
  567. {
  568. "installmentNo": 1,
  569. "refundFinanceConfirmAttachmentFiles": [{"url": "finance.pdf"}],
  570. },
  571. {"installmentNo": 1, "refundFinanceRemark": "confirmed"},
  572. ],
  573. )
  574. def test_protocol_create_rejects_nested_finance_and_refund_fields_before_crud(
  575. monkeypatch, payment_detail
  576. ):
  577. from app.api import collection_protocol
  578. called = []
  579. def forbidden_call(*args, **kwargs):
  580. called.append((args, kwargs))
  581. raise AssertionError("protocol CRUD must not be called")
  582. monkeypatch.setattr(
  583. collection_protocol.cp_svc,
  584. "create_collection_protocol",
  585. forbidden_call,
  586. )
  587. body = collection_protocol.ProtocolCreateBody(
  588. paymentModelVersion=1,
  589. paymentDetails=[payment_detail],
  590. )
  591. with pytest.raises(HTTPException) as exc_info:
  592. asyncio.run(
  593. collection_protocol.create_collection_protocol(
  594. body=body,
  595. user={"user_id": "u1", "role": "admin"},
  596. db=None,
  597. )
  598. )
  599. assert exc_info.value.status_code == 400
  600. assert called == []
  601. def test_protocol_update_rejects_snake_case_nested_sensitive_fields_before_crud(
  602. monkeypatch,
  603. ):
  604. from app.api import collection_protocol
  605. called = []
  606. def forbidden_call(*args, **kwargs):
  607. called.append((args, kwargs))
  608. raise AssertionError("protocol CRUD must not be called")
  609. monkeypatch.setattr(
  610. collection_protocol.cp_svc,
  611. "update_collection_protocol",
  612. forbidden_call,
  613. )
  614. body = collection_protocol.ProtocolUpdateBody(
  615. payment_details=[
  616. {
  617. "installment_no": 1,
  618. "finance_confirm_attachment_files": [{"url": "secret.pdf"}],
  619. }
  620. ]
  621. )
  622. with pytest.raises(HTTPException) as exc_info:
  623. asyncio.run(
  624. collection_protocol.update_collection_protocol(
  625. protocol_id="p1",
  626. body=body,
  627. user={"user_id": "u1", "role": "admin"},
  628. db=None,
  629. )
  630. )
  631. assert exc_info.value.status_code == 400
  632. assert called == []
  633. def test_payment_detail_sensitive_field_inventory_matches_crud_write_fields():
  634. from app.api.collection_protocol import _PAYMENT_DETAIL_SENSITIVE_FIELDS
  635. assert _PAYMENT_DETAIL_SENSITIVE_FIELDS == {
  636. "pay_status",
  637. "payStatus",
  638. "finance_confirm_attachments",
  639. "financeConfirmAttachments",
  640. "finance_confirm_attachment_files",
  641. "financeConfirmAttachmentFiles",
  642. "finance_remark",
  643. "financeRemark",
  644. "refund_amount",
  645. "refundAmount",
  646. "refund_remark",
  647. "refundRemark",
  648. "refund_attachments",
  649. "refundAttachments",
  650. "refund_attachment_files",
  651. "refundAttachmentFiles",
  652. "refund_finance_confirm_attachments",
  653. "refundFinanceConfirmAttachments",
  654. "refund_finance_confirm_attachment_files",
  655. "refundFinanceConfirmAttachmentFiles",
  656. "refund_finance_remark",
  657. "refundFinanceRemark",
  658. }