test_b4_documents.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. from __future__ import annotations
  2. import hashlib
  3. from pathlib import Path
  4. import pytest
  5. from docx import Document as WordDocument
  6. def auth(token: str) -> dict[str, str]:
  7. return {"Authorization": f"Bearer {token}"}
  8. def data(response):
  9. return response.get_json()["data"]
  10. def by_name(items, name: str):
  11. return next(item for item in items if item["documentName"] == name)
  12. def test_documents_require_authentication(b4_client):
  13. response = b4_client.get("/api/v1/documents")
  14. assert response.status_code == 401
  15. assert response.get_json()["code"] == "TOKEN_INVALID"
  16. def test_admin_sees_all_plan_statuses_but_not_attachments(
  17. b4_client, token_for
  18. ):
  19. response = b4_client.get(
  20. "/api/v1/documents?pageSize=100", headers=auth(token_for("admin"))
  21. )
  22. assert response.status_code == 200
  23. items = data(response)["items"]
  24. assert any(item["status"] == "DRAFT" for item in items)
  25. assert all(item["documentType"] != "ATTACHMENT" for item in items)
  26. def test_user_list_filters_status_security_and_acl(b4_client, token_for):
  27. response = b4_client.get(
  28. "/api/v1/documents?pageSize=100", headers=auth(token_for("user"))
  29. )
  30. assert response.status_code == 200
  31. names = {item["documentName"] for item in data(response)["items"]}
  32. assert "B4_TEST_全部主案" in names
  33. assert "B4_TEST_组织主案" in names
  34. assert "B4_TEST_自定义主案" in names
  35. assert "B4_TEST_动态继承子案" in names
  36. assert "B4_TEST_草稿主案" not in names
  37. assert "B4_TEST_绝密主案" not in names
  38. assert "B4_TEST_无授权主案" not in names
  39. def test_auditor_plan_list_is_forbidden(b4_client, token_for):
  40. response = b4_client.get(
  41. "/api/v1/documents", headers=auth(token_for("auditor"))
  42. )
  43. assert response.status_code == 403
  44. assert response.get_json()["code"] == "DOCUMENT_VIEW_FORBIDDEN"
  45. @pytest.mark.parametrize(
  46. ("query", "expected"),
  47. [
  48. ("documentType=ATTACHMENT", 400),
  49. ("documentType=MAIN,ATTACHMENT", 400),
  50. ("sortBy=fileRelativePath", 400),
  51. ("sortDirection=sideways", 400),
  52. ("pageSize=101", 400),
  53. ("categoryId=not-an-id", 400),
  54. ("status=INVALID", 400),
  55. ],
  56. )
  57. def test_document_invalid_filters(b4_client, token_for, query, expected):
  58. response = b4_client.get(
  59. f"/api/v1/documents?{query}", headers=auth(token_for("admin"))
  60. )
  61. assert response.status_code == expected
  62. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  63. @pytest.mark.parametrize(
  64. "sort_by",
  65. ["documentName", "createdAt", "updatedAt", "viewCount", "downloadCount"],
  66. )
  67. def test_document_sort_whitelist_accepts_contract_fields(
  68. b4_client, token_for, sort_by
  69. ):
  70. response = b4_client.get(
  71. f"/api/v1/documents?sortBy={sort_by}&sortDirection=asc",
  72. headers=auth(token_for("admin")),
  73. )
  74. assert response.status_code == 200
  75. def test_dynamic_sub_plan_inherits_root_visibility(b4_client, token_for):
  76. response = b4_client.get(
  77. "/api/v1/documents?documentType=SUB_PLAN",
  78. headers=auth(token_for("user")),
  79. )
  80. item = by_name(data(response)["items"], "B4_TEST_动态继承子案")
  81. assert item["visibilityType"] == "CUSTOM"
  82. assert item["rootDocumentId"].isdecimal()
  83. assert item["allowedActions"] == ["VIEW", "DOWNLOAD"]
  84. def test_document_detail_has_contract_fields_and_no_storage_path(
  85. b4_app, b4_client, token_for
  86. ):
  87. document_id = b4_app.config["B4_IDS"]["main_all"]
  88. response = b4_client.get(
  89. f"/api/v1/documents/{document_id}", headers=auth(token_for("user"))
  90. )
  91. assert response.status_code == 200
  92. item = data(response)
  93. assert item["id"] == str(document_id)
  94. assert item["fileSize"] == 128
  95. assert item["permissionSummary"]["inheritedFromMainPlan"] is False
  96. assert "fileRelativePath" not in item
  97. assert "updatedByName" not in item
  98. assert isinstance(item["tags"], list)
  99. def test_document_detail_increments_view_and_audits(
  100. b4_app, b4_client, token_for
  101. ):
  102. from dms.extensions import db
  103. from dms.models import AuditLog, Document
  104. document_id = b4_app.config["B4_IDS"]["main_all"]
  105. response = b4_client.get(
  106. f"/api/v1/documents/{document_id}", headers=auth(token_for("user"))
  107. )
  108. assert response.status_code == 200
  109. with b4_app.app_context():
  110. document = db.session.get(Document, document_id)
  111. audits = db.session.scalars(
  112. db.select(AuditLog).where(
  113. AuditLog.target_id == document_id,
  114. AuditLog.action_type == "VIEW_DOCUMENT",
  115. )
  116. ).all()
  117. assert document.view_count == 1
  118. assert len(audits) == 1
  119. assert audits[0].target_type == "DOCUMENT"
  120. def test_attachment_cannot_use_document_detail(
  121. b4_app, b4_client, token_for
  122. ):
  123. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  124. response = b4_client.get(
  125. f"/api/v1/documents/{attachment_id}", headers=auth(token_for("admin"))
  126. )
  127. assert response.status_code == 404
  128. assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
  129. def test_user_security_failure_is_specific(b4_client, token_for):
  130. response = b4_client.get(
  131. "/api/v1/documents?pageSize=100", headers=auth(token_for("admin"))
  132. )
  133. top_secret = by_name(data(response)["items"], "B4_TEST_绝密主案")
  134. forbidden = b4_client.get(
  135. f"/api/v1/documents/{top_secret['id']}",
  136. headers=auth(token_for("user")),
  137. )
  138. assert forbidden.status_code == 403
  139. assert forbidden.get_json()["code"] == "SECURITY_LEVEL_FORBIDDEN"
  140. def test_user_acl_failure_is_specific(b4_client, token_for):
  141. response = b4_client.get(
  142. "/api/v1/documents?pageSize=100", headers=auth(token_for("admin"))
  143. )
  144. hidden = by_name(data(response)["items"], "B4_TEST_无授权主案")
  145. forbidden = b4_client.get(
  146. f"/api/v1/documents/{hidden['id']}",
  147. headers=auth(token_for("user")),
  148. )
  149. assert forbidden.status_code == 403
  150. assert forbidden.get_json()["code"] == "DOCUMENT_VIEW_FORBIDDEN"
  151. def test_sub_plan_page_and_main_type_validation(b4_app, b4_client, token_for):
  152. main_id = b4_app.config["B4_IDS"]["main_custom"]
  153. response = b4_client.get(
  154. f"/api/v1/main-plans/{main_id}/sub-plans?keyword=动态&pageSize=1",
  155. headers=auth(token_for("user")),
  156. )
  157. assert response.status_code == 200
  158. assert data(response)["total"] == 1
  159. sub_id = b4_app.config["B4_IDS"]["sub"]
  160. invalid = b4_client.get(
  161. f"/api/v1/main-plans/{sub_id}/sub-plans",
  162. headers=auth(token_for("admin")),
  163. )
  164. assert invalid.status_code == 404
  165. assert invalid.get_json()["code"] == "MAIN_PLAN_NOT_FOUND"
  166. def test_all_roles_can_list_attachments(b4_client, token_for):
  167. for username in ("user", "admin", "auditor"):
  168. response = b4_client.get(
  169. "/api/v1/attachments?pageSize=100",
  170. headers=auth(token_for(username)),
  171. )
  172. assert response.status_code == 200
  173. assert data(response)["total"] == 2
  174. assert all(item["securityLevel"] == "PUBLIC" for item in data(response)["items"])
  175. def test_attachment_filters_and_real_mount_count(b4_client, token_for):
  176. response = b4_client.get(
  177. "/api/v1/attachments?attachmentType=WORK_STANDARD&fileExtension=docx",
  178. headers=auth(token_for("user")),
  179. )
  180. assert response.status_code == 200
  181. assert data(response)["total"] == 1
  182. assert data(response)["items"][0]["mountedPlanCount"] == 2
  183. def test_attachment_detail_increments_view_and_attachment_audit(
  184. b4_app, b4_client, token_for
  185. ):
  186. from dms.extensions import db
  187. from dms.models import AuditLog, Document
  188. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  189. response = b4_client.get(
  190. f"/api/v1/attachments/{attachment_id}",
  191. headers=auth(token_for("auditor")),
  192. )
  193. assert response.status_code == 200
  194. item = data(response)
  195. assert item["categoryId"] is None
  196. assert item["permissionSummary"] == {
  197. "organizationCount": 0,
  198. "userCount": 0,
  199. "inheritedFromMainPlan": False,
  200. }
  201. assert "fileRelativePath" not in item
  202. with b4_app.app_context():
  203. assert db.session.get(Document, attachment_id).view_count == 1
  204. audit = db.session.scalar(
  205. db.select(AuditLog).where(AuditLog.target_id == attachment_id)
  206. )
  207. assert audit.target_type == "ATTACHMENT"
  208. def test_non_attachment_returns_not_found(b4_app, b4_client, token_for):
  209. main_id = b4_app.config["B4_IDS"]["main_all"]
  210. response = b4_client.get(
  211. f"/api/v1/attachments/{main_id}", headers=auth(token_for("admin"))
  212. )
  213. assert response.status_code == 404
  214. def test_attachment_main_plans_respect_plan_access(
  215. b4_app, b4_client, token_for
  216. ):
  217. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  218. user_response = b4_client.get(
  219. f"/api/v1/attachments/{attachment_id}/main-plans",
  220. headers=auth(token_for("user")),
  221. )
  222. assert data(user_response)["total"] == 2
  223. auditor_response = b4_client.get(
  224. f"/api/v1/attachments/{attachment_id}/main-plans",
  225. headers=auth(token_for("auditor")),
  226. )
  227. assert data(auditor_response) == {"items": [], "total": 0}
  228. def test_main_plan_attachments_stable_binding_order(
  229. b4_app, b4_client, token_for
  230. ):
  231. main_id = b4_app.config["B4_IDS"]["main_all"]
  232. response = b4_client.get(
  233. f"/api/v1/main-plans/{main_id}/attachments",
  234. headers=auth(token_for("user")),
  235. )
  236. assert response.status_code == 200
  237. items = data(response)["items"]
  238. assert [item["bindingSortNo"] for item in items] == [10, 20]
  239. assert all(item["bindingId"].isdecimal() for item in items)
  240. def test_allowed_actions_are_stable(b4_app, b4_client, token_for):
  241. main_id = b4_app.config["B4_IDS"]["main_all"]
  242. admin = b4_client.get(
  243. f"/api/v1/documents/{main_id}", headers=auth(token_for("admin"))
  244. )
  245. assert data(admin)["allowedActions"] == [
  246. "VIEW",
  247. "DOWNLOAD",
  248. "EDIT",
  249. "CONFIG_PERMISSION",
  250. "DELETE",
  251. "BIND_ATTACHMENT",
  252. "UNBIND_ATTACHMENT",
  253. ]
  254. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  255. auditor = b4_client.get(
  256. f"/api/v1/attachments/{attachment_id}",
  257. headers=auth(token_for("auditor")),
  258. )
  259. assert data(auditor)["allowedActions"] == ["VIEW", "DOWNLOAD"]
  260. def test_seed_documents_is_idempotent_and_creates_valid_files(
  261. b3_app, tmp_path: Path
  262. ):
  263. from dms.extensions import db
  264. from dms.models import Category, Document
  265. from dms.seed_documents import seed_documents
  266. with b3_app.app_context():
  267. # B3测试分类编码与开发初始化编码不同,补齐seed的必要依赖。
  268. for code, name in (
  269. ("SCENE_A", "B4_TEST_场景A"),
  270. ("SCENE_B", "B4_TEST_场景B"),
  271. ("SCENE_C", "B4_TEST_场景C"),
  272. ):
  273. db.session.add(
  274. Category(
  275. category_code=code,
  276. category_name=name,
  277. category_type="SCENE",
  278. category_path=f"/{name}",
  279. sort_no=10,
  280. document_count=0,
  281. status="ENABLED",
  282. )
  283. )
  284. db.session.commit()
  285. first = seed_documents(tmp_path / "storage")
  286. second = seed_documents(tmp_path / "storage")
  287. assert first == (9, 0, 0)
  288. assert second == (0, 9, 0)
  289. documents = db.session.scalars(
  290. db.select(Document).where(
  291. Document.document_name.in_(
  292. ["2024年度综合应急预案", "装备保障工作规范"]
  293. )
  294. )
  295. ).all()
  296. assert len(documents) == 2
  297. for document in documents:
  298. path = tmp_path / "storage" / Path(document.file_relative_path)
  299. assert path.is_file()
  300. WordDocument(path)
  301. assert hashlib.sha256(path.read_bytes()).hexdigest() == document.file_hash