test_b8_recycle_restore.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. from __future__ import annotations
  2. import io
  3. import json
  4. import threading
  5. from concurrent.futures import ThreadPoolExecutor
  6. from datetime import datetime, timezone
  7. from pathlib import Path
  8. import pytest
  9. PDF_BYTES = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n"
  10. def _headers(token: str) -> dict[str, str]:
  11. return {"Authorization": f"Bearer {token}"}
  12. def _category_id(app) -> str:
  13. from dms.extensions import db
  14. from dms.models import Category
  15. with app.app_context():
  16. category = db.session.scalar(
  17. db.select(Category).where(Category.category_code == "ROOT_A")
  18. )
  19. assert category is not None
  20. return str(category.id)
  21. def _upload_plan(
  22. client,
  23. token: str,
  24. category_id: str,
  25. name: str,
  26. *,
  27. document_type: str = "MAIN",
  28. parent_id: str | None = None,
  29. visibility: str = "ALL_AUTHENTICATED",
  30. ):
  31. metadata = {
  32. "documentName": name,
  33. "documentType": document_type,
  34. "summary": f"{name}摘要",
  35. "categoryId": category_id,
  36. "securityLevel": "INTERNAL",
  37. "visibilityType": visibility,
  38. "status": "PUBLISHED",
  39. "tags": ["B8"],
  40. }
  41. if parent_id is not None:
  42. metadata["parentDocumentId"] = parent_id
  43. response = client.post(
  44. "/api/v1/documents",
  45. data={
  46. "file": (io.BytesIO(PDF_BYTES), f"{name}.pdf"),
  47. "metadata": json.dumps(metadata, ensure_ascii=False),
  48. },
  49. headers=_headers(token),
  50. content_type="multipart/form-data",
  51. )
  52. assert response.status_code == 201, response.get_json()
  53. return response.get_json()["data"]
  54. def _upload_attachment(client, token: str, name: str):
  55. response = client.post(
  56. "/api/v1/attachments",
  57. data={
  58. "file": (io.BytesIO(PDF_BYTES), f"{name}.pdf"),
  59. "metadata": json.dumps(
  60. {
  61. "documentName": name,
  62. "attachmentType": "OTHER",
  63. "summary": f"{name}摘要",
  64. "tags": ["B8"],
  65. },
  66. ensure_ascii=False,
  67. ),
  68. },
  69. headers=_headers(token),
  70. content_type="multipart/form-data",
  71. )
  72. assert response.status_code == 201, response.get_json()
  73. return response.get_json()["data"]
  74. def _delete(client, token: str, document: dict, *, attachment: bool = False):
  75. prefix = "/api/v1/attachments" if attachment else "/api/v1/documents"
  76. response = client.delete(
  77. f"{prefix}/{document['id']}?rowVersion={document['rowVersion']}",
  78. headers=_headers(token),
  79. )
  80. assert response.status_code == 200, response.get_json()
  81. return response.get_json()["data"]
  82. def _deleted_document(app, document_id: str):
  83. from dms.extensions import db
  84. from dms.models import Document
  85. with app.app_context():
  86. document = db.session.get(Document, int(document_id))
  87. assert document is not None and document.is_deleted
  88. return {
  89. "rowVersion": document.row_version,
  90. "deletedAt": document.deleted_at,
  91. "fileRelativePath": document.file_relative_path,
  92. "viewCount": document.view_count,
  93. }
  94. def _restore(client, token: str, document_id: str, row_version: int):
  95. return client.post(
  96. f"/api/v1/recycle-bin/documents/{document_id}/restore",
  97. json={"rowVersion": row_version},
  98. headers=_headers(token),
  99. )
  100. def test_recycle_bin_auth_query_filters_and_deleted_by(
  101. b3_app, b3_client, login_token, token_for
  102. ):
  103. category_id = _category_id(b3_app)
  104. normal = _upload_plan(
  105. b3_client, login_token, category_id, "B8正常文档"
  106. )
  107. deleted = _upload_plan(
  108. b3_client, login_token, category_id, "B8_百分号%_下划线_"
  109. )
  110. _delete(b3_client, login_token, deleted)
  111. assert b3_client.get("/api/v1/recycle-bin/documents").status_code == 401
  112. for username in ("user", "auditor"):
  113. response = b3_client.get(
  114. "/api/v1/recycle-bin/documents",
  115. headers=_headers(token_for(username)),
  116. )
  117. assert response.status_code == 403
  118. assert response.get_json()["code"] == "FORBIDDEN"
  119. response = b3_client.get(
  120. "/api/v1/recycle-bin/documents",
  121. query_string={
  122. "keyword": "百分号%",
  123. "documentType": "MAIN",
  124. "categoryId": category_id,
  125. "page": "1",
  126. "pageSize": "20",
  127. "sortField": "documentName",
  128. "sortDirection": "asc",
  129. },
  130. headers=_headers(login_token),
  131. )
  132. assert response.status_code == 200, response.get_json()
  133. body = response.get_json()
  134. assert body["data"]["total"] == 1
  135. item = body["data"]["items"][0]
  136. assert item["id"] == deleted["id"]
  137. assert item["id"] != normal["id"]
  138. assert item["documentStatus"] == "PUBLISHED"
  139. assert item["deletedBy"]["username"] == "admin"
  140. assert "fileRelativePath" not in item
  141. assert "fileHash" not in item
  142. assert isinstance(item["id"], str)
  143. @pytest.mark.parametrize(
  144. ("query", "message_part"),
  145. [
  146. ("unknown=x", "未知"),
  147. ("page=1&page=2", "重复"),
  148. ("documentType=OTHER", "documentType"),
  149. ("categoryId=0", "categoryId"),
  150. ("deletedFrom=2026-01-01T00:00:00%2B08:00", "UTC Z"),
  151. ("deletedFrom=2026-01-02T00:00:00Z&deletedTo=2026-01-01T00:00:00Z", "早于"),
  152. ("pageSize=101", "100"),
  153. ("sortField=id", "sortField"),
  154. ("sortDirection=sideways", "sortDirection"),
  155. ],
  156. )
  157. def test_recycle_bin_rejects_invalid_query(
  158. b3_client, login_token, query, message_part
  159. ):
  160. response = b3_client.get(
  161. f"/api/v1/recycle-bin/documents?{query}",
  162. headers=_headers(login_token),
  163. )
  164. assert response.status_code == 400
  165. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  166. assert message_part in response.get_json()["message"]
  167. def test_restore_main_restores_delete_batch_acl_binding_counts_and_audit(
  168. b3_app, b3_client, login_token
  169. ):
  170. from dms.extensions import db
  171. from dms.models import (
  172. AttachmentBinding,
  173. AuditLog,
  174. Category,
  175. Document,
  176. Organization,
  177. Permission,
  178. )
  179. category_id = _category_id(b3_app)
  180. main = _upload_plan(
  181. b3_client,
  182. login_token,
  183. category_id,
  184. "B8关系主案",
  185. visibility="ORGANIZATION",
  186. )
  187. attachment = _upload_attachment(b3_client, login_token, "B8关系附件")
  188. with b3_app.app_context():
  189. organization = db.session.scalar(
  190. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  191. )
  192. assert organization is not None
  193. organization_id = str(organization.id)
  194. permission_response = b3_client.put(
  195. f"/api/v1/documents/{main['id']}/permissions",
  196. json={
  197. "visibilityType": "ORGANIZATION",
  198. "documentRowVersion": main["rowVersion"],
  199. "entries": [
  200. {
  201. "subjectType": "ORG",
  202. "subjectId": organization_id,
  203. "canView": True,
  204. "canDownload": True,
  205. "canEdit": False,
  206. "canManagePermission": False,
  207. "canDelete": False,
  208. }
  209. ],
  210. },
  211. headers=_headers(login_token),
  212. )
  213. assert permission_response.status_code == 200, permission_response.get_json()
  214. main_version = permission_response.get_json()["data"]["documentRowVersion"]
  215. bind_response = b3_client.post(
  216. f"/api/v1/main-plans/{main['id']}/attachments/bind",
  217. json={
  218. "attachmentIds": [attachment["id"]],
  219. "mainPlanRowVersion": main_version,
  220. },
  221. headers=_headers(login_token),
  222. )
  223. assert bind_response.status_code == 200, bind_response.get_json()
  224. main["rowVersion"] = bind_response.get_json()["data"]["mainPlanRowVersion"]
  225. _delete(b3_client, login_token, main)
  226. deleted = _deleted_document(b3_app, main["id"])
  227. response = _restore(
  228. b3_client, login_token, main["id"], deleted["rowVersion"]
  229. )
  230. assert response.status_code == 200, response.get_json()
  231. data = response.get_json()["data"]
  232. assert data["restoredPermissionCount"] == 1
  233. assert data["skippedPermissionCount"] == 0
  234. assert data["restoredBindingCount"] == 1
  235. assert data["skippedBindingCount"] == 0
  236. assert data["document"]["attachmentCount"] == 1
  237. assert data["document"]["viewCount"] == deleted["viewCount"]
  238. assert "fileHash" not in data["document"]
  239. with b3_app.app_context():
  240. document = db.session.get(Document, int(main["id"]))
  241. category = db.session.get(Category, int(category_id))
  242. permission = db.session.scalar(
  243. db.select(Permission).where(
  244. Permission.document_id == int(main["id"]),
  245. Permission.is_deleted.is_(False),
  246. )
  247. )
  248. binding = db.session.scalar(
  249. db.select(AttachmentBinding).where(
  250. AttachmentBinding.main_document_id == int(main["id"]),
  251. AttachmentBinding.is_deleted.is_(False),
  252. )
  253. )
  254. audit = db.session.scalar(
  255. db.select(AuditLog)
  256. .where(
  257. AuditLog.action_type == "RESTORE_DOCUMENT",
  258. AuditLog.target_id == int(main["id"]),
  259. )
  260. .order_by(AuditLog.id.desc())
  261. )
  262. view_audits = db.session.scalar(
  263. db.select(db.func.count(AuditLog.id)).where(
  264. AuditLog.action_type == "VIEW_DOCUMENT",
  265. AuditLog.target_id == int(main["id"]),
  266. )
  267. )
  268. assert document is not None and not document.is_deleted
  269. assert document.row_version == deleted["rowVersion"] + 1
  270. assert category is not None and category.document_count >= 1
  271. assert permission is not None and permission.row_version == 2
  272. assert binding is not None and binding.row_version == 2
  273. assert audit is not None
  274. assert audit.operation_detail["restoredPermissionCount"] == 1
  275. assert "fileHash" not in audit.operation_detail
  276. assert view_audits == 0
  277. def test_restore_sub_plan_recounts_parent_and_inherits_current_acl(
  278. b3_app, b3_client, login_token
  279. ):
  280. from dms.extensions import db
  281. from dms.models import Document, Permission
  282. category_id = _category_id(b3_app)
  283. main = _upload_plan(
  284. b3_client, login_token, category_id, "B8子案父主案"
  285. )
  286. sub = _upload_plan(
  287. b3_client,
  288. login_token,
  289. category_id,
  290. "B8待恢复子案",
  291. document_type="SUB_PLAN",
  292. parent_id=main["id"],
  293. )
  294. _delete(b3_client, login_token, sub)
  295. deleted = _deleted_document(b3_app, sub["id"])
  296. with b3_app.app_context():
  297. parent_before = db.session.get(Document, int(main["id"])).row_version
  298. response = _restore(
  299. b3_client, login_token, sub["id"], deleted["rowVersion"]
  300. )
  301. assert response.status_code == 200, response.get_json()
  302. data = response.get_json()["data"]
  303. assert data["restoredPermissionCount"] == 0
  304. with b3_app.app_context():
  305. restored = db.session.get(Document, int(sub["id"]))
  306. parent = db.session.get(Document, int(main["id"]))
  307. acl_count = db.session.scalar(
  308. db.select(db.func.count(Permission.id)).where(
  309. Permission.document_id == restored.id,
  310. Permission.is_deleted.is_(False),
  311. )
  312. )
  313. assert restored.row_version == deleted["rowVersion"] + 1
  314. assert parent.child_count == 1
  315. assert parent.row_version == parent_before + 1
  316. assert acl_count == 0
  317. def test_restore_attachment_does_not_restore_historical_binding(
  318. b3_app, b3_client, login_token
  319. ):
  320. from dms.extensions import db
  321. from dms.models import AttachmentBinding, Document
  322. category_id = _category_id(b3_app)
  323. main = _upload_plan(
  324. b3_client, login_token, category_id, "B8附件挂载主案"
  325. )
  326. attachment = _upload_attachment(b3_client, login_token, "B8待恢复附件")
  327. bind = b3_client.post(
  328. f"/api/v1/main-plans/{main['id']}/attachments/bind",
  329. json={
  330. "attachmentIds": [attachment["id"]],
  331. "mainPlanRowVersion": main["rowVersion"],
  332. },
  333. headers=_headers(login_token),
  334. ).get_json()["data"]
  335. unbind = b3_client.delete(
  336. f"/api/v1/main-plans/{main['id']}/attachments/{attachment['id']}"
  337. f"?mainPlanRowVersion={bind['mainPlanRowVersion']}",
  338. headers=_headers(login_token),
  339. )
  340. assert unbind.status_code == 200
  341. _delete(b3_client, login_token, attachment, attachment=True)
  342. deleted = _deleted_document(b3_app, attachment["id"])
  343. response = _restore(
  344. b3_client, login_token, attachment["id"], deleted["rowVersion"]
  345. )
  346. assert response.status_code == 200, response.get_json()
  347. data = response.get_json()["data"]
  348. assert data["restoredPermissionCount"] == 0
  349. assert data["restoredBindingCount"] == 0
  350. assert data["document"]["mountedPlanCount"] == 0
  351. with b3_app.app_context():
  352. restored = db.session.get(Document, int(attachment["id"]))
  353. binding = db.session.scalar(
  354. db.select(AttachmentBinding).where(
  355. AttachmentBinding.attachment_document_id == restored.id
  356. )
  357. )
  358. assert restored.security_level == "PUBLIC"
  359. assert restored.visibility_type == "ALL_AUTHENTICATED"
  360. assert restored.category_id is None
  361. assert binding is not None and binding.is_deleted
  362. def test_restore_validates_body_state_and_old_path_stays_404(
  363. b3_app, b3_client, login_token
  364. ):
  365. category_id = _category_id(b3_app)
  366. document = _upload_plan(
  367. b3_client, login_token, category_id, "B8请求校验"
  368. )
  369. response = _restore(
  370. b3_client, login_token, document["id"], document["rowVersion"]
  371. )
  372. assert response.status_code == 409
  373. assert response.get_json()["code"] == "DOCUMENT_NOT_DELETED"
  374. assert (
  375. b3_client.post(
  376. f"/api/v1/documents/{document['id']}/restore",
  377. json={"rowVersion": document["rowVersion"]},
  378. headers=_headers(login_token),
  379. ).status_code
  380. == 404
  381. )
  382. _delete(b3_client, login_token, document)
  383. deleted = _deleted_document(b3_app, document["id"])
  384. for payload in (
  385. {},
  386. {"rowVersion": True},
  387. {"rowVersion": "1"},
  388. {"rowVersion": 1.5},
  389. {"rowVersion": -1},
  390. {"rowVersion": deleted["rowVersion"], "extra": 1},
  391. ):
  392. invalid = b3_client.post(
  393. f"/api/v1/recycle-bin/documents/{document['id']}/restore",
  394. json=payload,
  395. headers=_headers(login_token),
  396. )
  397. assert invalid.status_code == 400
  398. assert invalid.get_json()["code"] == "INVALID_ARGUMENT"
  399. conflict = _restore(
  400. b3_client, login_token, document["id"], deleted["rowVersion"] - 1
  401. )
  402. assert conflict.status_code == 409
  403. assert conflict.get_json()["code"] == "DATA_VERSION_CONFLICT"
  404. assert (
  405. conflict.get_json()["details"]["currentRowVersion"]
  406. == deleted["rowVersion"]
  407. )
  408. @pytest.mark.parametrize(
  409. ("mutation", "expected_code", "expected_status"),
  410. [
  411. ("missing", "FILE_NOT_FOUND", 404),
  412. ("size", "FILE_INTEGRITY_MISMATCH", 409),
  413. ("hash", "FILE_INTEGRITY_MISMATCH", 409),
  414. ("path", "FILE_PATH_INVALID", 500),
  415. ("type", "FILE_INTEGRITY_MISMATCH", 409),
  416. ],
  417. )
  418. def test_restore_file_failures_are_safe_and_rollback(
  419. b3_app, b3_client, login_token, mutation, expected_code, expected_status
  420. ):
  421. from dms.extensions import db
  422. from dms.models import Document
  423. from dms.storage.paths import resolve_storage_path
  424. category_id = _category_id(b3_app)
  425. document = _upload_plan(
  426. b3_client, login_token, category_id, f"B8文件失败{mutation}"
  427. )
  428. _delete(b3_client, login_token, document)
  429. deleted = _deleted_document(b3_app, document["id"])
  430. with b3_app.app_context():
  431. row = db.session.get(Document, int(document["id"]))
  432. path = resolve_storage_path(
  433. row.file_relative_path, b3_app.config["DMS_STORAGE_ROOT"]
  434. )
  435. if mutation == "missing":
  436. path.unlink()
  437. elif mutation == "size":
  438. path.write_bytes(PDF_BYTES + b"x")
  439. elif mutation == "hash":
  440. changed = bytearray(PDF_BYTES)
  441. changed[-2] = ord("X")
  442. path.write_bytes(changed)
  443. elif mutation == "path":
  444. row.file_relative_path = "../outside.pdf"
  445. db.session.commit()
  446. elif mutation == "type":
  447. path.write_bytes(b"NOT_A_PDF" + PDF_BYTES)
  448. response = _restore(
  449. b3_client, login_token, document["id"], deleted["rowVersion"]
  450. )
  451. assert response.status_code == expected_status
  452. body = response.get_json()
  453. assert body["code"] == expected_code
  454. serialized = json.dumps(body, ensure_ascii=False)
  455. assert str(Path(b3_app.config["DMS_STORAGE_ROOT"])) not in serialized
  456. assert "a" * 64 not in serialized
  457. with b3_app.app_context():
  458. assert db.session.get(Document, int(document["id"])).is_deleted
  459. def test_restore_file_recheck_and_audit_failure_roll_back(
  460. b3_app, b3_client, login_token, monkeypatch
  461. ):
  462. from dms.common.errors import RestoreFileChangedError
  463. from dms.extensions import db
  464. from dms.models import AuditLog, Document
  465. from dms.services import recycle_bin_service
  466. category_id = _category_id(b3_app)
  467. first = _upload_plan(
  468. b3_client, login_token, category_id, "B8文件竞态"
  469. )
  470. _delete(b3_client, login_token, first)
  471. first_deleted = _deleted_document(b3_app, first["id"])
  472. monkeypatch.setattr(
  473. recycle_bin_service,
  474. "_recheck_file",
  475. lambda *_args: (_ for _ in ()).throw(RestoreFileChangedError()),
  476. )
  477. response = _restore(
  478. b3_client, login_token, first["id"], first_deleted["rowVersion"]
  479. )
  480. assert response.status_code == 409
  481. assert response.get_json()["code"] == "RESTORE_FILE_CHANGED"
  482. with b3_app.app_context():
  483. assert db.session.get(Document, int(first["id"])).is_deleted
  484. monkeypatch.undo()
  485. second = _upload_plan(
  486. b3_client, login_token, category_id, "B8审计回滚"
  487. )
  488. _delete(b3_client, login_token, second)
  489. second_deleted = _deleted_document(b3_app, second["id"])
  490. def fail_audit(**_kwargs):
  491. raise RuntimeError("audit failed")
  492. monkeypatch.setattr(recycle_bin_service, "business_audit", fail_audit)
  493. audit_failure = _restore(
  494. b3_client, login_token, second["id"], second_deleted["rowVersion"]
  495. )
  496. assert audit_failure.status_code == 500
  497. assert audit_failure.get_json()["code"] == "INTERNAL_ERROR"
  498. with b3_app.app_context():
  499. assert db.session.get(Document, int(second["id"])).is_deleted
  500. count = db.session.scalar(
  501. db.select(db.func.count(AuditLog.id)).where(
  502. AuditLog.action_type == "RESTORE_DOCUMENT",
  503. AuditLog.target_id == int(second["id"]),
  504. )
  505. )
  506. assert count == 0
  507. def test_restore_invalid_category_and_parent_roll_back(
  508. b3_app, b3_client, login_token
  509. ):
  510. from dms.extensions import db
  511. from dms.models import Category, Document
  512. category_id = _category_id(b3_app)
  513. main = _upload_plan(
  514. b3_client, login_token, category_id, "B8失效分类"
  515. )
  516. _delete(b3_client, login_token, main)
  517. deleted_main = _deleted_document(b3_app, main["id"])
  518. with b3_app.app_context():
  519. category = db.session.get(Category, int(category_id))
  520. category.status = "DISABLED"
  521. db.session.commit()
  522. invalid_category = _restore(
  523. b3_client, login_token, main["id"], deleted_main["rowVersion"]
  524. )
  525. assert invalid_category.status_code == 409
  526. assert invalid_category.get_json()["code"] == "RESTORE_CATEGORY_INVALID"
  527. with b3_app.app_context():
  528. category = db.session.get(Category, int(category_id))
  529. category.status = "ENABLED"
  530. db.session.commit()
  531. parent = _upload_plan(
  532. b3_client, login_token, category_id, "B8失效父主案"
  533. )
  534. sub = _upload_plan(
  535. b3_client,
  536. login_token,
  537. category_id,
  538. "B8父级失效子案",
  539. document_type="SUB_PLAN",
  540. parent_id=parent["id"],
  541. )
  542. _delete(b3_client, login_token, sub)
  543. deleted_sub = _deleted_document(b3_app, sub["id"])
  544. with b3_app.app_context():
  545. parent_row = db.session.get(Document, int(parent["id"]))
  546. parent_row.is_deleted = True
  547. parent_row.deleted_at = datetime.now(timezone.utc).replace(tzinfo=None)
  548. db.session.commit()
  549. invalid_parent = _restore(
  550. b3_client, login_token, sub["id"], deleted_sub["rowVersion"]
  551. )
  552. assert invalid_parent.status_code == 409
  553. assert invalid_parent.get_json()["code"] == "RESTORE_PARENT_INVALID"
  554. def test_restore_custom_skips_disabled_subject_but_organization_rejects(
  555. b3_app, b3_client, login_token
  556. ):
  557. from dms.extensions import db
  558. from dms.models import Organization, User
  559. category_id = _category_id(b3_app)
  560. with b3_app.app_context():
  561. user = db.session.scalar(db.select(User).where(User.username == "user"))
  562. organization = db.session.scalar(
  563. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  564. )
  565. assert user is not None and organization is not None
  566. user_id = str(user.id)
  567. organization_id = str(organization.id)
  568. def permission(document, visibility, subject_type, subject_id):
  569. response = b3_client.put(
  570. f"/api/v1/documents/{document['id']}/permissions",
  571. json={
  572. "visibilityType": visibility,
  573. "documentRowVersion": document["rowVersion"],
  574. "entries": [
  575. {
  576. "subjectType": subject_type,
  577. "subjectId": subject_id,
  578. "canView": True,
  579. "canDownload": False,
  580. "canEdit": False,
  581. "canManagePermission": False,
  582. "canDelete": False,
  583. }
  584. ],
  585. },
  586. headers=_headers(login_token),
  587. )
  588. assert response.status_code == 200
  589. document["rowVersion"] = response.get_json()["data"]["documentRowVersion"]
  590. custom = _upload_plan(
  591. b3_client,
  592. login_token,
  593. category_id,
  594. "B8自定义跳过",
  595. visibility="CUSTOM",
  596. )
  597. permission(custom, "CUSTOM", "USER", user_id)
  598. _delete(b3_client, login_token, custom)
  599. custom_deleted = _deleted_document(b3_app, custom["id"])
  600. with b3_app.app_context():
  601. user = db.session.get(User, int(user_id))
  602. user.status = "DISABLED"
  603. db.session.commit()
  604. custom_restore = _restore(
  605. b3_client, login_token, custom["id"], custom_deleted["rowVersion"]
  606. )
  607. assert custom_restore.status_code == 200, custom_restore.get_json()
  608. assert custom_restore.get_json()["data"]["skippedPermissionCount"] == 1
  609. organization_main = _upload_plan(
  610. b3_client,
  611. login_token,
  612. category_id,
  613. "B8组织权限拒绝",
  614. visibility="ORGANIZATION",
  615. )
  616. permission(
  617. organization_main, "ORGANIZATION", "ORG", organization_id
  618. )
  619. _delete(b3_client, login_token, organization_main)
  620. organization_deleted = _deleted_document(
  621. b3_app, organization_main["id"]
  622. )
  623. with b3_app.app_context():
  624. organization = db.session.get(Organization, int(organization_id))
  625. organization.status = "DISABLED"
  626. db.session.commit()
  627. rejected = _restore(
  628. b3_client,
  629. login_token,
  630. organization_main["id"],
  631. organization_deleted["rowVersion"],
  632. )
  633. assert rejected.status_code == 409
  634. assert rejected.get_json()["code"] == "RESTORE_PERMISSION_INVALID"
  635. def test_deleted_by_is_null_without_reliable_audit_and_query_has_no_n_plus_one(
  636. b3_app, b3_client, login_token
  637. ):
  638. from sqlalchemy import event
  639. from dms.extensions import db
  640. from dms.models import AuditLog
  641. category_id = _category_id(b3_app)
  642. documents = [
  643. _upload_plan(
  644. b3_client, login_token, category_id, f"B8批量删除人{i}"
  645. )
  646. for i in range(3)
  647. ]
  648. for document in documents:
  649. _delete(b3_client, login_token, document)
  650. with b3_app.app_context():
  651. db.session.execute(
  652. db.delete(AuditLog).where(
  653. AuditLog.action_type == "DELETE_DOCUMENT",
  654. AuditLog.target_id == int(documents[0]["id"]),
  655. )
  656. )
  657. db.session.commit()
  658. select_count = 0
  659. def count_selects(
  660. _conn, _cursor, statement, _parameters, _context, _executemany
  661. ):
  662. nonlocal select_count
  663. if statement.lstrip().upper().startswith("SELECT"):
  664. select_count += 1
  665. with b3_app.app_context():
  666. event.listen(db.engine, "before_cursor_execute", count_selects)
  667. try:
  668. response = b3_client.get(
  669. "/api/v1/recycle-bin/documents?pageSize=100",
  670. headers=_headers(login_token),
  671. )
  672. finally:
  673. with b3_app.app_context():
  674. event.remove(db.engine, "before_cursor_execute", count_selects)
  675. assert response.status_code == 200
  676. items = {
  677. item["id"]: item for item in response.get_json()["data"]["items"]
  678. }
  679. assert items[documents[0]["id"]]["deletedBy"] is None
  680. assert items[documents[1]["id"]]["deletedBy"]["username"] == "admin"
  681. # Token校验1次、分页count 1次、文档页1次、窗口审计1次。
  682. assert select_count <= 4
  683. def test_restore_relation_conflicts_and_all_authenticated_acl_roll_back(
  684. b3_app, b3_client, login_token
  685. ):
  686. from dms.extensions import db
  687. from dms.models import Document, Organization, Permission
  688. category_id = _category_id(b3_app)
  689. with b3_app.app_context():
  690. organization = db.session.scalar(
  691. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  692. )
  693. assert organization is not None
  694. organization_id = str(organization.id)
  695. main = _upload_plan(
  696. b3_client,
  697. login_token,
  698. category_id,
  699. "B8权限关系冲突",
  700. visibility="ORGANIZATION",
  701. )
  702. permission = b3_client.put(
  703. f"/api/v1/documents/{main['id']}/permissions",
  704. json={
  705. "visibilityType": "ORGANIZATION",
  706. "documentRowVersion": main["rowVersion"],
  707. "entries": [
  708. {
  709. "subjectType": "ORG",
  710. "subjectId": organization_id,
  711. "canView": True,
  712. "canDownload": False,
  713. "canEdit": False,
  714. "canManagePermission": False,
  715. "canDelete": False,
  716. }
  717. ],
  718. },
  719. headers=_headers(login_token),
  720. )
  721. main["rowVersion"] = permission.get_json()["data"]["documentRowVersion"]
  722. _delete(b3_client, login_token, main)
  723. deleted = _deleted_document(b3_app, main["id"])
  724. with b3_app.app_context():
  725. db.session.add(
  726. Permission(
  727. document_id=int(main["id"]),
  728. subject_type="ORG",
  729. subject_id=int(organization_id),
  730. subject_name="并发新权限",
  731. can_view=True,
  732. )
  733. )
  734. db.session.commit()
  735. conflict = _restore(
  736. b3_client, login_token, main["id"], deleted["rowVersion"]
  737. )
  738. assert conflict.status_code == 409
  739. assert conflict.get_json()["code"] == "RESTORE_RELATION_CONFLICT"
  740. assert conflict.get_json()["details"]["permissionConflictCount"] == 1
  741. with b3_app.app_context():
  742. assert db.session.get(Document, int(main["id"])).is_deleted
  743. all_main = _upload_plan(
  744. b3_client,
  745. login_token,
  746. category_id,
  747. "B8全员历史ACL",
  748. visibility="CUSTOM",
  749. )
  750. saved = b3_client.put(
  751. f"/api/v1/documents/{all_main['id']}/permissions",
  752. json={
  753. "visibilityType": "CUSTOM",
  754. "documentRowVersion": all_main["rowVersion"],
  755. "entries": [
  756. {
  757. "subjectType": "ORG",
  758. "subjectId": organization_id,
  759. "canView": True,
  760. "canDownload": False,
  761. "canEdit": False,
  762. "canManagePermission": False,
  763. "canDelete": False,
  764. }
  765. ],
  766. },
  767. headers=_headers(login_token),
  768. ).get_json()["data"]
  769. edited = b3_client.put(
  770. f"/api/v1/documents/{all_main['id']}",
  771. json={
  772. "documentName": all_main["documentName"],
  773. "summary": "切换全员可见但保留历史ACL",
  774. "categoryId": category_id,
  775. "securityLevel": "INTERNAL",
  776. "visibilityType": "ALL_AUTHENTICATED",
  777. "status": "PUBLISHED",
  778. "tags": ["B8"],
  779. "rowVersion": saved["documentRowVersion"],
  780. },
  781. headers=_headers(login_token),
  782. )
  783. assert edited.status_code == 200
  784. all_main = edited.get_json()["data"]
  785. _delete(b3_client, login_token, all_main)
  786. all_deleted = _deleted_document(b3_app, all_main["id"])
  787. invalid = _restore(
  788. b3_client, login_token, all_main["id"], all_deleted["rowVersion"]
  789. )
  790. assert invalid.status_code == 409
  791. assert invalid.get_json()["code"] == "RESTORE_PERMISSION_INVALID"
  792. assert (
  793. invalid.get_json()["details"]["reason"]
  794. == "ALL_AUTHENTICATED_HAS_PERMISSIONS"
  795. )
  796. def test_restore_attachment_rejects_anomalous_active_binding(
  797. b3_app, b3_client, login_token
  798. ):
  799. from dms.extensions import db
  800. from dms.models import AttachmentBinding, Document
  801. category_id = _category_id(b3_app)
  802. main = _upload_plan(
  803. b3_client, login_token, category_id, "B8异常挂载主案"
  804. )
  805. attachment = _upload_attachment(
  806. b3_client, login_token, "B8异常挂载附件"
  807. )
  808. _delete(b3_client, login_token, attachment, attachment=True)
  809. deleted = _deleted_document(b3_app, attachment["id"])
  810. with b3_app.app_context():
  811. db.session.add(
  812. AttachmentBinding(
  813. main_document_id=int(main["id"]),
  814. attachment_document_id=int(attachment["id"]),
  815. sort_no=10,
  816. )
  817. )
  818. db.session.commit()
  819. response = _restore(
  820. b3_client, login_token, attachment["id"], deleted["rowVersion"]
  821. )
  822. assert response.status_code == 409
  823. assert response.get_json()["code"] == "RESTORE_RELATION_CONFLICT"
  824. assert response.get_json()["details"]["bindingConflictCount"] == 1
  825. with b3_app.app_context():
  826. assert db.session.get(Document, int(attachment["id"])).is_deleted
  827. def test_concurrent_double_restore_only_one_succeeds(
  828. b3_app, b3_client, login_token, monkeypatch
  829. ):
  830. from dms.services import recycle_bin_service
  831. category_id = _category_id(b3_app)
  832. document = _upload_plan(
  833. b3_client, login_token, category_id, "B8并发双恢复"
  834. )
  835. _delete(b3_client, login_token, document)
  836. deleted = _deleted_document(b3_app, document["id"])
  837. original = recycle_bin_service._restore_operation
  838. barrier = threading.Barrier(2)
  839. def synchronized(*args, **kwargs):
  840. barrier.wait(timeout=10)
  841. return original(*args, **kwargs)
  842. monkeypatch.setattr(
  843. recycle_bin_service, "_restore_operation", synchronized
  844. )
  845. def invoke():
  846. with b3_app.test_client() as client:
  847. response = _restore(
  848. client, login_token, document["id"], deleted["rowVersion"]
  849. )
  850. return response.status_code, response.get_json()["code"]
  851. with ThreadPoolExecutor(max_workers=2) as executor:
  852. results = list(executor.map(lambda _index: invoke(), range(2)))
  853. assert sorted(results) == [
  854. (200, "OK"),
  855. (409, "DOCUMENT_NOT_DELETED"),
  856. ]