test_q3b_business_content_preview.py 16 KB

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