"""任务删除安全回归:真实媒体副本验证用户目录与任务私有目录的归属边界。""" import shutil from pathlib import Path import pytest from fastapi.testclient import TestClient from wov_app.config import STORAGE_DIR from wov_app.main import app from wov_app.db import Database from wov_app.maintenance import OrphanCleaner @pytest.fixture def media_library(tmp_path): """复制已有真实视频与字幕,任何删除只作用于测试临时目录。""" assets = Path(__file__).resolve().parent.parent / "testdata" sources = [assets / "subtitle_10s.mp4", assets / "ocr_srt_run_ac7f480a3ccb.srt"] if not all(source.is_file() for source in sources): pytest.skip("缺少真实视频或字幕测试素材") library = tmp_path / "media" library.mkdir() files = [library / "movie.mp4", library / "movie.CN.srt"] for source, target in zip(sources, files): shutil.copy2(source, target) return files, [path.read_bytes() for path in files] def _create_run(db, run_id, video, source, status): """在真实数据库登记任务,不启动模型或调度器。""" workflow_id = "delete-safety" db.upsert_workflow({"id": workflow_id, "name": "删除安全回归"}) db.create_run({ "id": run_id, "workflow_id": workflow_id, "workflow_version": 1, "source": source, "status": status, "input_uri": str(video), "created_at": "2020-01-01T00:00:00+00:00", "updated_at": "2020-01-01T00:00:00+00:00", }) # 登记可读的真实字幕产物,拒绝删除时应连同记录保留。 db.create_artifact({ "run_id": run_id, "node_id": "ass", "name": "subtitle", "uri": str(video.with_suffix(".CN.srt")), "mime_type": "application/x-subrip", }) @pytest.mark.parametrize("status", ["QUEUED", "RUNNING", "PAUSED", "FAILED", "COMPLETED"]) def test_normal_delete_rejects_batch_run(media_library, status): """普通 DELETE 对所有状态的批量 run 返回 422,保留媒体与关联数据。""" files, contents = media_library run_id = f"run_batch_delete_safety_{status.lower()}" with TestClient(app) as client: db = app.state.db _create_run(db, run_id, files[0], "batch", status) before = db.get_run(run_id) artifacts = db.list_artifacts(run_id) response = client.delete(f"/api/runs/{run_id}") assert response.status_code == 422 assert "批量任务" in response.json()["detail"] assert db.get_run(run_id) == before assert db.list_artifacts(run_id) == artifacts assert [path.read_bytes() for path in files] == contents def test_upload_delete_ignores_external_input_parent(media_library): """历史上传记录即使指向外部视频,也仅清理按 run_id 定位的私有目录。""" files, contents = media_library run_id = "run_external_delete_safety" with TestClient(app) as client: db = app.state.db _create_run(db, run_id, files[0], "upload", "FAILED") private_dirs = [STORAGE_DIR / kind / run_id for kind in ("uploads", "runs")] for directory in private_dirs: directory.mkdir(parents=True) shutil.copy2(files[1], directory / "subtitle.srt") # 同级其他任务目录也不能被扩大范围删除。 sibling = STORAGE_DIR / "uploads" / "run_neighbor_delete_safety" sibling.mkdir(parents=True) shutil.copy2(files[1], sibling / "subtitle.srt") response = client.delete(f"/api/runs/{run_id}") assert response.status_code == 200 assert db.get_run(run_id) is None assert db.list_artifacts(run_id) == [] assert [path.read_bytes() for path in files] == contents assert all(not directory.exists() for directory in private_dirs) assert (sibling / "subtitle.srt").read_bytes() == contents[1] def test_orphan_cleanup_ignores_external_input_parent(media_library, tmp_path): """自动清理过期上传任务时也不能从 input_uri 推导递归删除范围。""" files, contents = media_library db = Database(tmp_path / "orphan.db") run_id = "run_orphan_delete_safety" _create_run(db, run_id, files[0], "upload", "COMPLETED") # 产物确实丢失,符合孤儿清理条件;源视频与旁挂字幕仍是用户数据。 db.delete_run_artifacts(run_id) storage = tmp_path / "private" upload_dir = storage / "uploads" / run_id upload_dir.mkdir(parents=True) shutil.copy2(files[0], upload_dir / "movie.mp4") OrphanCleaner(db, storage).clean_once() assert db.get_run(run_id) is None assert [path.read_bytes() for path in files] == contents assert not upload_dir.exists()