test_b4_documents.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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_动态继承子案" not 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_historical_auditor_cannot_login(b4_client, b2_password):
  40. response = b4_client.post(
  41. "/api/v1/auth/login",
  42. json={
  43. "username": "auditor",
  44. "password": b2_password,
  45. "keepSignedIn": False,
  46. },
  47. )
  48. assert response.status_code == 401
  49. assert response.get_json()["code"] == "INVALID_CREDENTIALS"
  50. @pytest.mark.parametrize(
  51. ("query", "expected"),
  52. [
  53. ("documentType=ATTACHMENT", 400),
  54. ("documentType=MAIN,ATTACHMENT", 400),
  55. ("sortBy=fileRelativePath", 400),
  56. ("sortDirection=sideways", 400),
  57. ("pageSize=101", 400),
  58. ("categoryId=not-an-id", 400),
  59. ("status=INVALID", 400),
  60. ],
  61. )
  62. def test_document_invalid_filters(b4_client, token_for, query, expected):
  63. response = b4_client.get(
  64. f"/api/v1/documents?{query}", headers=auth(token_for("admin"))
  65. )
  66. assert response.status_code == expected
  67. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  68. @pytest.mark.parametrize(
  69. "sort_by",
  70. ["documentName", "createdAt", "updatedAt", "viewCount", "downloadCount"],
  71. )
  72. def test_document_sort_whitelist_accepts_contract_fields(
  73. b4_client, token_for, sort_by
  74. ):
  75. response = b4_client.get(
  76. f"/api/v1/documents?sortBy={sort_by}&sortDirection=asc",
  77. headers=auth(token_for("admin")),
  78. )
  79. assert response.status_code == 200
  80. def test_dynamic_sub_plan_inherits_root_access(b4_client, token_for):
  81. response = b4_client.get(
  82. "/api/v1/documents?documentType=SUB_PLAN",
  83. headers=auth(token_for("user")),
  84. )
  85. item = by_name(data(response)["items"], "B4_TEST_动态继承子案")
  86. assert "visibilityType" not in item
  87. assert item["rootDocumentId"].isdecimal()
  88. assert item["allowedActions"] == ["VIEW", "DOWNLOAD"]
  89. def test_document_detail_has_contract_fields_and_no_storage_path(
  90. b4_app, b4_client, token_for
  91. ):
  92. document_id = b4_app.config["B4_IDS"]["main_all"]
  93. response = b4_client.get(
  94. f"/api/v1/documents/{document_id}", headers=auth(token_for("user"))
  95. )
  96. assert response.status_code == 200
  97. item = data(response)
  98. assert item["id"] == str(document_id)
  99. assert item["fileSize"] == 128
  100. assert item["permissionSummary"]["inheritedFromMainPlan"] is False
  101. assert "fileRelativePath" not in item
  102. assert "updatedByName" not in item
  103. assert isinstance(item["tags"], list)
  104. def test_document_detail_increments_view_and_audits(
  105. b4_app, b4_client, token_for
  106. ):
  107. from dms.extensions import db
  108. from dms.models import AuditLog, Document
  109. document_id = b4_app.config["B4_IDS"]["main_all"]
  110. response = b4_client.get(
  111. f"/api/v1/documents/{document_id}", headers=auth(token_for("user"))
  112. )
  113. assert response.status_code == 200
  114. with b4_app.app_context():
  115. document = db.session.get(Document, document_id)
  116. audits = db.session.scalars(
  117. db.select(AuditLog).where(
  118. AuditLog.target_id == document_id,
  119. AuditLog.action_type == "VIEW_DOCUMENT",
  120. )
  121. ).all()
  122. assert document.view_count == 1
  123. assert len(audits) == 1
  124. assert audits[0].target_type == "DOCUMENT"
  125. def test_attachment_cannot_use_document_detail(
  126. b4_app, b4_client, token_for
  127. ):
  128. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  129. response = b4_client.get(
  130. f"/api/v1/documents/{attachment_id}", headers=auth(token_for("admin"))
  131. )
  132. assert response.status_code == 404
  133. assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
  134. def test_user_security_failure_is_specific(b4_client, token_for):
  135. response = b4_client.get(
  136. "/api/v1/documents?pageSize=100", headers=auth(token_for("admin"))
  137. )
  138. top_secret = by_name(data(response)["items"], "B4_TEST_绝密主案")
  139. forbidden = b4_client.get(
  140. f"/api/v1/documents/{top_secret['id']}",
  141. headers=auth(token_for("user")),
  142. )
  143. assert forbidden.status_code == 403
  144. assert forbidden.get_json()["code"] == "SECURITY_LEVEL_FORBIDDEN"
  145. def test_user_acl_failure_is_specific(b4_client, token_for):
  146. response = b4_client.get(
  147. "/api/v1/documents?pageSize=100", headers=auth(token_for("admin"))
  148. )
  149. hidden = by_name(data(response)["items"], "B4_TEST_无授权主案")
  150. forbidden = b4_client.get(
  151. f"/api/v1/documents/{hidden['id']}",
  152. headers=auth(token_for("user")),
  153. )
  154. assert forbidden.status_code == 403
  155. assert forbidden.get_json()["code"] == "DOCUMENT_VIEW_FORBIDDEN"
  156. def test_sub_plan_page_and_main_type_validation(b4_app, b4_client, token_for):
  157. main_id = b4_app.config["B4_IDS"]["main_custom"]
  158. response = b4_client.get(
  159. f"/api/v1/main-plans/{main_id}/sub-plans?keyword=动态&pageSize=1",
  160. headers=auth(token_for("user")),
  161. )
  162. assert response.status_code == 200
  163. assert data(response)["total"] == 1
  164. sub_id = b4_app.config["B4_IDS"]["sub"]
  165. invalid = b4_client.get(
  166. f"/api/v1/main-plans/{sub_id}/sub-plans",
  167. headers=auth(token_for("admin")),
  168. )
  169. assert invalid.status_code == 404
  170. assert invalid.get_json()["code"] == "MAIN_PLAN_NOT_FOUND"
  171. def test_attachment_library_list_open_for_all_roles(b4_client, token_for):
  172. response = b4_client.get(
  173. "/api/v1/attachments?pageSize=100",
  174. headers=auth(token_for("admin")),
  175. )
  176. assert response.status_code == 200
  177. assert data(response)["total"] == 2
  178. assert all(
  179. item["securityLevel"] == "PUBLIC" for item in data(response)["items"]
  180. )
  181. # 列表全量可见(全局检索需要):普通用户返回全部标题,但操作位为空。
  182. user_response = b4_client.get(
  183. "/api/v1/attachments?pageSize=100",
  184. headers=auth(token_for("user")),
  185. )
  186. assert user_response.status_code == 200
  187. assert data(user_response)["total"] == 2
  188. assert all(
  189. item["allowedActions"] == [] for item in data(user_response)["items"]
  190. )
  191. def test_attachment_filters_and_real_mount_count(b4_client, token_for):
  192. response = b4_client.get(
  193. "/api/v1/attachments?attachmentType=TRAINING_MATERIAL&fileExtension=docx",
  194. headers=auth(token_for("admin")),
  195. )
  196. assert response.status_code == 200
  197. assert data(response)["total"] == 1
  198. assert data(response)["items"][0]["mountedPlanCount"] == 2
  199. def test_attachment_detail_increments_view_and_attachment_audit(
  200. b4_app, b4_client, token_for
  201. ):
  202. from dms.extensions import db
  203. from dms.models import AuditLog, Document
  204. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  205. response = b4_client.get(
  206. f"/api/v1/attachments/{attachment_id}",
  207. headers=auth(token_for("user")),
  208. )
  209. assert response.status_code == 200
  210. item = data(response)
  211. assert item["categoryId"] is None
  212. assert item["permissionSummary"] == {
  213. "borrowerCount": 0,
  214. "inheritedFromMainPlan": False,
  215. }
  216. assert "fileRelativePath" not in item
  217. with b4_app.app_context():
  218. assert db.session.get(Document, attachment_id).view_count == 1
  219. audit = db.session.scalar(
  220. db.select(AuditLog).where(AuditLog.target_id == attachment_id)
  221. )
  222. assert audit.target_type == "ATTACHMENT"
  223. def test_non_attachment_returns_not_found(b4_app, b4_client, token_for):
  224. main_id = b4_app.config["B4_IDS"]["main_all"]
  225. response = b4_client.get(
  226. f"/api/v1/attachments/{main_id}", headers=auth(token_for("admin"))
  227. )
  228. assert response.status_code == 404
  229. def test_attachment_main_plans_respect_plan_access(
  230. b4_app, b4_client, token_for
  231. ):
  232. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  233. user_response = b4_client.get(
  234. f"/api/v1/attachments/{attachment_id}/main-plans",
  235. headers=auth(token_for("user")),
  236. )
  237. assert data(user_response)["total"] == 2
  238. admin_response = b4_client.get(
  239. f"/api/v1/attachments/{attachment_id}/main-plans",
  240. headers=auth(token_for("admin")),
  241. )
  242. assert data(admin_response)["total"] == 2
  243. def test_main_plan_attachments_stable_binding_order(
  244. b4_app, b4_client, token_for
  245. ):
  246. main_id = b4_app.config["B4_IDS"]["main_all"]
  247. response = b4_client.get(
  248. f"/api/v1/main-plans/{main_id}/attachments",
  249. headers=auth(token_for("user")),
  250. )
  251. assert response.status_code == 200
  252. items = data(response)["items"]
  253. assert [item["bindingSortNo"] for item in items] == [10, 20]
  254. assert all(item["bindingId"].isdecimal() for item in items)
  255. def test_allowed_actions_are_stable(b4_app, b4_client, token_for):
  256. main_id = b4_app.config["B4_IDS"]["main_all"]
  257. admin = b4_client.get(
  258. f"/api/v1/documents/{main_id}", headers=auth(token_for("admin"))
  259. )
  260. assert data(admin)["allowedActions"] == [
  261. "VIEW",
  262. "DOWNLOAD",
  263. "EDIT",
  264. "CONFIG_PERMISSION",
  265. "DELETE",
  266. "BIND_ATTACHMENT",
  267. "UNBIND_ATTACHMENT",
  268. ]
  269. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  270. user = b4_client.get(
  271. f"/api/v1/attachments/{attachment_id}",
  272. headers=auth(token_for("user")),
  273. )
  274. assert data(user)["allowedActions"] == ["VIEW", "DOWNLOAD"]
  275. def test_seed_documents_is_idempotent_and_creates_valid_files(
  276. b3_app, tmp_path: Path
  277. ):
  278. from dms.extensions import db
  279. from dms.models import Category, Document
  280. from dms.seed_documents import seed_documents
  281. with b3_app.app_context():
  282. # B3测试分类编码与开发初始化编码不同,补齐seed的必要依赖。
  283. for code, name in (
  284. ("SCENE_A", "B4_TEST_场景A"),
  285. ("SCENE_B", "B4_TEST_场景B"),
  286. ("SCENE_C", "B4_TEST_场景C"),
  287. ):
  288. db.session.add(
  289. Category(
  290. category_code=code,
  291. category_name=name,
  292. category_type="SCENE",
  293. category_path=f"/{name}",
  294. sort_no=10,
  295. document_count=0,
  296. status="ENABLED",
  297. )
  298. )
  299. db.session.commit()
  300. scene_a = db.session.scalar(
  301. db.select(Category).where(Category.category_code == "SCENE_A")
  302. )
  303. db.session.add(
  304. Category(
  305. category_code="STYLE_A1",
  306. category_name="B4_TEST_样式A1",
  307. category_type="STYLE",
  308. parent_id=scene_a.id,
  309. category_path=f"{scene_a.category_path}/B4_TEST_样式A1",
  310. sort_no=10,
  311. document_count=0,
  312. status="ENABLED",
  313. )
  314. )
  315. db.session.commit()
  316. first = seed_documents(tmp_path / "storage")
  317. second = seed_documents(tmp_path / "storage")
  318. assert first == (9, 0, 0)
  319. assert second == (0, 9, 0)
  320. documents = db.session.scalars(
  321. db.select(Document).where(
  322. Document.document_name.in_(
  323. ["2024年度综合应急预案", "装备保障工作规范"]
  324. )
  325. )
  326. ).all()
  327. assert len(documents) == 2
  328. for document in documents:
  329. path = tmp_path / "storage" / Path(document.file_relative_path)
  330. assert path.is_file()
  331. WordDocument(path)
  332. assert hashlib.sha256(path.read_bytes()).hexdigest() == document.file_hash