mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-28 22:03:46 +08:00
Connect PostgreSQL services to the API, add collection package import/export, and surface task activity in the workbench. 将 PostgreSQL 服务接入 API,增加合集资源包导入导出,并在工作台展示任务活动状态。
150 lines
7.2 KiB
Python
150 lines
7.2 KiB
Python
"""合集 ZIP v1 格式、导出完整性和导入安全预检单元测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from backend.collection_packages import (
|
|
CollectionManifest,
|
|
CollectionManifestCollection,
|
|
CollectionManifestMember,
|
|
CollectionPackageError,
|
|
build_export_archive,
|
|
cleanup_archive,
|
|
parse_manifest,
|
|
preflight_archive,
|
|
resolve_import_filename,
|
|
serialize_manifest,
|
|
)
|
|
from backend.database import BlobStore, ScopeContext
|
|
|
|
|
|
def png_bytes(color: str = "red") -> bytes:
|
|
"""生成可解码的最小 PNG,供资源包单元测试复用。"""
|
|
output = io.BytesIO()
|
|
Image.new("RGB", (3, 3), color=color).save(output, format="PNG")
|
|
return output.getvalue()
|
|
|
|
|
|
def package_bytes(manifest: CollectionManifest, images: dict[str, bytes]) -> bytes:
|
|
"""按照资源包根目录规则构造内存 ZIP。"""
|
|
output = io.BytesIO()
|
|
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive:
|
|
archive.writestr("manifest.json", serialize_manifest(manifest))
|
|
for path, content in images.items():
|
|
archive.writestr(path, content)
|
|
return output.getvalue()
|
|
|
|
|
|
def member(*, source_id: str, filename: str, content: bytes) -> CollectionManifestMember:
|
|
"""创建与给定图片内容一致的 manifest 成员。"""
|
|
extension = Path(filename).suffix.lower()
|
|
return CollectionManifestMember(source_meme_id=source_id, filename_at_export=filename, path=f"images/{source_id}{extension}", extension=extension, size_bytes=len(content), sha256=__import__("hashlib").sha256(content).hexdigest())
|
|
|
|
|
|
def test_manifest_roundtrip_preserves_unicode_and_empty_collection() -> None:
|
|
"""空合集和 Unicode 名称可以稳定序列化并往返。"""
|
|
manifest = CollectionManifest(format="mememeow-collection", format_version=1, collection=CollectionManifestCollection(name="猫猫 · 工作"), members=[])
|
|
assert parse_manifest(serialize_manifest(manifest)) == manifest
|
|
package = preflight_archive(package_bytes(manifest, {}))
|
|
assert package.manifest.collection.name == "猫猫 · 工作"
|
|
assert package.members == ()
|
|
|
|
|
|
def test_export_writes_manifest_and_cleans_temporary_archive(tmp_path: Path) -> None:
|
|
"""导出归档包含可校验 manifest,清理函数删除文件和临时目录。"""
|
|
store = BlobStore(root=tmp_path / "images", scope=ScopeContext("local"), local=True)
|
|
content = png_bytes()
|
|
image = store.root / "疑惑.png"
|
|
image.write_bytes(content)
|
|
meme = SimpleNamespace(id=uuid4(), storage_key="疑惑.png", extension=".png", size_bytes=len(content), sha256=__import__("hashlib").sha256(content).hexdigest())
|
|
export_dir = tmp_path / ".collection-export-test"
|
|
archive_path = build_export_archive("我的合集", [meme], store, temp_root=export_dir)
|
|
package = preflight_archive(archive_path.read_bytes())
|
|
assert package.manifest.collection.name == "我的合集"
|
|
assert package.members[0].manifest.filename_at_export == "疑惑.png"
|
|
assert package.members[0].content == content
|
|
cleanup_archive(archive_path)
|
|
assert not archive_path.exists()
|
|
cleanup_archive(export_dir)
|
|
|
|
|
|
def test_preflight_rejects_path_attacks_duplicate_entries_and_bad_content() -> None:
|
|
"""预检拒绝路径穿越、重复条目、哈希错误和不可解码图片。"""
|
|
content = png_bytes()
|
|
valid_member = member(source_id="source-1", filename="safe.png", content=content)
|
|
manifest = CollectionManifest(format="mememeow-collection", format_version=1, collection=CollectionManifestCollection(name="安全"), members=[valid_member])
|
|
attacked = io.BytesIO()
|
|
with zipfile.ZipFile(attacked, "w") as archive:
|
|
archive.writestr("manifest.json", serialize_manifest(manifest))
|
|
archive.writestr("../outside.png", content)
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(attacked.getvalue())
|
|
assert error.value.code == "invalid_zip_path"
|
|
|
|
duplicate = io.BytesIO()
|
|
with zipfile.ZipFile(duplicate, "w") as archive:
|
|
archive.writestr("manifest.json", serialize_manifest(manifest))
|
|
archive.writestr(valid_member.path, content)
|
|
archive.writestr(valid_member.path, content)
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(duplicate.getvalue())
|
|
assert error.value.code == "duplicate_zip_entry"
|
|
|
|
bad_member = valid_member.model_copy(update={"sha256": "0" * 64})
|
|
bad_manifest = manifest.model_copy(update={"members": [bad_member]})
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(package_bytes(bad_manifest, {valid_member.path: content}))
|
|
assert error.value.code == "sha256_mismatch"
|
|
|
|
mismatched_path = valid_member.model_copy(update={"path": "images/another.png"})
|
|
mismatched_manifest = manifest.model_copy(update={"members": [mismatched_path]})
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(package_bytes(mismatched_manifest, {mismatched_path.path: content}))
|
|
assert error.value.code == "invalid_zip_path"
|
|
|
|
|
|
def test_preflight_rejects_unknown_format_and_resource_limits() -> None:
|
|
"""未知格式、单文件和总大小限制在业务写入前失败。"""
|
|
content = png_bytes()
|
|
manifest = CollectionManifest(format="other", format_version=1, collection=CollectionManifestCollection(name="无效"), members=[])
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(package_bytes(manifest, {}))
|
|
assert error.value.code == "unsupported_package_version"
|
|
|
|
one = member(source_id="large", filename="large.png", content=content)
|
|
large_manifest = CollectionManifest(format="mememeow-collection", format_version=1, collection=CollectionManifestCollection(name="限制"), members=[one])
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(package_bytes(large_manifest, {one.path: content}), max_file_size=len(content) - 1)
|
|
assert error.value.code == "file_too_large"
|
|
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(package_bytes(large_manifest, {one.path: content}), max_total_size=len(content) - 1)
|
|
assert error.value.code == "package_too_large"
|
|
|
|
archive = package_bytes(large_manifest, {one.path: content})
|
|
with zipfile.ZipFile(io.BytesIO(archive)) as zipped:
|
|
total_size = sum(info.file_size for info in zipped.infolist())
|
|
with pytest.raises(CollectionPackageError) as error:
|
|
preflight_archive(archive, max_total_size=total_size - 1)
|
|
assert error.value.code == "package_too_large"
|
|
|
|
|
|
def test_filename_conflicts_use_sha_prefix_and_reuse_same_content() -> None:
|
|
"""同名同 SHA 复用,异 SHA 从八位哈希前缀开始递增。"""
|
|
old = SimpleNamespace(id=uuid4(), sha256="a" * 64)
|
|
digest = "b" * 64
|
|
reused = resolve_import_filename("猫.png", old.sha256, {"猫.png": old})
|
|
assert reused.filename == "猫.png" and reused.existing_meme is old
|
|
first_conflict = f"猫-{digest[:8]}.png"
|
|
resolved = resolve_import_filename("猫.png", digest, {"猫.png": old, first_conflict: SimpleNamespace(id=uuid4(), sha256="c" * 64)})
|
|
assert resolved.filename == f"猫-{digest[:16]}.png"
|