| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 |
- 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"])
|