test_b4_documents.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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_visibility(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 item["visibilityType"] == "CUSTOM"
  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_only_admin_can_list_attachment_library(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. denied = b4_client.get(
  182. "/api/v1/attachments?pageSize=100",
  183. headers=auth(token_for("user")),
  184. )
  185. assert denied.status_code == 403
  186. def test_attachment_filters_and_real_mount_count(b4_client, token_for):
  187. response = b4_client.get(
  188. "/api/v1/attachments?attachmentType=WORK_STANDARD&fileExtension=docx",
  189. headers=auth(token_for("admin")),
  190. )
  191. assert response.status_code == 200
  192. assert data(response)["total"] == 1
  193. assert data(response)["items"][0]["mountedPlanCount"] == 2
  194. def test_attachment_detail_increments_view_and_attachment_audit(
  195. b4_app, b4_client, token_for
  196. ):
  197. from dms.extensions import db
  198. from dms.models import AuditLog, Document
  199. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  200. response = b4_client.get(
  201. f"/api/v1/attachments/{attachment_id}",
  202. headers=auth(token_for("user")),
  203. )
  204. assert response.status_code == 200
  205. item = data(response)
  206. assert item["categoryId"] is None
  207. assert item["permissionSummary"] == {
  208. "organizationCount": 0,
  209. "userCount": 0,
  210. "inheritedFromMainPlan": False,
  211. }
  212. assert "fileRelativePath" not in item
  213. with b4_app.app_context():
  214. assert db.session.get(Document, attachment_id).view_count == 1
  215. audit = db.session.scalar(
  216. db.select(AuditLog).where(AuditLog.target_id == attachment_id)
  217. )
  218. assert audit.target_type == "ATTACHMENT"
  219. def test_non_attachment_returns_not_found(b4_app, b4_client, token_for):
  220. main_id = b4_app.config["B4_IDS"]["main_all"]
  221. response = b4_client.get(
  222. f"/api/v1/attachments/{main_id}", headers=auth(token_for("admin"))
  223. )
  224. assert response.status_code == 404
  225. def test_attachment_main_plans_respect_plan_access(
  226. b4_app, b4_client, token_for
  227. ):
  228. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  229. user_response = b4_client.get(
  230. f"/api/v1/attachments/{attachment_id}/main-plans",
  231. headers=auth(token_for("user")),
  232. )
  233. assert data(user_response)["total"] == 2
  234. admin_response = b4_client.get(
  235. f"/api/v1/attachments/{attachment_id}/main-plans",
  236. headers=auth(token_for("admin")),
  237. )
  238. assert data(admin_response)["total"] == 2
  239. def test_main_plan_attachments_stable_binding_order(
  240. b4_app, b4_client, token_for
  241. ):
  242. main_id = b4_app.config["B4_IDS"]["main_all"]
  243. response = b4_client.get(
  244. f"/api/v1/main-plans/{main_id}/attachments",
  245. headers=auth(token_for("user")),
  246. )
  247. assert response.status_code == 200
  248. items = data(response)["items"]
  249. assert [item["bindingSortNo"] for item in items] == [10, 20]
  250. assert all(item["bindingId"].isdecimal() for item in items)
  251. def test_allowed_actions_are_stable(b4_app, b4_client, token_for):
  252. main_id = b4_app.config["B4_IDS"]["main_all"]
  253. admin = b4_client.get(
  254. f"/api/v1/documents/{main_id}", headers=auth(token_for("admin"))
  255. )
  256. assert data(admin)["allowedActions"] == [
  257. "VIEW",
  258. "DOWNLOAD",
  259. "EDIT",
  260. "CONFIG_PERMISSION",
  261. "DELETE",
  262. "BIND_ATTACHMENT",
  263. "UNBIND_ATTACHMENT",
  264. ]
  265. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  266. user = b4_client.get(
  267. f"/api/v1/attachments/{attachment_id}",
  268. headers=auth(token_for("user")),
  269. )
  270. assert data(user)["allowedActions"] == ["VIEW", "DOWNLOAD"]
  271. def test_seed_documents_is_idempotent_and_creates_valid_files(
  272. b3_app, tmp_path: Path
  273. ):
  274. from dms.extensions import db
  275. from dms.models import Category, Document
  276. from dms.seed_documents import seed_documents
  277. with b3_app.app_context():
  278. # B3测试分类编码与开发初始化编码不同,补齐seed的必要依赖。
  279. for code, name in (
  280. ("SCENE_A", "B4_TEST_场景A"),
  281. ("SCENE_B", "B4_TEST_场景B"),
  282. ("SCENE_C", "B4_TEST_场景C"),
  283. ):
  284. db.session.add(
  285. Category(
  286. category_code=code,
  287. category_name=name,
  288. category_type="SCENE",
  289. category_path=f"/{name}",
  290. sort_no=10,
  291. document_count=0,
  292. status="ENABLED",
  293. )
  294. )
  295. db.session.commit()
  296. scene_a = db.session.scalar(
  297. db.select(Category).where(Category.category_code == "SCENE_A")
  298. )
  299. db.session.add(
  300. Category(
  301. category_code="STYLE_A1",
  302. category_name="B4_TEST_样式A1",
  303. category_type="STYLE",
  304. parent_id=scene_a.id,
  305. category_path=f"{scene_a.category_path}/B4_TEST_样式A1",
  306. sort_no=10,
  307. document_count=0,
  308. status="ENABLED",
  309. )
  310. )
  311. db.session.commit()
  312. first = seed_documents(tmp_path / "storage")
  313. second = seed_documents(tmp_path / "storage")
  314. assert first == (9, 0, 0)
  315. assert second == (0, 9, 0)
  316. documents = db.session.scalars(
  317. db.select(Document).where(
  318. Document.document_name.in_(
  319. ["2024年度综合应急预案", "装备保障工作规范"]
  320. )
  321. )
  322. ).all()
  323. assert len(documents) == 2
  324. for document in documents:
  325. path = tmp_path / "storage" / Path(document.file_relative_path)
  326. assert path.is_file()
  327. WordDocument(path)
  328. assert hashlib.sha256(path.read_bytes()).hexdigest() == document.file_hash