q3b_http_verify.py 18 KB

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