from types import SimpleNamespace from unittest.mock import AsyncMock import uuid import pytest from fastapi import Response from jose import jwt from starlette.requests import Request from app.api.v1 import onlyoffice as onlyoffice_api from app.core.config import settings from app.core.exceptions import AppException from app.services import onlyoffice_service @pytest.fixture(autouse=True) def onlyoffice_settings(monkeypatch): monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True) monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough") monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test") monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000") @pytest.mark.asyncio async def test_attachment_config_reuses_permission_and_preserves_original_name(monkeypatch, tmp_path): resource_id = uuid.uuid4() study_id = uuid.uuid4() entity_id = uuid.uuid4() user = SimpleNamespace(id=uuid.uuid4(), full_name="预览用户") source_file = tmp_path / "stored-uuid" source_file.write_bytes(b"office") attachment = SimpleNamespace( id=resource_id, study_id=study_id, entity_type="startup_initiation", entity_id=entity_id, filename="研究方案最终版.DOCX", file_path=str(source_file), content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) permission = AsyncMock() audit = AsyncMock() monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock()) monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission) monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit) response = Response() db = object() result = await onlyoffice_api.get_attachment_preview_config(resource_id, response, db, user) permission.assert_awaited_once_with(db, study_id, "startup_initiation", entity_id, "read", user) assert result.file_name == "研究方案最终版.DOCX" assert result.config["document"]["title"] == "研究方案最终版.DOCX" assert response.headers["Cache-Control"] == "no-store" audit.assert_awaited_once() @pytest.mark.asyncio async def test_attachment_permission_failure_does_not_issue_config(monkeypatch, tmp_path): source_file = tmp_path / "source.xlsx" source_file.write_bytes(b"office") attachment = SimpleNamespace( id=uuid.uuid4(), study_id=uuid.uuid4(), entity_type="ethics", entity_id=uuid.uuid4(), filename="数据.xlsx", file_path=str(source_file), ) denied = AppException(code="FORBIDDEN", message="权限不足", status_code=403) monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock()) monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", AsyncMock(side_effect=denied)) health = AsyncMock() monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", health) with pytest.raises(AppException) as error: await onlyoffice_api.get_attachment_preview_config( attachment.id, Response(), object(), SimpleNamespace(id=uuid.uuid4(), full_name="无权限用户") ) assert error.value.status_code == 403 health.assert_not_awaited() @pytest.mark.asyncio async def test_version_config_reuses_document_access_and_version_hash(monkeypatch, tmp_path): source_file = tmp_path / "uuid-storage" source_file.write_bytes(b"office") version = SimpleNamespace( id=uuid.uuid4(), document_id=uuid.uuid4(), file_uri=str(source_file), original_filename="统计表.xlsx", file_hash="sha256-current", mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) document = SimpleNamespace(id=version.document_id, trial_id=uuid.uuid4()) user = SimpleNamespace(id=uuid.uuid4(), full_name="管理员") access = AsyncMock() monkeypatch.setattr(onlyoffice_api.version_crud, "get", AsyncMock(return_value=version)) monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document)) monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access) monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock()) result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user) assert access.await_args.args[1:] == (document.trial_id, user) assert access.await_args.kwargs == {"action": "view"} assert result.file_name == "统计表.xlsx" assert result.config["document"]["key"] == onlyoffice_service.onlyoffice_document_key( "version", version.id, file_hash="sha256-current" ) @pytest.mark.asyncio async def test_internal_attachment_requires_url_bound_jwt_and_returns_original_name(monkeypatch, tmp_path): source_file = tmp_path / "uuid-storage-name" source_file.write_bytes(b"office") attachment = SimpleNamespace( id=uuid.uuid4(), filename="中文原始文件名.xlsx", file_path=str(source_file), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment.id) token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256") request = Request({ "type": "http", "method": "GET", "path": f"/internal/onlyoffice/attachments/{attachment.id}/content", "headers": [(b"authorizationjwt", token.encode())], }) monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) response = await onlyoffice_api.get_internal_attachment_content(attachment.id, request, object()) assert "uuid-storage-name" not in response.headers["content-disposition"] assert "UTF-8''" in response.headers["content-disposition"] assert response.media_type == attachment.content_type missing_token_request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) with pytest.raises(AppException) as error: await onlyoffice_api.get_internal_attachment_content(attachment.id, missing_token_request, object()) assert error.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"