test_b8_recycle_restore.py 32 KB

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