test_web_sensitive_permissions.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  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. "delete_research_donation",
  299. "batch_delete_research_donations",
  300. ],
  301. {PROTOCOL_LIST},
  302. ),
  303. (
  304. "research_donation",
  305. [
  306. "submit_donation_audit",
  307. "change_donation_status",
  308. "convert_from_storage",
  309. "convert_to_storage",
  310. ],
  311. {PROTOCOL_AUDIT},
  312. ),
  313. (
  314. "protocol_sample_payment",
  315. ["confirm_payment_transfer", "reject_payment_transfer"],
  316. {PAYMENT_CONFIRM},
  317. ),
  318. (
  319. "presign_order",
  320. ["confirm_presign_transfer", "reject_presign_transfer"],
  321. {PAYMENT_CONFIRM},
  322. ),
  323. (
  324. "protocol_refund_record",
  325. [
  326. "create_protocol_refund_record",
  327. "update_protocol_refund_record",
  328. "delete_protocol_refund_record",
  329. ],
  330. {REFUND_CONFIRM},
  331. ),
  332. (
  333. "sample_cert_insurance",
  334. ["put_sample_storage_cert", "put_sample_insurance"],
  335. {STORAGE_CERT},
  336. ),
  337. (
  338. "sample_storage_cert_qc",
  339. ["put_sample_storage_cert_qc"],
  340. QC_MENUS,
  341. ),
  342. ],
  343. )
  344. def test_sensitive_write_routes_declare_strict_menu_dependencies(
  345. module_name, endpoint_names, expected_codes
  346. ):
  347. module = __import__(f"app.api.{module_name}", fromlist=["router"])
  348. for endpoint_name in endpoint_names:
  349. assert _route_strict_codes(module.router, endpoint_name) == expected_codes
  350. def test_collection_protocol_write_routes_have_no_strict_menu_dependencies():
  351. """采集协议单接口仅保留运营端登录守卫,不再挂菜单权限码。"""
  352. from app.api import collection_protocol
  353. write_endpoints = [
  354. "create_collection_protocol",
  355. "update_collection_protocol",
  356. "delete_collection_protocol",
  357. "submit_collection_protocol_audit",
  358. "post_receipt_confirm",
  359. "post_receipt_reject",
  360. "post_protocol_invoice_request",
  361. "complete_protocol_invoice_request",
  362. "post_submit_refund_finance",
  363. "post_submit_refund_finance_batch",
  364. "post_refund_confirm",
  365. "post_refund_reject",
  366. "update_fullmoon_todo_insurance",
  367. "update_storage_cert_handle",
  368. "update_fullmoon_todo_handle",
  369. ]
  370. for endpoint_name in write_endpoints:
  371. assert _route_strict_codes(collection_protocol.router, endpoint_name) == set()
  372. def test_collection_protocol_write_paths_keep_payment_detail_guard_only():
  373. source = Path("app/api/collection_protocol.py").read_text()
  374. assert "assert_protocol_sensitive_fields" in source
  375. assert "require_strict_menu_code" not in source
  376. assert "assert_strict_menu_code" not in source
  377. def test_research_donation_write_paths_assert_sensitive_field_permissions():
  378. source = Path("app/api/research_donation.py").read_text()
  379. assert "assert_donation_sensitive_fields" in source
  380. assert PROTOCOL_AUDIT in source
  381. def _permission_user(db, *codes):
  382. _add_user(db)
  383. _add_role(db, role_id="protocol", tenant_no="tenant-a", role_name="协议录入")
  384. _add_menu_grant(
  385. db,
  386. role_id="protocol",
  387. tenant_no="tenant-a",
  388. code=PROTOCOL_LIST,
  389. suffix="protocol",
  390. )
  391. _bind_direct(
  392. db,
  393. user_id="u1",
  394. role_id="protocol",
  395. tenant_no="tenant-a",
  396. suffix="protocol",
  397. )
  398. for index, code in enumerate(codes):
  399. role_id = f"extra-{index}"
  400. _add_role(db, role_id=role_id, tenant_no="tenant-a")
  401. _add_menu_grant(
  402. db,
  403. role_id=role_id,
  404. tenant_no="tenant-a",
  405. code=code,
  406. suffix=role_id,
  407. )
  408. _bind_direct(
  409. db,
  410. user_id="u1",
  411. role_id=role_id,
  412. tenant_no="tenant-a",
  413. suffix=role_id,
  414. )
  415. db.commit()
  416. return {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
  417. def test_protocol_sensitive_fields_no_longer_require_menu_permissions():
  418. from app.api.collection_protocol import (
  419. ProtocolCreateBody,
  420. ProtocolUpdateBody,
  421. assert_protocol_sensitive_fields,
  422. )
  423. db = _session()
  424. user = _permission_user(db)
  425. assert_protocol_sensitive_fields(db, user, ProtocolCreateBody(), creating=True)
  426. assert_protocol_sensitive_fields(
  427. db,
  428. user,
  429. ProtocolCreateBody(protocolStatus="sxz"),
  430. creating=True,
  431. )
  432. assert_protocol_sensitive_fields(
  433. db,
  434. user,
  435. ProtocolUpdateBody(storageCert={}),
  436. creating=False,
  437. )
  438. def test_donation_sensitive_fields_require_audit_permission():
  439. from app.api.research_donation import (
  440. DonationCreateBody,
  441. DonationUpdateBody,
  442. assert_donation_sensitive_fields,
  443. )
  444. db = _session()
  445. user = _permission_user(db)
  446. assert_donation_sensitive_fields(db, user, DonationCreateBody(), creating=True)
  447. for body, creating in (
  448. (DonationCreateBody(donationStatus="sxz"), True),
  449. (DonationCreateBody(sourceType="cell_storage"), True),
  450. (DonationUpdateBody(sourceProtocolId="p1"), False),
  451. ):
  452. with pytest.raises(HTTPException) as exc_info:
  453. assert_donation_sensitive_fields(db, user, body, creating=creating)
  454. assert exc_info.value.status_code == 403
  455. def test_market_only_user_is_stopped_before_sensitive_crud(monkeypatch):
  456. """其它仍挂菜单码的写接口:仅有小程序市场权限时仍应 403。
  457. 采集协议单接口已按方案去掉菜单码,不在本用例断言范围内。
  458. """
  459. from app.api import (
  460. protocol_refund_record,
  461. protocol_sample_payment,
  462. sample_cert_insurance,
  463. sample_storage_cert_qc,
  464. )
  465. db = _thread_session()
  466. _add_user(db)
  467. _add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
  468. _add_menu_grant(
  469. db,
  470. role_id="market",
  471. tenant_no="tenant-a",
  472. code="mini:market:specialist:protocol",
  473. suffix="mini-protocol",
  474. )
  475. _bind_direct(
  476. db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
  477. )
  478. db.commit()
  479. called = []
  480. def forbidden_call(*args, **kwargs):
  481. called.append((args, kwargs))
  482. raise AssertionError("sensitive CRUD must not be called")
  483. monkeypatch.setattr(
  484. protocol_sample_payment.psp_svc, "confirm_receipt", forbidden_call
  485. )
  486. monkeypatch.setattr(
  487. protocol_refund_record.rr_svc, "create_refund_record", forbidden_call
  488. )
  489. monkeypatch.setattr(
  490. sample_cert_insurance.cert_crud, "upsert_handle", forbidden_call
  491. )
  492. monkeypatch.setattr(sample_storage_cert_qc.svc, "upsert", forbidden_call)
  493. app = FastAPI()
  494. for router in (
  495. protocol_sample_payment.router,
  496. protocol_refund_record.router,
  497. sample_cert_insurance.router,
  498. sample_storage_cert_qc.router,
  499. ):
  500. app.include_router(router)
  501. user = {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
  502. def override_db():
  503. yield db
  504. app.dependency_overrides[get_current_user] = lambda: user
  505. app.dependency_overrides[get_db] = override_db
  506. requests = [
  507. ("post", "/web/admin/payment-transfers/t1/receipt-confirm", {}),
  508. (
  509. "post",
  510. "/web/admin/collection-protocols/p1/refund-records",
  511. {"refundAmount": 1},
  512. ),
  513. ("put", "/web/admin/sample-storage-certs/S1", {}),
  514. ("put", "/web/admin/sample-storage-cert-qc/S1", {"payload": {}}),
  515. ]
  516. client = TestClient(app)
  517. for method, url, body in requests:
  518. response = client.request(method, url, json=body)
  519. assert response.status_code == 403, (method, url, response.text)
  520. assert called == []
  521. @pytest.mark.parametrize("kind", ["storage_cert", "insurance", "all"])
  522. def test_market_only_user_cannot_use_sensitive_quick_register_branches(
  523. monkeypatch, kind
  524. ):
  525. from app.api import protocol_sample_collection
  526. db = _session()
  527. user = _permission_user(db)
  528. called = []
  529. def forbidden_call(*args, **kwargs):
  530. called.append((args, kwargs))
  531. return {"success": 1, "fail": 0}
  532. monkeypatch.setattr(
  533. protocol_sample_collection.quick_svc,
  534. "batch_handle_storage_certs",
  535. forbidden_call,
  536. )
  537. monkeypatch.setattr(
  538. protocol_sample_collection.quick_svc,
  539. "batch_handle_insurance",
  540. forbidden_call,
  541. )
  542. body = protocol_sample_collection.QuickRegisterBody(
  543. kind=kind,
  544. receptionSource="cell_storage",
  545. items=[{"sampleCode": "S1", "protocolId": "P1"}],
  546. handleTime="2026-10-14 10:00:00",
  547. )
  548. with pytest.raises(HTTPException) as exc_info:
  549. asyncio.run(
  550. protocol_sample_collection.quick_register_sample_info(
  551. body=body,
  552. db=db,
  553. user=user,
  554. )
  555. )
  556. assert exc_info.value.status_code == 403
  557. assert called == []
  558. @pytest.mark.parametrize(
  559. "payment_detail",
  560. [
  561. {"installmentNo": 1, "payStatus": "yjf"},
  562. {"installmentNo": 1, "PayStatus": "yjf"},
  563. {"installmentNo": 1, "financeConfirmAttachments": "secret.pdf"},
  564. {
  565. "installmentNo": 1,
  566. "financeConfirmAttachmentFiles": [{"url": "secret.pdf"}],
  567. },
  568. {"installmentNo": 1, "financeRemark": "confirmed"},
  569. {"installmentNo": 1, "refundAmount": 100},
  570. {"installmentNo": 1, "refundRemark": "refund"},
  571. {"installmentNo": 1, "refundAttachments": "refund.pdf"},
  572. {
  573. "installmentNo": 1,
  574. "refundAttachmentFiles": [{"url": "refund.pdf"}],
  575. },
  576. {
  577. "installmentNo": 1,
  578. "refundFinanceConfirmAttachments": "finance.pdf",
  579. },
  580. {
  581. "installmentNo": 1,
  582. "refundFinanceConfirmAttachmentFiles": [{"url": "finance.pdf"}],
  583. },
  584. {"installmentNo": 1, "refundFinanceRemark": "confirmed"},
  585. ],
  586. )
  587. def test_protocol_create_rejects_nested_finance_and_refund_fields_before_crud(
  588. monkeypatch, payment_detail
  589. ):
  590. from app.api import collection_protocol
  591. called = []
  592. def forbidden_call(*args, **kwargs):
  593. called.append((args, kwargs))
  594. raise AssertionError("protocol CRUD must not be called")
  595. monkeypatch.setattr(
  596. collection_protocol.cp_svc,
  597. "create_collection_protocol",
  598. forbidden_call,
  599. )
  600. body = collection_protocol.ProtocolCreateBody(
  601. paymentModelVersion=1,
  602. paymentDetails=[payment_detail],
  603. )
  604. with pytest.raises(HTTPException) as exc_info:
  605. asyncio.run(
  606. collection_protocol.create_collection_protocol(
  607. body=body,
  608. user={"user_id": "u1", "role": "admin"},
  609. db=None,
  610. )
  611. )
  612. assert exc_info.value.status_code == 400
  613. assert called == []
  614. def test_protocol_update_rejects_snake_case_nested_sensitive_fields_before_crud(
  615. monkeypatch,
  616. ):
  617. from app.api import collection_protocol
  618. called = []
  619. def forbidden_call(*args, **kwargs):
  620. called.append((args, kwargs))
  621. raise AssertionError("protocol CRUD must not be called")
  622. monkeypatch.setattr(
  623. collection_protocol.cp_svc,
  624. "update_collection_protocol",
  625. forbidden_call,
  626. )
  627. body = collection_protocol.ProtocolUpdateBody(
  628. payment_details=[
  629. {
  630. "installment_no": 1,
  631. "finance_confirm_attachment_files": [{"url": "secret.pdf"}],
  632. }
  633. ]
  634. )
  635. with pytest.raises(HTTPException) as exc_info:
  636. asyncio.run(
  637. collection_protocol.update_collection_protocol(
  638. protocol_id="p1",
  639. body=body,
  640. user={"user_id": "u1", "role": "admin"},
  641. db=None,
  642. )
  643. )
  644. assert exc_info.value.status_code == 400
  645. assert called == []
  646. def test_payment_detail_sensitive_field_inventory_matches_crud_write_fields():
  647. from app.api.collection_protocol import _PAYMENT_DETAIL_SENSITIVE_FIELDS
  648. assert _PAYMENT_DETAIL_SENSITIVE_FIELDS == {
  649. "pay_status",
  650. "payStatus",
  651. "finance_confirm_attachments",
  652. "financeConfirmAttachments",
  653. "finance_confirm_attachment_files",
  654. "financeConfirmAttachmentFiles",
  655. "finance_remark",
  656. "financeRemark",
  657. "refund_amount",
  658. "refundAmount",
  659. "refund_remark",
  660. "refundRemark",
  661. "refund_attachments",
  662. "refundAttachments",
  663. "refund_attachment_files",
  664. "refundAttachmentFiles",
  665. "refund_finance_confirm_attachments",
  666. "refundFinanceConfirmAttachments",
  667. "refund_finance_confirm_attachment_files",
  668. "refundFinanceConfirmAttachmentFiles",
  669. "refund_finance_remark",
  670. "refundFinanceRemark",
  671. }