from __future__ import annotations import io import json from pathlib import Path import fitz import pytest from docx import Document as WordDocument def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _category_id(app, code: str) -> 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 == code) ) assert category is not None return str(category.id) def _docx_bytes(term: str = "Q3正文唯一词") -> bytes: stream = io.BytesIO() document = WordDocument() document.add_paragraph(f"段落 {term}") table = document.add_table(rows=1, cols=1) table.cell(0, 0).text = "表格唯一词" section = document.sections[0] section.header.paragraphs[0].text = "页眉唯一词" section.footer.paragraphs[0].text = "页脚唯一词" document.save(stream) return stream.getvalue() def _pdf_bytes(term: str = "Q3PdfUniqueTerm") -> bytes: document = fitz.open() page = document.new_page() page.insert_text((72, 72), term) content = document.tobytes() document.close() return content def _main_metadata(category_id: str, name: str = "Q3主案") -> dict: return { "documentName": name, "documentType": "MAIN", "summary": "Q3摘要", "categoryId": category_id, "securityLevel": "INTERNAL", "tags": ["Q3"], } def _sub_metadata(parent_id: str, name: str = "Q3子方案") -> dict: return { "documentName": name, "documentType": "SUB_PLAN", "summary": "Q3子方案摘要", "parentDocumentId": parent_id, "securityLevel": "INTERNAL", "tags": ["Q3"], } def _upload(client, token: str, metadata: dict, content: bytes, suffix: str): return client.post( "/api/v1/documents", data={ "file": (io.BytesIO(content), f"{metadata['documentName']}.{suffix}"), "metadata": json.dumps(metadata, ensure_ascii=False), }, headers=_auth(token), content_type="multipart/form-data", ) def _tree_node(nodes: list[dict], category_id: str) -> dict: pending = list(nodes) while pending: node = pending.pop() if node["id"] == category_id: return node pending.extend(node["children"]) raise AssertionError(f"category {category_id} not found") def test_business_timezone_default_and_invalid_fail_fast(monkeypatch): from dms.config import load_dms_config monkeypatch.delenv("DMS_BUSINESS_TIMEZONE", raising=False) assert load_dms_config()["DMS_BUSINESS_TIMEZONE"] == "Asia/Shanghai" monkeypatch.setenv("DMS_BUSINESS_TIMEZONE", "Not/A_Real_Zone") with pytest.raises(ValueError, match="DMS_BUSINESS_TIMEZONE"): load_dms_config() def test_admin_modules_and_historical_auditor_are_invalid( b2_app, b2_client, b2_password, token_for ): from dms.extensions import db from dms.models import User from dms.security.jwt_tokens import issue_access_token admin = b2_client.get( "/api/v1/users/me", headers=_auth(token_for("admin")) ) assert admin.get_json()["data"]["allowedModules"] == [ "DOCUMENT_BROWSER", "BACKEND_MANAGEMENT", "AUDIT_LOG", ] failed = b2_client.post( "/api/v1/auth/login", json={ "username": "auditor", "password": b2_password, "keepSignedIn": False, }, ) assert failed.status_code == 401 with b2_app.app_context(): historical = db.session.scalar( db.select(User).where(User.username == "auditor") ) assert historical.role_code == "USER" assert historical.status == "DISABLED" assert historical.is_deleted old_token, _ = issue_access_token(historical, keep_signed_in=False) assert b2_client.get( "/api/v1/users/me", headers=_auth(old_token) ).status_code == 401 def test_leaf_rules_create_defaults_and_sub_plan_category_sync( b3_app, b3_client, token_for ): from dms.extensions import db from dms.models import Document token = token_for("admin") root_id = _category_id(b3_app, "ROOT_A") leaf_one = _category_id(b3_app, "CHILD_A1") leaf_two = _category_id(b3_app, "CHILD_A2") rejected = _upload( b3_client, token, _main_metadata(root_id), _docx_bytes(), "docx" ) assert rejected.status_code == 409 assert rejected.get_json()["code"] == "CATEGORY_NOT_LEAF" assert rejected.get_json()["details"] == { "categoryId": root_id, "childCategoryCount": 2, } created_response = _upload( b3_client, token, _main_metadata(leaf_one, "Q3叶子主案"), _docx_bytes(), "docx", ) assert created_response.status_code == 201 main = created_response.get_json()["data"] assert (main["status"], main["visibilityType"]) == ( "PUBLISHED", "CUSTOM", ) child_category = b3_client.post( "/api/v1/categories", json={ "categoryCode": "Q3_CHILD_FORBIDDEN", "categoryName": "禁止子分类", "categoryType": "OTHER", "parentId": leaf_one, "sortNo": 99, }, headers=_auth(token), ) assert child_category.status_code == 409 assert child_category.get_json()["code"] == "CATEGORY_NOT_LEAF" sub_response = _upload( b3_client, token, _sub_metadata(main["id"]), _docx_bytes("子方案正文"), "docx", ) assert sub_response.status_code == 201 sub = sub_response.get_json()["data"] assert sub["categoryId"] == leaf_one with b3_app.app_context(): current = db.session.get(Document, int(main["id"])) current_version = current.row_version moved = b3_client.put( f"/api/v1/documents/{main['id']}", json={ "documentName": main["documentName"], "summary": main["summary"], "categoryId": leaf_two, "securityLevel": main["securityLevel"], "tags": main["tags"], "rowVersion": current_version, }, headers=_auth(token), ) assert moved.status_code == 200 with b3_app.app_context(): persisted_sub = db.session.get(Document, int(sub["id"])) assert str(persisted_sub.category_id) == leaf_two assert persisted_sub.category_name == moved.get_json()["data"]["categoryName"] def test_include_descendants_and_permission_aware_subtree_count( b3_app, b3_client, token_for ): from dms.extensions import db from dms.models import AuditLog, User admin_token = token_for("admin") user_token = token_for("user") root_id = _category_id(b3_app, "ROOT_A") leaf_id = _category_id(b3_app, "CHILD_A1") created = _upload( b3_client, admin_token, _main_metadata(leaf_id, "Q3递归主案"), _docx_bytes(), "docx", ).get_json()["data"] exact = b3_client.get( f"/api/v1/documents?categoryId={root_id}", headers=_auth(admin_token) ).get_json()["data"] recursive = b3_client.get( f"/api/v1/documents?categoryId={root_id}&includeDescendants=true", headers=_auth(admin_token), ).get_json()["data"] all_categories = b3_client.get( "/api/v1/documents", headers=_auth(admin_token) ).get_json()["data"] assert exact["total"] == 0 assert recursive["total"] == 1 assert all_categories["total"] == 1 invalid = b3_client.get( f"/api/v1/documents?categoryId={root_id}&includeDescendants=yes", headers=_auth(admin_token), ) assert invalid.status_code == 400 admin_tree = b3_client.get( "/api/v1/categories/tree", headers=_auth(admin_token) ).get_json()["data"] user_tree = b3_client.get( "/api/v1/categories/tree", headers=_auth(user_token) ).get_json()["data"] assert _tree_node(admin_tree, root_id)["documentCount"] == 1 assert _tree_node(user_tree, root_id)["documentCount"] == 0 with b3_app.app_context(): user = db.session.scalar(db.select(User).where(User.username == "user")) audit_before = db.session.scalar( db.select(db.func.count()).select_from(AuditLog) ) saved = b3_client.put( f"/api/v1/documents/{created['id']}/permissions", json={ "visibilityType": "CUSTOM", "documentRowVersion": created["rowVersion"], "entries": [ { "subjectType": "USER", "subjectId": str(user.id), "canView": True, "canDownload": True, "canEdit": False, "canManagePermission": False, "canDelete": False, } ], }, headers=_auth(admin_token), ) assert saved.status_code == 200 keyword_tree = b3_client.get( "/api/v1/categories/tree?keyword=CHILD_A1", headers=_auth(user_token), ).get_json()["data"] assert _tree_node(keyword_tree, root_id)["documentCount"] == 1 with b3_app.app_context(): audit_after = db.session.scalar( db.select(db.func.count()).select_from(AuditLog) ) assert audit_after == audit_before + 1 # 仅权限配置产生审计,树查询不产生。 def test_docx_pdf_content_search_and_original_preview( b3_app, b3_client, token_for ): from dms.extensions import db from dms.models import AuditLog, Document token = token_for("admin") leaf_id = _category_id(b3_app, "CHILD_A1") docx_content = _docx_bytes("只在DOCX正文中的检索词") docx_item = _upload( b3_client, token, _main_metadata(leaf_id, "Q3-DOCX"), docx_content, "docx", ).get_json()["data"] pdf_content = _pdf_bytes("OnlyInsidePdfBody") pdf_item = _upload( b3_client, token, _main_metadata(leaf_id, "Q3-PDF"), pdf_content, "pdf", ).get_json()["data"] for keyword, expected_id in ( ("只在DOCX正文中的检索词", docx_item["id"]), ("表格唯一词", docx_item["id"]), ("页眉唯一词", docx_item["id"]), ("页脚唯一词", docx_item["id"]), ("OnlyInsidePdfBody", pdf_item["id"]), ): result = b3_client.get( f"/api/v1/documents?keyword={keyword}", headers=_auth(token) ).get_json()["data"] assert [item["id"] for item in result["items"]] == [expected_id] with b3_app.app_context(): before_download = { item.id: item.download_count for item in db.session.scalars( db.select(Document).where( Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])]) ) ) } before_audit = db.session.scalar( db.select(db.func.count(AuditLog.id)).where( AuditLog.action_type == "DOWNLOAD_DOCUMENT" ) ) for item, content, mime in ( ( docx_item, docx_content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ), (pdf_item, pdf_content, "application/pdf"), ): response = b3_client.get( f"/api/v1/documents/{item['id']}/preview", headers={**_auth(token), "Origin": "http://127.0.0.1:9346"}, ) assert response.status_code == 200 assert response.mimetype == mime assert response.data == content assert response.headers["Content-Disposition"].startswith("inline") assert "filename*=" in response.headers["Content-Disposition"] assert response.headers["X-Content-Type-Options"] == "nosniff" assert response.headers["Cache-Control"] == "private, no-store" assert "Content-Disposition" in response.headers[ "Access-Control-Expose-Headers" ] response.close() with b3_app.app_context(): after_download = { item.id: item.download_count for item in db.session.scalars( db.select(Document).where( Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])]) ) ) } after_audit = db.session.scalar( db.select(db.func.count(AuditLog.id)).where( AuditLog.action_type == "DOWNLOAD_DOCUMENT" ) ) assert after_download == before_download assert after_audit == before_audit def test_attachment_library_is_admin_only_and_user_cannot_guess_unmounted_id( b3_app, b3_client, token_for ): admin_token = token_for("admin") user_token = token_for("user") response = b3_client.post( "/api/v1/attachments", data={ "file": (io.BytesIO(_docx_bytes()), "Q3附件.docx"), "metadata": json.dumps( { "documentName": "Q3未挂载附件", "attachmentType": "OTHER", "summary": "未挂载", "tags": ["Q3"], }, ensure_ascii=False, ), }, headers=_auth(admin_token), content_type="multipart/form-data", ) attachment = response.get_json()["data"] assert b3_client.get( "/api/v1/attachments", headers=_auth(user_token) ).status_code == 403 assert b3_client.get( f"/api/v1/attachments/{attachment['id']}", headers=_auth(user_token) ).status_code == 403 assert b3_client.get( f"/api/v1/documents/{attachment['id']}/preview", headers=_auth(user_token), ).status_code == 403 def test_backfill_gate_requires_explicit_dms_test_url(monkeypatch, b2_app): from dms.backfill_document_content import _database_gate with b2_app.app_context(): monkeypatch.delenv("DMS_DATABASE_URL", raising=False) with pytest.raises(RuntimeError, match="DMS_DATABASE_URL"): _database_gate() monkeypatch.setenv( "DMS_DATABASE_URL", "mysql+pymysql://hidden:hidden@localhost:3306/dms", ) with pytest.raises(RuntimeError, match="dms_test"): _database_gate() def test_q3_openapi_contract_has_no_new_preview_path(): import yaml root = Path(__file__).resolve().parents[2] / "openapi" document = yaml.safe_load((root / "openapi.yaml").read_text("utf-8")) schemas = yaml.safe_load( (root / "components" / "schemas.yaml").read_text("utf-8") ) paths = yaml.safe_load( (root / "paths" / "documents.yaml").read_text("utf-8") ) assert document["info"]["version"] == "1.5.0" assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"] parameters = paths["Documents"]["get"]["parameters"] assert any(item.get("name") == "includeDescendants" for item in parameters) preview_content = paths["DocumentPreview"]["get"]["responses"]["200"][ "content" ] assert set(preview_content) == { "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", } assert all("content-preview" not in path for path in document["paths"])