feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration

- Migrate SQLModel base classes, mixins, and database management to
  external sqlmodel-ext package; remove sqlmodels/base/, sqlmodels/mixin/,
  and sqlmodels/database.py
- Add file viewer/editor system with WOPI protocol support for
  collaborative editing (OnlyOffice, Collabora)
- Add enterprise edition license verification module (ee/)
- Add Dockerfile multi-stage build with Cython compilation support
- Add new dependencies: sqlmodel-ext, cryptography, whatthepatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 14:23:17 +08:00
parent 53b757de7a
commit eac0766e79
74 changed files with 4819 additions and 4837 deletions

View File

@@ -0,0 +1,386 @@
"""
FileApp 模型单元测试
测试 FileApp、FileAppExtension、UserFileAppDefault 的 CRUD 和约束。
"""
from uuid import UUID
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodels.file_app import (
FileApp,
FileAppExtension,
FileAppGroupLink,
FileAppType,
UserFileAppDefault,
)
from sqlmodels.group import Group
from sqlmodels.user import User, UserStatus
from sqlmodels.policy import Policy, PolicyType
# ==================== Fixtures ====================
@pytest_asyncio.fixture
async def sample_group(db_session: AsyncSession) -> Group:
"""创建测试用户组"""
group = Group(name="测试组", max_storage=0, admin=False)
return await group.save(db_session)
@pytest_asyncio.fixture
async def sample_user(db_session: AsyncSession, sample_group: Group) -> User:
"""创建测试用户"""
user = User(
email="fileapp_test@test.local",
nickname="文件应用测试用户",
status=UserStatus.ACTIVE,
group_id=sample_group.id,
)
return await user.save(db_session)
@pytest_asyncio.fixture
async def sample_app(db_session: AsyncSession) -> FileApp:
"""创建测试文件应用"""
app = FileApp(
name="测试PDF阅读器",
app_key="test_pdfjs",
type=FileAppType.BUILTIN,
icon="file-pdf",
description="测试用 PDF 阅读器",
is_enabled=True,
is_restricted=False,
)
return await app.save(db_session)
@pytest_asyncio.fixture
async def sample_app_with_extensions(db_session: AsyncSession, sample_app: FileApp) -> FileApp:
"""创建带扩展名的文件应用"""
ext1 = FileAppExtension(app_id=sample_app.id, extension="pdf", priority=0)
ext2 = FileAppExtension(app_id=sample_app.id, extension="djvu", priority=1)
await ext1.save(db_session)
await ext2.save(db_session)
return sample_app
# ==================== FileApp CRUD ====================
class TestFileAppCRUD:
"""FileApp 基础 CRUD 测试"""
async def test_create_file_app(self, db_session: AsyncSession) -> None:
"""测试创建文件应用"""
app = FileApp(
name="Monaco 编辑器",
app_key="monaco",
type=FileAppType.BUILTIN,
description="代码编辑器",
is_enabled=True,
)
app = await app.save(db_session)
assert app.id is not None
assert app.name == "Monaco 编辑器"
assert app.app_key == "monaco"
assert app.type == FileAppType.BUILTIN
assert app.is_enabled is True
assert app.is_restricted is False
async def test_get_file_app_by_key(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试按 app_key 查询"""
found = await FileApp.get(db_session, FileApp.app_key == "test_pdfjs")
assert found is not None
assert found.id == sample_app.id
async def test_unique_app_key(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试 app_key 唯一约束"""
dup = FileApp(
name="重复应用",
app_key="test_pdfjs",
type=FileAppType.BUILTIN,
)
with pytest.raises(IntegrityError):
await dup.save(db_session)
async def test_update_file_app(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试更新文件应用"""
sample_app.name = "更新后的名称"
sample_app.is_enabled = False
sample_app = await sample_app.save(db_session)
found = await FileApp.get(db_session, FileApp.id == sample_app.id)
assert found.name == "更新后的名称"
assert found.is_enabled is False
async def test_delete_file_app(self, db_session: AsyncSession) -> None:
"""测试删除文件应用"""
app = FileApp(
name="待删除应用",
app_key="to_delete",
type=FileAppType.IFRAME,
)
app = await app.save(db_session)
app_id = app.id
await FileApp.delete(db_session, app)
found = await FileApp.get(db_session, FileApp.id == app_id)
assert found is None
async def test_create_wopi_app(self, db_session: AsyncSession) -> None:
"""测试创建 WOPI 类型应用"""
app = FileApp(
name="Collabora",
app_key="collabora",
type=FileAppType.WOPI,
wopi_discovery_url="http://collabora:9980/hosting/discovery",
wopi_editor_url_template="http://collabora:9980/loleaflet/dist/loleaflet.html?WOPISrc={wopi_src}&access_token={access_token}",
is_enabled=True,
)
app = await app.save(db_session)
assert app.type == FileAppType.WOPI
assert app.wopi_discovery_url is not None
assert app.wopi_editor_url_template is not None
async def test_create_iframe_app(self, db_session: AsyncSession) -> None:
"""测试创建 iframe 类型应用"""
app = FileApp(
name="Office 在线预览",
app_key="office_viewer",
type=FileAppType.IFRAME,
iframe_url_template="https://view.officeapps.live.com/op/embed.aspx?src={file_url}",
is_enabled=False,
)
app = await app.save(db_session)
assert app.type == FileAppType.IFRAME
assert "{file_url}" in app.iframe_url_template
async def test_to_summary(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试转换为摘要 DTO"""
summary = sample_app.to_summary()
assert summary.id == sample_app.id
assert summary.name == sample_app.name
assert summary.app_key == sample_app.app_key
assert summary.type == sample_app.type
# ==================== FileAppExtension ====================
class TestFileAppExtension:
"""FileAppExtension 测试"""
async def test_create_extension(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试创建扩展名关联"""
ext = FileAppExtension(
app_id=sample_app.id,
extension="pdf",
priority=0,
)
ext = await ext.save(db_session)
assert ext.id is not None
assert ext.extension == "pdf"
assert ext.priority == 0
async def test_query_by_extension(
self, db_session: AsyncSession, sample_app_with_extensions: FileApp
) -> None:
"""测试按扩展名查询"""
results: list[FileAppExtension] = await FileAppExtension.get(
db_session,
FileAppExtension.extension == "pdf",
fetch_mode="all",
)
assert len(results) >= 1
assert any(r.app_id == sample_app_with_extensions.id for r in results)
async def test_unique_app_extension(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试 (app_id, extension) 唯一约束"""
ext1 = FileAppExtension(app_id=sample_app.id, extension="txt", priority=0)
await ext1.save(db_session)
ext2 = FileAppExtension(app_id=sample_app.id, extension="txt", priority=1)
with pytest.raises(IntegrityError):
await ext2.save(db_session)
async def test_cascade_delete(
self, db_session: AsyncSession, sample_app_with_extensions: FileApp
) -> None:
"""测试级联删除:删除应用时扩展名也被删除"""
app_id = sample_app_with_extensions.id
# 确认扩展名存在
exts = await FileAppExtension.get(
db_session,
FileAppExtension.app_id == app_id,
fetch_mode="all",
)
assert len(exts) == 2
# 删除应用
await FileApp.delete(db_session, sample_app_with_extensions)
# 确认扩展名也被删除
exts = await FileAppExtension.get(
db_session,
FileAppExtension.app_id == app_id,
fetch_mode="all",
)
assert len(exts) == 0
# ==================== FileAppGroupLink ====================
class TestFileAppGroupLink:
"""FileAppGroupLink 用户组访问控制测试"""
async def test_create_group_link(
self, db_session: AsyncSession, sample_app: FileApp, sample_group: Group
) -> None:
"""测试创建用户组关联"""
link = FileAppGroupLink(app_id=sample_app.id, group_id=sample_group.id)
db_session.add(link)
await db_session.commit()
result = await db_session.exec(
select(FileAppGroupLink).where(
FileAppGroupLink.app_id == sample_app.id,
FileAppGroupLink.group_id == sample_group.id,
)
)
found = result.first()
assert found is not None
async def test_multiple_groups(self, db_session: AsyncSession, sample_app: FileApp) -> None:
"""测试一个应用关联多个用户组"""
group1 = Group(name="组A", admin=False)
group1 = await group1.save(db_session)
group2 = Group(name="组B", admin=False)
group2 = await group2.save(db_session)
db_session.add(FileAppGroupLink(app_id=sample_app.id, group_id=group1.id))
db_session.add(FileAppGroupLink(app_id=sample_app.id, group_id=group2.id))
await db_session.commit()
result = await db_session.exec(
select(FileAppGroupLink).where(FileAppGroupLink.app_id == sample_app.id)
)
links = result.all()
assert len(links) == 2
# ==================== UserFileAppDefault ====================
class TestUserFileAppDefault:
"""UserFileAppDefault 用户偏好测试"""
async def test_create_default(
self, db_session: AsyncSession, sample_app: FileApp, sample_user: User
) -> None:
"""测试创建用户默认偏好"""
default = UserFileAppDefault(
user_id=sample_user.id,
extension="pdf",
app_id=sample_app.id,
)
default = await default.save(db_session)
assert default.id is not None
assert default.extension == "pdf"
async def test_unique_user_extension(
self, db_session: AsyncSession, sample_app: FileApp, sample_user: User
) -> None:
"""测试 (user_id, extension) 唯一约束"""
default1 = UserFileAppDefault(
user_id=sample_user.id, extension="pdf", app_id=sample_app.id
)
await default1.save(db_session)
# 创建另一个应用
app2 = FileApp(
name="另一个阅读器",
app_key="pdf_alt",
type=FileAppType.BUILTIN,
)
app2 = await app2.save(db_session)
default2 = UserFileAppDefault(
user_id=sample_user.id, extension="pdf", app_id=app2.id
)
with pytest.raises(IntegrityError):
await default2.save(db_session)
async def test_cascade_delete_on_app(
self, db_session: AsyncSession, sample_user: User
) -> None:
"""测试级联删除:删除应用时用户偏好也被删除"""
app = FileApp(
name="待删除应用2",
app_key="to_delete_2",
type=FileAppType.BUILTIN,
)
app = await app.save(db_session)
app_id = app.id
default = UserFileAppDefault(
user_id=sample_user.id, extension="xyz", app_id=app_id
)
await default.save(db_session)
# 确认存在
found = await UserFileAppDefault.get(
db_session, UserFileAppDefault.app_id == app_id
)
assert found is not None
# 删除应用
await FileApp.delete(db_session, app)
# 确认用户偏好也被删除
found = await UserFileAppDefault.get(
db_session, UserFileAppDefault.app_id == app_id
)
assert found is None
# ==================== DTO ====================
class TestFileAppDTO:
"""DTO 模型测试"""
async def test_file_app_response_from_app(
self, db_session: AsyncSession, sample_app_with_extensions: FileApp, sample_group: Group
) -> None:
"""测试 FileAppResponse.from_app()"""
from sqlmodels.file_app import FileAppResponse
extensions = await FileAppExtension.get(
db_session,
FileAppExtension.app_id == sample_app_with_extensions.id,
fetch_mode="all",
)
# 直接构造 link 对象用于 DTO 测试,无需持久化
link = FileAppGroupLink(
app_id=sample_app_with_extensions.id,
group_id=sample_group.id,
)
response = FileAppResponse.from_app(
sample_app_with_extensions, extensions, [link]
)
assert response.id == sample_app_with_extensions.id
assert response.app_key == "test_pdfjs"
assert "pdf" in response.extensions
assert "djvu" in response.extensions
assert sample_group.id in response.allowed_group_ids

View File

@@ -113,7 +113,7 @@ async def test_setting_update_value(db_session: AsyncSession):
setting = await setting.save(db_session)
# 更新值
from sqlmodels.base import SQLModelBase
from sqlmodel_ext import SQLModelBase
class SettingUpdate(SQLModelBase):
value: str | None = None

View File

@@ -0,0 +1,178 @@
"""
文本文件 patch 逻辑单元测试
测试 whatthepatch 库的 patch 解析与应用,
以及换行符规范化和 SHA-256 哈希计算。
"""
import hashlib
import pytest
import whatthepatch
from whatthepatch.exceptions import HunkApplyException
class TestPatchApply:
"""测试 patch 解析与应用"""
def test_normal_patch(self) -> None:
"""正常 patch 应用"""
original = "line1\nline2\nline3"
patch_text = (
"--- a\n"
"+++ b\n"
"@@ -1,3 +1,3 @@\n"
" line1\n"
"-line2\n"
"+LINE2\n"
" line3\n"
)
diffs = list(whatthepatch.parse_patch(patch_text))
assert len(diffs) == 1
result = whatthepatch.apply_diff(diffs[0], original)
new_text = '\n'.join(result)
assert "LINE2" in new_text
assert "line2" not in new_text
def test_add_lines_patch(self) -> None:
"""添加行的 patch"""
original = "line1\nline2"
patch_text = (
"--- a\n"
"+++ b\n"
"@@ -1,2 +1,3 @@\n"
" line1\n"
" line2\n"
"+line3\n"
)
diffs = list(whatthepatch.parse_patch(patch_text))
result = whatthepatch.apply_diff(diffs[0], original)
new_text = '\n'.join(result)
assert "line3" in new_text
def test_delete_lines_patch(self) -> None:
"""删除行的 patch"""
original = "line1\nline2\nline3"
patch_text = (
"--- a\n"
"+++ b\n"
"@@ -1,3 +1,2 @@\n"
" line1\n"
"-line2\n"
" line3\n"
)
diffs = list(whatthepatch.parse_patch(patch_text))
result = whatthepatch.apply_diff(diffs[0], original)
new_text = '\n'.join(result)
assert "line2" not in new_text
assert "line1" in new_text
assert "line3" in new_text
def test_invalid_patch_format(self) -> None:
"""无效的 patch 格式返回空列表"""
diffs = list(whatthepatch.parse_patch("this is not a patch"))
assert len(diffs) == 0
def test_patch_context_mismatch(self) -> None:
"""patch 上下文不匹配时抛出异常"""
original = "line1\nline2\nline3\n"
patch_text = (
"--- a\n"
"+++ b\n"
"@@ -1,3 +1,3 @@\n"
" line1\n"
"-WRONG\n"
"+REPLACED\n"
" line3\n"
)
diffs = list(whatthepatch.parse_patch(patch_text))
with pytest.raises(HunkApplyException):
whatthepatch.apply_diff(diffs[0], original)
def test_empty_file_patch(self) -> None:
"""空文件应用 patch"""
original = ""
patch_text = (
"--- a\n"
"+++ b\n"
"@@ -0,0 +1,2 @@\n"
"+line1\n"
"+line2\n"
)
diffs = list(whatthepatch.parse_patch(patch_text))
result = whatthepatch.apply_diff(diffs[0], original)
new_text = '\n'.join(result)
assert "line1" in new_text
assert "line2" in new_text
class TestHashComputation:
"""测试 SHA-256 哈希计算"""
def test_hash_consistency(self) -> None:
"""相同内容产生相同哈希"""
content = "hello world\n"
content_bytes = content.encode('utf-8')
hash1 = hashlib.sha256(content_bytes).hexdigest()
hash2 = hashlib.sha256(content_bytes).hexdigest()
assert hash1 == hash2
assert len(hash1) == 64
def test_hash_differs_for_different_content(self) -> None:
"""不同内容产生不同哈希"""
hash1 = hashlib.sha256(b"content A").hexdigest()
hash2 = hashlib.sha256(b"content B").hexdigest()
assert hash1 != hash2
def test_hash_after_normalization(self) -> None:
"""换行符规范化后的哈希一致性"""
content_crlf = "line1\r\nline2\r\n"
content_lf = "line1\nline2\n"
# 规范化后应相同
normalized = content_crlf.replace('\r\n', '\n').replace('\r', '\n')
assert normalized == content_lf
hash_normalized = hashlib.sha256(normalized.encode('utf-8')).hexdigest()
hash_lf = hashlib.sha256(content_lf.encode('utf-8')).hexdigest()
assert hash_normalized == hash_lf
class TestLineEndingNormalization:
"""测试换行符规范化"""
def test_crlf_to_lf(self) -> None:
"""CRLF 转换为 LF"""
content = "line1\r\nline2\r\n"
normalized = content.replace('\r\n', '\n').replace('\r', '\n')
assert normalized == "line1\nline2\n"
def test_cr_to_lf(self) -> None:
"""CR 转换为 LF"""
content = "line1\rline2\r"
normalized = content.replace('\r\n', '\n').replace('\r', '\n')
assert normalized == "line1\nline2\n"
def test_lf_unchanged(self) -> None:
"""LF 保持不变"""
content = "line1\nline2\n"
normalized = content.replace('\r\n', '\n').replace('\r', '\n')
assert normalized == content
def test_mixed_line_endings(self) -> None:
"""混合换行符统一为 LF"""
content = "line1\r\nline2\rline3\n"
normalized = content.replace('\r\n', '\n').replace('\r', '\n')
assert normalized == "line1\nline2\nline3\n"

View File

@@ -0,0 +1,77 @@
"""
WOPI Token 单元测试
测试 WOPI 访问令牌的生成和验证。
"""
from uuid import uuid4
import pytest
import utils.JWT as JWT
from utils.JWT.wopi_token import create_wopi_token, verify_wopi_token
# 确保测试 secret key
JWT.SECRET_KEY = "test_secret_key_for_jwt_token_generation"
class TestWopiToken:
"""WOPI Token 测试"""
def test_create_and_verify_token(self) -> None:
"""创建和验证令牌"""
file_id = uuid4()
user_id = uuid4()
token, ttl = create_wopi_token(file_id, user_id, can_write=True)
assert isinstance(token, str)
assert isinstance(ttl, int)
assert ttl > 0
payload = verify_wopi_token(token)
assert payload is not None
assert payload.file_id == file_id
assert payload.user_id == user_id
assert payload.can_write is True
def test_verify_read_only_token(self) -> None:
"""验证只读令牌"""
file_id = uuid4()
user_id = uuid4()
token, ttl = create_wopi_token(file_id, user_id, can_write=False)
payload = verify_wopi_token(token)
assert payload is not None
assert payload.can_write is False
def test_verify_invalid_token(self) -> None:
"""验证无效令牌返回 None"""
payload = verify_wopi_token("invalid_token_string")
assert payload is None
def test_verify_non_wopi_token(self) -> None:
"""验证非 WOPI 类型令牌返回 None"""
import jwt as pyjwt
# 创建一个不含 type=wopi 的令牌
token = pyjwt.encode(
{"file_id": str(uuid4()), "user_id": str(uuid4()), "type": "download"},
JWT.SECRET_KEY,
algorithm="HS256",
)
payload = verify_wopi_token(token)
assert payload is None
def test_ttl_is_future_milliseconds(self) -> None:
"""TTL 应为未来的毫秒时间戳"""
import time
file_id = uuid4()
user_id = uuid4()
token, ttl = create_wopi_token(file_id, user_id)
current_ms = int(time.time() * 1000)
# TTL 应大于当前时间
assert ttl > current_ms
# TTL 不应超过 11 小时后10h + 余量)
assert ttl < current_ms + 11 * 3600 * 1000