test_b6_bindings_permissions.py 30 KB

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