test_q3b_business_content_preview.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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_permission_aware_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. assert _tree_node(user_tree, root_id)["documentCount"] == 0
  230. with b3_app.app_context():
  231. user = db.session.scalar(db.select(User).where(User.username == "user"))
  232. audit_before = db.session.scalar(
  233. db.select(db.func.count()).select_from(AuditLog)
  234. )
  235. saved = b3_client.put(
  236. f"/api/v1/documents/{created['id']}/permissions",
  237. json={
  238. "visibilityType": "CUSTOM",
  239. "documentRowVersion": created["rowVersion"],
  240. "entries": [
  241. {
  242. "subjectType": "USER",
  243. "subjectId": str(user.id),
  244. "canView": True,
  245. "canDownload": True,
  246. "canEdit": False,
  247. "canManagePermission": False,
  248. "canDelete": False,
  249. }
  250. ],
  251. },
  252. headers=_auth(admin_token),
  253. )
  254. assert saved.status_code == 200
  255. keyword_tree = b3_client.get(
  256. "/api/v1/categories/tree?keyword=CHILD_A1",
  257. headers=_auth(user_token),
  258. ).get_json()["data"]
  259. assert _tree_node(keyword_tree, root_id)["documentCount"] == 1
  260. with b3_app.app_context():
  261. audit_after = db.session.scalar(
  262. db.select(db.func.count()).select_from(AuditLog)
  263. )
  264. assert audit_after == audit_before + 1 # 仅权限配置产生审计,树查询不产生。
  265. def test_docx_pdf_content_search_and_original_preview(
  266. b3_app, b3_client, token_for
  267. ):
  268. from dms.extensions import db
  269. from dms.models import AuditLog, Document
  270. token = token_for("admin")
  271. leaf_id = _category_id(b3_app, "CHILD_A1")
  272. docx_content = _docx_bytes("只在DOCX正文中的检索词")
  273. docx_item = _upload(
  274. b3_client,
  275. token,
  276. _main_metadata(leaf_id, "Q3-DOCX"),
  277. docx_content,
  278. "docx",
  279. ).get_json()["data"]
  280. pdf_content = _pdf_bytes("OnlyInsidePdfBody")
  281. pdf_item = _upload(
  282. b3_client,
  283. token,
  284. _main_metadata(leaf_id, "Q3-PDF"),
  285. pdf_content,
  286. "pdf",
  287. ).get_json()["data"]
  288. for keyword, expected_id in (
  289. ("只在DOCX正文中的检索词", docx_item["id"]),
  290. ("表格唯一词", docx_item["id"]),
  291. ("页眉唯一词", docx_item["id"]),
  292. ("页脚唯一词", docx_item["id"]),
  293. ("OnlyInsidePdfBody", pdf_item["id"]),
  294. ):
  295. result = b3_client.get(
  296. f"/api/v1/documents?keyword={keyword}", headers=_auth(token)
  297. ).get_json()["data"]
  298. assert [item["id"] for item in result["items"]] == [expected_id]
  299. with b3_app.app_context():
  300. before_download = {
  301. item.id: item.download_count
  302. for item in db.session.scalars(
  303. db.select(Document).where(
  304. Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])])
  305. )
  306. )
  307. }
  308. before_audit = db.session.scalar(
  309. db.select(db.func.count(AuditLog.id)).where(
  310. AuditLog.action_type == "DOWNLOAD_DOCUMENT"
  311. )
  312. )
  313. for item, content, mime in (
  314. (
  315. docx_item,
  316. docx_content,
  317. "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  318. ),
  319. (pdf_item, pdf_content, "application/pdf"),
  320. ):
  321. response = b3_client.get(
  322. f"/api/v1/documents/{item['id']}/preview",
  323. headers={**_auth(token), "Origin": "http://127.0.0.1:9346"},
  324. )
  325. assert response.status_code == 200
  326. assert response.mimetype == mime
  327. assert response.data == content
  328. assert response.headers["Content-Disposition"].startswith("inline")
  329. assert "filename*=" in response.headers["Content-Disposition"]
  330. assert response.headers["X-Content-Type-Options"] == "nosniff"
  331. assert response.headers["Cache-Control"] == "private, no-store"
  332. assert "Content-Disposition" in response.headers[
  333. "Access-Control-Expose-Headers"
  334. ]
  335. response.close()
  336. with b3_app.app_context():
  337. after_download = {
  338. item.id: item.download_count
  339. for item in db.session.scalars(
  340. db.select(Document).where(
  341. Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])])
  342. )
  343. )
  344. }
  345. after_audit = db.session.scalar(
  346. db.select(db.func.count(AuditLog.id)).where(
  347. AuditLog.action_type == "DOWNLOAD_DOCUMENT"
  348. )
  349. )
  350. assert after_download == before_download
  351. assert after_audit == before_audit
  352. def test_attachment_library_is_admin_only_and_user_cannot_guess_unmounted_id(
  353. b3_app, b3_client, token_for
  354. ):
  355. admin_token = token_for("admin")
  356. user_token = token_for("user")
  357. response = b3_client.post(
  358. "/api/v1/attachments",
  359. data={
  360. "file": (io.BytesIO(_docx_bytes()), "Q3附件.docx"),
  361. "metadata": json.dumps(
  362. {
  363. "documentName": "Q3未挂载附件",
  364. "attachmentType": "OTHER",
  365. "summary": "未挂载",
  366. "tags": ["Q3"],
  367. },
  368. ensure_ascii=False,
  369. ),
  370. },
  371. headers=_auth(admin_token),
  372. content_type="multipart/form-data",
  373. )
  374. attachment = response.get_json()["data"]
  375. assert b3_client.get(
  376. "/api/v1/attachments", headers=_auth(user_token)
  377. ).status_code == 403
  378. assert b3_client.get(
  379. f"/api/v1/attachments/{attachment['id']}", headers=_auth(user_token)
  380. ).status_code == 403
  381. assert b3_client.get(
  382. f"/api/v1/documents/{attachment['id']}/preview",
  383. headers=_auth(user_token),
  384. ).status_code == 403
  385. def test_backfill_gate_requires_explicit_dms_test_url(monkeypatch, b2_app):
  386. from dms.backfill_document_content import _database_gate
  387. with b2_app.app_context():
  388. monkeypatch.delenv("DMS_DATABASE_URL", raising=False)
  389. with pytest.raises(RuntimeError, match="DMS_DATABASE_URL"):
  390. _database_gate()
  391. monkeypatch.setenv(
  392. "DMS_DATABASE_URL",
  393. "mysql+pymysql://hidden:hidden@localhost:3306/dms",
  394. )
  395. with pytest.raises(RuntimeError, match="dms_test"):
  396. _database_gate()
  397. def test_q3_openapi_contract_has_no_new_preview_path():
  398. import yaml
  399. root = Path(__file__).resolve().parents[2] / "openapi"
  400. document = yaml.safe_load((root / "openapi.yaml").read_text("utf-8"))
  401. schemas = yaml.safe_load(
  402. (root / "components" / "schemas.yaml").read_text("utf-8")
  403. )
  404. paths = yaml.safe_load(
  405. (root / "paths" / "documents.yaml").read_text("utf-8")
  406. )
  407. assert document["info"]["version"] == "1.6.0"
  408. assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
  409. parameters = paths["Documents"]["get"]["parameters"]
  410. assert any(item.get("name") == "includeDescendants" for item in parameters)
  411. preview_content = paths["DocumentPreview"]["get"]["responses"]["200"][
  412. "content"
  413. ]
  414. assert set(preview_content) == {"application/pdf"}
  415. assert all("content-preview" not in path for path in document["paths"])