test_document_move.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. """跨页移动排序:文档 move 与挂载附件 move 的行为契约测试。"""
  2. from __future__ import annotations
  3. import pytest
  4. from dms.extensions import db
  5. from dms.models import AuditLog, AttachmentBinding, Category, Document, User
  6. def _auth(token: str) -> dict[str, str]:
  7. return {"Authorization": f"Bearer {token}"}
  8. def _ids(app):
  9. return app.config["B4_IDS"]
  10. def _category(app, code: str) -> Category:
  11. with app.app_context():
  12. category = db.session.scalar(
  13. db.select(Category).where(Category.category_code == code)
  14. )
  15. assert category is not None
  16. db.session.expunge(category)
  17. return category
  18. def _main_document(name: str, category: Category) -> Document:
  19. return Document(
  20. document_name=name,
  21. document_type="MAIN",
  22. document_status="PUBLISHED",
  23. security_level="INTERNAL",
  24. category_id=category.id,
  25. category_name=category.category_name,
  26. category_path=category.category_path,
  27. original_file_name=f"{name}.docx",
  28. file_relative_path=f"original/MOVE_{name}.docx",
  29. file_extension="docx",
  30. file_hash="b" * 64,
  31. )
  32. def _attachment_document(name: str, attachment_type: str) -> Document:
  33. return Document(
  34. document_name=name,
  35. document_type="ATTACHMENT",
  36. document_status="PUBLISHED",
  37. security_level="PUBLIC",
  38. attachment_type=attachment_type,
  39. original_file_name=f"{name}.pdf",
  40. file_relative_path=f"original/MOVE_{name}.pdf",
  41. file_extension="pdf",
  42. file_hash="c" * 64,
  43. )
  44. def _move(client, document_id, payload, token):
  45. return client.put(
  46. f"/api/v1/documents/{document_id}/move",
  47. json=payload,
  48. headers=_auth(token),
  49. )
  50. def _main_order(app) -> list[int]:
  51. """全量主案按 (sort_no, id) 升序的 id 序列。"""
  52. with app.app_context():
  53. rows = db.session.scalars(
  54. db.select(Document).where(
  55. Document.document_type == "MAIN",
  56. Document.is_deleted.is_(False),
  57. )
  58. ).all()
  59. return sorted(rows, key=lambda item: (item.sort_no, item.id))
  60. def test_move_requires_authentication(b6_client):
  61. response = b6_client.put("/api/v1/documents/1/move", json={"target": "TOP"})
  62. assert response.status_code == 401
  63. @pytest.mark.parametrize(
  64. "payload",
  65. [
  66. {},
  67. {"targetPosition": 1, "target": "TOP"},
  68. {"targetPosition": 0},
  69. {"target": "MIDDLE"},
  70. {"targetPosition": "3"},
  71. {"targetPosition": 1, "unknown": 1},
  72. ],
  73. )
  74. def test_move_rejects_invalid_payload(b6_client, login_token, payload):
  75. response = _move(b6_client, "1", payload, login_token)
  76. assert response.status_code == 400
  77. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  78. def test_move_missing_document_returns_404(b6_client, login_token):
  79. response = _move(b6_client, "999999", {"target": "TOP"}, login_token)
  80. assert response.status_code == 404
  81. assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
  82. def test_move_main_plan_to_top_rewrites_sort_numbers(
  83. b6_app, b6_client, login_token
  84. ):
  85. ids = _ids(b6_app)
  86. # 种子主案 sort_no 全 0:顺序按 id 即 全部/组织/自定义/草稿/绝密/无授权
  87. assert [item.id for item in _main_order(b6_app)].index(ids["main_custom"]) == 2
  88. response = _move(
  89. b6_client, ids["main_custom"], {"target": "TOP"}, login_token
  90. )
  91. assert response.status_code == 200
  92. assert response.get_json()["data"] == {"position": 1, "total": 6}
  93. order = [item.id for item in _main_order(b6_app)]
  94. assert order.index(ids["main_custom"]) == 0
  95. # 其余主案相对顺序保持,且全量按步长10重编号
  96. assert [item.sort_no for item in _main_order(b6_app)] == [
  97. 10, 20, 30, 40, 50, 60,
  98. ]
  99. with b6_app.app_context():
  100. audit = db.session.scalar(
  101. db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
  102. )
  103. assert audit is not None
  104. assert audit.target_id == ids["main_custom"]
  105. detail = audit.operation_detail
  106. assert detail["fromPosition"] == 3
  107. assert detail["toPosition"] == 1
  108. assert detail["scope"] == "GLOBAL"
  109. assert detail["scopeTotal"] == 6
  110. def test_move_clamps_out_of_range_position_to_bottom(
  111. b6_app, b6_client, login_token
  112. ):
  113. ids = _ids(b6_app)
  114. response = _move(
  115. b6_client, ids["main_all"], {"targetPosition": 999}, login_token
  116. )
  117. assert response.status_code == 200
  118. assert response.get_json()["data"] == {"position": 6, "total": 6}
  119. assert [item.id for item in _main_order(b6_app)].index(ids["main_all"]) == 5
  120. def test_move_to_current_position_is_noop_without_audit(
  121. b6_app, b6_client, login_token
  122. ):
  123. ids = _ids(b6_app)
  124. response = _move(
  125. b6_client, ids["main_custom"], {"targetPosition": 3}, login_token
  126. )
  127. assert response.status_code == 200
  128. assert response.get_json()["data"] == {"position": 3, "total": 6}
  129. with b6_app.app_context():
  130. assert (
  131. db.session.scalar(
  132. db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
  133. )
  134. is None
  135. )
  136. assert [item.sort_no for item in _main_order(b6_app)] == [0] * 6
  137. def test_move_with_category_scope_keeps_other_category_relative_order(
  138. b6_app, b6_client, login_token
  139. ):
  140. ids = _ids(b6_app)
  141. root_a = _category(b6_app, "ROOT_A")
  142. root_b = _category(b6_app, "ROOT_B")
  143. with b6_app.app_context():
  144. admin = db.session.scalar(db.select(User).where(User.username == "admin"))
  145. extra_b1 = _main_document("MOVE_TEST_范围B甲", root_b)
  146. extra_b2 = _main_document("MOVE_TEST_范围B乙", root_b)
  147. db.session.add_all([extra_b1, extra_b2])
  148. db.session.flush()
  149. binding_before = [
  150. item.id for item in _main_order(b6_app)
  151. if item.id in {extra_b1.id, extra_b2.id}
  152. ]
  153. assert binding_before == [extra_b1.id, extra_b2.id]
  154. response = _move(
  155. b6_client,
  156. ids["main_custom"],
  157. {"target": "TOP", "scopeCategoryId": str(root_a.id)},
  158. login_token,
  159. )
  160. assert response.status_code == 200
  161. assert response.get_json()["data"] == {"position": 1, "total": 6}
  162. order = [item.id for item in _main_order(b6_app)]
  163. assert order.index(ids["main_custom"]) == 0
  164. # 范围外(ROOT_B)主案相对顺序保持
  165. assert order.index(extra_b1.id) < order.index(extra_b2.id)
  166. with b6_app.app_context():
  167. audit = db.session.scalar(
  168. db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
  169. )
  170. assert audit.operation_detail["scopeCategoryId"] == str(root_a.id)
  171. def test_move_rejects_document_outside_category_scope(
  172. b6_app, b6_client, login_token
  173. ):
  174. ids = _ids(b6_app)
  175. root_b = _category(b6_app, "ROOT_B")
  176. response = _move(
  177. b6_client,
  178. ids["main_custom"],
  179. {"target": "TOP", "scopeCategoryId": str(root_b.id)},
  180. login_token,
  181. )
  182. assert response.status_code == 409
  183. assert response.get_json()["code"] == "DOCUMENT_NOT_IN_MOVE_SCOPE"
  184. def test_move_rejects_scope_category_on_sub_plan(
  185. b6_app, b6_client, login_token
  186. ):
  187. ids = _ids(b6_app)
  188. root_a = _category(b6_app, "ROOT_A")
  189. response = _move(
  190. b6_client,
  191. ids["sub"],
  192. {"target": "TOP", "scopeCategoryId": str(root_a.id)},
  193. login_token,
  194. )
  195. assert response.status_code == 400
  196. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  197. def test_move_sub_plan_stays_within_parent_scope(
  198. b6_app, b6_client, login_token
  199. ):
  200. ids = _ids(b6_app)
  201. root_a = _category(b6_app, "ROOT_A")
  202. with b6_app.app_context():
  203. parent = db.session.get(Document, ids["main_custom"])
  204. second_sub = Document(
  205. document_name="MOVE_TEST_第二子案",
  206. document_type="SUB_PLAN",
  207. document_status="PUBLISHED",
  208. security_level="SECRET",
  209. category_id=root_a.id,
  210. category_name=root_a.category_name,
  211. category_path=root_a.category_path,
  212. parent_document_id=parent.id,
  213. root_document_id=parent.id,
  214. original_file_name="MOVE_TEST_第二子案.docx",
  215. file_relative_path="original/MOVE_TEST_第二子案.docx",
  216. file_extension="docx",
  217. file_hash="d" * 64,
  218. )
  219. db.session.add(second_sub)
  220. db.session.commit()
  221. second_sub_id = second_sub.id
  222. response = _move(b6_client, second_sub_id, {"target": "TOP"}, login_token)
  223. assert response.status_code == 200
  224. assert response.get_json()["data"] == {"position": 1, "total": 2}
  225. with b6_app.app_context():
  226. rows = db.session.scalars(
  227. db.select(Document).where(
  228. Document.document_type == "SUB_PLAN",
  229. Document.parent_document_id == ids["main_custom"],
  230. Document.is_deleted.is_(False),
  231. )
  232. ).all()
  233. by_sort = sorted(rows, key=lambda item: (item.sort_no, item.id))
  234. assert [item.id for item in by_sort] == [second_sub_id, ids["sub"]]
  235. # 子案移动不影响主案排序
  236. assert [item.sort_no for item in _main_order(b6_app)] == [0] * 6
  237. audit = db.session.scalar(
  238. db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
  239. )
  240. assert audit.operation_detail["scope"] == str(ids["main_custom"])
  241. def _binding_id(app, main_id: int, attachment_id: int) -> int:
  242. with app.app_context():
  243. binding = db.session.scalar(
  244. db.select(AttachmentBinding).where(
  245. AttachmentBinding.main_document_id == main_id,
  246. AttachmentBinding.attachment_document_id == attachment_id,
  247. AttachmentBinding.is_deleted.is_(False),
  248. )
  249. )
  250. assert binding is not None
  251. return binding.id
  252. def _binding_moves(app, main_id: int) -> list[tuple[int, int]]:
  253. """主案下有效挂载按 (sort_no, id) 升序的 (binding_id, sort_no)。"""
  254. with app.app_context():
  255. rows = db.session.scalars(
  256. db.select(AttachmentBinding).where(
  257. AttachmentBinding.main_document_id == main_id,
  258. AttachmentBinding.is_deleted.is_(False),
  259. )
  260. ).all()
  261. return [
  262. (item.id, item.sort_no)
  263. for item in sorted(rows, key=lambda row: (row.sort_no, row.id))
  264. ]
  265. def test_move_attachment_binding_to_top_bumps_row_version(
  266. b6_app, b6_client, login_token
  267. ):
  268. ids = _ids(b6_app)
  269. # main_all 挂载:表格附件(10) 在前、规范附件(20) 在后
  270. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  271. response = b6_client.put(
  272. f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
  273. json={"target": "TOP", "mainPlanRowVersion": 0},
  274. headers=_auth(login_token),
  275. )
  276. assert response.status_code == 200
  277. assert response.get_json()["data"] == {"position": 1, "total": 2, "mainPlanRowVersion": 1}
  278. moves = {binding: sort for binding, sort in _binding_moves(b6_app, ids["main_all"])}
  279. binding_two = _binding_id(b6_app, ids["main_all"], ids["attachment_two"])
  280. assert moves[binding_one] < moves[binding_two]
  281. with b6_app.app_context():
  282. main = db.session.get(Document, ids["main_all"])
  283. assert main.row_version == 1
  284. audit = db.session.scalar(
  285. db.select(AuditLog).where(AuditLog.action_type == "MOVE_ATTACHMENT")
  286. )
  287. assert audit is not None
  288. assert audit.target_id == binding_one
  289. detail = audit.operation_detail
  290. assert detail["fromPosition"] == 2
  291. assert detail["toPosition"] == 1
  292. assert detail["scopeTotal"] == 2
  293. def test_move_attachment_within_type_scope_keeps_other_type_order(
  294. b6_app, b6_client, login_token
  295. ):
  296. ids = _ids(b6_app)
  297. with b6_app.app_context():
  298. admin = db.session.scalar(db.select(User).where(User.username == "admin"))
  299. main = db.session.get(Document, ids["main_all"])
  300. third = _attachment_document("MOVE_TEST_训考乙", "TRAINING_MATERIAL")
  301. db.session.add(third)
  302. db.session.flush()
  303. db.session.add(
  304. AttachmentBinding(
  305. main_document_id=main.id,
  306. attachment_document_id=third.id,
  307. sort_no=30,
  308. created_by=admin.id,
  309. updated_by=admin.id,
  310. )
  311. )
  312. main.attachment_count = 3
  313. db.session.commit()
  314. third_id = third.id
  315. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  316. # 训考资料范围内:规范附件(20) 第1、训考乙(30) 第2,移动训考乙到第1
  317. response = b6_client.put(
  318. f"/api/v1/main-plans/{ids['main_all']}/attachments/"
  319. f"{_binding_id(b6_app, ids['main_all'], third_id)}/move",
  320. json={
  321. "targetPosition": 1,
  322. "scopeAttachmentType": "TRAINING_MATERIAL",
  323. "mainPlanRowVersion": 0,
  324. },
  325. headers=_auth(login_token),
  326. )
  327. assert response.status_code == 200
  328. assert response.get_json()["data"] == {"position": 1, "total": 2, "mainPlanRowVersion": 1}
  329. moves = _binding_moves(b6_app, ids["main_all"])
  330. binding_two = _binding_id(b6_app, ids["main_all"], ids["attachment_two"])
  331. binding_third = _binding_id(b6_app, ids["main_all"], third_id)
  332. # 支撑成果(类型外)保持最前,训考乙越过规范附件
  333. assert [binding for binding, _ in moves] == [
  334. binding_two, binding_third, binding_one,
  335. ]
  336. def test_move_attachment_rejects_type_scope_mismatch(
  337. b6_app, b6_client, login_token
  338. ):
  339. ids = _ids(b6_app)
  340. # 规范附件是训考资料,按支撑成果口径移动应拒绝
  341. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  342. response = b6_client.put(
  343. f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
  344. json={
  345. "target": "TOP",
  346. "scopeAttachmentType": "SUPPORT_ACHIEVEMENT",
  347. "mainPlanRowVersion": 0,
  348. },
  349. headers=_auth(login_token),
  350. )
  351. assert response.status_code == 409
  352. assert response.get_json()["code"] == "DOCUMENT_NOT_IN_MOVE_SCOPE"
  353. def test_move_attachment_rejects_stale_row_version(
  354. b6_app, b6_client, login_token
  355. ):
  356. ids = _ids(b6_app)
  357. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  358. response = b6_client.put(
  359. f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
  360. json={"target": "TOP", "mainPlanRowVersion": 9},
  361. headers=_auth(login_token),
  362. )
  363. assert response.status_code == 409
  364. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  365. @pytest.mark.parametrize(
  366. "payload",
  367. [
  368. {"target": "TOP"},
  369. {"mainPlanRowVersion": 0},
  370. {"targetPosition": 1, "target": "TOP", "mainPlanRowVersion": 0},
  371. {"target": "SIDE", "mainPlanRowVersion": 0},
  372. {"targetPosition": 1, "mainPlanRowVersion": -1},
  373. {"targetPosition": 1, "mainPlanRowVersion": 0, "extra": True},
  374. ],
  375. )
  376. def test_move_attachment_rejects_invalid_payload(
  377. b6_app, b6_client, login_token, payload
  378. ):
  379. ids = _ids(b6_app)
  380. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  381. response = b6_client.put(
  382. f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
  383. json=payload,
  384. headers=_auth(login_token),
  385. )
  386. assert response.status_code == 400
  387. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  388. def test_move_attachment_missing_binding_returns_404(
  389. b6_app, b6_client, login_token
  390. ):
  391. ids = _ids(b6_app)
  392. response = b6_client.put(
  393. f"/api/v1/main-plans/{ids['main_all']}/attachments/999999/move",
  394. json={"target": "TOP", "mainPlanRowVersion": 0},
  395. headers=_auth(login_token),
  396. )
  397. assert response.status_code == 404
  398. assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
  399. # ===== 移动=EDIT 权限收紧(契约1.30修订1.18) ==============================
  400. def _seed_limited_admin(app, password: str) -> None:
  401. """受限 ADMIN:矩阵仅授 BIND_ATTACHMENT(无 EDIT)。
  402. b4 场景文档均无机构归属,落入 OTHER 部门范围;main_all 密级 INTERNAL。
  403. 该用户在旧口径(BIND_ATTACHMENT)下可移动挂载,用于锁定新口径行为。
  404. """
  405. from dms.models import Organization, Role, RolePermission
  406. from dms.security.passwords import hash_password
  407. with app.app_context():
  408. ops = db.session.scalar(
  409. db.select(Organization).where(Organization.org_code == "ORG_OPS")
  410. )
  411. assert ops is not None
  412. role = Role(role_name="仅挂载角色", sort_no=10, status="ENABLED")
  413. db.session.add(role)
  414. db.session.flush()
  415. db.session.add(
  416. RolePermission(
  417. role_id=role.id,
  418. security_level="INTERNAL",
  419. dept_scope="OTHER",
  420. can_view=True,
  421. can_bind_attachment=True,
  422. )
  423. )
  424. db.session.add(
  425. User(
  426. username="move_limited_admin",
  427. password_hash=hash_password(password),
  428. real_name="仅挂载管理员",
  429. organization_id=ops.id,
  430. organization_name=ops.org_name,
  431. role_code="ADMIN",
  432. security_level="SECRET",
  433. status="ENABLED",
  434. role_id=role.id,
  435. )
  436. )
  437. db.session.commit()
  438. def test_move_main_plan_requires_edit(b6_app, b6_client, token_for):
  439. """借阅用户仅有 VIEW/DOWNLOAD(无 EDIT),移动主案返回 403。"""
  440. ids = _ids(b6_app)
  441. response = _move(
  442. b6_client, ids["main_all"], {"target": "TOP"}, token_for("user")
  443. )
  444. assert response.status_code == 403
  445. assert response.get_json()["code"] == "DOCUMENT_ACTION_FORBIDDEN"
  446. def test_move_attachment_binding_requires_main_plan_edit(
  447. b6_app, b6_client, token_for, b2_password
  448. ):
  449. """挂载移动按主案 EDIT 判定:有 BIND_ATTACHMENT 而无 EDIT 也被拒。"""
  450. ids = _ids(b6_app)
  451. _seed_limited_admin(b6_app, b2_password)
  452. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  453. response = b6_client.put(
  454. f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
  455. json={"target": "TOP", "mainPlanRowVersion": 0},
  456. headers=_auth(token_for("move_limited_admin")),
  457. )
  458. assert response.status_code == 403
  459. assert response.get_json()["code"] == "DOCUMENT_ACTION_FORBIDDEN"
  460. def test_reorder_attachment_bindings_requires_main_plan_edit(
  461. b6_app, b6_client, token_for, b2_password
  462. ):
  463. """挂载重排与移动同口径:无主案 EDIT 的受限 ADMIN 返回 403。"""
  464. ids = _ids(b6_app)
  465. _seed_limited_admin(b6_app, b2_password)
  466. binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
  467. binding_two = _binding_id(b6_app, ids["main_all"], ids["attachment_two"])
  468. response = b6_client.put(
  469. f"/api/v1/main-plans/{ids['main_all']}/attachments/reorder",
  470. json={
  471. "orderedBindingIds": [str(binding_two), str(binding_one)],
  472. "mainPlanRowVersion": 0,
  473. },
  474. headers=_auth(token_for("move_limited_admin")),
  475. )
  476. assert response.status_code == 403
  477. assert response.get_json()["code"] == "DOCUMENT_ACTION_FORBIDDEN"