Files
ctms/backend/tests/test_ledgers.py
T
chengchengzhou7 6c45cef4b7
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
同步全局协作台账与工作台界面优化
工作台新增与项目平级的全局协作台账,复用共享库在线协作能力。两本台账使用合同台账明细表、临床运营项目编号的空白模板,支持独立账号授权、在线编辑和历史版本保留,禁止删除整个台账及历史版本。

台账管理拆分为信息维护、访问与权限、历史版本三个独立入口;后两者与项目共享组件。统一项目和台账的卡片高度、数量徽标与标题布局,缩小工作台顶部留白,优化信息维护弹窗,并修复空用途说明和版本命名的默认提示。同步现有飞线图白色残影修复,以及在线文档的字体构建支持。

已验证:前端五百五十四项测试、后端台账与在线文档七十四项测试、类型检查、界面约束、运行时边界、桌面发布静态检查、网页构建和本地桌面应用构建均通过。数据库表结构升级已完成离线脚本生成检查,存储持久化检查通过。桌面凭据测试显式隔离构建环境,并覆盖使用默认服务器地址读取凭据的场景;在注入默认服务器地址的环境中完成全部前端测试。

提交包含必需的表结构升级和只有表头的初始模板;不包含本地台账业务记录、数据库导出、账号授权运行数据或上传目录中的历史文件。字体文件沿用部署方单独提供的方式。
2026-09-04 08:42:21 +08:00

464 lines
26 KiB
Python

