From 469145edd648f35de1318e32913d98e4f89f2038 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Fri, 11 Sep 2026 18:21:01 +0800 Subject: [PATCH] fix(wcd): verify imported stickers and remap production assets --- backend/service/app.py | 20 ++-- backend/tests/test_wcd_import.py | 169 +++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_wcd_import.py diff --git a/backend/service/app.py b/backend/service/app.py index 3eb25a7..d85a506 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -438,7 +438,7 @@ async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateR meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime) real_id = str(meta["asset_id"]) remap[pkg_id] = real_id - file_map[pkg_id] = { + file_map[real_id] = { "path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"), "mime": mime, } @@ -1323,15 +1323,15 @@ def _asset_meta_items() -> list[dict]: def _find_asset_by_sha256(digest: str) -> dict | None: - for meta in _asset_meta_items(): - if meta.get("sha256") == digest: - return meta for meta in _asset_meta_items(): try: ext = _asset_extension_for_mime(str(meta.get("mime_type") or "")) path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}" - if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest: - meta["sha256"] = digest + if not path.exists(): + continue + actual_digest = hashlib.sha256(path.read_bytes()).hexdigest() + if actual_digest == digest: + meta["sha256"] = actual_digest _write_asset_meta(_asset_dir(str(meta["asset_id"])), meta) return meta except Exception: @@ -1528,7 +1528,7 @@ async def update_design_template( return DesignTemplate(**data) -@app.delete("/api/design-templates/{template_id}", status_code=204) +@app.delete("/api/design-templates/{template_id}", status_code=204, response_model=None) def delete_design_template(template_id: str) -> None: template_dir = _design_template_dir(template_id) if not (template_dir / "template.json").exists(): @@ -1790,7 +1790,7 @@ def analyze_asset_line_spacing( return LineSpacingAnalysisSummary(**result.as_dict()) -@app.delete("/api/assets/{asset_id}", status_code=204) +@app.delete("/api/assets/{asset_id}", status_code=204, response_model=None) def delete_asset(asset_id: str) -> None: d = _asset_dir(asset_id) meta = _read_asset_meta(d) @@ -1916,7 +1916,7 @@ async def update_project( return Project(**data) -@app.delete("/api/projects/{project_id}", status_code=204) +@app.delete("/api/projects/{project_id}", status_code=204, response_model=None) def delete_project(project_id: str) -> None: pdir = _project_dir(project_id) path = pdir / "project.json" @@ -2011,7 +2011,7 @@ async def upload_font( return Font(**meta) -@app.delete("/api/fonts/{font_id}", status_code=204) +@app.delete("/api/fonts/{font_id}", status_code=204, response_model=None) def delete_font(font_id: str) -> None: """删除已上传字体(内置默认字体不可删除)。""" if font_id == "__default__": diff --git a/backend/tests/test_wcd_import.py b/backend/tests/test_wcd_import.py new file mode 100644 index 0000000..73de0a6 --- /dev/null +++ b/backend/tests/test_wcd_import.py @@ -0,0 +1,169 @@ +import asyncio +import json +import hashlib +import sys +import zipfile +from io import BytesIO +from pathlib import Path +from types import SimpleNamespace + +from fastapi import UploadFile +from PIL import Image +import pytest + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from service import app # noqa: E402 + + +SVG = ( + '' + '' +) + + +def png_bytes(color=(51, 102, 255)): + buffer = BytesIO() + Image.new("RGB", (1, 1), color).save(buffer, format="PNG") + return buffer.getvalue() + + +@pytest.fixture +def isolated_asset_store(tmp_path, monkeypatch): + assets_dir = tmp_path / "service_assets" + assets_dir.mkdir() + monkeypatch.setattr(app, "ASSETS_DIR", assets_dir) + design_templates_dir = tmp_path / "service_design_templates" + design_templates_dir.mkdir() + monkeypatch.setattr(app, "DESIGN_TEMPLATES_DIR", design_templates_dir) + return assets_dir + + +def asset_dirs(assets_dir): + return sorted(path for path in assets_dir.glob("*/*") if path.is_dir()) + + +def test_duplicate_content_is_reused(isolated_asset_store): + first = app._register_import_asset("first", SVG.encode(), "image/svg+xml") + second = app._register_import_asset("second", SVG.encode(), "image/svg+xml") + + assert first["asset_id"] == second["asset_id"] + assert second["name"] == "first" + assert len(asset_dirs(isolated_asset_store)) == 1 + + +def test_same_name_different_content_gets_unique_assets(isolated_asset_store): + first = app._register_import_asset("same-name", SVG.encode(), "image/svg+xml") + second = app._register_import_asset( + "same-name", SVG.replace("#3366ff", "#ff3366").encode(), "image/svg+xml" + ) + + assert first["asset_id"] != second["asset_id"] + assert len(asset_dirs(isolated_asset_store)) == 2 + assert first["sha256"] != second["sha256"] + + +def test_stale_sha256_meta_does_not_reuse_mismatched_file(isolated_asset_store): + content = SVG.encode() + existing_id = "asset_stale00000000000000000000000000" + existing_dir = isolated_asset_store / existing_id[:2] / existing_id + existing_dir.mkdir(parents=True) + (existing_dir / "asset.svg").write_text('') + (existing_dir / "meta.json").write_text( + json.dumps( + { + "asset_id": existing_id, + "name": "old", + "type": "sticker", + "mime_type": "image/svg+xml", + "sha256": hashlib.sha256(content).hexdigest(), + } + ) + ) + + imported = app._register_import_asset("new", content, "image/svg+xml") + + assert imported["asset_id"] != existing_id + assert len(asset_dirs(isolated_asset_store)) == 2 + + +def test_wcd_production_uses_real_asset_ids(tmp_path, isolated_asset_store, monkeypatch): + manifest = { + "format": "wordcloud-canvas", + "version": 1, + "name": "test-design", + "assets": [{"id": "pkg-1", "name": "sticker", "type": "svg", "mimeType": "image/svg+xml"}], + } + document_json = { + "width": 40, + "height": 20, + "background": "#ffffff", + "layers": [], + "layerFolders": [], + "elements": [ + { + "id": "element-1", + "type": "sticker", + "name": "sticker", + "assetId": "pkg-1", + "x": 5, + "y": 5, + "width": 10, + "height": 10, + "rotation": 0, + "opacity": 1, + } + ], + } + package = BytesIO() + with zipfile.ZipFile(package, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest)) + archive.writestr("document.json", json.dumps(document_json)) + archive.writestr("assets/pkg-1.png", png_bytes()) + + class Paths: + input_dir = tmp_path / "input" + output_dir = tmp_path / "output" + + def prepare_job_dirs(job_id): + Paths.input_dir.mkdir(parents=True) + Paths.output_dir.mkdir(parents=True) + return Paths + + manager = SimpleNamespace( + create_job=lambda: "job-test", + set_artifacts=lambda *args, **kwargs: None, + set_status=lambda *args, **kwargs: None, + ) + original_compose = app._compose_design_png + captured = {} + + def compose(document, file_map, output_path): + captured["document"] = document + captured["file_map"] = dict(file_map) + original_compose(document, file_map, output_path) + + monkeypatch.setattr(app, "manager", manager) + monkeypatch.setattr(app, "storage", SimpleNamespace(prepare_job_dirs=prepare_job_dirs)) + monkeypatch.setattr(app, "_write_order", lambda *args, **kwargs: None) + monkeypatch.setattr(app, "_compose_design_png", compose) + monkeypatch.setattr(app.threading, "Thread", lambda target, **kwargs: SimpleNamespace(start=target)) + + upload = UploadFile(filename="test.wcd", file=BytesIO(package.getvalue())) + asyncio.run(app._create_wcd_job(upload, {})) + + document = captured["document"] + file_map = captured["file_map"] + real_ids = [element["assetId"] for element in document["elements"]] + assert real_ids + assert all(asset_id in file_map for asset_id in real_ids) + assert all(Path(asset_id).is_absolute() is False for asset_id in real_ids) + + png_path = isolated_asset_store.parent / "composed.png" + original_compose(document, file_map, png_path) + + with Image.open(png_path) as image: + assert image.getpixel((10, 10)) == (51, 102, 255)