test_b6_bindings_permissions.py 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046
  1. from __future__ import annotations
  2. from concurrent.futures import ThreadPoolExecutor
  3. from pathlib import Path
  4. import pytest
  5. def _headers(token: str, *, origin: bool = False) -> dict[str, str]:
  6. headers = {"Authorization": f"Bearer {token}"}
  7. if origin:
  8. headers["Origin"] = "http://127.0.0.1:9346"
  9. return headers
  10. def _ids(app) -> dict[str, int]:
  11. return app.config["B4_IDS"]
  12. def _permission(subject_type: str, subject_id: int, **actions):
  13. values = {
  14. "canView": True,
  15. "canDownload": True,
  16. "canEdit": False,
  17. "canManagePermission": False,
  18. "canDelete": False,
  19. }
  20. values.update(actions)
  21. return {
  22. "subjectType": subject_type,
  23. "subjectId": str(subject_id),
  24. **values,
  25. }
  26. def _save(client, token, document_id, version, visibility, entries):
  27. return client.put(
  28. f"/api/v1/documents/{document_id}/permissions",
  29. json={
  30. "visibilityType": visibility,
  31. "documentRowVersion": version,
  32. "entries": entries,
  33. },
  34. headers=_headers(token),
  35. )
  36. def test_bind_deduplicates_preserves_order_and_is_idempotent(
  37. b6_app, b6_client, login_token
  38. ):
  39. from dms.extensions import db
  40. from dms.models import AttachmentBinding, AuditLog, Document
  41. ids = _ids(b6_app)
  42. requested = [
  43. str(ids["attachment_two"]),
  44. str(ids["attachment_one"]),
  45. str(ids["attachment_two"]),
  46. ]
  47. response = b6_client.post(
  48. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  49. json={"attachmentIds": requested, "mainPlanRowVersion": 0},
  50. headers=_headers(login_token),
  51. )
  52. assert response.status_code == 200
  53. assert response.get_json()["data"] == {
  54. "createdCount": 2,
  55. "existingCount": 0,
  56. "attachmentCount": 2,
  57. "mainPlanRowVersion": 1,
  58. }
  59. with b6_app.app_context():
  60. rows = db.session.scalars(
  61. db.select(AttachmentBinding)
  62. .where(
  63. AttachmentBinding.main_document_id == ids["main_custom"],
  64. AttachmentBinding.is_deleted.is_(False),
  65. )
  66. .order_by(AttachmentBinding.sort_no)
  67. ).all()
  68. assert [row.attachment_document_id for row in rows] == [
  69. ids["attachment_two"],
  70. ids["attachment_one"],
  71. ]
  72. assert [row.sort_no for row in rows] == [10, 20]
  73. assert db.session.get(Document, ids["main_custom"]).attachment_count == 2
  74. assert (
  75. db.session.scalar(
  76. db.select(db.func.count(AuditLog.id)).where(
  77. AuditLog.action_type == "BIND_ATTACHMENT"
  78. )
  79. )
  80. == 1
  81. )
  82. again = b6_client.post(
  83. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  84. json={"attachmentIds": requested, "mainPlanRowVersion": 1},
  85. headers=_headers(login_token),
  86. )
  87. assert again.status_code == 200
  88. assert again.get_json()["data"] == {
  89. "createdCount": 0,
  90. "existingCount": 2,
  91. "attachmentCount": 2,
  92. "mainPlanRowVersion": 1,
  93. }
  94. def test_bind_counts_existing_and_appends_without_renumbering(
  95. b6_app, b6_client, login_token
  96. ):
  97. ids = _ids(b6_app)
  98. response = b6_client.post(
  99. f"/api/v1/main-plans/{ids['main_all']}/attachments/bind",
  100. json={
  101. "attachmentIds": [
  102. str(ids["attachment_one"]),
  103. str(ids["attachment_two"]),
  104. ],
  105. "mainPlanRowVersion": 0,
  106. },
  107. headers=_headers(login_token),
  108. )
  109. assert response.status_code == 200
  110. assert response.get_json()["data"] == {
  111. "createdCount": 0,
  112. "existingCount": 2,
  113. "attachmentCount": 2,
  114. "mainPlanRowVersion": 0,
  115. }
  116. @pytest.mark.parametrize(
  117. "payload",
  118. [
  119. {"attachmentIds": [], "mainPlanRowVersion": 0},
  120. {"attachmentIds": [1], "mainPlanRowVersion": 0},
  121. {"attachmentIds": ["1"], "mainPlanRowVersion": True},
  122. {"attachmentIds": ["1"], "mainPlanRowVersion": -1},
  123. {"attachmentIds": ["1"], "mainPlanRowVersion": 0, "extra": 1},
  124. {"attachmentIds": "1", "mainPlanRowVersion": 0},
  125. ],
  126. )
  127. def test_bind_rejects_invalid_payloads(
  128. payload, b6_app, b6_client, login_token
  129. ):
  130. response = b6_client.post(
  131. f"/api/v1/main-plans/{_ids(b6_app)['main_custom']}/attachments/bind",
  132. json=payload,
  133. headers=_headers(login_token),
  134. )
  135. assert response.status_code == 400
  136. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  137. @pytest.mark.parametrize("username", ["user"])
  138. def test_binding_management_requires_admin(
  139. username, b6_app, b6_client, token_for
  140. ):
  141. ids = _ids(b6_app)
  142. token = token_for(username)
  143. response = b6_client.post(
  144. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  145. json={
  146. "attachmentIds": [str(ids["attachment_one"])],
  147. "mainPlanRowVersion": 0,
  148. },
  149. headers=_headers(token),
  150. )
  151. assert response.status_code == 403
  152. assert response.get_json()["code"] == "FORBIDDEN"
  153. def test_bind_invalid_attachment_rolls_back_everything(
  154. b6_app, b6_client, login_token
  155. ):
  156. from dms.extensions import db
  157. from dms.models import AttachmentBinding, Document
  158. ids = _ids(b6_app)
  159. response = b6_client.post(
  160. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  161. json={
  162. "attachmentIds": [str(ids["attachment_one"]), "999999999"],
  163. "mainPlanRowVersion": 0,
  164. },
  165. headers=_headers(login_token),
  166. )
  167. assert response.status_code == 404
  168. with b6_app.app_context():
  169. assert (
  170. db.session.scalar(
  171. db.select(db.func.count(AttachmentBinding.id)).where(
  172. AttachmentBinding.main_document_id == ids["main_custom"],
  173. AttachmentBinding.is_deleted.is_(False),
  174. )
  175. )
  176. == 0
  177. )
  178. main = db.session.get(Document, ids["main_custom"])
  179. assert (main.attachment_count, main.row_version) == (0, 0)
  180. def test_bind_rejects_wrong_document_types_and_version_conflict(
  181. b6_app, b6_client, login_token
  182. ):
  183. ids = _ids(b6_app)
  184. wrong_main = b6_client.post(
  185. f"/api/v1/main-plans/{ids['sub']}/attachments/bind",
  186. json={
  187. "attachmentIds": [str(ids["attachment_one"])],
  188. "mainPlanRowVersion": 0,
  189. },
  190. headers=_headers(login_token),
  191. )
  192. assert wrong_main.status_code == 404
  193. wrong_attachment = b6_client.post(
  194. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  195. json={
  196. "attachmentIds": [str(ids["main_all"])],
  197. "mainPlanRowVersion": 0,
  198. },
  199. headers=_headers(login_token),
  200. )
  201. assert wrong_attachment.status_code == 404
  202. conflict = b6_client.post(
  203. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  204. json={
  205. "attachmentIds": [str(ids["attachment_one"])],
  206. "mainPlanRowVersion": 9,
  207. },
  208. headers=_headers(login_token),
  209. )
  210. assert conflict.status_code == 409
  211. assert conflict.get_json()["details"]["currentRowVersion"] == 0
  212. def test_unbind_is_logical_keeps_attachment_and_updates_both_queries(
  213. b6_app, b6_client, login_token
  214. ):
  215. from dms.extensions import db
  216. from dms.models import AttachmentBinding, AuditLog, Document
  217. ids = _ids(b6_app)
  218. response = b6_client.delete(
  219. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  220. "?mainPlanRowVersion=0",
  221. headers=_headers(login_token),
  222. )
  223. assert response.status_code == 200
  224. assert response.get_json()["data"] == {
  225. "attachmentCount": 1,
  226. "mainPlanRowVersion": 1,
  227. }
  228. forward = b6_client.get(
  229. f"/api/v1/main-plans/{ids['main_all']}/attachments",
  230. headers=_headers(login_token),
  231. )
  232. assert ids["attachment_one"] not in {
  233. int(item["id"]) for item in forward.get_json()["data"]["items"]
  234. }
  235. reverse = b6_client.get(
  236. f"/api/v1/attachments/{ids['attachment_one']}/main-plans",
  237. headers=_headers(login_token),
  238. )
  239. assert ids["main_all"] not in {
  240. int(item["id"]) for item in reverse.get_json()["data"]["items"]
  241. }
  242. with b6_app.app_context():
  243. binding = db.session.scalar(
  244. db.select(AttachmentBinding).where(
  245. AttachmentBinding.main_document_id == ids["main_all"],
  246. AttachmentBinding.attachment_document_id
  247. == ids["attachment_one"],
  248. )
  249. )
  250. assert binding.is_deleted is True
  251. attachment = db.session.get(Document, ids["attachment_one"])
  252. assert attachment is not None and not attachment.is_deleted
  253. audit = db.session.scalar(
  254. db.select(AuditLog).where(
  255. AuditLog.action_type == "UNBIND_ATTACHMENT"
  256. )
  257. )
  258. assert audit is not None
  259. assert audit.target_id == binding.id
  260. assert audit.operation_detail["attachmentCount"] == 1
  261. assert audit.operation_detail["mainPlanRowVersion"] == 1
  262. def test_unbind_missing_relation_and_second_unbind_return_404(
  263. b6_app, b6_client, login_token
  264. ):
  265. ids = _ids(b6_app)
  266. missing = b6_client.delete(
  267. f"/api/v1/main-plans/{ids['main_custom']}/attachments/{ids['attachment_one']}"
  268. "?mainPlanRowVersion=0",
  269. headers=_headers(login_token),
  270. )
  271. assert missing.status_code == 404
  272. first = b6_client.delete(
  273. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  274. "?mainPlanRowVersion=0",
  275. headers=_headers(login_token),
  276. )
  277. assert first.status_code == 200
  278. second = b6_client.delete(
  279. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  280. "?mainPlanRowVersion=1",
  281. headers=_headers(login_token),
  282. )
  283. assert second.status_code == 404
  284. assert second.get_json()["code"] == "RESOURCE_NOT_FOUND"
  285. def test_unbind_then_rebind_creates_new_binding_and_appends_sort(
  286. b6_app, b6_client, login_token
  287. ):
  288. from dms.extensions import db
  289. from dms.models import AttachmentBinding
  290. ids = _ids(b6_app)
  291. b6_client.delete(
  292. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_two']}"
  293. "?mainPlanRowVersion=0",
  294. headers=_headers(login_token),
  295. )
  296. response = b6_client.post(
  297. f"/api/v1/main-plans/{ids['main_all']}/attachments/bind",
  298. json={
  299. "attachmentIds": [str(ids["attachment_two"])],
  300. "mainPlanRowVersion": 1,
  301. },
  302. headers=_headers(login_token),
  303. )
  304. assert response.status_code == 200
  305. with b6_app.app_context():
  306. rows = db.session.scalars(
  307. db.select(AttachmentBinding)
  308. .where(
  309. AttachmentBinding.main_document_id == ids["main_all"],
  310. AttachmentBinding.attachment_document_id
  311. == ids["attachment_two"],
  312. )
  313. .order_by(AttachmentBinding.id)
  314. ).all()
  315. assert len(rows) == 2
  316. assert rows[0].is_deleted is True
  317. assert rows[1].is_deleted is False
  318. assert rows[1].sort_no == 30
  319. def test_binding_audit_failure_rolls_back(
  320. monkeypatch, b6_app, b6_client, login_token
  321. ):
  322. from dms.extensions import db
  323. from dms.models import AttachmentBinding, Document
  324. import dms.services.attachment_binding_service as service
  325. ids = _ids(b6_app)
  326. monkeypatch.setattr(
  327. service, "business_audit", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError())
  328. )
  329. response = b6_client.post(
  330. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  331. json={
  332. "attachmentIds": [str(ids["attachment_one"])],
  333. "mainPlanRowVersion": 0,
  334. },
  335. headers=_headers(login_token),
  336. )
  337. assert response.status_code == 500
  338. with b6_app.app_context():
  339. assert (
  340. db.session.scalar(
  341. db.select(db.func.count(AttachmentBinding.id)).where(
  342. AttachmentBinding.main_document_id == ids["main_custom"],
  343. AttachmentBinding.is_deleted.is_(False),
  344. )
  345. )
  346. == 0
  347. )
  348. assert db.session.get(Document, ids["main_custom"]).row_version == 0
  349. @pytest.mark.parametrize("username", ["user"])
  350. def test_unbind_requires_admin(username, b6_app, b6_client, token_for):
  351. ids = _ids(b6_app)
  352. response = b6_client.delete(
  353. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  354. "?mainPlanRowVersion=0",
  355. headers=_headers(token_for(username)),
  356. )
  357. assert response.status_code == 403
  358. @pytest.mark.parametrize(
  359. "query",
  360. [
  361. "",
  362. "?mainPlanRowVersion=-1",
  363. "?mainPlanRowVersion=true",
  364. "?mainPlanRowVersion=0&extra=1",
  365. ],
  366. )
  367. def test_unbind_rejects_invalid_version_query(
  368. query, b6_app, b6_client, login_token
  369. ):
  370. ids = _ids(b6_app)
  371. response = b6_client.delete(
  372. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  373. f"{query}",
  374. headers=_headers(login_token),
  375. )
  376. assert response.status_code == 400
  377. def test_unbind_version_conflict_and_audit_failure_rollback(
  378. monkeypatch, b6_app, b6_client, login_token
  379. ):
  380. from dms.extensions import db
  381. from dms.models import AttachmentBinding, Document
  382. import dms.services.attachment_binding_service as service
  383. ids = _ids(b6_app)
  384. conflict = b6_client.delete(
  385. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  386. "?mainPlanRowVersion=8",
  387. headers=_headers(login_token),
  388. )
  389. assert conflict.status_code == 409
  390. assert conflict.get_json()["details"]["currentRowVersion"] == 0
  391. monkeypatch.setattr(
  392. service, "business_audit", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError())
  393. )
  394. failed = b6_client.delete(
  395. f"/api/v1/main-plans/{ids['main_all']}/attachments/{ids['attachment_one']}"
  396. "?mainPlanRowVersion=0",
  397. headers=_headers(login_token),
  398. )
  399. assert failed.status_code == 500
  400. with b6_app.app_context():
  401. binding = db.session.scalar(
  402. db.select(AttachmentBinding).where(
  403. AttachmentBinding.main_document_id == ids["main_all"],
  404. AttachmentBinding.attachment_document_id
  405. == ids["attachment_one"],
  406. AttachmentBinding.is_deleted.is_(False),
  407. )
  408. )
  409. assert binding is not None
  410. main = db.session.get(Document, ids["main_all"])
  411. assert (main.attachment_count, main.row_version) == (2, 0)
  412. def test_permission_get_main_sub_inheritance_and_attachment_error(
  413. b6_app, b6_client, login_token
  414. ):
  415. ids = _ids(b6_app)
  416. main = b6_client.get(
  417. f"/api/v1/documents/{ids['main_custom']}/permissions",
  418. headers=_headers(login_token),
  419. )
  420. assert main.status_code == 200
  421. main_data = main.get_json()["data"]
  422. assert main_data["documentId"] == str(ids["main_custom"])
  423. assert main_data["sourceDocumentId"] == str(ids["main_custom"])
  424. assert main_data["inherited"] is False
  425. assert len(main_data["entries"]) == 1
  426. assert set(main_data["entries"][0]) == {
  427. "id",
  428. "subjectType",
  429. "subjectId",
  430. "subjectName",
  431. *{
  432. "canView",
  433. "canDownload",
  434. "canEdit",
  435. "canManagePermission",
  436. "canDelete",
  437. },
  438. }
  439. sub = b6_client.get(
  440. f"/api/v1/documents/{ids['sub']}/permissions",
  441. headers=_headers(login_token),
  442. )
  443. sub_data = sub.get_json()["data"]
  444. assert sub_data["documentId"] == str(ids["sub"])
  445. assert sub_data["sourceDocumentId"] == str(ids["main_custom"])
  446. assert sub_data["inherited"] is True
  447. assert sub_data["entries"] == main_data["entries"]
  448. attachment = b6_client.get(
  449. f"/api/v1/documents/{ids['attachment_one']}/permissions",
  450. headers=_headers(login_token),
  451. )
  452. assert attachment.status_code == 400
  453. assert attachment.get_json()["code"] == "ATTACHMENT_HAS_NO_ACL"
  454. @pytest.mark.parametrize("username", ["user"])
  455. def test_permission_management_requires_admin(
  456. username, b6_app, b6_client, token_for
  457. ):
  458. response = b6_client.get(
  459. f"/api/v1/documents/{_ids(b6_app)['main_custom']}/permissions",
  460. headers=_headers(token_for(username)),
  461. )
  462. assert response.status_code == 403
  463. def test_permission_save_custom_mixed_and_stable_sort(
  464. b6_app, b6_client, login_token
  465. ):
  466. from dms.extensions import db
  467. from dms.models import Organization, User
  468. ids = _ids(b6_app)
  469. with b6_app.app_context():
  470. root = db.session.scalar(
  471. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  472. )
  473. user = db.session.scalar(db.select(User).where(User.username == "user"))
  474. assert root and user
  475. root_id, user_id = root.id, user.id
  476. response = _save(
  477. b6_client,
  478. login_token,
  479. ids["main_all"],
  480. 0,
  481. "CUSTOM",
  482. [
  483. _permission("USER", user_id, canEdit=True),
  484. _permission("ORG", root_id, canDownload=False),
  485. ],
  486. )
  487. assert response.status_code == 200
  488. data = response.get_json()["data"]
  489. assert data["documentRowVersion"] == 1
  490. assert data["visibilityType"] == "CUSTOM"
  491. assert [(entry["subjectType"], entry["subjectId"]) for entry in data["entries"]] == [
  492. ("ORG", str(root_id)),
  493. ("USER", str(user_id)),
  494. ]
  495. @pytest.mark.parametrize(
  496. ("visibility", "entries", "code"),
  497. [
  498. ("ALL_AUTHENTICATED", [{"bad": True}], "INVALID_ARGUMENT"),
  499. ("CUSTOM", [{"bad": True}], "INVALID_ARGUMENT"),
  500. ],
  501. )
  502. def test_permission_save_rejects_malformed_entries(
  503. visibility, entries, code, b6_app, b6_client, login_token
  504. ):
  505. response = _save(
  506. b6_client,
  507. login_token,
  508. _ids(b6_app)["main_all"],
  509. 0,
  510. visibility,
  511. entries,
  512. )
  513. assert response.status_code == 400
  514. assert response.get_json()["code"] == code
  515. @pytest.mark.parametrize("invalid_boolean", [0, 1, "true", None])
  516. def test_permission_actions_are_strict_booleans(
  517. invalid_boolean, b6_app, b6_client, login_token
  518. ):
  519. from dms.extensions import db
  520. from dms.models import Organization
  521. with b6_app.app_context():
  522. root_id = db.session.scalar(
  523. db.select(Organization.id).where(Organization.org_code == "ORG_ROOT")
  524. )
  525. entry = _permission("ORG", root_id)
  526. entry["canView"] = invalid_boolean
  527. response = _save(
  528. b6_client,
  529. login_token,
  530. _ids(b6_app)["main_all"],
  531. 0,
  532. "CUSTOM",
  533. [entry],
  534. )
  535. assert response.status_code == 400
  536. def test_permission_duplicate_subject_and_visibility_rules(
  537. b6_app, b6_client, login_token
  538. ):
  539. from dms.extensions import db
  540. from dms.models import Organization, User
  541. with b6_app.app_context():
  542. org_id = db.session.scalar(
  543. db.select(Organization.id).where(Organization.org_code == "ORG_ROOT")
  544. )
  545. user_id = db.session.scalar(
  546. db.select(User.id).where(User.username == "user")
  547. )
  548. duplicate = _save(
  549. b6_client,
  550. login_token,
  551. _ids(b6_app)["main_all"],
  552. 0,
  553. "CUSTOM",
  554. [_permission("ORG", org_id), _permission("ORG", org_id)],
  555. )
  556. assert duplicate.status_code == 400
  557. assert duplicate.get_json()["code"] == "DUPLICATE_PERMISSION_SUBJECT"
  558. all_nonempty = _save(
  559. b6_client,
  560. login_token,
  561. _ids(b6_app)["main_all"],
  562. 0,
  563. "ALL_AUTHENTICATED",
  564. [_permission("ORG", org_id)],
  565. )
  566. assert all_nonempty.status_code == 400
  567. org_with_user = _save(
  568. b6_client,
  569. login_token,
  570. _ids(b6_app)["main_all"],
  571. 0,
  572. "ORGANIZATION",
  573. [_permission("USER", user_id)],
  574. )
  575. assert org_with_user.status_code == 400
  576. empty_organization = _save(
  577. b6_client,
  578. login_token,
  579. _ids(b6_app)["main_all"],
  580. 0,
  581. "ORGANIZATION",
  582. [],
  583. )
  584. assert empty_organization.status_code == 400
  585. def test_permission_save_rejects_invalid_subject_and_unknown_fields(
  586. b6_app, b6_client, login_token
  587. ):
  588. ids = _ids(b6_app)
  589. invalid = _save(
  590. b6_client,
  591. login_token,
  592. ids["main_all"],
  593. 0,
  594. "CUSTOM",
  595. [_permission("ORG", 99999999)],
  596. )
  597. assert invalid.status_code == 404
  598. entry = _permission("ORG", 1)
  599. entry["subjectName"] = "客户端伪造"
  600. unknown = _save(
  601. b6_client, login_token, ids["main_all"], 0, "CUSTOM", [entry]
  602. )
  603. assert unknown.status_code == 400
  604. @pytest.mark.parametrize(
  605. ("subject_type", "lookup_model", "lookup_field", "lookup_value"),
  606. [
  607. ("ORG", "Organization", "org_code", "ORG_DISABLED"),
  608. ("USER", "User", "username", "disabled"),
  609. ],
  610. )
  611. def test_permission_rejects_disabled_subjects(
  612. subject_type,
  613. lookup_model,
  614. lookup_field,
  615. lookup_value,
  616. b6_app,
  617. b6_client,
  618. login_token,
  619. ):
  620. from dms.extensions import db
  621. from dms.models import Organization, User
  622. model = Organization if lookup_model == "Organization" else User
  623. with b6_app.app_context():
  624. subject_id = db.session.scalar(
  625. db.select(model.id).where(
  626. getattr(model, lookup_field) == lookup_value
  627. )
  628. )
  629. response = _save(
  630. b6_client,
  631. login_token,
  632. _ids(b6_app)["main_all"],
  633. 0,
  634. "CUSTOM",
  635. [_permission(subject_type, subject_id)],
  636. )
  637. assert response.status_code == 404
  638. def test_permission_sub_save_conflict_attachment_save_error_and_version_conflict(
  639. b6_app, b6_client, login_token
  640. ):
  641. ids = _ids(b6_app)
  642. sub = _save(
  643. b6_client, login_token, ids["sub"], 0, "ALL_AUTHENTICATED", []
  644. )
  645. assert sub.status_code == 409
  646. assert sub.get_json()["code"] == "SUB_PLAN_PERMISSION_INHERITED"
  647. attachment = _save(
  648. b6_client,
  649. login_token,
  650. ids["attachment_one"],
  651. 0,
  652. "ALL_AUTHENTICATED",
  653. [],
  654. )
  655. assert attachment.status_code == 400
  656. assert attachment.get_json()["code"] == "ATTACHMENT_HAS_NO_ACL"
  657. conflict = _save(
  658. b6_client, login_token, ids["main_all"], 99, "ALL_AUTHENTICATED", []
  659. )
  660. assert conflict.status_code == 409
  661. assert conflict.get_json()["details"]["currentRowVersion"] == 0
  662. def test_permission_full_set_logical_delete_and_readd_creates_new_record(
  663. b6_app, b6_client, login_token
  664. ):
  665. from dms.extensions import db
  666. from dms.models import Permission, User
  667. ids = _ids(b6_app)
  668. with b6_app.app_context():
  669. user_id = db.session.scalar(
  670. db.select(User.id).where(User.username == "user")
  671. )
  672. old_id = db.session.scalar(
  673. db.select(Permission.id).where(
  674. Permission.document_id == ids["main_custom"],
  675. Permission.subject_type == "USER",
  676. Permission.subject_id == user_id,
  677. Permission.is_deleted.is_(False),
  678. )
  679. )
  680. removed = _save(
  681. b6_client,
  682. login_token,
  683. ids["main_custom"],
  684. 0,
  685. "ALL_AUTHENTICATED",
  686. [],
  687. )
  688. assert removed.status_code == 200
  689. assert removed.get_json()["data"]["entries"] == []
  690. readded = _save(
  691. b6_client,
  692. login_token,
  693. ids["main_custom"],
  694. 1,
  695. "CUSTOM",
  696. [_permission("USER", user_id)],
  697. )
  698. assert readded.status_code == 200
  699. new_id = int(readded.get_json()["data"]["entries"][0]["id"])
  700. assert new_id != old_id
  701. with b6_app.app_context():
  702. old = db.session.get(Permission, old_id)
  703. assert old.is_deleted is True
  704. def test_permission_change_audit_contains_diff_and_actor_snapshots(
  705. b6_app, b6_client, login_token
  706. ):
  707. from dms.extensions import db
  708. from dms.models import AuditLog, Organization
  709. ids = _ids(b6_app)
  710. with b6_app.app_context():
  711. root_id = db.session.scalar(
  712. db.select(Organization.id).where(Organization.org_code == "ORG_ROOT")
  713. )
  714. response = _save(
  715. b6_client,
  716. login_token,
  717. ids["main_all"],
  718. 0,
  719. "ORGANIZATION",
  720. [_permission("ORG", root_id)],
  721. )
  722. assert response.status_code == 200
  723. with b6_app.app_context():
  724. audit = db.session.scalar(
  725. db.select(AuditLog).where(
  726. AuditLog.action_type == "CHANGE_PERMISSION",
  727. AuditLog.target_id == ids["main_all"],
  728. )
  729. )
  730. assert audit is not None
  731. assert audit.username == "admin"
  732. assert audit.real_name == "系统管理员"
  733. assert audit.organization_name == "机关"
  734. assert audit.request_id
  735. assert audit.operation_detail["visibilityTypeBefore"] == (
  736. "ALL_AUTHENTICATED"
  737. )
  738. assert audit.operation_detail["visibilityTypeAfter"] == "ORGANIZATION"
  739. assert audit.operation_detail["added"] == [
  740. {"subjectType": "ORG", "subjectId": str(root_id)}
  741. ]
  742. assert audit.operation_detail["documentRowVersion"] == 1
  743. def test_permission_audit_failure_rolls_back(
  744. monkeypatch, b6_app, b6_client, login_token
  745. ):
  746. from dms.extensions import db
  747. from dms.models import Document, Permission
  748. import dms.services.permission_service as service
  749. ids = _ids(b6_app)
  750. monkeypatch.setattr(
  751. service, "business_audit", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError())
  752. )
  753. response = _save(
  754. b6_client,
  755. login_token,
  756. ids["main_custom"],
  757. 0,
  758. "ALL_AUTHENTICATED",
  759. [],
  760. )
  761. assert response.status_code == 500
  762. with b6_app.app_context():
  763. document = db.session.get(Document, ids["main_custom"])
  764. assert document.row_version == 0
  765. assert document.visibility_type == "CUSTOM"
  766. assert (
  767. db.session.scalar(
  768. db.select(db.func.count(Permission.id)).where(
  769. Permission.document_id == ids["main_custom"],
  770. Permission.is_deleted.is_(False),
  771. )
  772. )
  773. == 1
  774. )
  775. def test_permission_authorization_linkage_and_dynamic_sub_inheritance(
  776. b6_app, b6_client, login_token, token_for
  777. ):
  778. from dms.extensions import db
  779. from dms.models import Document, Organization
  780. ids = _ids(b6_app)
  781. user_token = token_for("user")
  782. with b6_app.app_context():
  783. ops_id = db.session.scalar(
  784. db.select(Organization.id).where(Organization.org_code == "ORG_OPS")
  785. )
  786. main = db.session.get(Document, ids["main_custom"])
  787. relative = "original/b6/linkage.pdf"
  788. path = Path(b6_app.config["DMS_STORAGE_ROOT"]) / relative
  789. path.parent.mkdir(parents=True, exist_ok=True)
  790. path.write_bytes(b"%PDF-1.4\nB6\n%%EOF")
  791. main.file_relative_path = relative
  792. main.original_file_name = "B6联动.pdf"
  793. main.file_extension = "pdf"
  794. main.mime_type = "application/pdf"
  795. sub = db.session.get(Document, ids["sub"])
  796. sub.file_relative_path = relative
  797. sub.original_file_name = "B6子案.pdf"
  798. sub.file_extension = "pdf"
  799. sub.mime_type = "application/pdf"
  800. db.session.commit()
  801. denied = _save(
  802. b6_client,
  803. login_token,
  804. ids["main_custom"],
  805. 0,
  806. "CUSTOM",
  807. [_permission("ORG", ops_id, canView=True, canDownload=False)],
  808. )
  809. assert denied.status_code == 200
  810. detail = b6_client.get(
  811. f"/api/v1/documents/{ids['main_custom']}",
  812. headers=_headers(user_token),
  813. )
  814. assert detail.status_code == 200
  815. assert detail.get_json()["data"]["allowedActions"] == ["VIEW"]
  816. download = b6_client.get(
  817. f"/api/v1/documents/{ids['main_custom']}/download",
  818. headers=_headers(user_token),
  819. )
  820. assert download.status_code == 403
  821. sub_permissions = b6_client.get(
  822. f"/api/v1/documents/{ids['sub']}/permissions",
  823. headers=_headers(login_token),
  824. )
  825. assert sub_permissions.get_json()["data"]["documentRowVersion"] == 1
  826. allowed = _save(
  827. b6_client,
  828. login_token,
  829. ids["main_custom"],
  830. 1,
  831. "ORGANIZATION",
  832. [_permission("ORG", ops_id, canView=True, canDownload=True)],
  833. )
  834. assert allowed.status_code == 200
  835. download = b6_client.get(
  836. f"/api/v1/documents/{ids['sub']}/download",
  837. headers=_headers(user_token),
  838. )
  839. assert download.status_code == 200
  840. def test_all_authenticated_plan_and_mounted_attachment_are_visible_to_user(
  841. b6_app, b6_client, login_token, token_for
  842. ):
  843. ids = _ids(b6_app)
  844. saved = _save(
  845. b6_client,
  846. login_token,
  847. ids["main_all"],
  848. 0,
  849. "ALL_AUTHENTICATED",
  850. [],
  851. )
  852. assert saved.status_code == 200
  853. assert (
  854. b6_client.get(
  855. f"/api/v1/documents/{ids['main_all']}",
  856. headers=_headers(token_for("user")),
  857. ).status_code
  858. == 200
  859. )
  860. assert (
  861. b6_client.get(
  862. f"/api/v1/attachments/{ids['attachment_one']}",
  863. headers=_headers(token_for("user")),
  864. ).status_code
  865. == 200
  866. )
  867. def test_admin_cannot_bypass_security_for_b6(
  868. b6_app, b6_client, login_token
  869. ):
  870. from dms.extensions import db
  871. from dms.models import User
  872. ids = _ids(b6_app)
  873. with b6_app.app_context():
  874. admin = db.session.scalar(db.select(User).where(User.username == "admin"))
  875. admin.security_level = "PUBLIC"
  876. db.session.commit()
  877. permission = b6_client.get(
  878. f"/api/v1/documents/{ids['main_custom']}/permissions",
  879. headers=_headers(login_token),
  880. )
  881. assert permission.status_code == 403
  882. assert permission.get_json()["code"] == "SECURITY_LEVEL_FORBIDDEN"
  883. binding = b6_client.post(
  884. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  885. json={
  886. "attachmentIds": [str(ids["attachment_one"])],
  887. "mainPlanRowVersion": 0,
  888. },
  889. headers=_headers(login_token),
  890. )
  891. assert binding.status_code == 403
  892. def test_b6_cors_request_ids_and_ai_scope(
  893. b6_app, b6_client, login_token
  894. ):
  895. ids = _ids(b6_app)
  896. success = b6_client.get(
  897. f"/api/v1/documents/{ids['main_custom']}/permissions",
  898. headers=_headers(login_token, origin=True),
  899. )
  900. assert success.headers["Access-Control-Expose-Headers"] == (
  901. "Content-Disposition, X-Request-Id"
  902. )
  903. assert success.headers["X-Request-Id"] == success.get_json()["requestId"]
  904. error = b6_client.post(
  905. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  906. json={"attachmentIds": [], "mainPlanRowVersion": 0},
  907. headers=_headers(login_token, origin=True),
  908. )
  909. assert error.headers["Access-Control-Expose-Headers"] == (
  910. "Content-Disposition, X-Request-Id"
  911. )
  912. assert error.headers["X-Request-Id"] == error.get_json()["requestId"]
  913. ai = b6_client.get(
  914. "/api/health", headers={"Origin": "http://127.0.0.1:9346"}
  915. )
  916. assert "Access-Control-Expose-Headers" not in ai.headers
  917. def test_concurrent_same_version_bind_has_one_effect(
  918. b6_app, login_token
  919. ):
  920. ids = _ids(b6_app)
  921. def call():
  922. with b6_app.test_client() as client:
  923. return client.post(
  924. f"/api/v1/main-plans/{ids['main_custom']}/attachments/bind",
  925. json={
  926. "attachmentIds": [str(ids["attachment_one"])],
  927. "mainPlanRowVersion": 0,
  928. },
  929. headers=_headers(login_token),
  930. ).status_code
  931. with ThreadPoolExecutor(max_workers=2) as executor:
  932. statuses = sorted(executor.map(lambda _value: call(), range(2)))
  933. assert statuses == [200, 409]
  934. def test_concurrent_permission_save_does_not_silently_overwrite(
  935. b6_app, login_token
  936. ):
  937. ids = _ids(b6_app)
  938. def call(visibility):
  939. with b6_app.test_client() as client:
  940. return _save(
  941. client,
  942. login_token,
  943. ids["main_all"],
  944. 0,
  945. visibility,
  946. [],
  947. ).status_code
  948. with ThreadPoolExecutor(max_workers=2) as executor:
  949. statuses = sorted(
  950. executor.map(call, ["ALL_AUTHENTICATED", "CUSTOM"])
  951. )
  952. assert statuses == [200, 409]