test_document_password_lock.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. """方案密码锁:判定语义单元测试 + HTTP 集成测试。"""
  2. from __future__ import annotations
  3. import io
  4. import json
  5. from types import SimpleNamespace
  6. import pytest
  7. from dms.common.enums import DocumentPasswordAction, RoleCode
  8. from dms.common.errors import (
  9. DocumentPasswordInvalidError,
  10. DocumentPasswordRequiredError,
  11. InvalidArgumentError,
  12. )
  13. from dms.security.passwords import hash_password
  14. from dms.services.document_password_service import (
  15. _pop_password_spec,
  16. apply_password_lock,
  17. clear_password_lock,
  18. parse_protected_actions,
  19. password_required_for,
  20. protected_actions_of,
  21. require_document_password,
  22. )
  23. ALL_ACTIONS = [action.value for action in DocumentPasswordAction]
  24. # ---------------------------------------------------------------------------
  25. # 纯单元测试(无数据库依赖)
  26. # ---------------------------------------------------------------------------
  27. def stub_document(**overrides):
  28. base = dict(
  29. id=1,
  30. document_type="MAIN",
  31. password_hash=None,
  32. protect_view=False,
  33. protect_download=False,
  34. protect_edit=False,
  35. protect_delete=False,
  36. protect_config_permission=False,
  37. protect_bind_attachment=False,
  38. protect_unbind_attachment=False,
  39. )
  40. base.update(overrides)
  41. return SimpleNamespace(**base)
  42. def admin_ctx():
  43. return SimpleNamespace(role_code=RoleCode.ADMIN.value)
  44. def user_ctx():
  45. return SimpleNamespace(role_code=RoleCode.USER.value)
  46. @pytest.mark.parametrize("value", [None, [], "VIEW", [1], ["VIEW", "HACK"]])
  47. def test_parse_protected_actions_rejects_invalid(value):
  48. with pytest.raises(InvalidArgumentError):
  49. parse_protected_actions(value)
  50. def test_parse_protected_actions_dedup_and_stable_order():
  51. assert parse_protected_actions(
  52. ["EDIT", "VIEW", "VIEW", "DOWNLOAD"]
  53. ) == ["VIEW", "DOWNLOAD", "EDIT"]
  54. def test_protected_actions_of_empty_when_no_password():
  55. doc = stub_document(protect_view=True)
  56. assert protected_actions_of(doc) == []
  57. def test_protected_actions_of_lists_checked_columns():
  58. doc = stub_document(
  59. password_hash="x",
  60. protect_view=True,
  61. protect_unbind_attachment=True,
  62. )
  63. assert protected_actions_of(doc) == ["VIEW", "UNBIND_ATTACHMENT"]
  64. def test_password_required_for_user_only_checked_actions():
  65. doc = stub_document(password_hash="x", protect_view=True)
  66. ctx = user_ctx()
  67. assert password_required_for(doc, "VIEW", ctx) is True
  68. assert password_required_for(doc, "DOWNLOAD", ctx) is False
  69. def test_password_required_for_admin_all_but_delete():
  70. doc = stub_document(password_hash="x") # 未勾选任何操作
  71. ctx = admin_ctx()
  72. for action in ALL_ACTIONS:
  73. expected = action != "DELETE"
  74. assert password_required_for(doc, action, ctx) is expected, action
  75. def test_password_required_for_unlocked_document_is_false():
  76. assert password_required_for(stub_document(), "VIEW", admin_ctx()) is False
  77. def test_require_document_password_missing_raises_required():
  78. doc = stub_document(
  79. password_hash=hash_password("s3cret"), protect_view=True
  80. )
  81. with pytest.raises(DocumentPasswordRequiredError):
  82. require_document_password(doc, "VIEW", None, user_ctx())
  83. def test_require_document_password_wrong_raises_invalid():
  84. doc = stub_document(
  85. password_hash=hash_password("s3cret"), protect_view=True
  86. )
  87. with pytest.raises(DocumentPasswordInvalidError):
  88. require_document_password(doc, "VIEW", "wrong", user_ctx())
  89. def test_require_document_password_correct_passes():
  90. doc = stub_document(
  91. password_hash=hash_password("s3cret"), protect_view=True
  92. )
  93. require_document_password(doc, "VIEW", "s3cret", user_ctx())
  94. def test_apply_and_clear_password_lock():
  95. doc = stub_document()
  96. apply_password_lock(doc, "s3cret", ["VIEW", "EDIT"])
  97. assert doc.password_hash is not None
  98. assert doc.protect_view is True
  99. assert doc.protect_edit is True
  100. assert doc.protect_download is False
  101. clear_password_lock(doc)
  102. assert doc.password_hash is None
  103. assert doc.protect_view is False
  104. assert doc.protect_edit is False
  105. def test_pop_password_spec_absent_returns_none():
  106. assert _pop_password_spec(
  107. {"documentName": "x"}, attachment=False
  108. ) == (None, None)
  109. @pytest.mark.parametrize(
  110. "payload",
  111. [
  112. {"password": "p", "documentType": "ATTACHMENT"}, # 附件不允许
  113. {"password": "p", "documentType": "SUB_PLAN"}, # 子案不允许
  114. {"password": "p"}, # 缺 protectedActions
  115. {"protectedActions": ["VIEW"]}, # 缺 password
  116. {"password": "", "protectedActions": ["VIEW"]}, # 空密码
  117. {"password": "x" * 129, "protectedActions": ["VIEW"]}, # 超长
  118. {"password": "p", "protectedActions": ["HACK"]}, # 未知操作码
  119. ],
  120. )
  121. def test_pop_password_spec_rejects_invalid(payload):
  122. with pytest.raises(InvalidArgumentError):
  123. _pop_password_spec(payload, attachment=False)
  124. def test_pop_password_spec_rejects_overwrite_upload():
  125. payload = {
  126. "documentType": "MAIN",
  127. "password": "p",
  128. "protectedActions": ["VIEW"],
  129. "overwriteDocumentId": "123",
  130. }
  131. with pytest.raises(InvalidArgumentError):
  132. _pop_password_spec(payload, attachment=False)
  133. def test_pop_password_spec_valid_pops_fields():
  134. payload = {
  135. "documentType": "MAIN",
  136. "password": "p",
  137. "protectedActions": ["EDIT", "VIEW"],
  138. }
  139. password, actions = _pop_password_spec(payload, attachment=False)
  140. assert password == "p"
  141. assert actions == ["VIEW", "EDIT"]
  142. assert "password" not in payload
  143. assert "protectedActions" not in payload
  144. def test_pop_password_spec_attachment_rejected_even_for_main_type():
  145. payload = {
  146. "documentType": "MAIN",
  147. "password": "p",
  148. "protectedActions": ["VIEW"],
  149. }
  150. with pytest.raises(InvalidArgumentError):
  151. _pop_password_spec(payload, attachment=True)
  152. # ---------------------------------------------------------------------------
  153. # HTTP 集成测试(依赖独立测试库,本机无MySQL时自动跳过)
  154. # ---------------------------------------------------------------------------
  155. PASSWORD = "pw-test-1234"
  156. MINIMAL_PDF = b"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF"
  157. def auth(token: str) -> dict[str, str]:
  158. return {"Authorization": f"Bearer {token}"}
  159. def pwd_auth(token: str, password: str | None = None) -> dict[str, str]:
  160. headers = auth(token)
  161. if password is not None:
  162. headers["X-DMS-Document-Password"] = password
  163. return headers
  164. def category_id(app) -> str:
  165. from dms.extensions import db
  166. from dms.models import Category
  167. with app.app_context():
  168. category = db.session.scalar(
  169. db.select(Category).where(Category.category_code == "CHILD_A1")
  170. )
  171. return str(category.id)
  172. def upload_locked_main(b4_client, app, token_for, name="PW_TEST_主案", actions=("VIEW", "DOWNLOAD")):
  173. metadata = {
  174. "documentName": name,
  175. "documentType": "MAIN",
  176. "summary": "密码锁测试",
  177. "categoryId": category_id(app),
  178. "securityLevel": "INTERNAL",
  179. "tags": ["PW"],
  180. "password": PASSWORD,
  181. "protectedActions": list(actions),
  182. }
  183. response = b4_client.post(
  184. "/api/v1/documents",
  185. data={
  186. "file": (io.BytesIO(MINIMAL_PDF), name + ".pdf"),
  187. "metadata": json.dumps(metadata, ensure_ascii=False),
  188. },
  189. headers=auth(token_for("admin")),
  190. content_type="multipart/form-data",
  191. )
  192. assert response.status_code == 201, response.get_json()
  193. return response.get_json()["data"]
  194. def test_upload_with_password_sets_lock(b4_client, b4_app, token_for):
  195. detail = upload_locked_main(b4_client, b4_app, token_for)
  196. assert detail["hasPassword"] is True
  197. assert detail["passwordProtectedActions"] == ["VIEW", "DOWNLOAD"]
  198. # ADMIN对设密主案查看恒需密码
  199. assert detail["viewLocked"] is True
  200. def test_preview_blocked_without_or_wrong_password(
  201. b4_client, b4_app, token_for
  202. ):
  203. detail = upload_locked_main(b4_client, b4_app, token_for)
  204. url = f"/api/v1/documents/{detail['id']}/preview"
  205. blocked = b4_client.get(url, headers=auth(token_for("admin")))
  206. assert blocked.status_code == 403
  207. assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
  208. wrong = b4_client.get(
  209. url, headers=pwd_auth(token_for("admin"), "bad-password")
  210. )
  211. assert wrong.status_code == 403
  212. assert wrong.get_json()["code"] == "DOCUMENT_PASSWORD_INVALID"
  213. ok = b4_client.get(
  214. url, headers=pwd_auth(token_for("admin"), PASSWORD)
  215. )
  216. assert ok.status_code == 200
  217. def test_download_blocked_until_password_given(b4_client, b4_app, token_for):
  218. detail = upload_locked_main(b4_client, b4_app, token_for)
  219. url = f"/api/v1/documents/{detail['id']}/download"
  220. blocked = b4_client.get(url, headers=auth(token_for("admin")))
  221. assert blocked.status_code == 403
  222. assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
  223. ok = b4_client.get(url, headers=pwd_auth(token_for("admin"), PASSWORD))
  224. assert ok.status_code == 200
  225. def test_detail_shields_content_until_verified(b4_client, b4_app, token_for):
  226. detail = upload_locked_main(b4_client, b4_app, token_for)
  227. url = f"/api/v1/documents/{detail['id']}"
  228. shielded = b4_client.get(url, headers=auth(token_for("admin")))
  229. assert shielded.status_code == 200
  230. body = shielded.get_json()["data"]
  231. assert body["viewLocked"] is True
  232. assert body["viewPasswordVerified"] is False
  233. assert body["contentText"] is None
  234. verified = b4_client.get(
  235. url, headers=pwd_auth(token_for("admin"), PASSWORD)
  236. )
  237. assert verified.get_json()["data"]["viewPasswordVerified"] is True
  238. def test_admin_delete_exempt_from_password(b4_client, b4_app, token_for):
  239. detail = upload_locked_main(
  240. b4_client, b4_app, token_for, actions=("VIEW", "DELETE")
  241. )
  242. response = b4_client.delete(
  243. f"/api/v1/documents/{detail['id']}?rowVersion={detail['rowVersion']}",
  244. headers=auth(token_for("admin")),
  245. )
  246. assert response.status_code == 200
  247. def test_admin_edit_requires_password(b4_client, b4_app, token_for):
  248. detail = upload_locked_main(
  249. b4_client, b4_app, token_for, actions=("EDIT",)
  250. )
  251. payload = {
  252. "documentName": "PW_TEST_主案",
  253. "summary": "改摘要",
  254. "categoryId": category_id(b4_app),
  255. "securityLevel": "INTERNAL",
  256. "tags": ["PW"],
  257. "rowVersion": detail["rowVersion"],
  258. }
  259. blocked = b4_client.put(
  260. f"/api/v1/documents/{detail['id']}",
  261. json=payload,
  262. headers=auth(token_for("admin")),
  263. )
  264. assert blocked.status_code == 403
  265. assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
  266. ok = b4_client.put(
  267. f"/api/v1/documents/{detail['id']}",
  268. json=payload,
  269. headers=pwd_auth(token_for("admin"), PASSWORD),
  270. )
  271. assert ok.status_code == 200
  272. def test_password_management_set_modify_clear(
  273. b4_client, b4_app, token_for
  274. ):
  275. from dms.extensions import db
  276. from dms.models import Document
  277. metadata = {
  278. "documentName": "PW_TEST_无密主案",
  279. "documentType": "MAIN",
  280. "summary": "密码管理",
  281. "categoryId": category_id(b4_app),
  282. "securityLevel": "INTERNAL",
  283. "tags": ["PW"],
  284. }
  285. created = b4_client.post(
  286. "/api/v1/documents",
  287. data={
  288. "file": (io.BytesIO(MINIMAL_PDF), "PW_TEST_无密主案.pdf"),
  289. "metadata": json.dumps(metadata, ensure_ascii=False),
  290. },
  291. headers=auth(token_for("admin")),
  292. content_type="multipart/form-data",
  293. )
  294. assert created.status_code == 201
  295. data = created.get_json()["data"]
  296. document_id, url = data["id"], f"/api/v1/documents/{data['id']}/password"
  297. # 未设密时禁止提供oldPassword
  298. with_old = b4_client.put(
  299. url,
  300. json={"rowVersion": data["rowVersion"], "oldPassword": "x"},
  301. headers=auth(token_for("admin")),
  302. )
  303. assert with_old.status_code == 400
  304. # 设置:无需oldPassword
  305. set_response = b4_client.put(
  306. url,
  307. json={
  308. "rowVersion": data["rowVersion"],
  309. "password": "first-pw",
  310. "protectedActions": ["VIEW"],
  311. },
  312. headers=auth(token_for("admin")),
  313. )
  314. assert set_response.status_code == 200
  315. summary = set_response.get_json()["data"]
  316. assert summary["hasPassword"] is True
  317. version = summary["rowVersion"]
  318. # 修改:oldPassword错误被拒
  319. wrong = b4_client.put(
  320. url,
  321. json={
  322. "rowVersion": version,
  323. "oldPassword": "nope",
  324. "password": "second-pw",
  325. "protectedActions": ["VIEW", "EDIT"],
  326. },
  327. headers=auth(token_for("admin")),
  328. )
  329. assert wrong.status_code == 403
  330. assert wrong.get_json()["code"] == "DOCUMENT_PASSWORD_INVALID"
  331. # rowVersion冲突被拒
  332. stale = b4_client.put(
  333. url,
  334. json={
  335. "rowVersion": data["rowVersion"],
  336. "oldPassword": "first-pw",
  337. "password": "second-pw",
  338. "protectedActions": ["VIEW"],
  339. },
  340. headers=auth(token_for("admin")),
  341. )
  342. assert stale.status_code == 409
  343. # 修改成功
  344. modified = b4_client.put(
  345. url,
  346. json={
  347. "rowVersion": version,
  348. "oldPassword": "first-pw",
  349. "password": "second-pw",
  350. "protectedActions": ["VIEW", "EDIT"],
  351. },
  352. headers=auth(token_for("admin")),
  353. )
  354. assert modified.status_code == 200
  355. assert modified.get_json()["data"]["protectedActions"] == ["VIEW", "EDIT"]
  356. version = modified.get_json()["data"]["rowVersion"]
  357. # 清除:仅oldPassword
  358. removed = b4_client.put(
  359. url,
  360. json={"rowVersion": version, "oldPassword": "second-pw"},
  361. headers=auth(token_for("admin")),
  362. )
  363. assert removed.status_code == 200
  364. assert removed.get_json()["data"]["hasPassword"] is False
  365. # 密码锁清除后校验列复位
  366. with b4_app.app_context():
  367. document = db.session.get(Document, int(document_id))
  368. assert document.password_hash is None
  369. assert document.protect_view is False
  370. def test_attachment_upload_rejects_password_fields(
  371. b4_client, token_for
  372. ):
  373. metadata = {
  374. "documentName": "PW_TEST_附件",
  375. "attachmentType": "SUPPORT_ACHIEVEMENT",
  376. "summary": "附件禁用密码",
  377. "tags": ["PW"],
  378. "password": PASSWORD,
  379. "protectedActions": ["VIEW"],
  380. }
  381. response = b4_client.post(
  382. "/api/v1/attachments",
  383. data={
  384. "file": (io.BytesIO(MINIMAL_PDF), "PW_TEST_附件.pdf"),
  385. "metadata": json.dumps(metadata, ensure_ascii=False),
  386. },
  387. headers=auth(token_for("admin")),
  388. content_type="multipart/form-data",
  389. )
  390. assert response.status_code == 400
  391. def test_overwrite_upload_rejects_password_fields(
  392. b4_client, b4_app, token_for
  393. ):
  394. detail = upload_locked_main(b4_client, b4_app, token_for)
  395. metadata = {
  396. "documentName": "PW_TEST_主案",
  397. "documentType": "MAIN",
  398. "summary": "覆盖",
  399. "categoryId": category_id(b4_app),
  400. "securityLevel": "INTERNAL",
  401. "tags": ["PW"],
  402. "overwriteDocumentId": detail["id"],
  403. "rowVersion": detail["rowVersion"],
  404. "password": "new-pw",
  405. "protectedActions": ["VIEW"],
  406. }
  407. response = b4_client.post(
  408. "/api/v1/documents",
  409. data={
  410. "file": (io.BytesIO(MINIMAL_PDF), "PW_TEST_主案.pdf"),
  411. "metadata": json.dumps(metadata, ensure_ascii=False),
  412. },
  413. headers=auth(token_for("admin")),
  414. content_type="multipart/form-data",
  415. )
  416. assert response.status_code == 400