test_q3b_business_content_preview.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. from __future__ import annotations
  2. import io
  3. import json
  4. from pathlib import Path
  5. import fitz
  6. import pytest
  7. from docx import Document as WordDocument
  8. def _auth(token: str) -> dict[str, str]:
  9. return {"Authorization": f"Bearer {token}"}
  10. def _category_id(app, code: str) -> str:
  11. from dms.extensions import db
  12. from dms.models import Category
  13. with app.app_context():
  14. category = db.session.scalar(
  15. db.select(Category).where(Category.category_code == code)
  16. )
  17. assert category is not None
  18. return str(category.id)
  19. def _docx_bytes(term: str = "Q3正文唯一词") -> bytes:
  20. stream = io.BytesIO()
  21. document = WordDocument()
  22. document.add_paragraph(f"段落 {term}")
  23. table = document.add_table(rows=1, cols=1)
  24. table.cell(0, 0).text = "表格唯一词"
  25. section = document.sections[0]
  26. section.header.paragraphs[0].text = "页眉唯一词"
  27. section.footer.paragraphs[0].text = "页脚唯一词"
  28. document.save(stream)
  29. return stream.getvalue()
  30. def _pdf_bytes(term: str = "Q3PdfUniqueTerm") -> bytes:
  31. document = fitz.open()
  32. page = document.new_page()
  33. page.insert_text((72, 72), term)
  34. content = document.tobytes()
  35. document.close()
  36. return content
  37. def _main_metadata(category_id: str, name: str = "Q3主案") -> dict:
  38. return {
  39. "documentName": name,
  40. "documentType": "MAIN",
  41. "summary": "Q3摘要",
  42. "categoryId": category_id,
  43. "securityLevel": "INTERNAL",
  44. "tags": ["Q3"],
  45. }
  46. def _sub_metadata(parent_id: str, name: str = "Q3子方案") -> dict:
  47. return {
  48. "documentName": name,
  49. "documentType": "SUB_PLAN",
  50. "summary": "Q3子方案摘要",
  51. "parentDocumentId": parent_id,
  52. "securityLevel": "INTERNAL",
  53. "tags": ["Q3"],
  54. }
  55. def _upload(client, token: str, metadata: dict, content: bytes, suffix: str):
  56. return client.post(
  57. "/api/v1/documents",
  58. data={
  59. "file": (io.BytesIO(content), f"{metadata['documentName']}.{suffix}"),
  60. "metadata": json.dumps(metadata, ensure_ascii=False),
  61. },
  62. headers=_auth(token),
  63. content_type="multipart/form-data",
  64. )
  65. def _tree_node(nodes: list[dict], category_id: str) -> dict:
  66. pending = list(nodes)
  67. while pending:
  68. node = pending.pop()
  69. if node["id"] == category_id:
  70. return node
  71. pending.extend(node["children"])
  72. raise AssertionError(f"category {category_id} not found")
  73. def test_business_timezone_default_and_invalid_fail_fast(monkeypatch):
  74. from dms.config import load_dms_config
  75. monkeypatch.delenv("DMS_BUSINESS_TIMEZONE", raising=False)
  76. assert load_dms_config()["DMS_BUSINESS_TIMEZONE"] == "Asia/Shanghai"
  77. monkeypatch.setenv("DMS_BUSINESS_TIMEZONE", "Not/A_Real_Zone")
  78. with pytest.raises(ValueError, match="DMS_BUSINESS_TIMEZONE"):
  79. load_dms_config()
  80. def test_admin_modules_and_historical_auditor_are_invalid(
  81. b2_app, b2_client, b2_password, token_for
  82. ):
  83. from dms.extensions import db
  84. from dms.models import User
  85. from dms.security.jwt_tokens import issue_access_token
  86. admin = b2_client.get(
  87. "/api/v1/users/me", headers=_auth(token_for("admin"))
  88. )
  89. assert admin.get_json()["data"]["allowedModules"] == [
  90. "DOCUMENT_BROWSER",
  91. "BACKEND_MANAGEMENT",
  92. "AUDIT_LOG",
  93. ]
  94. failed = b2_client.post(
  95. "/api/v1/auth/login",
  96. json={
  97. "username": "auditor",
  98. "password": b2_password,
  99. "keepSignedIn": False,
  100. },
  101. )
  102. assert failed.status_code == 401
  103. with b2_app.app_context():
  104. historical = db.session.scalar(
  105. db.select(User).where(User.username == "auditor")
  106. )
  107. assert historical.role_code == "USER"
  108. assert historical.status == "DISABLED"
  109. assert historical.is_deleted
  110. old_token, _ = issue_access_token(historical, keep_signed_in=False)
  111. assert b2_client.get(
  112. "/api/v1/users/me", headers=_auth(old_token)
  113. ).status_code == 401
  114. def test_leaf_rules_create_defaults_and_sub_plan_category_sync(
  115. b3_app, b3_client, token_for
  116. ):
  117. from dms.extensions import db
  118. from dms.models import Document
  119. token = token_for("admin")
  120. root_id = _category_id(b3_app, "ROOT_A")
  121. leaf_one = _category_id(b3_app, "CHILD_A1")
  122. leaf_two = _category_id(b3_app, "CHILD_A2")
  123. rejected = _upload(
  124. b3_client, token, _main_metadata(root_id), _docx_bytes(), "docx"
  125. )
  126. assert rejected.status_code == 409
  127. assert rejected.get_json()["code"] == "CATEGORY_NOT_LEAF"
  128. assert rejected.get_json()["details"] == {
  129. "categoryId": root_id,
  130. "childCategoryCount": 2,
  131. }
  132. created_response = _upload(
  133. b3_client,
  134. token,
  135. _main_metadata(leaf_one, "Q3叶子主案"),
  136. _docx_bytes(),
  137. "docx",
  138. )
  139. assert created_response.status_code == 201
  140. main = created_response.get_json()["data"]
  141. assert (main["status"], main["visibilityType"]) == (
  142. "PUBLISHED",
  143. "CUSTOM",
  144. )
  145. child_category = b3_client.post(
  146. "/api/v1/categories",
  147. json={
  148. "categoryCode": "Q3_CHILD_FORBIDDEN",
  149. "categoryName": "禁止子分类",
  150. "categoryType": "OTHER",
  151. "parentId": leaf_one,
  152. "sortNo": 99,
  153. },
  154. headers=_auth(token),
  155. )
  156. assert child_category.status_code == 409
  157. assert child_category.get_json()["code"] == "CATEGORY_NOT_LEAF"
  158. sub_response = _upload(
  159. b3_client,
  160. token,
  161. _sub_metadata(main["id"]),
  162. _docx_bytes("子方案正文"),
  163. "docx",
  164. )
  165. assert sub_response.status_code == 201
  166. sub = sub_response.get_json()["data"]
  167. assert sub["categoryId"] == leaf_one
  168. with b3_app.app_context():
  169. current = db.session.get(Document, int(main["id"]))
  170. current_version = current.row_version
  171. moved = b3_client.put(
  172. f"/api/v1/documents/{main['id']}",
  173. json={
  174. "documentName": main["documentName"],
  175. "summary": main["summary"],
  176. "categoryId": leaf_two,
  177. "securityLevel": main["securityLevel"],
  178. "tags": main["tags"],
  179. "rowVersion": current_version,
  180. },
  181. headers=_auth(token),
  182. )
  183. assert moved.status_code == 200
  184. with b3_app.app_context():
  185. persisted_sub = db.session.get(Document, int(sub["id"]))
  186. assert str(persisted_sub.category_id) == leaf_two
  187. assert persisted_sub.category_name == moved.get_json()["data"]["categoryName"]
  188. def test_include_descendants_and_full_subtree_count(
  189. b3_app, b3_client, token_for
  190. ):
  191. from dms.extensions import db
  192. from dms.models import AuditLog, User
  193. admin_token = token_for("admin")
  194. user_token = token_for("user")
  195. root_id = _category_id(b3_app, "ROOT_A")
  196. leaf_id = _category_id(b3_app, "CHILD_A1")
  197. created = _upload(
  198. b3_client,
  199. admin_token,
  200. _main_metadata(leaf_id, "Q3递归主案"),
  201. _docx_bytes(),
  202. "docx",
  203. ).get_json()["data"]
  204. exact = b3_client.get(
  205. f"/api/v1/documents?categoryId={root_id}", headers=_auth(admin_token)
  206. ).get_json()["data"]
  207. recursive = b3_client.get(
  208. f"/api/v1/documents?categoryId={root_id}&includeDescendants=true",
  209. headers=_auth(admin_token),
  210. ).get_json()["data"]
  211. all_categories = b3_client.get(
  212. "/api/v1/documents", headers=_auth(admin_token)
  213. ).get_json()["data"]
  214. assert exact["total"] == 0
  215. assert recursive["total"] == 1
  216. assert all_categories["total"] == 1
  217. invalid = b3_client.get(
  218. f"/api/v1/documents?categoryId={root_id}&includeDescendants=yes",
  219. headers=_auth(admin_token),
  220. )
  221. assert invalid.status_code == 400
  222. admin_tree = b3_client.get(
  223. "/api/v1/categories/tree", headers=_auth(admin_token)
  224. ).get_json()["data"]
  225. user_tree = b3_client.get(
  226. "/api/v1/categories/tree", headers=_auth(user_token)
  227. ).get_json()["data"]
  228. assert _tree_node(admin_tree, root_id)["documentCount"] == 1
  229. # 计数口径为子树全部主体方案,不按密级/权限过滤,普通用户看到相同数字。
  230. assert _tree_node(user_tree, root_id)["documentCount"] == 1
  231. with b3_app.app_context():
  232. user = db.session.scalar(db.select(User).where(User.username == "user"))
  233. audit_before = db.session.scalar(
  234. db.select(db.func.count()).select_from(AuditLog)
  235. )
  236. saved = b3_client.put(
  237. f"/api/v1/documents/{created['id']}/permissions",
  238. json={
  239. "visibilityType": "CUSTOM",
  240. "documentRowVersion": created["rowVersion"],
  241. "entries": [
  242. {
  243. "subjectType": "USER",
  244. "subjectId": str(user.id),
  245. "canView": True,
  246. "canDownload": True,
  247. "canEdit": False,
  248. "canManagePermission": False,
  249. "canDelete": False,
  250. }
  251. ],
  252. },
  253. headers=_auth(admin_token),
  254. )
  255. assert saved.status_code == 200
  256. keyword_tree = b3_client.get(
  257. "/api/v1/categories/tree?keyword=CHILD_A1",
  258. headers=_auth(user_token),
  259. ).get_json()["data"]
  260. assert _tree_node(keyword_tree, root_id)["documentCount"] == 1
  261. with b3_app.app_context():
  262. audit_after = db.session.scalar(
  263. db.select(db.func.count()).select_from(AuditLog)
  264. )
  265. assert audit_after == audit_before + 1 # 仅权限配置产生审计,树查询不产生。
  266. def test_docx_pdf_content_search_and_original_preview(
  267. b3_app, b3_client, token_for
  268. ):
  269. from dms.extensions import db
  270. from dms.models import AuditLog, Document
  271. token = token_for("admin")
  272. leaf_id = _category_id(b3_app, "CHILD_A1")
  273. docx_content = _docx_bytes("只在DOCX正文中的检索词")
  274. docx_item = _upload(
  275. b3_client,
  276. token,
  277. _main_metadata(leaf_id, "Q3-DOCX"),
  278. docx_content,
  279. "docx",
  280. ).get_json()["data"]
  281. pdf_content = _pdf_bytes("OnlyInsidePdfBody")
  282. pdf_item = _upload(
  283. b3_client,
  284. token,
  285. _main_metadata(leaf_id, "Q3-PDF"),
  286. pdf_content,
  287. "pdf",
  288. ).get_json()["data"]
  289. for keyword, expected_id in (
  290. ("只在DOCX正文中的检索词", docx_item["id"]),
  291. ("表格唯一词", docx_item["id"]),
  292. ("页眉唯一词", docx_item["id"]),
  293. ("页脚唯一词", docx_item["id"]),
  294. ("OnlyInsidePdfBody", pdf_item["id"]),
  295. ):
  296. result = b3_client.get(
  297. f"/api/v1/documents?contentKeyword={keyword}", headers=_auth(token)
  298. ).get_json()["data"]
  299. assert [item["id"] for item in result["items"]] == [expected_id]
  300. with b3_app.app_context():
  301. before_download = {
  302. item.id: item.download_count
  303. for item in db.session.scalars(
  304. db.select(Document).where(
  305. Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])])
  306. )
  307. )
  308. }
  309. before_audit = db.session.scalar(
  310. db.select(db.func.count(AuditLog.id)).where(
  311. AuditLog.action_type == "DOWNLOAD_DOCUMENT"
  312. )
  313. )
  314. for item, content, mime in (
  315. (
  316. docx_item,
  317. docx_content,
  318. "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  319. ),
  320. (pdf_item, pdf_content, "application/pdf"),
  321. ):
  322. response = b3_client.get(
  323. f"/api/v1/documents/{item['id']}/preview",
  324. headers={**_auth(token), "Origin": "http://127.0.0.1:9346"},
  325. )
  326. assert response.status_code == 200
  327. assert response.mimetype == mime
  328. assert response.data == content
  329. assert response.headers["Content-Disposition"].startswith("inline")
  330. assert "filename*=" in response.headers["Content-Disposition"]
  331. assert response.headers["X-Content-Type-Options"] == "nosniff"
  332. assert response.headers["Cache-Control"] == "private, no-store"
  333. assert "Content-Disposition" in response.headers[
  334. "Access-Control-Expose-Headers"
  335. ]
  336. response.close()
  337. with b3_app.app_context():
  338. after_download = {
  339. item.id: item.download_count
  340. for item in db.session.scalars(
  341. db.select(Document).where(
  342. Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])])
  343. )
  344. )
  345. }
  346. after_audit = db.session.scalar(
  347. db.select(db.func.count(AuditLog.id)).where(
  348. AuditLog.action_type == "DOWNLOAD_DOCUMENT"
  349. )
  350. )
  351. assert after_download == before_download
  352. assert after_audit == before_audit
  353. def test_attachment_library_list_open_but_content_gated_for_user(
  354. b3_app, b3_client, token_for
  355. ):
  356. admin_token = token_for("admin")
  357. user_token = token_for("user")
  358. response = b3_client.post(
  359. "/api/v1/attachments",
  360. data={
  361. "file": (io.BytesIO(_docx_bytes()), "Q3附件.docx"),
  362. "metadata": json.dumps(
  363. {
  364. "documentName": "Q3未挂载附件",
  365. "attachmentType": "OTHER",
  366. "summary": "未挂载",
  367. "tags": ["Q3"],
  368. },
  369. ensure_ascii=False,
  370. ),
  371. },
  372. headers=_auth(admin_token),
  373. content_type="multipart/form-data",
  374. )
  375. attachment = response.get_json()["data"]
  376. # 列表全量可见:普通用户可通过标题检索到全部附件,但 allowedActions 为空,
  377. # 详细说明与正文片段不返回;详情/预览仍按挂载主案权限拦截。
  378. listing = b3_client.get(
  379. "/api/v1/attachments?keyword=Q3未挂载", headers=_auth(user_token)
  380. )
  381. assert listing.status_code == 200
  382. listed = listing.get_json()["data"]["items"]
  383. assert [item["id"] for item in listed] == [attachment["id"]]
  384. assert listed[0]["allowedActions"] == []
  385. assert listed[0]["description"] is None
  386. assert "contentSnippet" not in listed[0]
  387. assert b3_client.get(
  388. f"/api/v1/attachments/{attachment['id']}", headers=_auth(user_token)
  389. ).status_code == 403
  390. assert b3_client.get(
  391. f"/api/v1/documents/{attachment['id']}/preview",
  392. headers=_auth(user_token),
  393. ).status_code == 403
  394. def test_backfill_gate_requires_explicit_dms_test_url(monkeypatch, b2_app):
  395. from dms.backfill_document_content import _database_gate
  396. with b2_app.app_context():
  397. monkeypatch.delenv("DMS_DATABASE_URL", raising=False)
  398. with pytest.raises(RuntimeError, match="DMS_DATABASE_URL"):
  399. _database_gate()
  400. monkeypatch.setenv(
  401. "DMS_DATABASE_URL",
  402. "mysql+pymysql://hidden:hidden@localhost:3306/dms",
  403. )
  404. with pytest.raises(RuntimeError, match="dms_test"):
  405. _database_gate()
  406. def test_q3_openapi_contract_has_no_new_preview_path():
  407. import yaml
  408. root = Path(__file__).resolve().parents[2] / "openapi"
  409. document = yaml.safe_load((root / "openapi.yaml").read_text("utf-8"))
  410. schemas = yaml.safe_load(
  411. (root / "components" / "schemas.yaml").read_text("utf-8")
  412. )
  413. paths = yaml.safe_load(
  414. (root / "paths" / "documents.yaml").read_text("utf-8")
  415. )
  416. assert document["info"]["version"] == "1.16.0"
  417. assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
  418. parameters = paths["Documents"]["get"]["parameters"]
  419. assert any(item.get("name") == "includeDescendants" for item in parameters)
  420. preview_content = paths["DocumentPreview"]["get"]["responses"]["200"][
  421. "content"
  422. ]
  423. assert set(preview_content) == {"application/pdf"}
  424. assert all("content-preview" not in path for path in document["paths"])