test_b5_file_mutations.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. from __future__ import annotations
  2. import hashlib
  3. import io
  4. import json
  5. import struct
  6. import zipfile
  7. from pathlib import Path
  8. import pytest
  9. from docx import Document as WordDocument
  10. def auth(token: str) -> dict[str, str]:
  11. return {"Authorization": f"Bearer {token}"}
  12. def docx_bytes(text: str = "B5测试文档") -> bytes:
  13. stream = io.BytesIO()
  14. document = WordDocument()
  15. document.add_paragraph(text)
  16. document.save(stream)
  17. return stream.getvalue()
  18. def xlsx_bytes() -> bytes:
  19. stream = io.BytesIO()
  20. with zipfile.ZipFile(stream, "w") as archive:
  21. archive.writestr("[Content_Types].xml", "<Types/>")
  22. archive.writestr("xl/workbook.xml", "<workbook/>")
  23. return stream.getvalue()
  24. def fake_zip() -> bytes:
  25. stream = io.BytesIO()
  26. with zipfile.ZipFile(stream, "w") as archive:
  27. archive.writestr("hello.txt", "not office")
  28. return stream.getvalue()
  29. def cfb_bytes(stream_name: str) -> bytes:
  30. def entry(name: str, kind: int, child: int = 0xFFFFFFFF) -> bytes:
  31. value = bytearray(128)
  32. encoded = (name + "\0").encode("utf-16le")
  33. value[: len(encoded)] = encoded
  34. struct.pack_into("<H", value, 64, len(encoded))
  35. value[66] = kind
  36. value[67] = 1
  37. struct.pack_into(
  38. "<III", value, 68, 0xFFFFFFFF, 0xFFFFFFFF, child
  39. )
  40. struct.pack_into("<I", value, 116, 0xFFFFFFFE)
  41. return bytes(value)
  42. header = bytearray(512)
  43. header[:8] = bytes.fromhex("D0CF11E0A1B11AE1")
  44. struct.pack_into("<HH", header, 24, 0x003E, 3)
  45. struct.pack_into("<H", header, 28, 0xFFFE)
  46. struct.pack_into("<HH", header, 30, 9, 6)
  47. struct.pack_into("<I", header, 44, 1)
  48. struct.pack_into("<I", header, 48, 0)
  49. struct.pack_into("<I", header, 56, 4096)
  50. struct.pack_into("<I", header, 60, 0xFFFFFFFE)
  51. struct.pack_into("<I", header, 68, 0xFFFFFFFE)
  52. struct.pack_into("<I", header, 76, 1)
  53. for index in range(1, 109):
  54. struct.pack_into("<I", header, 76 + 4 * index, 0xFFFFFFFF)
  55. directory = bytearray(512)
  56. directory[:128] = entry("Root Entry", 5, 1)
  57. directory[128:256] = entry(stream_name, 2)
  58. fat = bytearray(b"\xff" * 512)
  59. struct.pack_into("<I", fat, 0, 0xFFFFFFFE)
  60. struct.pack_into("<I", fat, 4, 0xFFFFFFFD)
  61. return bytes(header + directory + fat)
  62. def category_id(app) -> str:
  63. from dms.extensions import db
  64. from dms.models import Category
  65. with app.app_context():
  66. category = db.session.scalar(
  67. db.select(Category).where(Category.category_code == "CHILD_A1")
  68. )
  69. return str(category.id)
  70. def main_metadata(app, name: str = "B5_TEST_主案") -> dict:
  71. return {
  72. "documentName": name,
  73. "documentType": "MAIN",
  74. "summary": "B5上传测试",
  75. "categoryId": category_id(app),
  76. "securityLevel": "INTERNAL",
  77. "tags": ["B5", "上传"],
  78. }
  79. def attachment_metadata(name: str = "B5_TEST_附件") -> dict:
  80. return {
  81. "documentName": name,
  82. "attachmentType": "POLICY",
  83. "summary": "B5附件",
  84. "tags": ["B5"],
  85. }
  86. def upload(client, token, path, content, metadata):
  87. return client.post(
  88. path,
  89. data={
  90. "file": (io.BytesIO(content), metadata["documentName"] + ".docx"),
  91. "metadata": json.dumps(metadata, ensure_ascii=False),
  92. },
  93. headers=auth(token),
  94. content_type="multipart/form-data",
  95. )
  96. def test_main_upload_persists_relative_uuid_file_and_hash(
  97. b5_app, b5_client, token_for
  98. ):
  99. from dms.extensions import db
  100. from dms.models import Document
  101. content = docx_bytes()
  102. response = upload(
  103. b5_client,
  104. token_for("admin"),
  105. "/api/v1/documents",
  106. content,
  107. main_metadata(b5_app),
  108. )
  109. assert response.status_code == 201
  110. item = response.get_json()["data"]
  111. assert item["id"].isdecimal()
  112. assert "fileRelativePath" not in item
  113. with b5_app.app_context():
  114. document = db.session.get(Document, int(item["id"]))
  115. relative = Path(document.file_relative_path)
  116. assert not relative.is_absolute()
  117. assert relative.parent.as_posix() == "original"
  118. assert document.original_file_name == "B5_TEST_主案.docx"
  119. assert relative.name != document.original_file_name
  120. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / relative
  121. assert path.read_bytes() == content
  122. assert document.file_hash == hashlib.sha256(content).hexdigest()
  123. def test_sub_plan_upload_updates_parent_count(b5_app, b5_client, token_for):
  124. from dms.extensions import db
  125. from dms.models import Document
  126. token = token_for("admin")
  127. main = upload(
  128. b5_client,
  129. token,
  130. "/api/v1/documents",
  131. docx_bytes(),
  132. main_metadata(b5_app, "B5_TEST_父主案"),
  133. ).get_json()["data"]
  134. metadata = main_metadata(b5_app, "B5_TEST_子案")
  135. metadata["documentType"] = "SUB_PLAN"
  136. metadata["parentDocumentId"] = main["id"]
  137. metadata.pop("categoryId")
  138. child = upload(
  139. b5_client, token, "/api/v1/documents", docx_bytes(), metadata
  140. )
  141. assert child.status_code == 201
  142. assert child.get_json()["data"]["rootDocumentId"] == main["id"]
  143. with b5_app.app_context():
  144. assert db.session.get(Document, int(main["id"])).child_count == 1
  145. def test_duplicate_main_requires_confirmation_and_overwrites_same_record(
  146. b5_app, b5_client, token_for
  147. ):
  148. from dms.extensions import db
  149. from dms.models import Document
  150. token = token_for("admin")
  151. metadata = main_metadata(b5_app, "B5_TEST_同名覆盖主案")
  152. first_content = docx_bytes()
  153. first = upload(
  154. b5_client, token, "/api/v1/documents", first_content, metadata
  155. )
  156. assert first.status_code == 201
  157. first_item = first.get_json()["data"]
  158. with b5_app.app_context():
  159. first_document = db.session.get(Document, int(first_item["id"]))
  160. old_path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / first_document.file_relative_path
  161. old_version = first_document.row_version
  162. second_content = docx_bytes("B5测试覆盖后的新内容")
  163. conflict = upload(
  164. b5_client, token, "/api/v1/documents", second_content, metadata
  165. )
  166. assert conflict.status_code == 409
  167. payload = conflict.get_json()
  168. assert payload["code"] == "DOCUMENT_NAME_CONFLICT"
  169. assert payload["details"]["existingDocumentId"] == first_item["id"]
  170. assert payload["details"]["existingRowVersion"] == old_version
  171. confirmed = dict(
  172. metadata,
  173. overwriteDocumentId=first_item["id"],
  174. rowVersion=old_version,
  175. )
  176. replaced = upload(
  177. b5_client, token, "/api/v1/documents", second_content, confirmed
  178. )
  179. assert replaced.status_code == 201
  180. replaced_item = replaced.get_json()["data"]
  181. assert replaced_item["id"] == first_item["id"]
  182. assert replaced_item["rowVersion"] == old_version + 1
  183. with b5_app.app_context():
  184. document = db.session.get(Document, int(first_item["id"]))
  185. new_path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  186. assert new_path.read_bytes() == second_content
  187. assert not old_path.exists()
  188. def test_duplicate_sub_plan_is_scoped_to_parent(b5_app, b5_client, token_for):
  189. token = token_for("admin")
  190. parent_one = upload(
  191. b5_client, token, "/api/v1/documents", docx_bytes(),
  192. main_metadata(b5_app, "B5_TEST_父案一"),
  193. ).get_json()["data"]
  194. parent_two = upload(
  195. b5_client, token, "/api/v1/documents", docx_bytes(),
  196. main_metadata(b5_app, "B5_TEST_父案二"),
  197. ).get_json()["data"]
  198. def child_metadata(parent_id):
  199. value = main_metadata(b5_app, "B5_TEST_同名子案")
  200. value.update(documentType="SUB_PLAN", parentDocumentId=parent_id)
  201. value.pop("categoryId")
  202. return value
  203. assert upload(
  204. b5_client, token, "/api/v1/documents", docx_bytes(),
  205. child_metadata(parent_one["id"]),
  206. ).status_code == 201
  207. assert upload(
  208. b5_client, token, "/api/v1/documents", docx_bytes(),
  209. child_metadata(parent_one["id"]),
  210. ).status_code == 409
  211. assert upload(
  212. b5_client, token, "/api/v1/documents", docx_bytes(),
  213. child_metadata(parent_two["id"]),
  214. ).status_code == 201
  215. def test_attachment_upload_forces_fixed_security(b5_client, token_for):
  216. response = upload(
  217. b5_client,
  218. token_for("admin"),
  219. "/api/v1/attachments",
  220. docx_bytes(),
  221. attachment_metadata(),
  222. )
  223. assert response.status_code == 201
  224. item = response.get_json()["data"]
  225. assert item["documentType"] == "ATTACHMENT"
  226. assert item["securityLevel"] == "PUBLIC"
  227. assert item["visibilityType"] == "ALL_AUTHENTICATED"
  228. assert item["categoryId"] is None
  229. def test_non_admin_upload_forbidden(b5_app, b5_client, token_for):
  230. response = upload(
  231. b5_client,
  232. token_for("user"),
  233. "/api/v1/documents",
  234. docx_bytes(),
  235. main_metadata(b5_app),
  236. )
  237. assert response.status_code == 403
  238. @pytest.mark.parametrize(
  239. "mutator",
  240. [
  241. lambda value: value.update(categoryId=1),
  242. lambda value: value.update(extra="bad"),
  243. lambda value: value.update(status="INVALID"),
  244. lambda value: value.update(categoryId="999999999"),
  245. ],
  246. )
  247. def test_upload_rejects_invalid_metadata(
  248. b5_app, b5_client, token_for, mutator
  249. ):
  250. metadata = main_metadata(b5_app)
  251. mutator(metadata)
  252. response = upload(
  253. b5_client,
  254. token_for("admin"),
  255. "/api/v1/documents",
  256. docx_bytes(),
  257. metadata,
  258. )
  259. assert response.status_code in {400, 404}
  260. @pytest.mark.parametrize(
  261. ("filename", "content"),
  262. [
  263. ("bad.exe", b"MZ"),
  264. ("empty.pdf", b""),
  265. ("mismatch.pdf", docx_bytes()),
  266. ("broken.docx", b"PK\\x03\\x04broken"),
  267. ("fake.docx", fake_zip()),
  268. ("fake.xlsx", fake_zip()),
  269. ],
  270. ids=["unsupported", "empty", "mismatch", "broken-docx", "fake-docx", "fake-xlsx"],
  271. )
  272. def test_file_validation_rejects_spoofed_or_broken_files(
  273. b5_client, token_for, filename, content
  274. ):
  275. response = b5_client.post(
  276. "/api/v1/attachments",
  277. data={
  278. "file": (io.BytesIO(content), filename),
  279. "metadata": json.dumps(attachment_metadata()),
  280. },
  281. headers=auth(token_for("admin")),
  282. content_type="multipart/form-data",
  283. )
  284. assert response.status_code == 415
  285. assert response.get_json()["code"] == "UNSUPPORTED_FILE_TYPE"
  286. @pytest.mark.parametrize(
  287. ("filename", "content"),
  288. [
  289. ("legal.pdf", b"%PDF-1.4\\nB5"),
  290. ("legal.docx", docx_bytes()),
  291. ("legal.xlsx", xlsx_bytes()),
  292. ("legal.doc", cfb_bytes("WordDocument")),
  293. ("legal.xls", cfb_bytes("Workbook")),
  294. ],
  295. ids=["pdf", "docx", "xlsx", "doc", "xls"],
  296. )
  297. def test_all_allowed_file_structures_upload(
  298. b5_client, token_for, filename, content
  299. ):
  300. metadata = attachment_metadata("B5_TEST_" + filename)
  301. response = b5_client.post(
  302. "/api/v1/attachments",
  303. data={
  304. "file": (io.BytesIO(content), filename),
  305. "metadata": json.dumps(metadata),
  306. },
  307. headers=auth(token_for("admin")),
  308. content_type="multipart/form-data",
  309. )
  310. assert response.status_code == 201
  311. def test_same_hash_creates_distinct_documents(b5_client, token_for):
  312. content = docx_bytes()
  313. token = token_for("admin")
  314. first = upload(
  315. b5_client,
  316. token,
  317. "/api/v1/attachments",
  318. content,
  319. attachment_metadata("B5_TEST_同哈希1"),
  320. )
  321. second = upload(
  322. b5_client,
  323. token,
  324. "/api/v1/attachments",
  325. content,
  326. attachment_metadata("B5_TEST_同哈希2"),
  327. )
  328. assert first.status_code == second.status_code == 201
  329. assert first.get_json()["data"]["id"] != second.get_json()["data"]["id"]
  330. assert first.get_json()["data"]["fileHash"] == second.get_json()["data"]["fileHash"]
  331. def test_database_failure_removes_new_file_and_record(
  332. b5_app, b5_client, token_for, monkeypatch
  333. ):
  334. from dms.extensions import db
  335. from dms.models import Document
  336. original_dir = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "original"
  337. before = set(original_dir.iterdir())
  338. monkeypatch.setattr(
  339. "dms.services.document_mutation_service.business_audit",
  340. lambda **_: (_ for _ in ()).throw(RuntimeError("audit failed")),
  341. )
  342. response = upload(
  343. b5_client,
  344. token_for("admin"),
  345. "/api/v1/attachments",
  346. docx_bytes(),
  347. attachment_metadata("B5_TEST_补偿"),
  348. )
  349. assert response.status_code == 500
  350. assert set(original_dir.iterdir()) == before
  351. with b5_app.app_context():
  352. assert (
  353. db.session.scalar(
  354. db.select(Document).where(
  355. Document.document_name == "B5_TEST_补偿"
  356. )
  357. )
  358. is None
  359. )
  360. def test_batch_partial_success_and_manifest_mismatch(
  361. b5_client, token_for
  362. ):
  363. token = token_for("admin")
  364. metadata = [
  365. attachment_metadata("B5_TEST_批量成功"),
  366. attachment_metadata("B5_TEST_批量失败"),
  367. ]
  368. response = b5_client.post(
  369. "/api/v1/attachments/batch-import",
  370. data={
  371. "files": [
  372. (io.BytesIO(docx_bytes()), "good.docx"),
  373. (io.BytesIO(b"bad"), "bad.docx"),
  374. ],
  375. "items": json.dumps(metadata),
  376. },
  377. headers=auth(token),
  378. content_type="multipart/form-data",
  379. )
  380. assert response.status_code == 200
  381. result = response.get_json()["data"]
  382. assert (result["successCount"], result["failureCount"]) == (1, 1)
  383. assert [item["index"] for item in result["items"]] == [0, 1]
  384. mismatch = b5_client.post(
  385. "/api/v1/attachments/batch-import",
  386. data={
  387. "files": [(io.BytesIO(docx_bytes()), "one.docx")],
  388. "items": "[]",
  389. },
  390. headers=auth(token),
  391. content_type="multipart/form-data",
  392. )
  393. assert mismatch.status_code == 400
  394. assert mismatch.get_json()["code"] == "BATCH_MANIFEST_MISMATCH"
  395. def test_edit_version_conflict_and_forbidden_identity_fields(
  396. b5_app, b5_client, token_for
  397. ):
  398. token = token_for("admin")
  399. created = upload(
  400. b5_client,
  401. token,
  402. "/api/v1/documents",
  403. docx_bytes(),
  404. main_metadata(b5_app, "B5_TEST_编辑前"),
  405. ).get_json()["data"]
  406. payload = {
  407. "documentName": "B5_TEST_编辑后",
  408. "summary": "更新摘要",
  409. "categoryId": category_id(b5_app),
  410. "securityLevel": "SECRET",
  411. "tags": ["更新"],
  412. "rowVersion": created["rowVersion"],
  413. }
  414. response = b5_client.put(
  415. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  416. )
  417. assert response.status_code == 200
  418. assert response.get_json()["data"]["documentName"] == "B5_TEST_编辑后"
  419. conflict = b5_client.put(
  420. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  421. )
  422. assert conflict.status_code == 409
  423. payload["fileHash"] = "b" * 64
  424. forbidden = b5_client.put(
  425. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  426. )
  427. assert forbidden.status_code == 400
  428. def test_delete_sub_updates_parent_and_keeps_file(
  429. b5_app, b5_client, token_for
  430. ):
  431. from dms.extensions import db
  432. from dms.models import Document
  433. token = token_for("admin")
  434. main = upload(
  435. b5_client, token, "/api/v1/documents", docx_bytes(),
  436. main_metadata(b5_app, "B5_TEST_删除父案")
  437. ).get_json()["data"]
  438. metadata = main_metadata(b5_app, "B5_TEST_删除子案")
  439. metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
  440. metadata.pop("categoryId")
  441. child = upload(
  442. b5_client, token, "/api/v1/documents", docx_bytes(), metadata
  443. ).get_json()["data"]
  444. with b5_app.app_context():
  445. document = db.session.get(Document, int(child["id"]))
  446. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  447. response = b5_client.delete(
  448. f"/api/v1/documents/{child['id']}?rowVersion={child['rowVersion']}",
  449. headers=auth(token),
  450. )
  451. assert response.status_code == 200
  452. assert path.is_file()
  453. with b5_app.app_context():
  454. assert db.session.get(Document, int(main["id"])).child_count == 0
  455. def test_main_with_child_and_mounted_attachment_deletion_rules(
  456. b5_app, b5_client, token_for
  457. ):
  458. token = token_for("admin")
  459. main = upload(
  460. b5_client, token, "/api/v1/documents", docx_bytes(),
  461. main_metadata(b5_app, "B5_TEST_有子主案")
  462. ).get_json()["data"]
  463. metadata = main_metadata(b5_app, "B5_TEST_保留子案")
  464. metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
  465. metadata.pop("categoryId")
  466. upload(b5_client, token, "/api/v1/documents", docx_bytes(), metadata)
  467. response = b5_client.delete(
  468. f"/api/v1/documents/{main['id']}?rowVersion={main['rowVersion'] + 1}",
  469. headers=auth(token),
  470. )
  471. assert response.status_code == 409
  472. assert response.get_json()["code"] == "MAIN_PLAN_HAS_CHILDREN"
  473. def test_attachment_in_use_conflict(b4_app, b4_client, token_for):
  474. from dms.extensions import db
  475. from dms.models import Document
  476. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  477. with b4_app.app_context():
  478. version = db.session.get(Document, attachment_id).row_version
  479. response = b4_client.delete(
  480. f"/api/v1/attachments/{attachment_id}?rowVersion={version}",
  481. headers=auth(token_for("admin")),
  482. )
  483. assert response.status_code == 409
  484. details = response.get_json()["details"]
  485. assert details["mountedPlanCount"] == 2
  486. assert len(details["mainPlans"]) == 2
  487. def test_pdf_preview_and_download_count_audit(
  488. b5_app, b5_client, token_for
  489. ):
  490. from dms.extensions import db
  491. from dms.models import AuditLog, Document
  492. token = token_for("admin")
  493. metadata = attachment_metadata("B5_TEST_PDF")
  494. created_response = b5_client.post(
  495. "/api/v1/attachments",
  496. data={
  497. "file": (io.BytesIO(b"%PDF-1.4\\nB5-CONTENT"), "safe.pdf"),
  498. "metadata": json.dumps(metadata),
  499. },
  500. headers=auth(token),
  501. content_type="multipart/form-data",
  502. )
  503. created = created_response.get_json()["data"]
  504. preview = b5_client.get(
  505. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  506. )
  507. assert preview.status_code == 200
  508. assert preview.mimetype == "application/pdf"
  509. assert preview.headers["Content-Disposition"].startswith("inline")
  510. preview.close()
  511. download = b5_client.get(
  512. f"/api/v1/documents/{created['id']}/download", headers=auth(token)
  513. )
  514. assert download.status_code == 200
  515. assert download.data == b"%PDF-1.4\\nB5-CONTENT"
  516. assert download.headers["X-Request-Id"]
  517. assert "\r" not in download.headers["Content-Disposition"]
  518. download.close()
  519. with b5_app.app_context():
  520. document = db.session.get(Document, int(created["id"]))
  521. assert document.download_count == 1
  522. assert (
  523. db.session.scalar(
  524. db.select(db.func.count(AuditLog.id)).where(
  525. AuditLog.target_id == document.id,
  526. AuditLog.action_type == "DOWNLOAD_DOCUMENT",
  527. )
  528. )
  529. == 1
  530. )
  531. def test_office_preview_file_missing_and_no_restore_route(
  532. b5_app, b5_client, token_for
  533. ):
  534. from dms.extensions import db
  535. from dms.models import Document
  536. token = token_for("admin")
  537. content = docx_bytes()
  538. created = upload(
  539. b5_client,
  540. token,
  541. "/api/v1/attachments",
  542. content,
  543. attachment_metadata("B5_TEST_Office预览"),
  544. ).get_json()["data"]
  545. preview = b5_client.get(
  546. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  547. )
  548. assert preview.status_code == 200
  549. assert preview.mimetype == (
  550. "application/vnd.openxmlformats-officedocument."
  551. "wordprocessingml.document"
  552. )
  553. assert preview.data == content
  554. preview.close()
  555. with b5_app.app_context():
  556. document = db.session.get(Document, int(created["id"]))
  557. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  558. path.unlink()
  559. missing = b5_client.get(
  560. f"/api/v1/documents/{created['id']}/download", headers=auth(token)
  561. )
  562. assert missing.status_code == 404
  563. assert missing.get_json()["code"] == "FILE_NOT_FOUND"
  564. restore = b5_client.post(
  565. f"/api/v1/documents/{created['id']}/restore", headers=auth(token)
  566. )
  567. assert restore.status_code == 404
  568. def test_legacy_doc_preview_is_explicitly_unsupported(
  569. b5_client, token_for
  570. ):
  571. token = token_for("admin")
  572. created_response = b5_client.post(
  573. "/api/v1/attachments",
  574. data={
  575. "file": (io.BytesIO(cfb_bytes("WordDocument")), "legacy.doc"),
  576. "metadata": json.dumps(
  577. attachment_metadata("B5_TEST_旧版DOC"), ensure_ascii=False
  578. ),
  579. },
  580. headers=auth(token),
  581. content_type="multipart/form-data",
  582. )
  583. assert created_response.status_code == 201
  584. created = created_response.get_json()["data"]
  585. preview = b5_client.get(
  586. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  587. )
  588. assert preview.status_code == 415
  589. assert preview.get_json()["code"] == "PREVIEW_UNAVAILABLE"
  590. def test_size_limit_and_temporary_cleanup(b5_app, b5_client, token_for):
  591. b5_app.config["DMS_MAX_FILE_SIZE"] = 8
  592. temporary = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary"
  593. response = b5_client.post(
  594. "/api/v1/attachments",
  595. data={
  596. "file": (io.BytesIO(b"%PDF-1.4\nlarge"), "large.pdf"),
  597. "metadata": json.dumps(attachment_metadata("B5_TEST_超限")),
  598. },
  599. headers=auth(token_for("admin")),
  600. content_type="multipart/form-data",
  601. )
  602. assert response.status_code == 413
  603. assert not list(temporary.glob("*.upload"))
  604. def test_file_promotion_failure_rolls_back_database(
  605. b5_app, b5_client, token_for, monkeypatch
  606. ):
  607. from dms.extensions import db
  608. from dms.models import Document
  609. monkeypatch.setattr(
  610. "dms.storage.uploads.StagedUpload.promote",
  611. lambda _self: (_ for _ in ()).throw(OSError("disk failure")),
  612. )
  613. response = upload(
  614. b5_client,
  615. token_for("admin"),
  616. "/api/v1/attachments",
  617. docx_bytes(),
  618. attachment_metadata("B5_TEST_文件失败回滚"),
  619. )
  620. assert response.status_code == 500
  621. with b5_app.app_context():
  622. assert db.session.scalar(
  623. db.select(Document).where(
  624. Document.document_name == "B5_TEST_文件失败回滚"
  625. )
  626. ) is None
  627. assert not list(
  628. (Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary").glob("*.upload")
  629. )
  630. def test_batch_all_success_and_all_failure(b5_client, token_for):
  631. token = token_for("admin")
  632. success = b5_client.post(
  633. "/api/v1/attachments/batch-import",
  634. data={
  635. "files": [
  636. (io.BytesIO(docx_bytes()), "a.docx"),
  637. (io.BytesIO(xlsx_bytes()), "b.xlsx"),
  638. ],
  639. "items": json.dumps(
  640. [
  641. attachment_metadata("B5_TEST_全成功1"),
  642. attachment_metadata("B5_TEST_全成功2"),
  643. ]
  644. ),
  645. },
  646. headers=auth(token),
  647. content_type="multipart/form-data",
  648. ).get_json()["data"]
  649. assert (success["successCount"], success["failureCount"]) == (2, 0)
  650. failed = b5_client.post(
  651. "/api/v1/attachments/batch-import",
  652. data={
  653. "files": [
  654. (io.BytesIO(b"bad"), "a.docx"),
  655. (io.BytesIO(b"bad"), "b.xlsx"),
  656. ],
  657. "items": json.dumps(
  658. [
  659. attachment_metadata("B5_TEST_全失败1"),
  660. attachment_metadata("B5_TEST_全失败2"),
  661. ]
  662. ),
  663. },
  664. headers=auth(token),
  665. content_type="multipart/form-data",
  666. ).get_json()["data"]
  667. assert (failed["successCount"], failed["failureCount"]) == (0, 2)
  668. def test_attachment_edit_and_unmounted_delete_keep_file(
  669. b5_app, b5_client, token_for
  670. ):
  671. from dms.extensions import db
  672. from dms.models import Document
  673. token = token_for("admin")
  674. created = upload(
  675. b5_client,
  676. token,
  677. "/api/v1/attachments",
  678. docx_bytes(),
  679. attachment_metadata("B5_TEST_待编辑附件"),
  680. ).get_json()["data"]
  681. edited = b5_client.put(
  682. f"/api/v1/attachments/{created['id']}",
  683. json={
  684. "documentName": "B5_TEST_已编辑附件",
  685. "attachmentType": "WORK_STANDARD",
  686. "summary": "已编辑",
  687. "tags": ["编辑"],
  688. "rowVersion": created["rowVersion"],
  689. },
  690. headers=auth(token),
  691. ).get_json()["data"]
  692. assert edited["securityLevel"] == "PUBLIC"
  693. with b5_app.app_context():
  694. document = db.session.get(Document, int(created["id"]))
  695. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  696. response = b5_client.delete(
  697. f"/api/v1/attachments/{created['id']}?rowVersion={edited['rowVersion']}",
  698. headers=auth(token),
  699. )
  700. assert response.status_code == 200
  701. assert path.is_file()
  702. assert b5_client.get(
  703. f"/api/v1/attachments/{created['id']}", headers=auth(token)
  704. ).status_code == 404
  705. def test_plan_download_requires_acl_and_path_escape_is_rejected(
  706. b5_app, b5_client, token_for
  707. ):
  708. from dms.extensions import db
  709. from dms.models import Document
  710. admin_token = token_for("admin")
  711. metadata = main_metadata(b5_app, "B5_TEST_无下载权限")
  712. created = upload(
  713. b5_client, admin_token, "/api/v1/documents", docx_bytes(), metadata
  714. ).get_json()["data"]
  715. denied = b5_client.get(
  716. f"/api/v1/documents/{created['id']}/download",
  717. headers=auth(token_for("user")),
  718. )
  719. assert denied.status_code == 403
  720. assert denied.get_json()["code"] == "DOCUMENT_DOWNLOAD_FORBIDDEN"
  721. attachment = upload(
  722. b5_client,
  723. admin_token,
  724. "/api/v1/attachments",
  725. docx_bytes(),
  726. attachment_metadata("B5_TEST_越界路径"),
  727. ).get_json()["data"]
  728. with b5_app.app_context():
  729. document = db.session.get(Document, int(attachment["id"]))
  730. document.file_relative_path = "../outside.docx"
  731. db.session.commit()
  732. escaped = b5_client.get(
  733. f"/api/v1/documents/{attachment['id']}/download",
  734. headers=auth(admin_token),
  735. )
  736. assert escaped.status_code == 500
  737. assert "outside.docx" not in escaped.get_data(as_text=True)