| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- """方案密码锁:判定语义单元测试 + HTTP 集成测试。"""
- from __future__ import annotations
- import io
- import json
- from types import SimpleNamespace
- import pytest
- from dms.common.enums import DocumentPasswordAction, RoleCode
- from dms.common.errors import (
- DocumentPasswordInvalidError,
- DocumentPasswordRequiredError,
- InvalidArgumentError,
- )
- from dms.security.passwords import hash_password
- from dms.services.document_password_service import (
- _pop_password_spec,
- apply_password_lock,
- clear_password_lock,
- parse_protected_actions,
- password_required_for,
- protected_actions_of,
- require_document_password,
- )
- ALL_ACTIONS = [action.value for action in DocumentPasswordAction]
- # ---------------------------------------------------------------------------
- # 纯单元测试(无数据库依赖)
- # ---------------------------------------------------------------------------
- def stub_document(**overrides):
- base = dict(
- id=1,
- document_type="MAIN",
- password_hash=None,
- protect_view=False,
- protect_download=False,
- protect_edit=False,
- protect_delete=False,
- protect_config_permission=False,
- protect_bind_attachment=False,
- protect_unbind_attachment=False,
- )
- base.update(overrides)
- return SimpleNamespace(**base)
- def admin_ctx():
- return SimpleNamespace(role_code=RoleCode.ADMIN.value)
- def user_ctx():
- return SimpleNamespace(role_code=RoleCode.USER.value)
- @pytest.mark.parametrize("value", [None, [], "VIEW", [1], ["VIEW", "HACK"]])
- def test_parse_protected_actions_rejects_invalid(value):
- with pytest.raises(InvalidArgumentError):
- parse_protected_actions(value)
- def test_parse_protected_actions_dedup_and_stable_order():
- assert parse_protected_actions(
- ["EDIT", "VIEW", "VIEW", "DOWNLOAD"]
- ) == ["VIEW", "DOWNLOAD", "EDIT"]
- def test_protected_actions_of_empty_when_no_password():
- doc = stub_document(protect_view=True)
- assert protected_actions_of(doc) == []
- def test_protected_actions_of_lists_checked_columns():
- doc = stub_document(
- password_hash="x",
- protect_view=True,
- protect_unbind_attachment=True,
- )
- assert protected_actions_of(doc) == ["VIEW", "UNBIND_ATTACHMENT"]
- def test_password_required_for_user_only_checked_actions():
- doc = stub_document(password_hash="x", protect_view=True)
- ctx = user_ctx()
- assert password_required_for(doc, "VIEW", ctx) is True
- assert password_required_for(doc, "DOWNLOAD", ctx) is False
- def test_password_required_for_admin_all_but_delete():
- doc = stub_document(password_hash="x") # 未勾选任何操作
- ctx = admin_ctx()
- for action in ALL_ACTIONS:
- expected = action != "DELETE"
- assert password_required_for(doc, action, ctx) is expected, action
- def test_password_required_for_unlocked_document_is_false():
- assert password_required_for(stub_document(), "VIEW", admin_ctx()) is False
- def test_require_document_password_missing_raises_required():
- doc = stub_document(
- password_hash=hash_password("s3cret"), protect_view=True
- )
- with pytest.raises(DocumentPasswordRequiredError):
- require_document_password(doc, "VIEW", None, user_ctx())
- def test_require_document_password_wrong_raises_invalid():
- doc = stub_document(
- password_hash=hash_password("s3cret"), protect_view=True
- )
- with pytest.raises(DocumentPasswordInvalidError):
- require_document_password(doc, "VIEW", "wrong", user_ctx())
- def test_require_document_password_correct_passes():
- doc = stub_document(
- password_hash=hash_password("s3cret"), protect_view=True
- )
- require_document_password(doc, "VIEW", "s3cret", user_ctx())
- def test_apply_and_clear_password_lock():
- doc = stub_document()
- apply_password_lock(doc, "s3cret", ["VIEW", "EDIT"])
- assert doc.password_hash is not None
- assert doc.protect_view is True
- assert doc.protect_edit is True
- assert doc.protect_download is False
- clear_password_lock(doc)
- assert doc.password_hash is None
- assert doc.protect_view is False
- assert doc.protect_edit is False
- def test_pop_password_spec_absent_returns_none():
- assert _pop_password_spec(
- {"documentName": "x"}, attachment=False
- ) == (None, None)
- @pytest.mark.parametrize(
- "payload",
- [
- {"password": "p", "documentType": "ATTACHMENT"}, # 附件不允许
- {"password": "p", "documentType": "SUB_PLAN"}, # 子案不允许
- {"password": "p"}, # 缺 protectedActions
- {"protectedActions": ["VIEW"]}, # 缺 password
- {"password": "", "protectedActions": ["VIEW"]}, # 空密码
- {"password": "x" * 129, "protectedActions": ["VIEW"]}, # 超长
- {"password": "p", "protectedActions": ["HACK"]}, # 未知操作码
- ],
- )
- def test_pop_password_spec_rejects_invalid(payload):
- with pytest.raises(InvalidArgumentError):
- _pop_password_spec(payload, attachment=False)
- def test_pop_password_spec_rejects_overwrite_upload():
- payload = {
- "documentType": "MAIN",
- "password": "p",
- "protectedActions": ["VIEW"],
- "overwriteDocumentId": "123",
- }
- with pytest.raises(InvalidArgumentError):
- _pop_password_spec(payload, attachment=False)
- def test_pop_password_spec_valid_pops_fields():
- payload = {
- "documentType": "MAIN",
- "password": "p",
- "protectedActions": ["EDIT", "VIEW"],
- }
- password, actions = _pop_password_spec(payload, attachment=False)
- assert password == "p"
- assert actions == ["VIEW", "EDIT"]
- assert "password" not in payload
- assert "protectedActions" not in payload
- def test_pop_password_spec_attachment_rejected_even_for_main_type():
- payload = {
- "documentType": "MAIN",
- "password": "p",
- "protectedActions": ["VIEW"],
- }
- with pytest.raises(InvalidArgumentError):
- _pop_password_spec(payload, attachment=True)
- # ---------------------------------------------------------------------------
- # HTTP 集成测试(依赖独立测试库,本机无MySQL时自动跳过)
- # ---------------------------------------------------------------------------
- PASSWORD = "pw-test-1234"
- MINIMAL_PDF = b"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF"
- def auth(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
- def pwd_auth(token: str, password: str | None = None) -> dict[str, str]:
- headers = auth(token)
- if password is not None:
- headers["X-DMS-Document-Password"] = password
- return headers
- def category_id(app) -> str:
- from dms.extensions import db
- from dms.models import Category
- with app.app_context():
- category = db.session.scalar(
- db.select(Category).where(Category.category_code == "CHILD_A1")
- )
- return str(category.id)
- def upload_locked_main(b4_client, app, token_for, name="PW_TEST_主案", actions=("VIEW", "DOWNLOAD")):
- metadata = {
- "documentName": name,
- "documentType": "MAIN",
- "summary": "密码锁测试",
- "categoryId": category_id(app),
- "securityLevel": "INTERNAL",
- "tags": ["PW"],
- "password": PASSWORD,
- "protectedActions": list(actions),
- }
- response = b4_client.post(
- "/api/v1/documents",
- data={
- "file": (io.BytesIO(MINIMAL_PDF), name + ".pdf"),
- "metadata": json.dumps(metadata, ensure_ascii=False),
- },
- headers=auth(token_for("admin")),
- content_type="multipart/form-data",
- )
- assert response.status_code == 201, response.get_json()
- return response.get_json()["data"]
- def test_upload_with_password_sets_lock(b4_client, b4_app, token_for):
- detail = upload_locked_main(b4_client, b4_app, token_for)
- assert detail["hasPassword"] is True
- assert detail["passwordProtectedActions"] == ["VIEW", "DOWNLOAD"]
- # ADMIN对设密主案查看恒需密码
- assert detail["viewLocked"] is True
- def test_preview_blocked_without_or_wrong_password(
- b4_client, b4_app, token_for
- ):
- detail = upload_locked_main(b4_client, b4_app, token_for)
- url = f"/api/v1/documents/{detail['id']}/preview"
- blocked = b4_client.get(url, headers=auth(token_for("admin")))
- assert blocked.status_code == 403
- assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
- wrong = b4_client.get(
- url, headers=pwd_auth(token_for("admin"), "bad-password")
- )
- assert wrong.status_code == 403
- assert wrong.get_json()["code"] == "DOCUMENT_PASSWORD_INVALID"
- ok = b4_client.get(
- url, headers=pwd_auth(token_for("admin"), PASSWORD)
- )
- assert ok.status_code == 200
- def test_download_blocked_until_password_given(b4_client, b4_app, token_for):
- detail = upload_locked_main(b4_client, b4_app, token_for)
- url = f"/api/v1/documents/{detail['id']}/download"
- blocked = b4_client.get(url, headers=auth(token_for("admin")))
- assert blocked.status_code == 403
- assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
- ok = b4_client.get(url, headers=pwd_auth(token_for("admin"), PASSWORD))
- assert ok.status_code == 200
- def test_detail_shields_content_until_verified(b4_client, b4_app, token_for):
- detail = upload_locked_main(b4_client, b4_app, token_for)
- url = f"/api/v1/documents/{detail['id']}"
- shielded = b4_client.get(url, headers=auth(token_for("admin")))
- assert shielded.status_code == 200
- body = shielded.get_json()["data"]
- assert body["viewLocked"] is True
- assert body["viewPasswordVerified"] is False
- assert body["contentText"] is None
- verified = b4_client.get(
- url, headers=pwd_auth(token_for("admin"), PASSWORD)
- )
- assert verified.get_json()["data"]["viewPasswordVerified"] is True
- def test_admin_delete_exempt_from_password(b4_client, b4_app, token_for):
- detail = upload_locked_main(
- b4_client, b4_app, token_for, actions=("VIEW", "DELETE")
- )
- response = b4_client.delete(
- f"/api/v1/documents/{detail['id']}?rowVersion={detail['rowVersion']}",
- headers=auth(token_for("admin")),
- )
- assert response.status_code == 200
- def test_admin_edit_requires_password(b4_client, b4_app, token_for):
- detail = upload_locked_main(
- b4_client, b4_app, token_for, actions=("EDIT",)
- )
- payload = {
- "documentName": "PW_TEST_主案",
- "summary": "改摘要",
- "categoryId": category_id(b4_app),
- "securityLevel": "INTERNAL",
- "tags": ["PW"],
- "rowVersion": detail["rowVersion"],
- }
- blocked = b4_client.put(
- f"/api/v1/documents/{detail['id']}",
- json=payload,
- headers=auth(token_for("admin")),
- )
- assert blocked.status_code == 403
- assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
- ok = b4_client.put(
- f"/api/v1/documents/{detail['id']}",
- json=payload,
- headers=pwd_auth(token_for("admin"), PASSWORD),
- )
- assert ok.status_code == 200
- def test_password_management_set_modify_clear(
- b4_client, b4_app, token_for
- ):
- from dms.extensions import db
- from dms.models import Document
- metadata = {
- "documentName": "PW_TEST_无密主案",
- "documentType": "MAIN",
- "summary": "密码管理",
- "categoryId": category_id(b4_app),
- "securityLevel": "INTERNAL",
- "tags": ["PW"],
- }
- created = b4_client.post(
- "/api/v1/documents",
- data={
- "file": (io.BytesIO(MINIMAL_PDF), "PW_TEST_无密主案.pdf"),
- "metadata": json.dumps(metadata, ensure_ascii=False),
- },
- headers=auth(token_for("admin")),
- content_type="multipart/form-data",
- )
- assert created.status_code == 201
- data = created.get_json()["data"]
- document_id, url = data["id"], f"/api/v1/documents/{data['id']}/password"
- # 未设密时禁止提供oldPassword
- with_old = b4_client.put(
- url,
- json={"rowVersion": data["rowVersion"], "oldPassword": "x"},
- headers=auth(token_for("admin")),
- )
- assert with_old.status_code == 400
- # 设置:无需oldPassword
- set_response = b4_client.put(
- url,
- json={
- "rowVersion": data["rowVersion"],
- "password": "first-pw",
- "protectedActions": ["VIEW"],
- },
- headers=auth(token_for("admin")),
- )
- assert set_response.status_code == 200
- summary = set_response.get_json()["data"]
- assert summary["hasPassword"] is True
- version = summary["rowVersion"]
- # 修改:oldPassword错误被拒
- wrong = b4_client.put(
- url,
- json={
- "rowVersion": version,
- "oldPassword": "nope",
- "password": "second-pw",
- "protectedActions": ["VIEW", "EDIT"],
- },
- headers=auth(token_for("admin")),
- )
- assert wrong.status_code == 403
- assert wrong.get_json()["code"] == "DOCUMENT_PASSWORD_INVALID"
- # rowVersion冲突被拒
- stale = b4_client.put(
- url,
- json={
- "rowVersion": data["rowVersion"],
- "oldPassword": "first-pw",
- "password": "second-pw",
- "protectedActions": ["VIEW"],
- },
- headers=auth(token_for("admin")),
- )
- assert stale.status_code == 409
- # 修改成功
- modified = b4_client.put(
- url,
- json={
- "rowVersion": version,
- "oldPassword": "first-pw",
- "password": "second-pw",
- "protectedActions": ["VIEW", "EDIT"],
- },
- headers=auth(token_for("admin")),
- )
- assert modified.status_code == 200
- assert modified.get_json()["data"]["protectedActions"] == ["VIEW", "EDIT"]
- version = modified.get_json()["data"]["rowVersion"]
- # 清除:仅oldPassword
- removed = b4_client.put(
- url,
- json={"rowVersion": version, "oldPassword": "second-pw"},
- headers=auth(token_for("admin")),
- )
- assert removed.status_code == 200
- assert removed.get_json()["data"]["hasPassword"] is False
- # 密码锁清除后校验列复位
- with b4_app.app_context():
- document = db.session.get(Document, int(document_id))
- assert document.password_hash is None
- assert document.protect_view is False
- def test_attachment_upload_rejects_password_fields(
- b4_client, token_for
- ):
- metadata = {
- "documentName": "PW_TEST_附件",
- "attachmentType": "SUPPORT_ACHIEVEMENT",
- "summary": "附件禁用密码",
- "tags": ["PW"],
- "password": PASSWORD,
- "protectedActions": ["VIEW"],
- }
- response = b4_client.post(
- "/api/v1/attachments",
- data={
- "file": (io.BytesIO(MINIMAL_PDF), "PW_TEST_附件.pdf"),
- "metadata": json.dumps(metadata, ensure_ascii=False),
- },
- headers=auth(token_for("admin")),
- content_type="multipart/form-data",
- )
- assert response.status_code == 400
- def test_overwrite_upload_rejects_password_fields(
- b4_client, b4_app, token_for
- ):
- detail = upload_locked_main(b4_client, b4_app, token_for)
- metadata = {
- "documentName": "PW_TEST_主案",
- "documentType": "MAIN",
- "summary": "覆盖",
- "categoryId": category_id(b4_app),
- "securityLevel": "INTERNAL",
- "tags": ["PW"],
- "overwriteDocumentId": detail["id"],
- "rowVersion": detail["rowVersion"],
- "password": "new-pw",
- "protectedActions": ["VIEW"],
- }
- response = b4_client.post(
- "/api/v1/documents",
- data={
- "file": (io.BytesIO(MINIMAL_PDF), "PW_TEST_主案.pdf"),
- "metadata": json.dumps(metadata, ensure_ascii=False),
- },
- headers=auth(token_for("admin")),
- content_type="multipart/form-data",
- )
- assert response.status_code == 400
|