Files
ctms/backend/tests/test_collaboration_service.py
T
Cheng Zhou 1d26646a96
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
2026-07-16 14:14:54 +08:00

1065 lines
42 KiB
Python

import hashlib
import io
import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock
import pytest
from fastapi import HTTPException
from jose import jwt
from app.core.config import settings
from app.models.collaboration import (
CollaborationFile,
CollaborationFolder,
CollaborationRevision,
CollaborationShareLink,
)
from app.schemas.collaboration import (
CollaborationCallbackPayload,
CollaborationFileUpdate,
CollaborationRevisionCopyRequest,
CollaborationRevisionUpdate,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
@pytest.fixture(autouse=True)
def collaboration_settings(monkeypatch):
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True)
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "collaboration-secret-that-is-long-enough")
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-collaboration-test")
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice")
monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000")
monkeypatch.setattr(settings, "ONLYOFFICE_CONFIG_TTL_SECONDS", 300)
@pytest.mark.parametrize(
("file_type", "expected_extension", "required_part"),
[
("word", "docx", "word/document.xml"),
("cell", "xlsx", "xl/worksheets/sheet1.xml"),
("slide", "pptx", "ppt/slides/slide1.xml"),
],
)
def test_blank_collaboration_templates_are_ooxml_packages(file_type, expected_extension, required_part):
content = collaboration_service.blank_file_bytes(file_type)
assert zipfile.is_zipfile(io.BytesIO(content))
with zipfile.ZipFile(io.BytesIO(content)) as package:
assert "[Content_Types].xml" in package.namelist()
assert required_part in package.namelist()
assert collaboration_service.FILE_TYPE_EXTENSION[file_type] == expected_extension
def test_collaboration_key_is_stable_per_generation_and_is_independent_from_document_versions():
file_id = uuid.uuid4()
first = onlyoffice_collaboration_service.collaboration_document_key(file_id, 1)
repeated = onlyoffice_collaboration_service.collaboration_document_key(file_id, 1)
next_generation = onlyoffice_collaboration_service.collaboration_document_key(file_id, 2)
assert first == repeated
assert first != next_generation
assert first.startswith("ctms-collab-")
assert len(first) <= 128
def test_collaboration_revision_preview_config_is_read_only():
revision_id = uuid.uuid4()
config = onlyoffice_service.build_preview_config(
resource_type="collaboration_revision",
resource_id=revision_id,
file_name="方案-R2.docx",
file_hash="revision-hash",
user_id=uuid.uuid4(),
user_name="测试用户",
)
assert config.resource_type == "collaboration_revision"
assert config.config["editorConfig"]["mode"] == "view"
assert config.config["document"]["permissions"]["edit"] is False
assert f"/internal/onlyoffice/collaboration-revisions/{revision_id}/content" in config.config["document"]["url"]
def test_revision_action_payloads_trim_user_input():
assert CollaborationRevisionUpdate(change_summary=" 伦理送审版 ").change_summary == "伦理送审版"
folder_id = uuid.uuid4()
payload = CollaborationRevisionCopyRequest(title=" 方案-R2.docx ", folder_id=folder_id)
assert payload.title == "方案-R2.docx"
assert payload.folder_id == folder_id
@pytest.mark.asyncio
async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch, tmp_path):
source = tmp_path / "revision.docx"
content = collaboration_service.blank_file_bytes("word")
source.write_bytes(content)
revision_id = uuid.uuid4()
target_folder_id = uuid.uuid4()
item = SimpleNamespace(
id=uuid.uuid4(),
study_id=uuid.uuid4(),
folder_id=uuid.uuid4(),
file_type="word",
current_revision_id=uuid.uuid4(),
)
revision = SimpleNamespace(
id=revision_id,
file_id=item.id,
revision_no=2,
file_uri=str(source),
deleted_at=None,
)
copied = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision))
require_exporter = AsyncMock()
folder_lookup = AsyncMock()
create_copy = AsyncMock(return_value=copied)
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
monkeypatch.setattr(collaboration_service, "_folder_or_404", folder_lookup)
monkeypatch.setattr(collaboration_service, "_create_file_from_bytes", create_copy)
user = SimpleNamespace(id=uuid.uuid4())
result = await collaboration_service.copy_revision(
db,
item,
revision_id,
CollaborationRevisionCopyRequest(title="送审版.docx", folder_id=target_folder_id),
user,
)
assert result is copied
require_exporter.assert_awaited_once_with(db, item, user)
folder_lookup.assert_awaited_once_with(db, item.study_id, target_folder_id)
create_copy.assert_awaited_once_with(
db,
item.study_id,
title="送审版.docx",
file_type="word",
folder_id=target_folder_id,
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@pytest.mark.asyncio
async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch):
revision_id = uuid.uuid4()
current_revision_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4())
item = SimpleNamespace(id=uuid.uuid4(), current_revision_id=current_revision_id)
revision = SimpleNamespace(
id=revision_id,
file_id=item.id,
revision_no=2,
deleted_at=None,
deleted_by=None,
)
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_manager = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager)
monkeypatch.setattr(collaboration_service, "_audit", audit)
await collaboration_service.delete_revision(db, item, revision_id, user)
require_manager.assert_awaited_once_with(db, item, user)
assert revision.deleted_at is not None
assert revision.deleted_by == user.id
audit.assert_awaited_once_with(
db,
item,
"COLLABORATION_REVISION_DELETED",
user,
{"revision_id": str(revision_id), "revision_no": 2},
)
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_revision_rejects_the_current_version(monkeypatch):
revision_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4())
item = SimpleNamespace(id=uuid.uuid4(), current_revision_id=revision_id)
revision = SimpleNamespace(
id=revision_id,
file_id=item.id,
revision_no=4,
deleted_at=None,
deleted_by=None,
)
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
with pytest.raises(HTTPException) as exc:
await collaboration_service.delete_revision(db, item, revision_id, user)
assert exc.value.status_code == 409
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_revision_history_includes_creator_display_metadata():
file_id = uuid.uuid4()
creator_id = uuid.uuid4()
now = datetime.now(timezone.utc)
revision = SimpleNamespace(
id=uuid.uuid4(),
file_id=file_id,
revision_no=3,
parent_revision_id=None,
original_filename="方案.docx",
file_hash="hash",
file_size=2048,
mime_type="application/docx",
source="SESSION_CLOSE",
change_summary="自动保存",
created_by=creator_id,
created_at=now,
)
creator = SimpleNamespace(full_name="测试用户", avatar_url="/avatars/test.png")
result = SimpleNamespace(all=lambda: [(revision, creator)])
db = SimpleNamespace(execute=AsyncMock(return_value=result))
history = await collaboration_service.list_revisions(db, SimpleNamespace(id=file_id))
assert len(history) == 1
assert history[0].created_by_name == "测试用户"
assert history[0].created_by_avatar_url == "/avatars/test.png"
statement = db.execute.await_args.args[0]
assert "collaboration_revisions.source !=" in str(statement)
@pytest.mark.asyncio
async def test_sheet_permission_change_updates_current_file_without_creating_revision(monkeypatch, tmp_path):
original = collaboration_service.blank_file_bytes("cell")
revision_path = tmp_path / "current.xlsx"
revision_path.write_bytes(original)
revision = SimpleNamespace(
id=uuid.uuid4(),
file_uri=str(revision_path),
file_hash="old-hash",
file_size=len(original),
)
item = SimpleNamespace(
id=uuid.uuid4(),
study_id=uuid.uuid4(),
file_type="cell",
extension="xlsx",
title="协作表.xlsx",
folder_id=None,
current_revision_id=revision.id,
generation=1,
allow_export=True,
allow_edit_request=False,
allow_sheet_structure_edit=True,
sheet_structure_protection_backup=None,
)
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock())
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
persist_revision = AsyncMock()
monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision)
result = await collaboration_service.update_file(
db,
item,
CollaborationFileUpdate(allow_sheet_structure_edit=False),
user,
)
assert result is item
assert item.allow_sheet_structure_edit is False
assert item.generation == 2
persist_revision.assert_not_awaited()
assert revision.file_hash == hashlib.sha256(revision_path.read_bytes()).hexdigest()
assert revision.file_size == revision_path.stat().st_size
with zipfile.ZipFile(revision_path) as package:
workbook = package.read("xl/workbook.xml").decode("utf-8")
assert "workbookProtection" in workbook
def test_callback_token_is_bound_to_key_status_and_result_url():
result_url = "http://onlyoffice/cache/result.docx"
payload = CollaborationCallbackPayload(key="collaboration-key", status=2, url=result_url)
token = jwt.encode(
{"payload": {"key": payload.key, "status": payload.status, "url": payload.url}},
settings.ONLYOFFICE_JWT_SECRET,
algorithm="HS256",
)
decoded = onlyoffice_collaboration_service.validate_callback_token(token, payload)
assert decoded["payload"]["key"] == payload.key
forged = payload.model_copy(update={"url": "http://onlyoffice/cache/other.docx"})
with pytest.raises(HTTPException) as mismatch:
onlyoffice_collaboration_service.validate_callback_token(token, forged)
assert mismatch.value.status_code == 401
config_token = jwt.encode(
{"document": {"key": payload.key}, "editorConfig": {"mode": "edit"}},
settings.ONLYOFFICE_JWT_SECRET,
algorithm="HS256",
)
with pytest.raises(HTTPException):
onlyoffice_collaboration_service.validate_callback_token(config_token, payload)
def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(monkeypatch):
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "http://localhost:8888")
assert onlyoffice_collaboration_service._validate_result_url(
"http://onlyoffice/cache/result.docx?token=signed"
) == "http://onlyoffice/cache/result.docx?token=signed"
assert onlyoffice_collaboration_service._validate_result_url(
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
) == "http://onlyoffice/cache/result.docx?token=signed"
for value in (
"http://backend:8000/internal/file",
"http://onlyoffice.evil.example/cache/result.docx",
"http://user:password@onlyoffice/cache/result.docx",
"http://onlyoffice:8080/cache/result.docx",
"http://localhost:8888/api/v1/files/result.docx",
"http://localhost.evil.example:8888/onlyoffice/cache/result.docx",
):
with pytest.raises(HTTPException):
onlyoffice_collaboration_service._validate_result_url(value)
@pytest.mark.asyncio
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
user_id = uuid.uuid4()
file_id = uuid.uuid4()
revision_id = uuid.uuid4()
source = tmp_path / "协作表.xlsx"
source.write_bytes(collaboration_service.blank_file_bytes("cell"))
item = CollaborationFile(
id=file_id,
study_id=uuid.uuid4(),
title="协作表.xlsx",
file_type="cell",
extension="xlsx",
owner_id=user_id,
current_revision_id=revision_id,
generation=1,
allow_export=False,
)
revision = SimpleNamespace(id=revision_id, file_uri=str(source))
session = SimpleNamespace(id=uuid.uuid4(), document_key="ctms-collab-key")
db = SimpleNamespace(get=AsyncMock(return_value=revision))
user = SimpleNamespace(id=user_id, full_name="测试用户")
monkeypatch.setattr(onlyoffice_collaboration_service.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_collaboration_service.collaboration_service, "can_edit_file", AsyncMock(return_value=True))
can_request_edit = AsyncMock(return_value=False)
monkeypatch.setattr(
onlyoffice_collaboration_service.collaboration_service,
"can_request_edit_file",
can_request_edit,
)
can_export = AsyncMock(return_value=True)
monkeypatch.setattr(onlyoffice_collaboration_service.collaboration_service, "can_export_file", can_export)
can_create = AsyncMock(return_value=True)
monkeypatch.setattr(onlyoffice_collaboration_service.collaboration_service, "can_create_file", can_create)
monkeypatch.setattr(onlyoffice_collaboration_service, "_active_session", AsyncMock(return_value=session))
result = await onlyoffice_collaboration_service.build_editor_config(db, item, user)
assert result.access_mode == "edit"
assert result.can_save_as is True
assert result.can_download is True
assert result.config["editorConfig"]["mode"] == "edit"
assert result.config["editorConfig"]["coEditing"] == {"mode": "fast", "change": False}
assert result.config["document"]["permissions"]["edit"] is True
assert result.config["document"]["permissions"]["download"] is True
assert result.config["document"]["permissions"]["print"] is True
assert "/internal/onlyoffice/collaboration/sessions/" in result.config["document"]["url"]
assert "callbackUrl" in result.config["editorConfig"]
onlyoffice_collaboration_service.collaboration_service.can_edit_file.return_value = False
can_request_edit.return_value = True
requestable = await onlyoffice_collaboration_service.build_editor_config(db, item, user)
assert requestable.access_mode == "view"
assert requestable.can_request_edit is True
assert requestable.config["editorConfig"]["mode"] == "view"
assert requestable.config["document"]["permissions"]["edit"] is True
onlyoffice_collaboration_service.collaboration_service.can_edit_file.return_value = True
can_request_edit.return_value = False
can_create.return_value = False
restricted = await onlyoffice_collaboration_service.build_editor_config(db, item, user)
assert restricted.can_save_as is False
assert restricted.can_download is True
assert restricted.config["document"]["permissions"]["download"] is True
can_export.return_value = False
export_restricted = await onlyoffice_collaboration_service.build_editor_config(db, item, user)
assert export_restricted.can_save_as is False
assert export_restricted.can_download is False
assert export_restricted.config["document"]["permissions"]["download"] is False
@pytest.mark.asyncio
async def test_shared_editor_config_respects_link_mode_and_disables_export(monkeypatch, tmp_path):
owner_id = uuid.uuid4()
revision_id = uuid.uuid4()
source = tmp_path / "共享表.xlsx"
source.write_bytes(collaboration_service.blank_file_bytes("cell"))
item = CollaborationFile(
id=uuid.uuid4(),
study_id=uuid.uuid4(),
title="共享表.xlsx",
file_type="cell",
extension="xlsx",
owner_id=owner_id,
current_revision_id=revision_id,
generation=1,
allow_export=False,
)
link = CollaborationShareLink(
id=uuid.uuid4(),
file_id=item.id,
enabled=True,
access_mode="EDIT",
expiry_policy="SEVEN_DAYS",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
created_by=owner_id,
updated_by=owner_id,
)
revision = SimpleNamespace(id=revision_id, file_uri=str(source))
session = SimpleNamespace(id=uuid.uuid4(), document_key="ctms-public-share-key")
db = SimpleNamespace(get=AsyncMock(return_value=revision))
monkeypatch.setattr(onlyoffice_collaboration_service.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_collaboration_service, "_active_session", AsyncMock(return_value=session))
result = await onlyoffice_collaboration_service.build_shared_editor_config(
db,
item,
link,
client_id="visitor-client-1",
display_name="链接访客",
)
assert result.access_mode == "edit"
assert result.can_save_as is False
assert result.can_download is False
assert result.config["document"]["permissions"]["edit"] is True
assert result.config["document"]["permissions"]["copy"] is False
assert result.config["document"]["permissions"]["download"] is False
assert result.config["document"]["permissions"]["print"] is False
assert result.config["editorConfig"]["user"]["id"].startswith("share-")
item.allow_export = True
export_result = await onlyoffice_collaboration_service.build_shared_editor_config(
db,
item,
link,
client_id="visitor-client-2",
display_name="链接访客",
)
assert export_result.can_save_as is False
assert export_result.can_download is True
assert export_result.config["document"]["permissions"]["copy"] is True
assert export_result.config["document"]["permissions"]["download"] is True
assert export_result.config["document"]["permissions"]["print"] is True
@pytest.mark.asyncio
async def test_share_tokens_are_stable_revocable_and_not_accepted_after_expiry(monkeypatch):
monkeypatch.setattr(settings, "JWT_SECRET_KEY", "share-token-test-secret")
now = datetime.now(timezone.utc)
link = CollaborationShareLink(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
enabled=True,
access_mode="VIEW",
expiry_policy="SEVEN_DAYS",
expires_at=now + timedelta(days=7),
token_version=2,
created_by=uuid.uuid4(),
updated_by=uuid.uuid4(),
created_at=now,
updated_at=now,
)
item = SimpleNamespace(
id=link.file_id,
title="方案.docx",
file_type="word",
status="ACTIVE",
allow_export=False,
deleted_at=None,
)
db = SimpleNamespace(scalar=AsyncMock(return_value=link), get=AsyncMock(return_value=item))
token = collaboration_share_service.share_token(link)
repeated = collaboration_share_service.share_token(link)
metadata = await collaboration_share_service.public_metadata(db, token)
assert token == repeated
assert metadata.file_name == "方案.docx"
assert metadata.access_mode == "view"
assert metadata.allow_export is False
with pytest.raises(HTTPException) as tampered:
replacement = "A" if token[-1] != "A" else "B"
await collaboration_share_service.public_metadata(db, f"{token[:-1]}{replacement}")
assert tampered.value.status_code == 404
link.token_version += 1
with pytest.raises(HTTPException) as revoked:
await collaboration_share_service.public_metadata(db, token)
assert revoked.value.status_code == 404
current = collaboration_share_service.share_token(link)
link.expires_at = now - timedelta(seconds=1)
with pytest.raises(HTTPException) as expired:
await collaboration_share_service.public_metadata(db, current)
assert expired.value.status_code == 410
@pytest.mark.asyncio
async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkeypatch):
monkeypatch.setattr(settings, "JWT_SECRET_KEY", "immutable-share-link-test-secret")
now = datetime.now(timezone.utc)
link = CollaborationShareLink(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
enabled=True,
access_mode="VIEW",
expiry_policy="PERMANENT",
expires_at=None,
token_version=4,
created_by=uuid.uuid4(),
updated_by=uuid.uuid4(),
created_at=now,
updated_at=now,
)
item = SimpleNamespace(id=link.file_id, allow_export=False)
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(
scalar=AsyncMock(return_value=link),
commit=AsyncMock(),
refresh=AsyncMock(),
)
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
original_token = collaboration_share_service.share_token(link)
await collaboration_share_service.update_share_link(
db,
item,
CollaborationShareLinkUpdate(enabled=False, expiry_policy="PERMANENT"),
user,
)
assert link.enabled is False
assert link.token_version == 4
assert collaboration_share_service.share_token(link) == original_token
await collaboration_share_service.update_share_link(
db,
item,
CollaborationShareLinkUpdate(
enabled=True,
access_mode="EDIT",
expiry_policy="THIRTY_DAYS",
),
user,
)
assert link.enabled is True
assert link.access_mode == "EDIT"
assert item.allow_export is False
assert link.token_version == 4
assert collaboration_share_service.share_token(link) == original_token
@pytest.mark.asyncio
async def test_share_password_failures_lock_the_link_and_grants_are_version_bound(monkeypatch):
monkeypatch.setattr(settings, "JWT_SECRET_KEY", "share-password-test-secret")
now = datetime.now(timezone.utc)
link = CollaborationShareLink(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
enabled=True,
access_mode="VIEW",
expiry_policy="PERMANENT",
password_hash="hashed-password",
token_version=3,
failed_attempts=0,
created_by=uuid.uuid4(),
updated_by=uuid.uuid4(),
created_at=now,
updated_at=now,
)
item = SimpleNamespace(id=link.file_id, status="ACTIVE", deleted_at=None)
db = SimpleNamespace(
scalar=AsyncMock(return_value=link),
get=AsyncMock(return_value=item),
commit=AsyncMock(),
)
monkeypatch.setattr(collaboration_share_service, "verify_password", lambda *_: False)
token = collaboration_share_service.share_token(link)
for _ in range(collaboration_share_service.PASSWORD_FAILURE_LIMIT - 1):
with pytest.raises(HTTPException) as wrong:
await collaboration_share_service.verify_share_password(db, token, "wrong")
assert wrong.value.status_code == 401
with pytest.raises(HTTPException) as locked:
await collaboration_share_service.verify_share_password(db, token, "wrong")
assert locked.value.status_code == 429
assert link.locked_until is not None
link.locked_until = None
link.failed_attempts = 0
monkeypatch.setattr(collaboration_share_service, "verify_password", lambda *_: True)
grant = await collaboration_share_service.verify_share_password(db, token, "correct")
collaboration_share_service.validate_access_grant(link, grant.access_token)
link.token_version += 1
with pytest.raises(HTTPException) as stale:
collaboration_share_service.validate_access_grant(link, grant.access_token)
assert stale.value.status_code == 401
@pytest.mark.asyncio
async def test_public_share_callback_is_not_attributed_to_the_file_owner():
owner_id = uuid.uuid4()
db = SimpleNamespace(get=AsyncMock())
session = SimpleNamespace(started_by=owner_id)
payload = CollaborationCallbackPayload(
key="shared-key",
status=6,
users=["share-0123456789ab-visitor-client"],
)
actor = await onlyoffice_collaboration_service._callback_user(db, payload, session)
assert actor is None
db.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_roles_define_effective_edit_manage_and_export_permissions(monkeypatch):
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
item = SimpleNamespace(
id=uuid.uuid4(),
study_id=uuid.uuid4(),
owner_id=uuid.uuid4(),
allow_export=False,
)
membership = SimpleNamespace(is_active=True, role_in_study="CRA")
member_role = AsyncMock(return_value="EDITOR")
monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership))
monkeypatch.setattr(collaboration_service, "_member_role", member_role)
assert await collaboration_service.can_edit_file(SimpleNamespace(), item, user) is True
assert await collaboration_service.can_manage_file(SimpleNamespace(), item, user) is False
assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is False
item.allow_export = True
assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is True
item.allow_export = False
member_role.return_value = "MANAGER"
assert await collaboration_service.can_edit_file(SimpleNamespace(), item, user) is True
assert await collaboration_service.can_manage_file(SimpleNamespace(), item, user) is True
assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is True
member_role.return_value = None
item.owner_id = user.id
assert await collaboration_service.can_edit_file(SimpleNamespace(), item, user) is True
assert await collaboration_service.can_manage_file(SimpleNamespace(), item, user) is True
assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is True
assert await collaboration_service.can_transfer_ownership(SimpleNamespace(), item, user) is True
@pytest.mark.asyncio
async def test_project_permissions_only_control_role_assignment_eligibility(monkeypatch):
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
membership = SimpleNamespace(is_active=True, role_in_study="CTA")
permission_check = AsyncMock(
side_effect=lambda _db, _study_id, _role, permission: permission in {
"collaboration:read",
"collaboration:edit",
}
)
monkeypatch.setattr(collaboration_service, "role_has_api_permission", permission_check)
assert await collaboration_service._role_is_assignable(
SimpleNamespace(), uuid.uuid4(), user, membership, "EDITOR"
) is True
assert await collaboration_service._role_is_assignable(
SimpleNamespace(), uuid.uuid4(), user, membership, "MANAGER"
) is False
with pytest.raises(HTTPException) as exc_info:
await collaboration_service._require_role_assignable(
SimpleNamespace(), uuid.uuid4(), user, membership, "MANAGER"
)
assert exc_info.value.status_code == 422
assert "不具备管理者或所有者资格" in exc_info.value.detail
@pytest.mark.asyncio
async def test_manager_assignment_rejects_an_unqualified_project_member(monkeypatch):
target_id = uuid.uuid4()
item = SimpleNamespace(id=uuid.uuid4(), study_id=uuid.uuid4(), owner_id=uuid.uuid4())
actor = SimpleNamespace(id=uuid.uuid4())
target = SimpleNamespace(id=target_id, is_admin=False)
membership = SimpleNamespace(is_active=True, role_in_study="CTA")
db = SimpleNamespace(get=AsyncMock(return_value=target))
role_check = AsyncMock(side_effect=HTTPException(status_code=422, detail="资格不足"))
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership))
monkeypatch.setattr(collaboration_service, "_require_role_assignable", role_check)
with pytest.raises(HTTPException) as exc_info:
await collaboration_service.upsert_member(
db,
item,
SimpleNamespace(user_id=target_id, role="MANAGER"),
actor,
)
assert exc_info.value.status_code == 422
role_check.assert_awaited_once_with(db, item.study_id, target, membership, "MANAGER")
@pytest.mark.asyncio
async def test_ownership_transfer_rejects_an_unqualified_new_owner(monkeypatch):
target_id = uuid.uuid4()
item = SimpleNamespace(id=uuid.uuid4(), study_id=uuid.uuid4(), owner_id=uuid.uuid4())
target = SimpleNamespace(id=target_id, is_admin=False)
membership = SimpleNamespace(is_active=True, role_in_study="CTA")
db = SimpleNamespace(
scalar=AsyncMock(return_value=item),
get=AsyncMock(return_value=target),
)
role_check = AsyncMock(side_effect=HTTPException(status_code=422, detail="资格不足"))
monkeypatch.setattr(collaboration_service, "require_ownership_transfer", AsyncMock())
monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership))
monkeypatch.setattr(collaboration_service, "_require_role_assignable", role_check)
with pytest.raises(HTTPException) as exc_info:
await collaboration_service.transfer_ownership(
db,
item,
SimpleNamespace(new_owner_id=target_id),
SimpleNamespace(id=item.owner_id),
)
assert exc_info.value.status_code == 422
role_check.assert_awaited_once_with(db, item.study_id, target, membership, "MANAGER")
@pytest.mark.asyncio
async def test_workspace_save_as_also_requires_create_permission(monkeypatch):
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
item = SimpleNamespace(study_id=uuid.uuid4())
membership = SimpleNamespace(is_active=True, role_in_study="CRA")
permission_check = AsyncMock(return_value=False)
monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership))
monkeypatch.setattr(collaboration_service, "role_has_api_permission", permission_check)
assert await collaboration_service.can_create_file(SimpleNamespace(), item, user) is False
permission_check.assert_awaited_once_with(
ANY, item.study_id, membership.role_in_study, "collaboration:create"
)
permission_check.return_value = True
assert await collaboration_service.can_create_file(SimpleNamespace(), item, user) is True
@pytest.mark.asyncio
async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypatch, tmp_path):
source = tmp_path / "source.xlsx"
content = collaboration_service.blank_file_bytes("cell")
source.write_bytes(content)
revision = SimpleNamespace(file_uri=str(source))
item = SimpleNamespace(
id=uuid.uuid4(),
study_id=uuid.uuid4(),
folder_id=uuid.uuid4(),
title="研究数据.xlsx",
file_type="cell",
extension="xlsx",
current_revision_id=uuid.uuid4(),
)
target = SimpleNamespace(id=uuid.uuid4(), title="研究数据 - 副本.xlsx")
db = SimpleNamespace(get=AsyncMock(return_value=revision))
user = SimpleNamespace(id=uuid.uuid4())
require_exporter = AsyncMock()
create_copy = AsyncMock(return_value=target)
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
monkeypatch.setattr(collaboration_service, "_next_copy_title", AsyncMock(return_value=target.title))
monkeypatch.setattr(collaboration_service, "_create_file_from_bytes", create_copy)
result = await collaboration_service.copy_file(db, item, user)
assert result is target
require_exporter.assert_awaited_once_with(db, item, user)
create_copy.assert_awaited_once_with(
db,
item.study_id,
title=target.title,
file_type="cell",
folder_id=item.folder_id,
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@pytest.mark.asyncio
async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path):
source = tmp_path / "source.docx"
source.write_bytes(collaboration_service.blank_file_bytes("word"))
revision = SimpleNamespace(
id=uuid.uuid4(),
revision_no=3,
file_uri=str(source),
mime_type=collaboration_service.MIME_TYPES["docx"],
)
item = SimpleNamespace(id=uuid.uuid4(), current_revision_id=revision.id)
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_exporter = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
monkeypatch.setattr(collaboration_service, "_audit", audit)
result = await collaboration_service.prepare_download(db, item, user)
assert result is revision
require_exporter.assert_awaited_once_with(db, item, user)
audit.assert_awaited_once_with(
db,
item,
"COLLABORATION_FILE_DOWNLOADED",
user,
{"revision_id": str(revision.id), "revision_no": 3},
)
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_file_read_includes_information_and_export_capability(monkeypatch):
now = datetime.now(timezone.utc)
owner_id = uuid.uuid4()
folder_id = uuid.uuid4()
revision_id = uuid.uuid4()
item = CollaborationFile(
id=uuid.uuid4(),
study_id=uuid.uuid4(),
folder_id=folder_id,
title="研究方案.docx",
file_type="word",
extension="docx",
status="ACTIVE",
owner_id=owner_id,
current_revision_id=revision_id,
generation=2,
allow_export=False,
allow_edit_request=False,
allow_sheet_structure_edit=True,
created_at=now,
updated_at=now,
)
owner = SimpleNamespace(full_name="文件所有者")
folder = CollaborationFolder(id=folder_id, name="方案", study_id=item.study_id, created_by=owner_id)
revision = CollaborationRevision(
id=revision_id,
file_id=item.id,
revision_no=4,
file_uri="/hidden/revision.docx",
original_filename=item.title,
file_hash="hash",
file_size=4096,
mime_type=collaboration_service.MIME_TYPES["docx"],
source="SESSION_CLOSE",
created_at=now,
)
async def get_model(model, identifier):
if model is CollaborationFolder and identifier == folder_id:
return folder
if model is CollaborationRevision and identifier == revision_id:
return revision
return owner
db = SimpleNamespace(get=AsyncMock(side_effect=get_model))
user = SimpleNamespace(id=owner_id, is_admin=False)
monkeypatch.setattr(collaboration_service, "_member_role", AsyncMock(return_value="MANAGER"))
monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=True))
monkeypatch.setattr(collaboration_service, "can_manage_file", AsyncMock(return_value=True))
monkeypatch.setattr(collaboration_service, "can_export_file", AsyncMock(return_value=True))
monkeypatch.setattr(collaboration_service, "edit_request_status", AsyncMock(return_value=None))
monkeypatch.setattr(collaboration_service, "can_request_edit_file", AsyncMock(return_value=False))
monkeypatch.setattr(collaboration_service, "can_transfer_ownership", AsyncMock(return_value=True))
result = await collaboration_service._file_read(db, item, user, collaborators=[])
assert result.folder_name == "方案"
assert result.current_revision_no == 4
assert result.current_revision_file_size == 4096
assert result.current_revision_mime_type == collaboration_service.MIME_TYPES["docx"]
assert result.current_revision_created_at == now
assert result.can_export is True
assert result.can_transfer_ownership is True
def test_workbook_structure_policy_locks_and_restores_sheet_operations():
file_id = uuid.uuid4()
original = collaboration_service.blank_file_bytes("cell")
protected, backup = collaboration_service.apply_workbook_structure_policy(
original,
file_id=file_id,
allow_sheet_structure_edit=False,
protection_backup=None,
)
assert backup == ""
with zipfile.ZipFile(io.BytesIO(protected)) as package:
workbook = package.read("xl/workbook.xml").decode("utf-8")
assert "workbookProtection" in workbook
assert 'lockStructure="1"' in workbook
assert "workbookPassword=" in workbook
restored, restored_backup = collaboration_service.apply_workbook_structure_policy(
protected,
file_id=file_id,
allow_sheet_structure_edit=True,
protection_backup=backup,
)
assert restored_backup is None
with zipfile.ZipFile(io.BytesIO(restored)) as package:
workbook = package.read("xl/workbook.xml").decode("utf-8")
assert "workbookProtection" not in workbook
def test_collaboration_models_do_not_reference_attachment_or_document_tables():
foreign_keys = {
foreign_key.target_fullname
for table in (
CollaborationFile.__table__,
)
for foreign_key in table.foreign_keys
}
assert all(not target.startswith("attachments.") for target in foreign_keys)
assert all(not target.startswith("documents.") for target in foreign_keys)
assert all(not target.startswith("document_versions.") for target in foreign_keys)
@pytest.mark.asyncio
async def test_collaborator_summaries_are_loaded_in_one_batch_for_the_file_list():
first_file_id = uuid.uuid4()
second_file_id = uuid.uuid4()
first_member = SimpleNamespace(
file_id=first_file_id,
user_id=uuid.uuid4(),
role="MANAGER",
)
second_member = SimpleNamespace(
file_id=second_file_id,
user_id=uuid.uuid4(),
role="EDITOR",
)
first_user = SimpleNamespace(full_name="文件所有者", avatar_url="/avatars/owner.png")
second_user = SimpleNamespace(full_name="协作编辑者", avatar_url=None)
query_result = SimpleNamespace(all=lambda: [(first_member, first_user), (second_member, second_user)])
db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
result = await collaboration_service._collaborators_by_file(db, [first_file_id, second_file_id])
db.execute.assert_awaited_once()
assert result[first_file_id][0].full_name == "文件所有者"
assert result[first_file_id][0].avatar_url == "/avatars/owner.png"
assert result[second_file_id][0].role == "EDITOR"
class _CallbackDb:
def __init__(self, session, item):
self._scalar_results = iter((session, None))
self._item = item
self.added = []
async def scalar(self, _statement):
return next(self._scalar_results)
async def get(self, model, _identifier):
return self._item if model is CollaborationFile else None
def add(self, value):
self.added.append(value)
async def commit(self):
return None
@pytest.mark.asyncio
async def test_callback_error_status_is_recorded_and_acknowledged():
session = SimpleNamespace(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
document_key="ctms-collab-error-key",
generation=1,
status="ACTIVE",
active_users=None,
last_callback_at=None,
)
item = SimpleNamespace(id=session.file_id, generation=1)
db = _CallbackDb(session, item)
result = await onlyoffice_collaboration_service.process_callback(
db,
session.id,
CollaborationCallbackPayload(key=session.document_key, status=3),
)
assert result == {"error": 0}
assert session.status == "ERROR"
assert db.added[0].result == "ERROR"
@pytest.mark.asyncio
async def test_force_save_updates_session_recovery_revision(monkeypatch):
session = SimpleNamespace(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
document_key="ctms-collab-force-save-key",
generation=2,
status="ACTIVE",
active_users=None,
last_callback_at=None,
base_revision_id=uuid.uuid4(),
)
item = SimpleNamespace(id=session.file_id, generation=2)
actor = SimpleNamespace(id=uuid.uuid4())
revision = SimpleNamespace(id=uuid.uuid4(), revision_no=4)
db = _CallbackDb(session, item)
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"saved"))
monkeypatch.setattr(onlyoffice_collaboration_service, "_callback_user", AsyncMock(return_value=actor))
monkeypatch.setattr(
onlyoffice_collaboration_service.collaboration_service,
"append_revision",
AsyncMock(return_value=(revision, True)),
)
monkeypatch.setattr(
onlyoffice_collaboration_service.collaboration_service,
"_audit",
AsyncMock(),
)
result = await onlyoffice_collaboration_service.process_callback(
db,
session.id,
CollaborationCallbackPayload(
key=session.document_key,
status=6,
url="http://onlyoffice/cache/force-save.docx",
users=[str(actor.id)],
),
)
assert result == {"error": 0}
assert session.base_revision_id == revision.id
assert session.status == "ACTIVE"
assert db.added[0].revision_id == revision.id