test_b8_recycle_restore.py 28 KB

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