| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946 |
- from __future__ import annotations
- import io
- import json
- import threading
- from concurrent.futures import ThreadPoolExecutor
- from datetime import datetime, timezone
- from pathlib import Path
- import pytest
- PDF_BYTES = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n"
- def _headers(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
- 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")
- )
- assert category is not None
- return str(category.id)
- def _upload_plan(
- client,
- token: str,
- category_id: str,
- name: str,
- *,
- document_type: str = "MAIN",
- parent_id: str | None = None,
- visibility: str = "ALL_AUTHENTICATED",
- ):
- metadata = {
- "documentName": name,
- "documentType": document_type,
- "summary": f"{name}摘要",
- "securityLevel": "INTERNAL",
- "tags": ["B8"],
- }
- if document_type == "MAIN":
- metadata["categoryId"] = category_id
- if document_type == "SUB_PLAN" and parent_id is not None:
- metadata["parentDocumentId"] = parent_id
- response = client.post(
- "/api/v1/documents",
- data={
- "file": (io.BytesIO(PDF_BYTES), f"{name}.pdf"),
- "metadata": json.dumps(metadata, ensure_ascii=False),
- },
- headers=_headers(token),
- content_type="multipart/form-data",
- )
- assert response.status_code == 201, response.get_json()
- document = response.get_json()["data"]
- if document_type == "MAIN" and visibility == "ALL_AUTHENTICATED":
- permission = client.put(
- f"/api/v1/documents/{document['id']}/permissions",
- json={
- "visibilityType": "ALL_AUTHENTICATED",
- "documentRowVersion": document["rowVersion"],
- "entries": [],
- },
- headers=_headers(token),
- )
- assert permission.status_code == 200, permission.get_json()
- document["rowVersion"] = permission.get_json()["data"][
- "documentRowVersion"
- ]
- document["visibilityType"] = "ALL_AUTHENTICATED"
- return document
- def _upload_attachment(client, token: str, name: str):
- response = client.post(
- "/api/v1/attachments",
- data={
- "file": (io.BytesIO(PDF_BYTES), f"{name}.pdf"),
- "metadata": json.dumps(
- {
- "documentName": name,
- "attachmentType": "OTHER",
- "summary": f"{name}摘要",
- "tags": ["B8"],
- },
- ensure_ascii=False,
- ),
- },
- headers=_headers(token),
- content_type="multipart/form-data",
- )
- assert response.status_code == 201, response.get_json()
- return response.get_json()["data"]
- def _delete(client, token: str, document: dict, *, attachment: bool = False):
- prefix = "/api/v1/attachments" if attachment else "/api/v1/documents"
- response = client.delete(
- f"{prefix}/{document['id']}?rowVersion={document['rowVersion']}",
- headers=_headers(token),
- )
- assert response.status_code == 200, response.get_json()
- return response.get_json()["data"]
- def _deleted_document(app, document_id: str):
- from dms.extensions import db
- from dms.models import Document
- with app.app_context():
- document = db.session.get(Document, int(document_id))
- assert document is not None and document.is_deleted
- return {
- "rowVersion": document.row_version,
- "deletedAt": document.deleted_at,
- "fileRelativePath": document.file_relative_path,
- "viewCount": document.view_count,
- }
- def _restore(client, token: str, document_id: str, row_version: int):
- return client.post(
- f"/api/v1/recycle-bin/documents/{document_id}/restore",
- json={"rowVersion": row_version},
- headers=_headers(token),
- )
- def test_recycle_bin_auth_query_filters_and_deleted_by(
- b3_app, b3_client, login_token, token_for
- ):
- category_id = _category_id(b3_app)
- normal = _upload_plan(
- b3_client, login_token, category_id, "B8正常文档"
- )
- deleted = _upload_plan(
- b3_client, login_token, category_id, "B8_百分号%_下划线_"
- )
- _delete(b3_client, login_token, deleted)
- assert b3_client.get("/api/v1/recycle-bin/documents").status_code == 401
- for username in ("user",):
- response = b3_client.get(
- "/api/v1/recycle-bin/documents",
- headers=_headers(token_for(username)),
- )
- assert response.status_code == 403
- assert response.get_json()["code"] == "FORBIDDEN"
- response = b3_client.get(
- "/api/v1/recycle-bin/documents",
- query_string={
- "keyword": "百分号%",
- "documentType": "MAIN",
- "categoryId": category_id,
- "page": "1",
- "pageSize": "20",
- "sortField": "documentName",
- "sortDirection": "asc",
- },
- headers=_headers(login_token),
- )
- assert response.status_code == 200, response.get_json()
- body = response.get_json()
- assert body["data"]["total"] == 1
- item = body["data"]["items"][0]
- assert item["id"] == deleted["id"]
- assert item["id"] != normal["id"]
- assert item["documentStatus"] == "PUBLISHED"
- assert item["deletedBy"]["username"] == "admin"
- assert "fileRelativePath" not in item
- assert "fileHash" not in item
- assert isinstance(item["id"], str)
- @pytest.mark.parametrize(
- ("query", "message_part"),
- [
- ("unknown=x", "未知"),
- ("page=1&page=2", "重复"),
- ("documentType=OTHER", "documentType"),
- ("categoryId=0", "categoryId"),
- ("deletedFrom=2026-01-01T00:00:00%2B08:00", "UTC Z"),
- ("deletedFrom=2026-01-02T00:00:00Z&deletedTo=2026-01-01T00:00:00Z", "早于"),
- ("pageSize=101", "100"),
- ("sortField=id", "sortField"),
- ("sortDirection=sideways", "sortDirection"),
- ],
- )
- def test_recycle_bin_rejects_invalid_query(
- b3_client, login_token, query, message_part
- ):
- response = b3_client.get(
- f"/api/v1/recycle-bin/documents?{query}",
- headers=_headers(login_token),
- )
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- assert message_part in response.get_json()["message"]
- def test_restore_main_restores_delete_batch_acl_binding_counts_and_audit(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import (
- AttachmentBinding,
- AuditLog,
- Category,
- Document,
- Organization,
- Permission,
- )
- category_id = _category_id(b3_app)
- main = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8关系主案",
- visibility="ORGANIZATION",
- )
- attachment = _upload_attachment(b3_client, login_token, "B8关系附件")
- with b3_app.app_context():
- organization = db.session.scalar(
- db.select(Organization).where(Organization.org_code == "ORG_ROOT")
- )
- assert organization is not None
- organization_id = str(organization.id)
- permission_response = b3_client.put(
- f"/api/v1/documents/{main['id']}/permissions",
- json={
- "visibilityType": "ORGANIZATION",
- "documentRowVersion": main["rowVersion"],
- "entries": [
- {
- "subjectType": "ORG",
- "subjectId": organization_id,
- "canView": True,
- "canDownload": True,
- "canEdit": False,
- "canManagePermission": False,
- "canDelete": False,
- }
- ],
- },
- headers=_headers(login_token),
- )
- assert permission_response.status_code == 200, permission_response.get_json()
- main_version = permission_response.get_json()["data"]["documentRowVersion"]
- bind_response = b3_client.post(
- f"/api/v1/main-plans/{main['id']}/attachments/bind",
- json={
- "attachmentIds": [attachment["id"]],
- "mainPlanRowVersion": main_version,
- },
- headers=_headers(login_token),
- )
- assert bind_response.status_code == 200, bind_response.get_json()
- main["rowVersion"] = bind_response.get_json()["data"]["mainPlanRowVersion"]
- _delete(b3_client, login_token, main)
- deleted = _deleted_document(b3_app, main["id"])
- response = _restore(
- b3_client, login_token, main["id"], deleted["rowVersion"]
- )
- assert response.status_code == 200, response.get_json()
- data = response.get_json()["data"]
- assert data["restoredPermissionCount"] == 1
- assert data["skippedPermissionCount"] == 0
- assert data["restoredBindingCount"] == 1
- assert data["skippedBindingCount"] == 0
- assert data["document"]["attachmentCount"] == 1
- assert data["document"]["viewCount"] == deleted["viewCount"]
- assert "fileHash" not in data["document"]
- with b3_app.app_context():
- document = db.session.get(Document, int(main["id"]))
- category = db.session.get(Category, int(category_id))
- permission = db.session.scalar(
- db.select(Permission).where(
- Permission.document_id == int(main["id"]),
- Permission.is_deleted.is_(False),
- )
- )
- binding = db.session.scalar(
- db.select(AttachmentBinding).where(
- AttachmentBinding.main_document_id == int(main["id"]),
- AttachmentBinding.is_deleted.is_(False),
- )
- )
- audit = db.session.scalar(
- db.select(AuditLog)
- .where(
- AuditLog.action_type == "RESTORE_DOCUMENT",
- AuditLog.target_id == int(main["id"]),
- )
- .order_by(AuditLog.id.desc())
- )
- view_audits = db.session.scalar(
- db.select(db.func.count(AuditLog.id)).where(
- AuditLog.action_type == "VIEW_DOCUMENT",
- AuditLog.target_id == int(main["id"]),
- )
- )
- assert document is not None and not document.is_deleted
- assert document.row_version == deleted["rowVersion"] + 1
- assert category is not None and category.document_count >= 1
- assert permission is not None and permission.row_version == 2
- assert binding is not None and binding.row_version == 2
- assert audit is not None
- assert audit.operation_detail["restoredPermissionCount"] == 1
- assert "fileHash" not in audit.operation_detail
- assert view_audits == 0
- def test_restore_sub_plan_recounts_parent_and_inherits_current_acl(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import Document, Permission
- category_id = _category_id(b3_app)
- main = _upload_plan(
- b3_client, login_token, category_id, "B8子案父主案"
- )
- sub = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8待恢复子案",
- document_type="SUB_PLAN",
- parent_id=main["id"],
- )
- _delete(b3_client, login_token, sub)
- deleted = _deleted_document(b3_app, sub["id"])
- with b3_app.app_context():
- parent_before = db.session.get(Document, int(main["id"])).row_version
- response = _restore(
- b3_client, login_token, sub["id"], deleted["rowVersion"]
- )
- assert response.status_code == 200, response.get_json()
- data = response.get_json()["data"]
- assert data["restoredPermissionCount"] == 0
- with b3_app.app_context():
- restored = db.session.get(Document, int(sub["id"]))
- parent = db.session.get(Document, int(main["id"]))
- acl_count = db.session.scalar(
- db.select(db.func.count(Permission.id)).where(
- Permission.document_id == restored.id,
- Permission.is_deleted.is_(False),
- )
- )
- assert restored.row_version == deleted["rowVersion"] + 1
- assert parent.child_count == 1
- assert parent.row_version == parent_before + 1
- assert acl_count == 0
- def test_restore_attachment_does_not_restore_historical_binding(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import AttachmentBinding, Document
- category_id = _category_id(b3_app)
- main = _upload_plan(
- b3_client, login_token, category_id, "B8附件挂载主案"
- )
- attachment = _upload_attachment(b3_client, login_token, "B8待恢复附件")
- bind = b3_client.post(
- f"/api/v1/main-plans/{main['id']}/attachments/bind",
- json={
- "attachmentIds": [attachment["id"]],
- "mainPlanRowVersion": main["rowVersion"],
- },
- headers=_headers(login_token),
- ).get_json()["data"]
- unbind = b3_client.delete(
- f"/api/v1/main-plans/{main['id']}/attachments/{attachment['id']}"
- f"?mainPlanRowVersion={bind['mainPlanRowVersion']}",
- headers=_headers(login_token),
- )
- assert unbind.status_code == 200
- _delete(b3_client, login_token, attachment, attachment=True)
- deleted = _deleted_document(b3_app, attachment["id"])
- response = _restore(
- b3_client, login_token, attachment["id"], deleted["rowVersion"]
- )
- assert response.status_code == 200, response.get_json()
- data = response.get_json()["data"]
- assert data["restoredPermissionCount"] == 0
- assert data["restoredBindingCount"] == 0
- assert data["document"]["mountedPlanCount"] == 0
- with b3_app.app_context():
- restored = db.session.get(Document, int(attachment["id"]))
- binding = db.session.scalar(
- db.select(AttachmentBinding).where(
- AttachmentBinding.attachment_document_id == restored.id
- )
- )
- assert restored.security_level == "PUBLIC"
- assert restored.visibility_type == "ALL_AUTHENTICATED"
- assert restored.category_id is None
- assert binding is not None and binding.is_deleted
- def test_restore_validates_body_state_and_old_path_stays_404(
- b3_app, b3_client, login_token
- ):
- category_id = _category_id(b3_app)
- document = _upload_plan(
- b3_client, login_token, category_id, "B8请求校验"
- )
- response = _restore(
- b3_client, login_token, document["id"], document["rowVersion"]
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "DOCUMENT_NOT_DELETED"
- assert (
- b3_client.post(
- f"/api/v1/documents/{document['id']}/restore",
- json={"rowVersion": document["rowVersion"]},
- headers=_headers(login_token),
- ).status_code
- == 404
- )
- _delete(b3_client, login_token, document)
- deleted = _deleted_document(b3_app, document["id"])
- for payload in (
- {},
- {"rowVersion": True},
- {"rowVersion": "1"},
- {"rowVersion": 1.5},
- {"rowVersion": -1},
- {"rowVersion": deleted["rowVersion"], "extra": 1},
- ):
- invalid = b3_client.post(
- f"/api/v1/recycle-bin/documents/{document['id']}/restore",
- json=payload,
- headers=_headers(login_token),
- )
- assert invalid.status_code == 400
- assert invalid.get_json()["code"] == "INVALID_ARGUMENT"
- conflict = _restore(
- b3_client, login_token, document["id"], deleted["rowVersion"] - 1
- )
- assert conflict.status_code == 409
- assert conflict.get_json()["code"] == "DATA_VERSION_CONFLICT"
- assert (
- conflict.get_json()["details"]["currentRowVersion"]
- == deleted["rowVersion"]
- )
- @pytest.mark.parametrize(
- ("mutation", "expected_code", "expected_status"),
- [
- ("missing", "FILE_NOT_FOUND", 404),
- ("size", "FILE_INTEGRITY_MISMATCH", 409),
- ("hash", "FILE_INTEGRITY_MISMATCH", 409),
- ("path", "FILE_PATH_INVALID", 500),
- ("type", "FILE_INTEGRITY_MISMATCH", 409),
- ],
- )
- def test_restore_file_failures_are_safe_and_rollback(
- b3_app, b3_client, login_token, mutation, expected_code, expected_status
- ):
- from dms.extensions import db
- from dms.models import Document
- from dms.storage.paths import resolve_storage_path
- category_id = _category_id(b3_app)
- document = _upload_plan(
- b3_client, login_token, category_id, f"B8文件失败{mutation}"
- )
- _delete(b3_client, login_token, document)
- deleted = _deleted_document(b3_app, document["id"])
- with b3_app.app_context():
- row = db.session.get(Document, int(document["id"]))
- path = resolve_storage_path(
- row.file_relative_path, b3_app.config["DMS_STORAGE_ROOT"]
- )
- if mutation == "missing":
- path.unlink()
- elif mutation == "size":
- path.write_bytes(PDF_BYTES + b"x")
- elif mutation == "hash":
- changed = bytearray(PDF_BYTES)
- changed[-2] = ord("X")
- path.write_bytes(changed)
- elif mutation == "path":
- row.file_relative_path = "../outside.pdf"
- db.session.commit()
- elif mutation == "type":
- path.write_bytes(b"NOT_A_PDF" + PDF_BYTES)
- response = _restore(
- b3_client, login_token, document["id"], deleted["rowVersion"]
- )
- assert response.status_code == expected_status
- body = response.get_json()
- assert body["code"] == expected_code
- serialized = json.dumps(body, ensure_ascii=False)
- assert str(Path(b3_app.config["DMS_STORAGE_ROOT"])) not in serialized
- assert "a" * 64 not in serialized
- with b3_app.app_context():
- assert db.session.get(Document, int(document["id"])).is_deleted
- def test_restore_file_recheck_and_audit_failure_roll_back(
- b3_app, b3_client, login_token, monkeypatch
- ):
- from dms.common.errors import RestoreFileChangedError
- from dms.extensions import db
- from dms.models import AuditLog, Document
- from dms.services import recycle_bin_service
- category_id = _category_id(b3_app)
- first = _upload_plan(
- b3_client, login_token, category_id, "B8文件竞态"
- )
- _delete(b3_client, login_token, first)
- first_deleted = _deleted_document(b3_app, first["id"])
- monkeypatch.setattr(
- recycle_bin_service,
- "_recheck_file",
- lambda *_args: (_ for _ in ()).throw(RestoreFileChangedError()),
- )
- response = _restore(
- b3_client, login_token, first["id"], first_deleted["rowVersion"]
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "RESTORE_FILE_CHANGED"
- with b3_app.app_context():
- assert db.session.get(Document, int(first["id"])).is_deleted
- monkeypatch.undo()
- second = _upload_plan(
- b3_client, login_token, category_id, "B8审计回滚"
- )
- _delete(b3_client, login_token, second)
- second_deleted = _deleted_document(b3_app, second["id"])
- def fail_audit(**_kwargs):
- raise RuntimeError("audit failed")
- monkeypatch.setattr(recycle_bin_service, "business_audit", fail_audit)
- audit_failure = _restore(
- b3_client, login_token, second["id"], second_deleted["rowVersion"]
- )
- assert audit_failure.status_code == 500
- assert audit_failure.get_json()["code"] == "INTERNAL_ERROR"
- with b3_app.app_context():
- assert db.session.get(Document, int(second["id"])).is_deleted
- count = db.session.scalar(
- db.select(db.func.count(AuditLog.id)).where(
- AuditLog.action_type == "RESTORE_DOCUMENT",
- AuditLog.target_id == int(second["id"]),
- )
- )
- assert count == 0
- def test_restore_invalid_category_and_parent_roll_back(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import Category, Document
- category_id = _category_id(b3_app)
- main = _upload_plan(
- b3_client, login_token, category_id, "B8失效分类"
- )
- _delete(b3_client, login_token, main)
- deleted_main = _deleted_document(b3_app, main["id"])
- with b3_app.app_context():
- category = db.session.get(Category, int(category_id))
- category.status = "DISABLED"
- db.session.commit()
- invalid_category = _restore(
- b3_client, login_token, main["id"], deleted_main["rowVersion"]
- )
- assert invalid_category.status_code == 409
- assert invalid_category.get_json()["code"] == "RESTORE_CATEGORY_INVALID"
- with b3_app.app_context():
- category = db.session.get(Category, int(category_id))
- category.status = "ENABLED"
- db.session.commit()
- parent = _upload_plan(
- b3_client, login_token, category_id, "B8失效父主案"
- )
- sub = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8父级失效子案",
- document_type="SUB_PLAN",
- parent_id=parent["id"],
- )
- _delete(b3_client, login_token, sub)
- deleted_sub = _deleted_document(b3_app, sub["id"])
- with b3_app.app_context():
- parent_row = db.session.get(Document, int(parent["id"]))
- parent_row.is_deleted = True
- parent_row.deleted_at = datetime.now(timezone.utc).replace(tzinfo=None)
- db.session.commit()
- invalid_parent = _restore(
- b3_client, login_token, sub["id"], deleted_sub["rowVersion"]
- )
- assert invalid_parent.status_code == 409
- assert invalid_parent.get_json()["code"] == "RESTORE_PARENT_INVALID"
- def test_restore_custom_skips_disabled_subject_but_organization_rejects(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import Organization, User
- category_id = _category_id(b3_app)
- with b3_app.app_context():
- user = db.session.scalar(db.select(User).where(User.username == "user"))
- organization = db.session.scalar(
- db.select(Organization).where(Organization.org_code == "ORG_ROOT")
- )
- assert user is not None and organization is not None
- user_id = str(user.id)
- organization_id = str(organization.id)
- def permission(document, visibility, subject_type, subject_id):
- response = b3_client.put(
- f"/api/v1/documents/{document['id']}/permissions",
- json={
- "visibilityType": visibility,
- "documentRowVersion": document["rowVersion"],
- "entries": [
- {
- "subjectType": subject_type,
- "subjectId": subject_id,
- "canView": True,
- "canDownload": False,
- "canEdit": False,
- "canManagePermission": False,
- "canDelete": False,
- }
- ],
- },
- headers=_headers(login_token),
- )
- assert response.status_code == 200
- document["rowVersion"] = response.get_json()["data"]["documentRowVersion"]
- custom = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8自定义跳过",
- visibility="CUSTOM",
- )
- permission(custom, "CUSTOM", "USER", user_id)
- _delete(b3_client, login_token, custom)
- custom_deleted = _deleted_document(b3_app, custom["id"])
- with b3_app.app_context():
- user = db.session.get(User, int(user_id))
- user.status = "DISABLED"
- db.session.commit()
- custom_restore = _restore(
- b3_client, login_token, custom["id"], custom_deleted["rowVersion"]
- )
- assert custom_restore.status_code == 200, custom_restore.get_json()
- assert custom_restore.get_json()["data"]["skippedPermissionCount"] == 1
- organization_main = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8组织权限拒绝",
- visibility="ORGANIZATION",
- )
- permission(
- organization_main, "ORGANIZATION", "ORG", organization_id
- )
- _delete(b3_client, login_token, organization_main)
- organization_deleted = _deleted_document(
- b3_app, organization_main["id"]
- )
- with b3_app.app_context():
- organization = db.session.get(Organization, int(organization_id))
- organization.status = "DISABLED"
- db.session.commit()
- rejected = _restore(
- b3_client,
- login_token,
- organization_main["id"],
- organization_deleted["rowVersion"],
- )
- assert rejected.status_code == 409
- assert rejected.get_json()["code"] == "RESTORE_PERMISSION_INVALID"
- def test_deleted_by_is_null_without_reliable_audit_and_query_has_no_n_plus_one(
- b3_app, b3_client, login_token
- ):
- from sqlalchemy import event
- from dms.extensions import db
- from dms.models import AuditLog
- category_id = _category_id(b3_app)
- documents = [
- _upload_plan(
- b3_client, login_token, category_id, f"B8批量删除人{i}"
- )
- for i in range(3)
- ]
- for document in documents:
- _delete(b3_client, login_token, document)
- with b3_app.app_context():
- db.session.execute(
- db.delete(AuditLog).where(
- AuditLog.action_type == "DELETE_DOCUMENT",
- AuditLog.target_id == int(documents[0]["id"]),
- )
- )
- db.session.commit()
- select_count = 0
- def count_selects(
- _conn, _cursor, statement, _parameters, _context, _executemany
- ):
- nonlocal select_count
- if statement.lstrip().upper().startswith("SELECT"):
- select_count += 1
- with b3_app.app_context():
- event.listen(db.engine, "before_cursor_execute", count_selects)
- try:
- response = b3_client.get(
- "/api/v1/recycle-bin/documents?pageSize=100",
- headers=_headers(login_token),
- )
- finally:
- with b3_app.app_context():
- event.remove(db.engine, "before_cursor_execute", count_selects)
- assert response.status_code == 200
- items = {
- item["id"]: item for item in response.get_json()["data"]["items"]
- }
- assert items[documents[0]["id"]]["deletedBy"] is None
- assert items[documents[1]["id"]]["deletedBy"]["username"] == "admin"
- # Token校验1次、分页count 1次、文档页1次、窗口审计1次。
- assert select_count <= 4
- def test_restore_relation_conflicts_and_all_authenticated_acl_roll_back(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import Document, Organization, Permission
- category_id = _category_id(b3_app)
- with b3_app.app_context():
- organization = db.session.scalar(
- db.select(Organization).where(Organization.org_code == "ORG_ROOT")
- )
- assert organization is not None
- organization_id = str(organization.id)
- main = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8权限关系冲突",
- visibility="ORGANIZATION",
- )
- permission = b3_client.put(
- f"/api/v1/documents/{main['id']}/permissions",
- json={
- "visibilityType": "ORGANIZATION",
- "documentRowVersion": main["rowVersion"],
- "entries": [
- {
- "subjectType": "ORG",
- "subjectId": organization_id,
- "canView": True,
- "canDownload": False,
- "canEdit": False,
- "canManagePermission": False,
- "canDelete": False,
- }
- ],
- },
- headers=_headers(login_token),
- )
- main["rowVersion"] = permission.get_json()["data"]["documentRowVersion"]
- _delete(b3_client, login_token, main)
- deleted = _deleted_document(b3_app, main["id"])
- with b3_app.app_context():
- db.session.add(
- Permission(
- document_id=int(main["id"]),
- subject_type="ORG",
- subject_id=int(organization_id),
- subject_name="并发新权限",
- can_view=True,
- )
- )
- db.session.commit()
- conflict = _restore(
- b3_client, login_token, main["id"], deleted["rowVersion"]
- )
- assert conflict.status_code == 409
- assert conflict.get_json()["code"] == "RESTORE_RELATION_CONFLICT"
- assert conflict.get_json()["details"]["permissionConflictCount"] == 1
- with b3_app.app_context():
- assert db.session.get(Document, int(main["id"])).is_deleted
- all_main = _upload_plan(
- b3_client,
- login_token,
- category_id,
- "B8全员历史ACL",
- visibility="CUSTOM",
- )
- saved = b3_client.put(
- f"/api/v1/documents/{all_main['id']}/permissions",
- json={
- "visibilityType": "CUSTOM",
- "documentRowVersion": all_main["rowVersion"],
- "entries": [
- {
- "subjectType": "ORG",
- "subjectId": organization_id,
- "canView": True,
- "canDownload": False,
- "canEdit": False,
- "canManagePermission": False,
- "canDelete": False,
- }
- ],
- },
- headers=_headers(login_token),
- ).get_json()["data"]
- # 构造历史异常数据:全员可见文档仍残留有效ACL。
- with b3_app.app_context():
- document = db.session.get(Document, int(all_main["id"]))
- document.visibility_type = "ALL_AUTHENTICATED"
- document.row_version = saved["documentRowVersion"] + 1
- db.session.commit()
- all_main["rowVersion"] = document.row_version
- all_main["visibilityType"] = document.visibility_type
- _delete(b3_client, login_token, all_main)
- all_deleted = _deleted_document(b3_app, all_main["id"])
- invalid = _restore(
- b3_client, login_token, all_main["id"], all_deleted["rowVersion"]
- )
- assert invalid.status_code == 409
- assert invalid.get_json()["code"] == "RESTORE_PERMISSION_INVALID"
- assert (
- invalid.get_json()["details"]["reason"]
- == "ALL_AUTHENTICATED_HAS_PERMISSIONS"
- )
- def test_restore_attachment_rejects_anomalous_active_binding(
- b3_app, b3_client, login_token
- ):
- from dms.extensions import db
- from dms.models import AttachmentBinding, Document
- category_id = _category_id(b3_app)
- main = _upload_plan(
- b3_client, login_token, category_id, "B8异常挂载主案"
- )
- attachment = _upload_attachment(
- b3_client, login_token, "B8异常挂载附件"
- )
- _delete(b3_client, login_token, attachment, attachment=True)
- deleted = _deleted_document(b3_app, attachment["id"])
- with b3_app.app_context():
- db.session.add(
- AttachmentBinding(
- main_document_id=int(main["id"]),
- attachment_document_id=int(attachment["id"]),
- sort_no=10,
- )
- )
- db.session.commit()
- response = _restore(
- b3_client, login_token, attachment["id"], deleted["rowVersion"]
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "RESTORE_RELATION_CONFLICT"
- assert response.get_json()["details"]["bindingConflictCount"] == 1
- with b3_app.app_context():
- assert db.session.get(Document, int(attachment["id"])).is_deleted
- def test_concurrent_double_restore_only_one_succeeds(
- b3_app, b3_client, login_token, monkeypatch
- ):
- from dms.services import recycle_bin_service
- category_id = _category_id(b3_app)
- document = _upload_plan(
- b3_client, login_token, category_id, "B8并发双恢复"
- )
- _delete(b3_client, login_token, document)
- deleted = _deleted_document(b3_app, document["id"])
- original = recycle_bin_service._restore_operation
- barrier = threading.Barrier(2)
- def synchronized(*args, **kwargs):
- barrier.wait(timeout=10)
- return original(*args, **kwargs)
- monkeypatch.setattr(
- recycle_bin_service, "_restore_operation", synchronized
- )
- def invoke():
- with b3_app.test_client() as client:
- response = _restore(
- client, login_token, document["id"], deleted["rowVersion"]
- )
- return response.status_code, response.get_json()["code"]
- with ThreadPoolExecutor(max_workers=2) as executor:
- results = list(executor.map(lambda _index: invoke(), range(2)))
- assert sorted(results) == [
- (200, "OK"),
- (409, "DOCUMENT_NOT_DELETED"),
- ]
|