test_b5_file_mutations.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825
  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": "SUPPORT_ACHIEVEMENT",
  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 "visibilityType" not in item
  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. ("notole.wps", b"plain text not ole"),
  270. ],
  271. ids=["unsupported", "empty", "mismatch", "broken-docx", "fake-docx", "fake-xlsx", "not-ole-wps"],
  272. )
  273. def test_file_validation_rejects_spoofed_or_broken_files(
  274. b5_client, token_for, filename, content
  275. ):
  276. response = b5_client.post(
  277. "/api/v1/attachments",
  278. data={
  279. "file": (io.BytesIO(content), filename),
  280. "metadata": json.dumps(attachment_metadata()),
  281. },
  282. headers=auth(token_for("admin")),
  283. content_type="multipart/form-data",
  284. )
  285. assert response.status_code == 415
  286. assert response.get_json()["code"] == "UNSUPPORTED_FILE_TYPE"
  287. @pytest.mark.parametrize(
  288. ("filename", "content"),
  289. [
  290. ("legal.pdf", b"%PDF-1.4\\nB5"),
  291. ("legal.docx", docx_bytes()),
  292. ("legal.xlsx", xlsx_bytes()),
  293. ("legal.doc", cfb_bytes("WordDocument")),
  294. ("legal.xls", cfb_bytes("Workbook")),
  295. ("legal.wps", cfb_bytes("WordDocument")),
  296. ],
  297. ids=["pdf", "docx", "xlsx", "doc", "xls", "wps"],
  298. )
  299. def test_all_allowed_file_structures_upload(
  300. b5_client, token_for, filename, content
  301. ):
  302. metadata = attachment_metadata("B5_TEST_" + filename)
  303. response = b5_client.post(
  304. "/api/v1/attachments",
  305. data={
  306. "file": (io.BytesIO(content), filename),
  307. "metadata": json.dumps(metadata),
  308. },
  309. headers=auth(token_for("admin")),
  310. content_type="multipart/form-data",
  311. )
  312. assert response.status_code == 201
  313. def test_same_hash_creates_distinct_documents(b5_client, token_for):
  314. content = docx_bytes()
  315. token = token_for("admin")
  316. first = upload(
  317. b5_client,
  318. token,
  319. "/api/v1/attachments",
  320. content,
  321. attachment_metadata("B5_TEST_同哈希1"),
  322. )
  323. second = upload(
  324. b5_client,
  325. token,
  326. "/api/v1/attachments",
  327. content,
  328. attachment_metadata("B5_TEST_同哈希2"),
  329. )
  330. assert first.status_code == second.status_code == 201
  331. assert first.get_json()["data"]["id"] != second.get_json()["data"]["id"]
  332. assert first.get_json()["data"]["fileHash"] == second.get_json()["data"]["fileHash"]
  333. def test_database_failure_removes_new_file_and_record(
  334. b5_app, b5_client, token_for, monkeypatch
  335. ):
  336. from dms.extensions import db
  337. from dms.models import Document
  338. original_dir = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "original"
  339. before = set(original_dir.iterdir())
  340. monkeypatch.setattr(
  341. "dms.services.document_mutation_service.business_audit",
  342. lambda **_: (_ for _ in ()).throw(RuntimeError("audit failed")),
  343. )
  344. response = upload(
  345. b5_client,
  346. token_for("admin"),
  347. "/api/v1/attachments",
  348. docx_bytes(),
  349. attachment_metadata("B5_TEST_补偿"),
  350. )
  351. assert response.status_code == 500
  352. assert set(original_dir.iterdir()) == before
  353. with b5_app.app_context():
  354. assert (
  355. db.session.scalar(
  356. db.select(Document).where(
  357. Document.document_name == "B5_TEST_补偿"
  358. )
  359. )
  360. is None
  361. )
  362. def test_batch_partial_success_and_manifest_mismatch(
  363. b5_client, token_for
  364. ):
  365. token = token_for("admin")
  366. metadata = [
  367. attachment_metadata("B5_TEST_批量成功"),
  368. attachment_metadata("B5_TEST_批量失败"),
  369. ]
  370. response = b5_client.post(
  371. "/api/v1/attachments/batch-import",
  372. data={
  373. "files": [
  374. (io.BytesIO(docx_bytes()), "good.docx"),
  375. (io.BytesIO(b"bad"), "bad.docx"),
  376. ],
  377. "items": json.dumps(metadata),
  378. },
  379. headers=auth(token),
  380. content_type="multipart/form-data",
  381. )
  382. assert response.status_code == 200
  383. result = response.get_json()["data"]
  384. assert (result["successCount"], result["failureCount"]) == (1, 1)
  385. assert [item["index"] for item in result["items"]] == [0, 1]
  386. mismatch = b5_client.post(
  387. "/api/v1/attachments/batch-import",
  388. data={
  389. "files": [(io.BytesIO(docx_bytes()), "one.docx")],
  390. "items": "[]",
  391. },
  392. headers=auth(token),
  393. content_type="multipart/form-data",
  394. )
  395. assert mismatch.status_code == 400
  396. assert mismatch.get_json()["code"] == "BATCH_MANIFEST_MISMATCH"
  397. def test_edit_version_conflict_and_forbidden_identity_fields(
  398. b5_app, b5_client, token_for
  399. ):
  400. token = token_for("admin")
  401. created = upload(
  402. b5_client,
  403. token,
  404. "/api/v1/documents",
  405. docx_bytes(),
  406. main_metadata(b5_app, "B5_TEST_编辑前"),
  407. ).get_json()["data"]
  408. payload = {
  409. "documentName": "B5_TEST_编辑后",
  410. "summary": "更新摘要",
  411. "categoryId": category_id(b5_app),
  412. "securityLevel": "SECRET",
  413. "tags": ["更新"],
  414. "rowVersion": created["rowVersion"],
  415. }
  416. response = b5_client.put(
  417. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  418. )
  419. assert response.status_code == 200
  420. assert response.get_json()["data"]["documentName"] == "B5_TEST_编辑后"
  421. conflict = b5_client.put(
  422. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  423. )
  424. assert conflict.status_code == 409
  425. payload["fileHash"] = "b" * 64
  426. forbidden = b5_client.put(
  427. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  428. )
  429. assert forbidden.status_code == 400
  430. def test_delete_sub_updates_parent_and_keeps_file(
  431. b5_app, b5_client, token_for
  432. ):
  433. from dms.extensions import db
  434. from dms.models import Document
  435. token = token_for("admin")
  436. main = upload(
  437. b5_client, token, "/api/v1/documents", docx_bytes(),
  438. main_metadata(b5_app, "B5_TEST_删除父案")
  439. ).get_json()["data"]
  440. metadata = main_metadata(b5_app, "B5_TEST_删除子案")
  441. metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
  442. metadata.pop("categoryId")
  443. child = upload(
  444. b5_client, token, "/api/v1/documents", docx_bytes(), metadata
  445. ).get_json()["data"]
  446. with b5_app.app_context():
  447. document = db.session.get(Document, int(child["id"]))
  448. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  449. response = b5_client.delete(
  450. f"/api/v1/documents/{child['id']}?rowVersion={child['rowVersion']}",
  451. headers=auth(token),
  452. )
  453. assert response.status_code == 200
  454. assert path.is_file()
  455. with b5_app.app_context():
  456. assert db.session.get(Document, int(main["id"])).child_count == 0
  457. def test_main_with_child_and_mounted_attachment_deletion_rules(
  458. b5_app, b5_client, token_for
  459. ):
  460. token = token_for("admin")
  461. main = upload(
  462. b5_client, token, "/api/v1/documents", docx_bytes(),
  463. main_metadata(b5_app, "B5_TEST_有子主案")
  464. ).get_json()["data"]
  465. metadata = main_metadata(b5_app, "B5_TEST_保留子案")
  466. metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
  467. metadata.pop("categoryId")
  468. upload(b5_client, token, "/api/v1/documents", docx_bytes(), metadata)
  469. response = b5_client.delete(
  470. f"/api/v1/documents/{main['id']}?rowVersion={main['rowVersion'] + 1}",
  471. headers=auth(token),
  472. )
  473. assert response.status_code == 409
  474. assert response.get_json()["code"] == "MAIN_PLAN_HAS_CHILDREN"
  475. def test_attachment_in_use_conflict(b4_app, b4_client, token_for):
  476. from dms.extensions import db
  477. from dms.models import Document
  478. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  479. with b4_app.app_context():
  480. version = db.session.get(Document, attachment_id).row_version
  481. response = b4_client.delete(
  482. f"/api/v1/attachments/{attachment_id}?rowVersion={version}",
  483. headers=auth(token_for("admin")),
  484. )
  485. assert response.status_code == 409
  486. details = response.get_json()["details"]
  487. assert details["mountedPlanCount"] == 2
  488. assert len(details["mainPlans"]) == 2
  489. def test_pdf_preview_and_download_count_audit(
  490. b5_app, b5_client, token_for
  491. ):
  492. from dms.extensions import db
  493. from dms.models import AuditLog, Document
  494. token = token_for("admin")
  495. metadata = attachment_metadata("B5_TEST_PDF")
  496. created_response = b5_client.post(
  497. "/api/v1/attachments",
  498. data={
  499. "file": (io.BytesIO(b"%PDF-1.4\\nB5-CONTENT"), "safe.pdf"),
  500. "metadata": json.dumps(metadata),
  501. },
  502. headers=auth(token),
  503. content_type="multipart/form-data",
  504. )
  505. created = created_response.get_json()["data"]
  506. preview = b5_client.get(
  507. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  508. )
  509. assert preview.status_code == 200
  510. assert preview.mimetype == "application/pdf"
  511. assert preview.headers["Content-Disposition"].startswith("inline")
  512. preview.close()
  513. download = b5_client.get(
  514. f"/api/v1/documents/{created['id']}/download", headers=auth(token)
  515. )
  516. assert download.status_code == 200
  517. assert download.data == b"%PDF-1.4\\nB5-CONTENT"
  518. assert download.headers["X-Request-Id"]
  519. assert "\r" not in download.headers["Content-Disposition"]
  520. download.close()
  521. with b5_app.app_context():
  522. document = db.session.get(Document, int(created["id"]))
  523. assert document.download_count == 1
  524. assert (
  525. db.session.scalar(
  526. db.select(db.func.count(AuditLog.id)).where(
  527. AuditLog.target_id == document.id,
  528. AuditLog.action_type == "DOWNLOAD_DOCUMENT",
  529. )
  530. )
  531. == 1
  532. )
  533. def test_office_preview_file_missing_and_no_restore_route(
  534. b5_app, b5_client, token_for
  535. ):
  536. from dms.extensions import db
  537. from dms.models import Document
  538. token = token_for("admin")
  539. content = docx_bytes()
  540. created = upload(
  541. b5_client,
  542. token,
  543. "/api/v1/attachments",
  544. content,
  545. attachment_metadata("B5_TEST_Office预览"),
  546. ).get_json()["data"]
  547. preview = b5_client.get(
  548. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  549. )
  550. assert preview.status_code == 200
  551. assert preview.mimetype == (
  552. "application/vnd.openxmlformats-officedocument."
  553. "wordprocessingml.document"
  554. )
  555. assert preview.data == content
  556. preview.close()
  557. with b5_app.app_context():
  558. document = db.session.get(Document, int(created["id"]))
  559. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  560. path.unlink()
  561. missing = b5_client.get(
  562. f"/api/v1/documents/{created['id']}/download", headers=auth(token)
  563. )
  564. assert missing.status_code == 404
  565. assert missing.get_json()["code"] == "FILE_NOT_FOUND"
  566. restore = b5_client.post(
  567. f"/api/v1/documents/{created['id']}/restore", headers=auth(token)
  568. )
  569. assert restore.status_code == 404
  570. def test_legacy_doc_preview_is_explicitly_unsupported(
  571. b5_client, token_for
  572. ):
  573. token = token_for("admin")
  574. created_response = b5_client.post(
  575. "/api/v1/attachments",
  576. data={
  577. "file": (io.BytesIO(cfb_bytes("WordDocument")), "legacy.doc"),
  578. "metadata": json.dumps(
  579. attachment_metadata("B5_TEST_旧版DOC"), ensure_ascii=False
  580. ),
  581. },
  582. headers=auth(token),
  583. content_type="multipart/form-data",
  584. )
  585. assert created_response.status_code == 201
  586. created = created_response.get_json()["data"]
  587. preview = b5_client.get(
  588. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  589. )
  590. assert preview.status_code == 415
  591. assert preview.get_json()["code"] == "PREVIEW_UNAVAILABLE"
  592. def test_size_limit_and_temporary_cleanup(b5_app, b5_client, token_for):
  593. b5_app.config["DMS_MAX_FILE_SIZE"] = 8
  594. temporary = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary"
  595. response = b5_client.post(
  596. "/api/v1/attachments",
  597. data={
  598. "file": (io.BytesIO(b"%PDF-1.4\nlarge"), "large.pdf"),
  599. "metadata": json.dumps(attachment_metadata("B5_TEST_超限")),
  600. },
  601. headers=auth(token_for("admin")),
  602. content_type="multipart/form-data",
  603. )
  604. assert response.status_code == 413
  605. assert not list(temporary.glob("*.upload"))
  606. def test_file_promotion_failure_rolls_back_database(
  607. b5_app, b5_client, token_for, monkeypatch
  608. ):
  609. from dms.extensions import db
  610. from dms.models import Document
  611. monkeypatch.setattr(
  612. "dms.storage.uploads.StagedUpload.promote",
  613. lambda _self: (_ for _ in ()).throw(OSError("disk failure")),
  614. )
  615. response = upload(
  616. b5_client,
  617. token_for("admin"),
  618. "/api/v1/attachments",
  619. docx_bytes(),
  620. attachment_metadata("B5_TEST_文件失败回滚"),
  621. )
  622. assert response.status_code == 500
  623. with b5_app.app_context():
  624. assert db.session.scalar(
  625. db.select(Document).where(
  626. Document.document_name == "B5_TEST_文件失败回滚"
  627. )
  628. ) is None
  629. assert not list(
  630. (Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary").glob("*.upload")
  631. )
  632. def test_batch_all_success_and_all_failure(b5_client, token_for):
  633. token = token_for("admin")
  634. success = b5_client.post(
  635. "/api/v1/attachments/batch-import",
  636. data={
  637. "files": [
  638. (io.BytesIO(docx_bytes()), "a.docx"),
  639. (io.BytesIO(xlsx_bytes()), "b.xlsx"),
  640. ],
  641. "items": json.dumps(
  642. [
  643. attachment_metadata("B5_TEST_全成功1"),
  644. attachment_metadata("B5_TEST_全成功2"),
  645. ]
  646. ),
  647. },
  648. headers=auth(token),
  649. content_type="multipart/form-data",
  650. ).get_json()["data"]
  651. assert (success["successCount"], success["failureCount"]) == (2, 0)
  652. failed = b5_client.post(
  653. "/api/v1/attachments/batch-import",
  654. data={
  655. "files": [
  656. (io.BytesIO(b"bad"), "a.docx"),
  657. (io.BytesIO(b"bad"), "b.xlsx"),
  658. ],
  659. "items": json.dumps(
  660. [
  661. attachment_metadata("B5_TEST_全失败1"),
  662. attachment_metadata("B5_TEST_全失败2"),
  663. ]
  664. ),
  665. },
  666. headers=auth(token),
  667. content_type="multipart/form-data",
  668. ).get_json()["data"]
  669. assert (failed["successCount"], failed["failureCount"]) == (0, 2)
  670. def test_attachment_edit_and_unmounted_delete_keep_file(
  671. b5_app, b5_client, token_for
  672. ):
  673. from dms.extensions import db
  674. from dms.models import Document
  675. token = token_for("admin")
  676. created = upload(
  677. b5_client,
  678. token,
  679. "/api/v1/attachments",
  680. docx_bytes(),
  681. attachment_metadata("B5_TEST_待编辑附件"),
  682. ).get_json()["data"]
  683. edited = b5_client.put(
  684. f"/api/v1/attachments/{created['id']}",
  685. json={
  686. "documentName": "B5_TEST_已编辑附件",
  687. "attachmentType": "TRAINING_MATERIAL",
  688. "summary": "已编辑",
  689. "tags": ["编辑"],
  690. "rowVersion": created["rowVersion"],
  691. },
  692. headers=auth(token),
  693. ).get_json()["data"]
  694. assert edited["securityLevel"] == "PUBLIC"
  695. with b5_app.app_context():
  696. document = db.session.get(Document, int(created["id"]))
  697. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  698. response = b5_client.delete(
  699. f"/api/v1/attachments/{created['id']}?rowVersion={edited['rowVersion']}",
  700. headers=auth(token),
  701. )
  702. assert response.status_code == 200
  703. assert path.is_file()
  704. assert b5_client.get(
  705. f"/api/v1/attachments/{created['id']}", headers=auth(token)
  706. ).status_code == 404
  707. def test_plan_download_requires_acl_and_path_escape_is_rejected(
  708. b5_app, b5_client, token_for
  709. ):
  710. from dms.extensions import db
  711. from dms.models import Document
  712. admin_token = token_for("admin")
  713. metadata = main_metadata(b5_app, "B5_TEST_无下载权限")
  714. created = upload(
  715. b5_client, admin_token, "/api/v1/documents", docx_bytes(), metadata
  716. ).get_json()["data"]
  717. denied = b5_client.get(
  718. f"/api/v1/documents/{created['id']}/download",
  719. headers=auth(token_for("user")),
  720. )
  721. assert denied.status_code == 403
  722. assert denied.get_json()["code"] == "DOCUMENT_DOWNLOAD_FORBIDDEN"
  723. attachment = upload(
  724. b5_client,
  725. admin_token,
  726. "/api/v1/attachments",
  727. docx_bytes(),
  728. attachment_metadata("B5_TEST_越界路径"),
  729. ).get_json()["data"]
  730. with b5_app.app_context():
  731. document = db.session.get(Document, int(attachment["id"]))
  732. document.file_relative_path = "../outside.docx"
  733. db.session.commit()
  734. escaped = b5_client.get(
  735. f"/api/v1/documents/{attachment['id']}/download",
  736. headers=auth(admin_token),
  737. )
  738. assert escaped.status_code == 500
  739. assert "outside.docx" not in escaped.get_data(as_text=True)