import io
import uuid
import zipfile
from datetime import timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import DateTime, TypeDecorator
from conftest import _create_test_engine
from app.api.v1.ledgers import router
from app.core.config import settings
from app.core.deps import get_current_user, get_db_session
from app.models.collaboration import CollaborationCallbackReceipt, CollaborationFile, CollaborationRevision, CollaborationSession, CollaborationShareLink
from app.models.user import User, UserStatus
from app.schemas.collaboration import CollaborationCallbackPayload
from app.schemas.ledger import LedgerMemberUpsert, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_service as ledgers, onlyoffice_collaboration_service as office, onlyoffice_service
class SQLiteUTCDateTime(TypeDecorator):
"""Preserve the PostgreSQL UTC timestamp contract in the SQLite fixture."""
impl = DateTime(timezone=True)
cache_ok = True
def process_result_value(self, value, dialect):
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
@pytest_asyncio.fixture
async def env(monkeypatch, tmp_path):
for column in CollaborationShareLink.__table__.columns:
if isinstance(column.type, DateTime) and column.type.timezone:
monkeypatch.setattr(column, "type", SQLiteUTCDateTime())
engine = await _create_test_engine()
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
async with AsyncSession(engine, expire_on_commit=False) as db:
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash",
full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
status=UserStatus.ACTIVE) for i in range(4)]
db.add_all(users)
await db.commit()
initial = await ledgers.initialize_ledgers(db, users[0])
app = FastAPI()
app.include_router(router, prefix="/api/v1/ledgers")
identity = SimpleNamespace(user=users[0])
app.dependency_overrides[get_current_user] = lambda: identity.user
app.dependency_overrides[get_db_session] = lambda: db
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield SimpleNamespace(db=db, admin=users[0], editor=users[1], viewer=users[2], outsider=users[3],
initial=initial, identity=identity, client=client, app=app)
await engine.dispose()
async def grant(env, user, role, index=0):
item = await ledgers.get_ledger(env.db, env.initial[index].id, env.admin, manage=True, lock=True)
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=user.id, role=role), env.admin)
return item
@pytest.mark.asyncio
async def test_initialization_preserves_both_uploaded_templates_and_is_idempotent(env):
repeated = await ledgers.initialize_ledgers(env.db, env.admin)
assert [r.id for r in repeated] == [r.id for r in env.initial]
for result, (_, title, filename, _) in zip(repeated, ledgers.LEDGER_TEMPLATES):
item = await env.db.get(CollaborationFile, result.id)
assert item.study_id is None and item.folder_id is None and item.scope == "LEDGER"
assert item.title == title
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(ledgers.TEMPLATE_ROOT / filename) as original, zipfile.ZipFile(revision.file_uri) as saved:
assert original.namelist() == saved.namelist()
for part in original.namelist():
if part != "xl/workbook.xml":
assert original.read(part) == saved.read(part)
assert b'lockStructure="1"' in saved.read("xl/workbook.xml")
assert "ledgers" in Path(revision.file_uri).parts
@pytest.mark.asyncio
async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
await grant(env, env.editor, "EDITOR")
env.identity.user = env.editor
response = await env.client.get("/api/v1/ledgers")
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
assert [row["id"] for row in response.json()] == [str(env.initial[0].id)]
assert response.json()[0]["can_edit"] is True
other = env.initial[1].id
for suffix in ["", "/editor-config", "/revisions", "/members", "/candidates"]:
assert (await env.client.get(f"/api/v1/ledgers/{other}{suffix}")).status_code == 404
assert (await env.client.post("/api/v1/ledgers/initialize")).status_code == 403
env.identity.user = env.outsider
assert (await env.client.get("/api/v1/ledgers")).json() == []
env.app.dependency_overrides.pop(get_current_user)
assert (await env.client.get("/api/v1/ledgers")).status_code == 401
@pytest.mark.asyncio
async def test_editor_and_viewer_share_session_but_receive_distinct_signed_permissions(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
editor = await office.build_editor_config(env.db, item, env.editor)
viewer = await office.build_editor_config(env.db, item, env.viewer)
assert editor.config["document"]["key"] == viewer.config["document"]["key"]
assert editor.access_mode == "edit" and viewer.access_mode == "view"
assert editor.config["document"]["permissions"]["edit"] is True
assert viewer.config["document"]["permissions"]["edit"] is False
assert not editor.can_save_as and not editor.can_request_edit and not editor.can_download
env.identity.user = env.editor
assert (await env.client.get(f"/api/v1/ledgers/{item.id}/members")).status_code == 403
assert (await env.client.post(f"/api/v1/ledgers/{item.id}/downloads", json={"file_type": "xlsx"})).status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.restore_revision(env.db, item, item.current_revision_id, env.editor, None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_neither_editors_nor_admins_can_delete_ledger_or_revision(env):
item = await grant(env, env.editor, "EDITOR")
for user in [env.admin, env.editor]:
env.identity.user = user
assert (await env.client.delete(f"/api/v1/ledgers/{item.id}")).status_code == 405
with pytest.raises(HTTPException) as error:
await collaboration.move_to_trash(env.db, item, user)
assert error.value.status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.delete_revision(env.db, item, item.current_revision_id, user)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_revocation_invalidates_old_callback_before_result_download(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
baseline = item.current_revision_id
download = AsyncMock(return_value=b"should never be downloaded")
monkeypatch.setattr(office, "_download_result", download)
await ledgers.remove_member(env.db, item, env.editor.id, env.admin)
result = await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=config.config["document"]["key"], status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
assert result == {"error": 0} and item.current_revision_id == baseline
download.assert_not_awaited()
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "STALE"
@pytest.mark.asyncio
async def test_saved_callback_is_versioned_and_duplicate_is_idempotent(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
monkeypatch.setattr(office, "_download_result", AsyncMock(return_value=collaboration.blank_file_bytes("cell")))
payload = CollaborationCallbackPayload(key=config.config["document"]["key"], status=6,
url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)])
await office.process_callback(env.db, session.id, payload)
saved = item.current_revision_id
await office.process_callback(env.db, session.id, payload)
assert item.current_revision_id == saved
assert len(await collaboration.list_revisions(env.db, item)) == 2
@pytest.mark.asyncio
async def test_archive_disables_edit_and_stale_settings_are_rejected(env):
item = await grant(env, env.editor, "EDITOR")
payload = LedgerSettings(title=item.title, description="Archived", status="ARCHIVED", owner_id=item.owner_id,
allow_export=False, allow_sheet_structure_edit=False, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
assert not (await ledgers.read_ledger(env.db, item, env.editor)).can_edit
assert (await office.build_editor_config(env.db, item, env.editor)).access_mode == "view"
with pytest.raises(HTTPException) as error:
await ledgers.update_settings(env.db, item, payload, env.admin)
assert error.value.status_code == 409
@pytest.mark.asyncio
async def test_import_and_restore_keep_previous_versions_and_workbook_protection(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
first = item.current_revision_id
upload = UploadFile(filename="import.xlsx", file=io.BytesIO(collaboration.blank_file_bytes("cell")))
await ledgers.import_content(env.db, item, upload, item.generation, env.admin)
second = item.current_revision_id
await ledgers.restore_revision(env.db, item, first, item.generation, env.admin)
assert item.current_revision_id not in {first, second}
assert len(await collaboration.list_revisions(env.db, item)) == 3
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b'lockStructure="1"' in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_inactive_account_cannot_be_granted_or_save_existing_session(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
env.editor.status = UserStatus.DISABLED
await env.db.commit()
with pytest.raises(HTTPException) as error:
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=env.editor.id, role="MANAGER"), env.admin)
assert error.value.status_code == 422
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
download.assert_not_awaited()
@pytest.mark.asyncio
async def test_restore_respects_current_unlocked_workbook_policy(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
initial = item.current_revision_id
payload = LedgerSettings(title=item.title, status="ACTIVE", owner_id=item.owner_id,
allow_sheet_structure_edit=True, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
await ledgers.import_content(env.db, item, UploadFile(filename="blank.xlsx", file=io.BytesIO(
collaboration.blank_file_bytes("cell"))), item.generation, env.admin)
await ledgers.restore_revision(env.db, item, initial, item.generation, env.admin)
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b"workbookProtection" not in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_shared_access_uses_global_candidates_and_protects_implicit_managers(env):
item = await grant(env, env.editor, "EDITOR")
base = f"/api/v1/ledgers/{item.id}"
members = (await env.client.get(f"{base}/members")).json()
owner = next(row for row in members if row["user_id"] == str(env.admin.id))
assert owner["protected_label"] == "所有者" and owner["role"] == "MANAGER"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
candidates = (await env.client.get(f"{base}/candidates")).json()
assert {row["user_id"] for row in candidates} == {str(user.id) for user in [env.admin, env.editor, env.viewer, env.outsider]}
assert all(row["can_be_manager"] and row["can_be_editor"] for row in candidates)
original_title = item.title
result = await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": False})
assert result.status_code == 200 and not result.json()["allow_edit_request"]
assert result.json()["title"] == original_title and not result.json()["allow_export"]
for actor, code in [(env.editor, 403), (env.outsider, 404)]:
env.identity.user = actor
for suffix in ["share-link", "edit-requests", "members", "candidates"]:
assert (await env.client.get(f"{base}/{suffix}")).status_code == code
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})).status_code == code
assert (await env.client.put(f"{base}/share-link", json={"enabled": True})).status_code == code
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == code
@pytest.mark.asyncio
async def test_viewer_edit_request_is_deduplicated_and_approval_grants_editor(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
assert (await env.client.get(base)).json()["can_request_edit"]
request = await env.client.post(f"{base}/edit-requests")
assert request.status_code == 200
request_id = request.json()["id"]
assert (await env.client.post(f"{base}/edit-requests")).json()["id"] == request_id
read = (await env.client.get(base)).json()
assert read["edit_request_status"] == "PENDING" and not read["can_request_edit"]
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 403
generation = item.generation
env.identity.user = env.admin
assert len((await env.client.get(f"{base}/edit-requests")).json()) == 1
result = await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})
assert result.status_code == 200 and result.json()["status"] == "APPROVED"
env.identity.user = env.viewer
read = (await env.client.get(base)).json()
assert read["role"] == "EDITOR" and read["can_edit"] and read["generation"] == generation + 1
assert not read["can_request_edit"]
@pytest.mark.asyncio
async def test_revoked_requester_cannot_regain_access_through_pending_approval(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
request_id = (await env.client.post(f"{base}/edit-requests")).json()["id"]
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
env.identity.user = env.admin
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 422
assert not await collaboration.can_edit_file(env.db, item, env.viewer)
@pytest.mark.asyncio
async def test_ownership_transfer_accepts_system_accounts_and_keeps_previous_owner_access(env):
item = await grant(env, env.editor, "MANAGER")
base = f"/api/v1/ledgers/{item.id}"
env.identity.user = env.editor
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 403
env.identity.user = env.admin
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 200
env.identity.user = env.viewer
result = await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.outsider.id)})
assert result.status_code == 200 and result.json()["owner_id"] == str(env.outsider.id)
assert result.json()["can_manage"] and not result.json()["can_transfer_ownership"]
assert item.study_id is None
members = (await env.client.get(f"{base}/members")).json()
admin = next(row for row in members if row["user_id"] == str(env.admin.id))
assert admin["protected_label"] == "系统管理员"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
async def shared_ledger(env, *, mode="EDIT", password=None):
from app.models.collaboration import CollaborationShareLink
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin)
response = await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={
"enabled": True, "access_mode": mode, "expiry_policy": "SEVEN_DAYS",
"password_mode": "SET" if password else "KEEP", **({"password": password} if password else {}),
})
assert response.status_code == 200
link = await env.db.get(CollaborationShareLink, uuid.UUID(response.json()["id"]))
return item, link, response.json()["share_token"]
@pytest.mark.asyncio
async def test_ledger_share_defaults_closed_and_password_change_revokes_old_credentials(env):
from app.services import collaboration_share_service as shares
base = f"/api/v1/ledgers/{env.initial[0].id}"
initial = (await env.client.get(f"{base}/share-link")).json()
assert not initial["enabled"] and initial["share_token"] is None
item, link, token = await shared_ledger(env, password="test-password")
grant_token = await shares.verify_share_password(env.db, token, "test-password")
shares.validate_access_grant(link, grant_token.access_token)
with pytest.raises(HTTPException) as wrong:
await shares.verify_share_password(env.db, token, "incorrect")
assert wrong.value.status_code == 401
generation = item.generation
await env.client.put(f"{base}/share-link", json={"enabled": True, "password_mode": "SET", "password": "new-password"})
assert item.generation == generation + 1
with pytest.raises(HTTPException):
await shares.resolve_active_share(env.db, token)
with pytest.raises(HTTPException):
shares.validate_access_grant(link, grant_token.access_token)
@pytest.mark.asyncio
async def test_anonymous_ledger_save_keeps_share_identity_and_close_blocks_stale_callback(env, monkeypatch):
item, link, _ = await shared_ledger(env)
config = await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
assert config.access_mode == "edit" and not config.can_save_as and not config.can_download
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
download = AsyncMock(return_value=collaboration.blank_file_bytes("cell"))
monkeypatch.setattr(office, "_download_result", download)
# ONLYOFFICE can send a final save with an empty user list. It must not become an owner save.
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/first.xlsx", users=[],
))
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert revision.source == "SHARE_FORCE_SAVE" and revision.created_by is None
saved = item.current_revision_id
await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={"enabled": False})
download.reset_mock()
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=2, url="http://onlyoffice/after-close.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
with pytest.raises(HTTPException):
await office.build_shared_editor_config(env.db, item, link, client_id="new-client", display_name="访客")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode,expired", [("VIEW", False), ("EDIT", True)])
async def test_anonymous_view_or_expired_link_cannot_save_even_without_callback_users(env, monkeypatch, mode, expired):
from datetime import datetime, timedelta, timezone
item, link, _ = await shared_ledger(env, mode=mode)
await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
if expired:
link.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await env.db.commit()
saved = item.current_revision_id
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/denied.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "ACCESS_REVOKED"
@pytest.mark.asyncio
async def test_ledger_uses_existing_public_share_http_flow_without_a_project(env):
from app.api.v1.collaboration import public_router
env.app.include_router(public_router, prefix="/api/v1/collaboration/shares")
item, _, token = await shared_ledger(env, password="share-password")
env.app.dependency_overrides.pop(get_current_user)
headers = {"X-CTMS-Share-Token": token}
base = "/api/v1/collaboration/shares"
metadata = await env.client.get(f"{base}/metadata", headers=headers)
assert metadata.status_code == 200 and metadata.json()["requires_password"]
body = {"client_id": "test-client", "display_name": "访客"}
assert (await env.client.post(f"{base}/editor-config", headers=headers, json=body)).status_code == 401
access = await env.client.post(f"{base}/access", headers=headers, json={"password": "share-password"})
assert access.status_code == 200
config = await env.client.post(f"{base}/editor-config", headers=headers,
json={**body, "access_token": access.json()["access_token"]})
assert config.status_code == 200 and config.headers["cache-control"] == "no-store"
assert config.json()["file_name"] == item.title and config.json()["access_mode"] == "edit"
assert not config.json()["can_save_as"] and item.study_id is None
@pytest.mark.asyncio
async def test_revision_names_reuse_collaboration_metadata_without_replacing_content(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
revision_id = item.current_revision_id
generation = item.generation
base = f"/api/v1/ledgers/{item.id}/revisions/{revision_id}"
env.identity.user = env.editor
result = await env.client.patch(base, json={"change_summary": "审核提交版"})
assert result.status_code == 200 and result.json()["change_summary"] == "审核提交版"
assert item.current_revision_id == revision_id and item.generation == generation
assert len(await collaboration.list_revisions(env.db, item)) == 1
for actor, status in [(env.viewer, 403), (env.outsider, 404)]:
env.identity.user = actor
assert (await env.client.patch(base, json={"change_summary": "不应保存"})).status_code == status
env.identity.user = env.editor
other_revision = env.initial[1].current_revision_id
assert (await env.client.patch(f"/api/v1/ledgers/{item.id}/revisions/{other_revision}", json={"change_summary": "跨台账"})).status_code == 404
env.identity.user = env.admin
await env.client.patch(f"/api/v1/ledgers/{item.id}", json={
"title": item.title, "status": "ARCHIVED", "owner_id": str(item.owner_id), "generation": item.generation,
})
assert (await env.client.patch(base, json={"change_summary": "归档后"})).status_code == 403
@pytest.mark.asyncio
async def test_history_export_uses_live_export_permission_and_checks_revision_belongs_to_ledger(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
path = f"{base}/revisions/{item.current_revision_id}/download"
for actor in [env.editor, env.viewer]:
env.identity.user = actor
assert (await env.client.get(path)).status_code == 403
env.identity.user = env.admin
response = await env.client.get(path)
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert response.content == Path(revision.file_uri).read_bytes()
await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})
env.identity.user = env.viewer
assert (await env.client.get(path)).status_code == 200
assert (await env.client.get(f"{base}/revisions/{env.initial[1].current_revision_id}/download")).status_code == 404
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
assert (await env.client.get(path)).status_code == 404
env.identity.user = env.outsider
assert (await env.client.get(path)).status_code == 404