test_b5_file_mutations.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  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() -> bytes:
  13. stream = io.BytesIO()
  14. document = WordDocument()
  15. document.add_paragraph("B5测试文档")
  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 == "ROOT_A")
  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. "visibilityType": "ALL_AUTHENTICATED",
  78. "status": "PUBLISHED",
  79. "tags": ["B5", "上传"],
  80. }
  81. def attachment_metadata(name: str = "B5_TEST_附件") -> dict:
  82. return {
  83. "documentName": name,
  84. "attachmentType": "POLICY",
  85. "summary": "B5附件",
  86. "tags": ["B5"],
  87. }
  88. def upload(client, token, path, content, metadata):
  89. return client.post(
  90. path,
  91. data={
  92. "file": (io.BytesIO(content), metadata["documentName"] + ".docx"),
  93. "metadata": json.dumps(metadata, ensure_ascii=False),
  94. },
  95. headers=auth(token),
  96. content_type="multipart/form-data",
  97. )
  98. def test_main_upload_persists_relative_uuid_file_and_hash(
  99. b5_app, b5_client, token_for
  100. ):
  101. from dms.extensions import db
  102. from dms.models import Document
  103. content = docx_bytes()
  104. response = upload(
  105. b5_client,
  106. token_for("admin"),
  107. "/api/v1/documents",
  108. content,
  109. main_metadata(b5_app),
  110. )
  111. assert response.status_code == 201
  112. item = response.get_json()["data"]
  113. assert item["id"].isdecimal()
  114. assert "fileRelativePath" not in item
  115. with b5_app.app_context():
  116. document = db.session.get(Document, int(item["id"]))
  117. relative = Path(document.file_relative_path)
  118. assert not relative.is_absolute()
  119. assert relative.parent.as_posix() == "original"
  120. assert document.original_file_name == "B5_TEST_主案.docx"
  121. assert relative.name != document.original_file_name
  122. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / relative
  123. assert path.read_bytes() == content
  124. assert document.file_hash == hashlib.sha256(content).hexdigest()
  125. def test_sub_plan_upload_updates_parent_count(b5_app, b5_client, token_for):
  126. from dms.extensions import db
  127. from dms.models import Document
  128. token = token_for("admin")
  129. main = upload(
  130. b5_client,
  131. token,
  132. "/api/v1/documents",
  133. docx_bytes(),
  134. main_metadata(b5_app, "B5_TEST_父主案"),
  135. ).get_json()["data"]
  136. metadata = main_metadata(b5_app, "B5_TEST_子案")
  137. metadata["documentType"] = "SUB_PLAN"
  138. metadata["parentDocumentId"] = main["id"]
  139. child = upload(
  140. b5_client, token, "/api/v1/documents", docx_bytes(), metadata
  141. )
  142. assert child.status_code == 201
  143. assert child.get_json()["data"]["rootDocumentId"] == main["id"]
  144. with b5_app.app_context():
  145. assert db.session.get(Document, int(main["id"])).child_count == 1
  146. def test_attachment_upload_forces_fixed_security(b5_client, token_for):
  147. response = upload(
  148. b5_client,
  149. token_for("admin"),
  150. "/api/v1/attachments",
  151. docx_bytes(),
  152. attachment_metadata(),
  153. )
  154. assert response.status_code == 201
  155. item = response.get_json()["data"]
  156. assert item["documentType"] == "ATTACHMENT"
  157. assert item["securityLevel"] == "PUBLIC"
  158. assert item["visibilityType"] == "ALL_AUTHENTICATED"
  159. assert item["categoryId"] is None
  160. def test_non_admin_upload_forbidden(b5_app, b5_client, token_for):
  161. response = upload(
  162. b5_client,
  163. token_for("user"),
  164. "/api/v1/documents",
  165. docx_bytes(),
  166. main_metadata(b5_app),
  167. )
  168. assert response.status_code == 403
  169. @pytest.mark.parametrize(
  170. "mutator",
  171. [
  172. lambda value: value.update(categoryId=1),
  173. lambda value: value.update(extra="bad"),
  174. lambda value: value.update(status="INVALID"),
  175. lambda value: value.update(categoryId="999999999"),
  176. ],
  177. )
  178. def test_upload_rejects_invalid_metadata(
  179. b5_app, b5_client, token_for, mutator
  180. ):
  181. metadata = main_metadata(b5_app)
  182. mutator(metadata)
  183. response = upload(
  184. b5_client,
  185. token_for("admin"),
  186. "/api/v1/documents",
  187. docx_bytes(),
  188. metadata,
  189. )
  190. assert response.status_code in {400, 404}
  191. @pytest.mark.parametrize(
  192. ("filename", "content"),
  193. [
  194. ("bad.exe", b"MZ"),
  195. ("empty.pdf", b""),
  196. ("mismatch.pdf", docx_bytes()),
  197. ("broken.docx", b"PK\\x03\\x04broken"),
  198. ("fake.docx", fake_zip()),
  199. ("fake.xlsx", fake_zip()),
  200. ],
  201. ids=["unsupported", "empty", "mismatch", "broken-docx", "fake-docx", "fake-xlsx"],
  202. )
  203. def test_file_validation_rejects_spoofed_or_broken_files(
  204. b5_client, token_for, filename, content
  205. ):
  206. response = b5_client.post(
  207. "/api/v1/attachments",
  208. data={
  209. "file": (io.BytesIO(content), filename),
  210. "metadata": json.dumps(attachment_metadata()),
  211. },
  212. headers=auth(token_for("admin")),
  213. content_type="multipart/form-data",
  214. )
  215. assert response.status_code == 415
  216. assert response.get_json()["code"] == "UNSUPPORTED_FILE_TYPE"
  217. @pytest.mark.parametrize(
  218. ("filename", "content"),
  219. [
  220. ("legal.pdf", b"%PDF-1.4\\nB5"),
  221. ("legal.docx", docx_bytes()),
  222. ("legal.xlsx", xlsx_bytes()),
  223. ("legal.doc", cfb_bytes("WordDocument")),
  224. ("legal.xls", cfb_bytes("Workbook")),
  225. ],
  226. ids=["pdf", "docx", "xlsx", "doc", "xls"],
  227. )
  228. def test_all_allowed_file_structures_upload(
  229. b5_client, token_for, filename, content
  230. ):
  231. metadata = attachment_metadata("B5_TEST_" + filename)
  232. response = b5_client.post(
  233. "/api/v1/attachments",
  234. data={
  235. "file": (io.BytesIO(content), filename),
  236. "metadata": json.dumps(metadata),
  237. },
  238. headers=auth(token_for("admin")),
  239. content_type="multipart/form-data",
  240. )
  241. assert response.status_code == 201
  242. def test_same_hash_creates_distinct_documents(b5_client, token_for):
  243. content = docx_bytes()
  244. token = token_for("admin")
  245. first = upload(
  246. b5_client,
  247. token,
  248. "/api/v1/attachments",
  249. content,
  250. attachment_metadata("B5_TEST_同哈希1"),
  251. )
  252. second = upload(
  253. b5_client,
  254. token,
  255. "/api/v1/attachments",
  256. content,
  257. attachment_metadata("B5_TEST_同哈希2"),
  258. )
  259. assert first.status_code == second.status_code == 201
  260. assert first.get_json()["data"]["id"] != second.get_json()["data"]["id"]
  261. assert first.get_json()["data"]["fileHash"] == second.get_json()["data"]["fileHash"]
  262. def test_database_failure_removes_new_file_and_record(
  263. b5_app, b5_client, token_for, monkeypatch
  264. ):
  265. from dms.extensions import db
  266. from dms.models import Document
  267. original_dir = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "original"
  268. before = set(original_dir.iterdir())
  269. monkeypatch.setattr(
  270. "dms.services.document_mutation_service.business_audit",
  271. lambda **_: (_ for _ in ()).throw(RuntimeError("audit failed")),
  272. )
  273. response = upload(
  274. b5_client,
  275. token_for("admin"),
  276. "/api/v1/attachments",
  277. docx_bytes(),
  278. attachment_metadata("B5_TEST_补偿"),
  279. )
  280. assert response.status_code == 500
  281. assert set(original_dir.iterdir()) == before
  282. with b5_app.app_context():
  283. assert (
  284. db.session.scalar(
  285. db.select(Document).where(
  286. Document.document_name == "B5_TEST_补偿"
  287. )
  288. )
  289. is None
  290. )
  291. def test_batch_partial_success_and_manifest_mismatch(
  292. b5_client, token_for
  293. ):
  294. token = token_for("admin")
  295. metadata = [
  296. attachment_metadata("B5_TEST_批量成功"),
  297. attachment_metadata("B5_TEST_批量失败"),
  298. ]
  299. response = b5_client.post(
  300. "/api/v1/attachments/batch-import",
  301. data={
  302. "files": [
  303. (io.BytesIO(docx_bytes()), "good.docx"),
  304. (io.BytesIO(b"bad"), "bad.docx"),
  305. ],
  306. "items": json.dumps(metadata),
  307. },
  308. headers=auth(token),
  309. content_type="multipart/form-data",
  310. )
  311. assert response.status_code == 200
  312. result = response.get_json()["data"]
  313. assert (result["successCount"], result["failureCount"]) == (1, 1)
  314. assert [item["index"] for item in result["items"]] == [0, 1]
  315. mismatch = b5_client.post(
  316. "/api/v1/attachments/batch-import",
  317. data={
  318. "files": [(io.BytesIO(docx_bytes()), "one.docx")],
  319. "items": "[]",
  320. },
  321. headers=auth(token),
  322. content_type="multipart/form-data",
  323. )
  324. assert mismatch.status_code == 400
  325. assert mismatch.get_json()["code"] == "BATCH_MANIFEST_MISMATCH"
  326. def test_edit_version_conflict_and_forbidden_identity_fields(
  327. b5_app, b5_client, token_for
  328. ):
  329. token = token_for("admin")
  330. created = upload(
  331. b5_client,
  332. token,
  333. "/api/v1/documents",
  334. docx_bytes(),
  335. main_metadata(b5_app, "B5_TEST_编辑前"),
  336. ).get_json()["data"]
  337. payload = {
  338. "documentName": "B5_TEST_编辑后",
  339. "summary": "更新摘要",
  340. "categoryId": category_id(b5_app),
  341. "securityLevel": "SECRET",
  342. "visibilityType": "CUSTOM",
  343. "status": "DRAFT",
  344. "tags": ["更新"],
  345. "rowVersion": created["rowVersion"],
  346. }
  347. response = b5_client.put(
  348. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  349. )
  350. assert response.status_code == 200
  351. assert response.get_json()["data"]["documentName"] == "B5_TEST_编辑后"
  352. conflict = b5_client.put(
  353. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  354. )
  355. assert conflict.status_code == 409
  356. payload["fileHash"] = "b" * 64
  357. forbidden = b5_client.put(
  358. f"/api/v1/documents/{created['id']}", json=payload, headers=auth(token)
  359. )
  360. assert forbidden.status_code == 400
  361. def test_delete_sub_updates_parent_and_keeps_file(
  362. b5_app, b5_client, token_for
  363. ):
  364. from dms.extensions import db
  365. from dms.models import Document
  366. token = token_for("admin")
  367. main = upload(
  368. b5_client, token, "/api/v1/documents", docx_bytes(),
  369. main_metadata(b5_app, "B5_TEST_删除父案")
  370. ).get_json()["data"]
  371. metadata = main_metadata(b5_app, "B5_TEST_删除子案")
  372. metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
  373. child = upload(
  374. b5_client, token, "/api/v1/documents", docx_bytes(), metadata
  375. ).get_json()["data"]
  376. with b5_app.app_context():
  377. document = db.session.get(Document, int(child["id"]))
  378. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  379. response = b5_client.delete(
  380. f"/api/v1/documents/{child['id']}?rowVersion={child['rowVersion']}",
  381. headers=auth(token),
  382. )
  383. assert response.status_code == 200
  384. assert path.is_file()
  385. with b5_app.app_context():
  386. assert db.session.get(Document, int(main["id"])).child_count == 0
  387. def test_main_with_child_and_mounted_attachment_deletion_rules(
  388. b5_app, b5_client, token_for
  389. ):
  390. token = token_for("admin")
  391. main = upload(
  392. b5_client, token, "/api/v1/documents", docx_bytes(),
  393. main_metadata(b5_app, "B5_TEST_有子主案")
  394. ).get_json()["data"]
  395. metadata = main_metadata(b5_app, "B5_TEST_保留子案")
  396. metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
  397. upload(b5_client, token, "/api/v1/documents", docx_bytes(), metadata)
  398. response = b5_client.delete(
  399. f"/api/v1/documents/{main['id']}?rowVersion={main['rowVersion'] + 1}",
  400. headers=auth(token),
  401. )
  402. assert response.status_code == 409
  403. assert response.get_json()["code"] == "MAIN_PLAN_HAS_CHILDREN"
  404. def test_attachment_in_use_conflict(b4_app, b4_client, token_for):
  405. from dms.extensions import db
  406. from dms.models import Document
  407. attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
  408. with b4_app.app_context():
  409. version = db.session.get(Document, attachment_id).row_version
  410. response = b4_client.delete(
  411. f"/api/v1/attachments/{attachment_id}?rowVersion={version}",
  412. headers=auth(token_for("admin")),
  413. )
  414. assert response.status_code == 409
  415. details = response.get_json()["details"]
  416. assert details["mountedPlanCount"] == 2
  417. assert len(details["mainPlans"]) == 2
  418. def test_pdf_preview_and_download_count_audit(
  419. b5_app, b5_client, token_for
  420. ):
  421. from dms.extensions import db
  422. from dms.models import AuditLog, Document
  423. token = token_for("admin")
  424. metadata = attachment_metadata("B5_TEST_PDF")
  425. created_response = b5_client.post(
  426. "/api/v1/attachments",
  427. data={
  428. "file": (io.BytesIO(b"%PDF-1.4\\nB5-CONTENT"), "safe.pdf"),
  429. "metadata": json.dumps(metadata),
  430. },
  431. headers=auth(token),
  432. content_type="multipart/form-data",
  433. )
  434. created = created_response.get_json()["data"]
  435. preview = b5_client.get(
  436. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  437. )
  438. assert preview.status_code == 200
  439. assert preview.mimetype == "application/pdf"
  440. assert preview.headers["Content-Disposition"].startswith("inline")
  441. download = b5_client.get(
  442. f"/api/v1/documents/{created['id']}/download", headers=auth(token)
  443. )
  444. assert download.status_code == 200
  445. assert download.data == b"%PDF-1.4\\nB5-CONTENT"
  446. assert download.headers["X-Request-Id"]
  447. assert "\r" not in download.headers["Content-Disposition"]
  448. with b5_app.app_context():
  449. document = db.session.get(Document, int(created["id"]))
  450. assert document.download_count == 1
  451. assert (
  452. db.session.scalar(
  453. db.select(db.func.count(AuditLog.id)).where(
  454. AuditLog.target_id == document.id,
  455. AuditLog.action_type == "DOWNLOAD_DOCUMENT",
  456. )
  457. )
  458. == 1
  459. )
  460. def test_office_preview_file_missing_and_no_restore_route(
  461. b5_app, b5_client, token_for
  462. ):
  463. from dms.extensions import db
  464. from dms.models import Document
  465. token = token_for("admin")
  466. created = upload(
  467. b5_client,
  468. token,
  469. "/api/v1/attachments",
  470. docx_bytes(),
  471. attachment_metadata("B5_TEST_Office预览"),
  472. ).get_json()["data"]
  473. preview = b5_client.get(
  474. f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
  475. )
  476. assert preview.status_code == 415
  477. assert preview.get_json()["code"] == "PREVIEW_UNAVAILABLE"
  478. with b5_app.app_context():
  479. document = db.session.get(Document, int(created["id"]))
  480. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  481. path.unlink()
  482. missing = b5_client.get(
  483. f"/api/v1/documents/{created['id']}/download", headers=auth(token)
  484. )
  485. assert missing.status_code == 404
  486. assert missing.get_json()["code"] == "FILE_NOT_FOUND"
  487. restore = b5_client.post(
  488. f"/api/v1/documents/{created['id']}/restore", headers=auth(token)
  489. )
  490. assert restore.status_code == 404
  491. def test_size_limit_and_temporary_cleanup(b5_app, b5_client, token_for):
  492. b5_app.config["DMS_MAX_FILE_SIZE"] = 8
  493. temporary = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary"
  494. response = b5_client.post(
  495. "/api/v1/attachments",
  496. data={
  497. "file": (io.BytesIO(b"%PDF-1.4\nlarge"), "large.pdf"),
  498. "metadata": json.dumps(attachment_metadata("B5_TEST_超限")),
  499. },
  500. headers=auth(token_for("admin")),
  501. content_type="multipart/form-data",
  502. )
  503. assert response.status_code == 413
  504. assert not list(temporary.glob("*.upload"))
  505. def test_file_promotion_failure_rolls_back_database(
  506. b5_app, b5_client, token_for, monkeypatch
  507. ):
  508. from dms.extensions import db
  509. from dms.models import Document
  510. monkeypatch.setattr(
  511. "dms.storage.uploads.StagedUpload.promote",
  512. lambda _self: (_ for _ in ()).throw(OSError("disk failure")),
  513. )
  514. response = upload(
  515. b5_client,
  516. token_for("admin"),
  517. "/api/v1/attachments",
  518. docx_bytes(),
  519. attachment_metadata("B5_TEST_文件失败回滚"),
  520. )
  521. assert response.status_code == 500
  522. with b5_app.app_context():
  523. assert db.session.scalar(
  524. db.select(Document).where(
  525. Document.document_name == "B5_TEST_文件失败回滚"
  526. )
  527. ) is None
  528. assert not list(
  529. (Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary").glob("*.upload")
  530. )
  531. def test_batch_all_success_and_all_failure(b5_client, token_for):
  532. token = token_for("admin")
  533. success = b5_client.post(
  534. "/api/v1/attachments/batch-import",
  535. data={
  536. "files": [
  537. (io.BytesIO(docx_bytes()), "a.docx"),
  538. (io.BytesIO(xlsx_bytes()), "b.xlsx"),
  539. ],
  540. "items": json.dumps(
  541. [
  542. attachment_metadata("B5_TEST_全成功1"),
  543. attachment_metadata("B5_TEST_全成功2"),
  544. ]
  545. ),
  546. },
  547. headers=auth(token),
  548. content_type="multipart/form-data",
  549. ).get_json()["data"]
  550. assert (success["successCount"], success["failureCount"]) == (2, 0)
  551. failed = b5_client.post(
  552. "/api/v1/attachments/batch-import",
  553. data={
  554. "files": [
  555. (io.BytesIO(b"bad"), "a.docx"),
  556. (io.BytesIO(b"bad"), "b.xlsx"),
  557. ],
  558. "items": json.dumps(
  559. [
  560. attachment_metadata("B5_TEST_全失败1"),
  561. attachment_metadata("B5_TEST_全失败2"),
  562. ]
  563. ),
  564. },
  565. headers=auth(token),
  566. content_type="multipart/form-data",
  567. ).get_json()["data"]
  568. assert (failed["successCount"], failed["failureCount"]) == (0, 2)
  569. def test_attachment_edit_and_unmounted_delete_keep_file(
  570. b5_app, b5_client, token_for
  571. ):
  572. from dms.extensions import db
  573. from dms.models import Document
  574. token = token_for("admin")
  575. created = upload(
  576. b5_client,
  577. token,
  578. "/api/v1/attachments",
  579. docx_bytes(),
  580. attachment_metadata("B5_TEST_待编辑附件"),
  581. ).get_json()["data"]
  582. edited = b5_client.put(
  583. f"/api/v1/attachments/{created['id']}",
  584. json={
  585. "documentName": "B5_TEST_已编辑附件",
  586. "attachmentType": "WORK_STANDARD",
  587. "summary": "已编辑",
  588. "tags": ["编辑"],
  589. "rowVersion": created["rowVersion"],
  590. },
  591. headers=auth(token),
  592. ).get_json()["data"]
  593. assert edited["securityLevel"] == "PUBLIC"
  594. with b5_app.app_context():
  595. document = db.session.get(Document, int(created["id"]))
  596. path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
  597. response = b5_client.delete(
  598. f"/api/v1/attachments/{created['id']}?rowVersion={edited['rowVersion']}",
  599. headers=auth(token),
  600. )
  601. assert response.status_code == 200
  602. assert path.is_file()
  603. assert b5_client.get(
  604. f"/api/v1/attachments/{created['id']}", headers=auth(token)
  605. ).status_code == 404
  606. def test_plan_download_requires_acl_and_path_escape_is_rejected(
  607. b5_app, b5_client, token_for
  608. ):
  609. from dms.extensions import db
  610. from dms.models import Document
  611. admin_token = token_for("admin")
  612. metadata = main_metadata(b5_app, "B5_TEST_无下载权限")
  613. metadata["visibilityType"] = "CUSTOM"
  614. created = upload(
  615. b5_client, admin_token, "/api/v1/documents", docx_bytes(), metadata
  616. ).get_json()["data"]
  617. denied = b5_client.get(
  618. f"/api/v1/documents/{created['id']}/download",
  619. headers=auth(token_for("user")),
  620. )
  621. assert denied.status_code == 403
  622. assert denied.get_json()["code"] == "DOCUMENT_DOWNLOAD_FORBIDDEN"
  623. attachment = upload(
  624. b5_client,
  625. admin_token,
  626. "/api/v1/attachments",
  627. docx_bytes(),
  628. attachment_metadata("B5_TEST_越界路径"),
  629. ).get_json()["data"]
  630. with b5_app.app_context():
  631. document = db.session.get(Document, int(attachment["id"]))
  632. document.file_relative_path = "../outside.docx"
  633. db.session.commit()
  634. escaped = b5_client.get(
  635. f"/api/v1/documents/{attachment['id']}/download",
  636. headers=auth(admin_token),
  637. )
  638. assert escaped.status_code == 500
  639. assert "outside.docx" not in escaped.get_data(as_text=True)