q3b_http_verify.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. """Q3-B 8755短生命周期真实HTTP闭环验证工具。"""
  2. from __future__ import annotations
  3. import io
  4. import json
  5. import os
  6. import struct
  7. import sys
  8. import threading
  9. from pathlib import Path
  10. from urllib.parse import urlparse
  11. import fitz
  12. import requests
  13. from docx import Document as WordDocument
  14. from sqlalchemy import func, select, text
  15. from werkzeug.serving import make_server
  16. BACKEND_ROOT = Path(__file__).resolve().parents[2]
  17. if str(BACKEND_ROOT) not in sys.path:
  18. sys.path.insert(0, str(BACKEND_ROOT))
  19. def _assert_test_database(database_url: str) -> None:
  20. if urlparse(database_url).path.lstrip("/") != "dms_test":
  21. raise RuntimeError("Q3-B真实HTTP验证只允许连接dms_test")
  22. def _docx_bytes(term: str) -> bytes:
  23. stream = io.BytesIO()
  24. document = WordDocument()
  25. document.add_paragraph(term)
  26. table = document.add_table(rows=1, cols=1)
  27. table.cell(0, 0).text = "Q3HttpDocxTableTerm"
  28. document.save(stream)
  29. return stream.getvalue()
  30. def _pdf_bytes(term: str) -> 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 _doc_bytes() -> bytes:
  38. def entry(name: str, kind: int, child: int = 0xFFFFFFFF) -> bytes:
  39. value = bytearray(128)
  40. encoded = (name + "\0").encode("utf-16le")
  41. value[: len(encoded)] = encoded
  42. struct.pack_into("<H", value, 64, len(encoded))
  43. value[66] = kind
  44. value[67] = 1
  45. struct.pack_into("<III", value, 68, 0xFFFFFFFF, 0xFFFFFFFF, child)
  46. struct.pack_into("<I", value, 116, 0xFFFFFFFE)
  47. return bytes(value)
  48. header = bytearray(512)
  49. header[:8] = bytes.fromhex("D0CF11E0A1B11AE1")
  50. struct.pack_into("<HH", header, 24, 0x003E, 3)
  51. struct.pack_into("<H", header, 28, 0xFFFE)
  52. struct.pack_into("<HH", header, 30, 9, 6)
  53. struct.pack_into("<I", header, 44, 1)
  54. struct.pack_into("<I", header, 48, 0)
  55. struct.pack_into("<I", header, 56, 4096)
  56. struct.pack_into("<I", header, 60, 0xFFFFFFFE)
  57. struct.pack_into("<I", header, 68, 0xFFFFFFFE)
  58. struct.pack_into("<I", header, 76, 1)
  59. for index in range(1, 109):
  60. struct.pack_into("<I", header, 76 + 4 * index, 0xFFFFFFFF)
  61. directory = bytearray(512)
  62. directory[:128] = entry("Root Entry", 5, 1)
  63. directory[128:256] = entry("WordDocument", 2)
  64. fat = bytearray(b"\xff" * 512)
  65. struct.pack_into("<I", fat, 0, 0xFFFFFFFE)
  66. struct.pack_into("<I", fat, 4, 0xFFFFFFFD)
  67. return bytes(header + directory + fat)
  68. def _token(base_url: str, username: str, password: str) -> str:
  69. response = requests.post(
  70. f"{base_url}/auth/login",
  71. json={
  72. "username": username,
  73. "password": password,
  74. "keepSignedIn": False,
  75. },
  76. timeout=10,
  77. )
  78. response.raise_for_status()
  79. return response.json()["data"]["accessToken"]
  80. def _headers(token: str) -> dict[str, str]:
  81. return {"Authorization": f"Bearer {token}"}
  82. def _tree_count(nodes: list[dict], category_id: str) -> int:
  83. pending = list(nodes)
  84. while pending:
  85. node = pending.pop()
  86. if node["id"] == category_id:
  87. return node["documentCount"]
  88. pending.extend(node["children"])
  89. raise AssertionError(f"category {category_id} not found")
  90. def _upload(
  91. base_url: str,
  92. token: str,
  93. path: str,
  94. filename: str,
  95. content: bytes,
  96. metadata: dict,
  97. ) -> dict:
  98. response = requests.post(
  99. f"{base_url}{path}",
  100. headers=_headers(token),
  101. files={"file": (filename, content)},
  102. data={"metadata": json.dumps(metadata, ensure_ascii=False)},
  103. timeout=20,
  104. )
  105. response.raise_for_status()
  106. return response.json()["data"]
  107. def main() -> None:
  108. database_url = os.environ.get("DMS_DATABASE_URL", "")
  109. _assert_test_database(database_url)
  110. admin_password = os.environ["DMS_HTTP_ADMIN_PASSWORD"]
  111. user_password = os.environ["DMS_HTTP_USER_PASSWORD"]
  112. from app import app
  113. from dms.extensions import db
  114. from dms.models import AuditLog, Category, Document, User
  115. with app.app_context():
  116. assert db.session.execute(text("SELECT DATABASE()" )).scalar() == "dms_test"
  117. leaf = db.session.scalar(
  118. select(Category).where(Category.category_code == "STYLE_A1")
  119. )
  120. parent = db.session.get(Category, leaf.parent_id)
  121. user = db.session.scalar(select(User).where(User.username == "user"))
  122. leaf_id, parent_id, user_id = str(leaf.id), str(parent.id), str(user.id)
  123. server = make_server("127.0.0.1", 8755, app, threaded=True)
  124. thread = threading.Thread(target=server.serve_forever, daemon=True)
  125. thread.start()
  126. base_url = "http://127.0.0.1:8755/api/v1"
  127. created: list[tuple[str, str, int]] = []
  128. main_version = 0
  129. result: dict[str, object] = {"port": 8755, "database": "dms_test"}
  130. try:
  131. admin_token = _token(base_url, "admin", admin_password)
  132. user_token = _token(base_url, "user", user_password)
  133. for path in ("attachments", "documents"):
  134. stale = requests.get(
  135. f"{base_url}/{path}",
  136. params={"keyword": "Q3_HTTP_", "pageSize": 100},
  137. headers=_headers(admin_token),
  138. timeout=10,
  139. )
  140. stale.raise_for_status()
  141. for item in stale.json()["data"]["items"]:
  142. if item["documentName"].startswith("Q3_HTTP_"):
  143. removed = requests.delete(
  144. f"{base_url}/{path}/{item['id']}",
  145. params={"rowVersion": item["rowVersion"]},
  146. headers=_headers(admin_token),
  147. timeout=10,
  148. )
  149. removed.raise_for_status()
  150. auditor = requests.post(
  151. f"{base_url}/auth/login",
  152. json={
  153. "username": "auditor",
  154. "password": admin_password,
  155. "keepSignedIn": False,
  156. },
  157. timeout=10,
  158. )
  159. assert auditor.status_code == 401
  160. me = requests.get(
  161. f"{base_url}/users/me", headers=_headers(admin_token), timeout=10
  162. )
  163. me.raise_for_status()
  164. assert me.json()["data"]["allowedModules"] == [
  165. "DOCUMENT_BROWSER",
  166. "BACKEND_MANAGEMENT",
  167. "AUDIT_LOG",
  168. ]
  169. audit_api = requests.get(
  170. f"{base_url}/audit/logs", headers=_headers(admin_token), timeout=10
  171. )
  172. audit_api.raise_for_status()
  173. user_library = requests.get(
  174. f"{base_url}/attachments", headers=_headers(user_token), timeout=10
  175. )
  176. assert user_library.status_code == 403
  177. baseline_all = requests.get(
  178. f"{base_url}/documents", headers=_headers(admin_token), timeout=10
  179. )
  180. baseline_all.raise_for_status()
  181. baseline_recursive = requests.get(
  182. f"{base_url}/documents",
  183. params={"categoryId": parent_id, "includeDescendants": "true"},
  184. headers=_headers(admin_token),
  185. timeout=10,
  186. )
  187. baseline_recursive.raise_for_status()
  188. baseline_tree_counts = {}
  189. for username, token in (("admin", admin_token), ("user", user_token)):
  190. response = requests.get(
  191. f"{base_url}/categories/tree", headers=_headers(token), timeout=10
  192. )
  193. response.raise_for_status()
  194. baseline_tree_counts[username] = _tree_count(
  195. response.json()["data"], parent_id
  196. )
  197. docx_content = _docx_bytes("Q3HttpDocxBodyOnlyTerm")
  198. main = _upload(
  199. base_url,
  200. admin_token,
  201. "/documents",
  202. "q3-http.docx",
  203. docx_content,
  204. {
  205. "documentName": "Q3_HTTP_DOCX",
  206. "documentType": "MAIN",
  207. "categoryId": leaf_id,
  208. "securityLevel": "INTERNAL",
  209. "summary": "真实HTTP验证",
  210. "tags": ["Q3HTTP"],
  211. },
  212. )
  213. created.append(("document", main["id"], main["rowVersion"]))
  214. assert main["status"] == "PUBLISHED"
  215. assert main["visibilityType"] == "CUSTOM"
  216. sub = _upload(
  217. base_url,
  218. admin_token,
  219. "/documents",
  220. "q3-http-sub.docx",
  221. _docx_bytes("Q3HttpSubBodyTerm"),
  222. {
  223. "documentName": "Q3_HTTP_SUB",
  224. "documentType": "SUB_PLAN",
  225. "parentDocumentId": main["id"],
  226. "securityLevel": "INTERNAL",
  227. "summary": "真实HTTP子方案",
  228. "tags": ["Q3HTTP"],
  229. },
  230. )
  231. created.append(("document", sub["id"], sub["rowVersion"]))
  232. assert sub["categoryId"] == leaf_id
  233. pdf_content = _pdf_bytes("Q3HttpPdfBodyOnlyTerm")
  234. pdf = _upload(
  235. base_url,
  236. admin_token,
  237. "/documents",
  238. "q3-http.pdf",
  239. pdf_content,
  240. {
  241. "documentName": "Q3_HTTP_PDF",
  242. "documentType": "MAIN",
  243. "categoryId": leaf_id,
  244. "securityLevel": "INTERNAL",
  245. "summary": "真实HTTP PDF",
  246. "tags": ["Q3HTTP"],
  247. },
  248. )
  249. created.append(("document", pdf["id"], pdf["rowVersion"]))
  250. attachment = _upload(
  251. base_url,
  252. admin_token,
  253. "/attachments",
  254. "q3-http-attachment.docx",
  255. _docx_bytes("Q3HttpAttachmentTerm"),
  256. {
  257. "documentName": "Q3_HTTP_ATTACHMENT",
  258. "attachmentType": "OTHER",
  259. "summary": "真实HTTP附件",
  260. "tags": ["Q3HTTP"],
  261. },
  262. )
  263. created.append(("attachment", attachment["id"], attachment["rowVersion"]))
  264. legacy = _upload(
  265. base_url,
  266. admin_token,
  267. "/attachments",
  268. "q3-http.doc",
  269. _doc_bytes(),
  270. {
  271. "documentName": "Q3_HTTP_DOC",
  272. "attachmentType": "OTHER",
  273. "summary": "旧版DOC",
  274. "tags": ["Q3HTTP"],
  275. },
  276. )
  277. created.append(("attachment", legacy["id"], legacy["rowVersion"]))
  278. current_main = requests.get(
  279. f"{base_url}/documents/{main['id']}",
  280. headers=_headers(admin_token),
  281. timeout=10,
  282. )
  283. current_main.raise_for_status()
  284. main_version = current_main.json()["data"]["rowVersion"]
  285. permission = requests.put(
  286. f"{base_url}/documents/{main['id']}/permissions",
  287. headers=_headers(admin_token),
  288. json={
  289. "visibilityType": "CUSTOM",
  290. "documentRowVersion": main_version,
  291. "entries": [
  292. {
  293. "subjectType": "USER",
  294. "subjectId": user_id,
  295. "canView": True,
  296. "canDownload": True,
  297. "canEdit": False,
  298. "canManagePermission": False,
  299. "canDelete": False,
  300. }
  301. ],
  302. },
  303. timeout=10,
  304. )
  305. permission.raise_for_status()
  306. main_version = permission.json()["data"]["documentRowVersion"]
  307. binding = requests.post(
  308. f"{base_url}/main-plans/{main['id']}/attachments/bind",
  309. headers=_headers(admin_token),
  310. json={
  311. "attachmentIds": [attachment["id"]],
  312. "mainPlanRowVersion": main_version,
  313. },
  314. timeout=10,
  315. )
  316. binding.raise_for_status()
  317. main_version = binding.json()["data"]["mainPlanRowVersion"]
  318. mounted = requests.get(
  319. f"{base_url}/attachments/{attachment['id']}",
  320. headers=_headers(user_token),
  321. timeout=10,
  322. )
  323. mounted.raise_for_status()
  324. with app.app_context():
  325. audit_before_queries = db.session.scalar(
  326. select(func.count()).select_from(AuditLog)
  327. )
  328. recursive = requests.get(
  329. f"{base_url}/documents",
  330. params={"categoryId": parent_id, "includeDescendants": "true"},
  331. headers=_headers(admin_token),
  332. timeout=10,
  333. )
  334. recursive.raise_for_status()
  335. assert recursive.json()["data"]["total"] == (
  336. baseline_recursive.json()["data"]["total"] + 2
  337. )
  338. all_documents = requests.get(
  339. f"{base_url}/documents", headers=_headers(admin_token), timeout=10
  340. )
  341. all_documents.raise_for_status()
  342. assert all_documents.json()["data"]["total"] == (
  343. baseline_all.json()["data"]["total"] + 2
  344. )
  345. current_tree_counts = {}
  346. for username, token in (("admin", admin_token), ("user", user_token)):
  347. tree_response = requests.get(
  348. f"{base_url}/categories/tree", headers=_headers(token), timeout=10
  349. )
  350. tree_response.raise_for_status()
  351. current_tree_counts[username] = _tree_count(
  352. tree_response.json()["data"], parent_id
  353. )
  354. assert current_tree_counts["admin"] == baseline_tree_counts["admin"] + 2
  355. assert current_tree_counts["user"] == baseline_tree_counts["user"] + 1
  356. for term, expected_id in (
  357. ("Q3HttpDocxBodyOnlyTerm", main["id"]),
  358. ("Q3HttpPdfBodyOnlyTerm", pdf["id"]),
  359. ):
  360. search = requests.get(
  361. f"{base_url}/documents",
  362. params={"keyword": term},
  363. headers=_headers(admin_token),
  364. timeout=10,
  365. )
  366. search.raise_for_status()
  367. assert [item["id"] for item in search.json()["data"]["items"]] == [
  368. expected_id
  369. ]
  370. with app.app_context():
  371. audit_after_queries = db.session.scalar(
  372. select(func.count()).select_from(AuditLog)
  373. )
  374. download_before = {
  375. row.id: row.download_count
  376. for row in db.session.scalars(
  377. select(Document).where(
  378. Document.id.in_([int(main["id"]), int(pdf["id"])])
  379. )
  380. )
  381. }
  382. assert audit_after_queries == audit_before_queries
  383. docx_preview = requests.get(
  384. f"{base_url}/documents/{main['id']}/preview",
  385. headers=_headers(admin_token),
  386. timeout=10,
  387. )
  388. docx_preview.raise_for_status()
  389. assert docx_preview.content == docx_content
  390. assert docx_preview.headers["Content-Type"].startswith(
  391. "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  392. )
  393. assert docx_preview.headers["Content-Disposition"].startswith("inline")
  394. assert "filename*=" in docx_preview.headers["Content-Disposition"]
  395. pdf_preview = requests.get(
  396. f"{base_url}/documents/{pdf['id']}/preview",
  397. headers=_headers(admin_token),
  398. timeout=10,
  399. )
  400. pdf_preview.raise_for_status()
  401. assert pdf_preview.content == pdf_content
  402. assert pdf_preview.headers["Content-Type"].startswith("application/pdf")
  403. legacy_preview = requests.get(
  404. f"{base_url}/documents/{legacy['id']}/preview",
  405. headers=_headers(admin_token),
  406. timeout=10,
  407. )
  408. assert legacy_preview.status_code == 415
  409. assert legacy_preview.json()["code"] == "PREVIEW_UNAVAILABLE"
  410. with app.app_context():
  411. download_after = {
  412. row.id: row.download_count
  413. for row in db.session.scalars(
  414. select(Document).where(
  415. Document.id.in_([int(main["id"]), int(pdf["id"])])
  416. )
  417. )
  418. }
  419. assert download_after == download_before
  420. result.update(
  421. {
  422. "adminLogin": 200,
  423. "userLogin": 200,
  424. "auditorLogin": auditor.status_code,
  425. "adminModules": me.json()["data"]["allowedModules"],
  426. "adminAudit": audit_api.status_code,
  427. "userAttachmentLibrary": user_library.status_code,
  428. "userMountedAttachment": mounted.status_code,
  429. "recursiveDocumentDelta": 2,
  430. "allDocumentDelta": 2,
  431. "adminTreeCountDelta": 2,
  432. "userTreeCountDelta": 1,
  433. "docxPreview": docx_preview.status_code,
  434. "pdfPreview": pdf_preview.status_code,
  435. "docPreview": legacy_preview.status_code,
  436. "bodySearch": True,
  437. "queryAuditUnchanged": True,
  438. "previewDownloadCountUnchanged": True,
  439. }
  440. )
  441. finally:
  442. if "admin_token" in locals():
  443. if "main" in locals() and "attachment" in locals() and main_version:
  444. response = requests.delete(
  445. f"{base_url}/main-plans/{main['id']}/attachments/"
  446. f"{attachment['id']}",
  447. params={"mainPlanRowVersion": main_version},
  448. headers=_headers(admin_token),
  449. timeout=10,
  450. )
  451. if response.ok:
  452. main_version = response.json()["data"]["mainPlanRowVersion"]
  453. for kind, document_id, version in reversed(created):
  454. if "main" in locals() and document_id == main["id"]:
  455. current = requests.get(
  456. f"{base_url}/documents/{document_id}",
  457. headers=_headers(admin_token),
  458. timeout=10,
  459. )
  460. if current.ok:
  461. version = current.json()["data"]["rowVersion"]
  462. path = "attachments" if kind == "attachment" else "documents"
  463. requests.delete(
  464. f"{base_url}/{path}/{document_id}",
  465. params={"rowVersion": version},
  466. headers=_headers(admin_token),
  467. timeout=10,
  468. )
  469. server.shutdown()
  470. server.server_close()
  471. thread.join(timeout=5)
  472. with app.app_context():
  473. active_left = db.session.scalar(
  474. select(func.count()).select_from(Document).where(
  475. Document.document_name.like("Q3_HTTP_%"),
  476. Document.is_deleted.is_(False),
  477. )
  478. )
  479. assert active_left == 0
  480. result["activeTemporaryDocumentsAfterCleanup"] = active_left
  481. print(json.dumps(result, ensure_ascii=False, sort_keys=True))
  482. if __name__ == "__main__":
  483. main()