import asyncio import json import shutil import sqlite3 import stat from pathlib import Path import httpx import pytest from imagefind.backups import ( HEADER, BackupError, BackupLimitError, BackupNotEmptyError, BackupService, BackupStorageError, ) from imagefind.config import Settings from imagefind.container import Services from imagefind.database import utcnow from imagefind.main import create_app PASSWORD = "a separate backup password" def make_services(path: Path, **overrides) -> Services: settings = Settings( data_dir=path, embedding_backend="hash", scan_interval_seconds=86400, backup_reserve_mb=0, **overrides, ) settings.prepare() return Services(settings) def seed_source(services: Services, root: Path, *, source_id: str = "source-1", enabled: int = 1) -> None: now = utcnow() secret_blob = services.secrets.encrypt_json( {"password": "remote login", "crypt_password": "rclone crypt", "crypt_salt": "rclone salt"} ) with services.db.transaction() as conn: conn.execute( "INSERT INTO sources(id,kind,name,config_json,secret_blob,enabled,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?)", ( source_id, "webdav", "Encrypted AList", json.dumps( { "driver": "alist", "mode": "encrypted", "base_url": "https://alist.example", "root_path": "private", "username": "backup-user", "verify_tls": True, } ), secret_blob, enabled, now, now, ), ) def seed_full(services: Services, media_root: Path) -> None: now = utcnow() media_root.mkdir(parents=True) video_path = media_root / "movie.mp4" video_path.write_bytes(b"video") with services.db.transaction() as conn: conn.execute( "INSERT INTO sources(id,kind,name,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("local-source", "local", "Local", json.dumps({"path": str(media_root)}), now, now), ) conn.execute( "INSERT INTO videos(id,source_id,source_key,display_name,location,size_bytes,fingerprint,duration_ms," "width,height,codec,container,status,available,indexed_fingerprint,basic_fingerprint," "visual_model_version,ocr_model_version,faces_model_version,error,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( "video-1", "local-source", "movie.mp4", "movie.mp4", str(video_path), 5, "old-fingerprint", 42_000, 1920, 1080, "h264", "mp4", "ready", 1, "indexed", "basic", "visual-v1", "ocr-v1", "faces-v1", "old error", now, now, ), ) conn.execute( "INSERT INTO people(id,name,normalized_name,is_named,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("person-1", "Named Person", "named person", 1, now, now), ) conn.execute( "INSERT INTO actors(id,name,aliases_json,person_id,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("actor-1", "Actor", '["Alias"]', "person-1", now, now), ) conn.execute( "INSERT INTO tag_groups(id,name,selection_mode,sort_order,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("group-1", "Genre", "multi", 1, now, now), ) conn.execute( "INSERT INTO tags(id,group_id,name,ai_enabled,ai_method,ai_description,ai_threshold,match_terms_json," "created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", ("tag-1", "group-1", "Favorite", 1, "text", "", 0.5, '["movie"]', now, now), ) conn.execute( "INSERT INTO video_metadata(video_id,title,catalog_code,studio,series,release_date,description,updated_at) " "VALUES(?,?,?,?,?,?,?,?)", ("video-1", "Manual title", "CAT-001", "Studio", "Series", "2026-01-01", "Notes", now), ) conn.execute("INSERT INTO video_actors(video_id,actor_id) VALUES('video-1','actor-1')") conn.execute("INSERT INTO video_tags(video_id,tag_id) VALUES('video-1','tag-1')") conn.execute( "INSERT INTO video_state(video_id,liked,favorited,progress_ms,completed,last_played_at,updated_at) " "VALUES('video-1',1,1,12345,0,?,?)", (now, now), ) conn.execute( "INSERT INTO frames(id,video_id,timestamp_ms,segment_start_ms,segment_end_ms,thumbnail_path,vector_blob," "created_at) VALUES('frame-1','video-1',1000,0,2000,'thumb.webp',?,?)", (b"vector", now), ) conn.execute( "INSERT INTO faces(id,frame_id,video_id,person_id,bbox_json,confidence,vector_blob,created_at) " "VALUES('face-1','frame-1','video-1','person-1','[]',0.9,?,?)", (b"face-vector", now), ) conn.execute( "INSERT INTO text_entries(id,video_id,frame_id,kind,raw_text,tokens,created_at) " "VALUES('text-1','video-1','frame-1','ocr','secret text','secret text',?)", (now,), ) conn.execute("INSERT INTO text_fts(entry_id,tokens) VALUES('text-1','secret text')") conn.execute( "INSERT INTO tag_suggestions(id,video_id,tag_id,confidence,video_fingerprint,tag_revision,created_at," "updated_at) VALUES('suggestion-1','video-1','tag-1',0.9,'old','old',?,?)", (now, now), ) services.db.set_setting( "preferences", { "autoplay": False, "mask_covers": True, "home_video_columns": 1, "upload_paths": {"local-source": "incoming"}, }, ) services.db.set_setting("model_hf_endpoint", "https://hf-mirror.example") services.db.set_setting("pip_index_url", "https://pypi-mirror.example/simple") services.db.set_setting("pytorch_index_url", "https://torch-mirror.example/cpu") services.db.set_setting("profile_nickname", "Backup Administrator") def test_keys_backup_restores_sources_with_a_new_secret_key(tmp_path: Path): source = make_services(tmp_path / "old") source.auth.setup("old administrator password") seed_source(source, tmp_path) source.auth.create_api_token("old token") artifact = source.backups.export("keys", PASSWORD) assert stat.S_IMODE(artifact.path.stat().st_mode) == 0o600 assert list(artifact.temporary_dir.iterdir()) == [artifact.path] target = make_services(tmp_path / "new") target.auth.setup("new administrator password") target.auth.create_api_token("new token") old_secret_key = (source.settings.data_dir / ".secret-key").read_bytes() new_secret_key = (target.settings.data_dir / ".secret-key").read_bytes() assert old_secret_key != new_secret_key result = target.backups.restore(artifact.path, PASSWORD, True) restored = target.sources.get("source-1") assert restored["secrets"] == { "password": "remote login", "crypt_password": "rclone crypt", "crypt_salt": "rclone salt", } assert result["scope"] == "keys" assert len(result["scan_jobs"]) == 1 assert target.auth.login("new administrator password") with target.db.read() as conn: assert conn.execute("SELECT count(*) FROM admin").fetchone()[0] == 1 assert conn.execute("SELECT count(*) FROM api_tokens").fetchone()[0] == 1 assert conn.execute("SELECT count(*) FROM videos").fetchone()[0] == 0 artifact.cleanup() def test_full_backup_keeps_user_metadata_and_excludes_derived_data(tmp_path: Path): media_root = tmp_path / "media" source = make_services(tmp_path / "old") source.auth.setup("old administrator password") seed_full(source, media_root) source.auth.create_api_token("old token") artifact = source.backups.export("full", PASSWORD) target = make_services(tmp_path / "new") target.auth.setup("new administrator password") target.auth.create_api_token("new token") result = target.backups.restore(artifact.path, PASSWORD, True) assert result["scope"] == "full" assert result["counts"]["videos"] == 1 assert result["counts"]["actors"] == 1 with target.db.read() as conn: video = conn.execute("SELECT * FROM videos WHERE id='video-1'").fetchone() assert dict(video) | {} # sqlite.Row remains readable after the assertion block assert video["source_key"] == "movie.mp4" assert video["location"] == "" assert video["size_bytes"] == 0 assert video["status"] == "pending" assert video["available"] == 0 assert video["indexed_fingerprint"] is None assert video["basic_fingerprint"] is None assert video["visual_model_version"] is None assert video["ocr_model_version"] is None assert video["faces_model_version"] is None metadata = conn.execute("SELECT * FROM video_metadata WHERE video_id='video-1'").fetchone() assert metadata["title"] == "Manual title" state = conn.execute("SELECT * FROM video_state WHERE video_id='video-1'").fetchone() assert (state["liked"], state["favorited"], state["progress_ms"]) == (1, 1, 12345) actor = conn.execute("SELECT * FROM actors WHERE id='actor-1'").fetchone() assert actor["person_id"] is None assert conn.execute("SELECT count(*) FROM video_actors").fetchone()[0] == 1 assert conn.execute("SELECT count(*) FROM video_tags").fetchone()[0] == 1 for table in ("frames", "text_entries", "people", "faces", "tag_suggestions"): assert conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 0 assert conn.execute("SELECT count(*) FROM api_tokens").fetchone()[0] == 1 assert target.db.setting("preferences")["mask_covers"] is True assert target.db.setting("preferences")["home_video_columns"] == 1 assert target.db.setting("model_hf_endpoint") == "https://hf-mirror.example" assert target.db.setting("pip_index_url") == "https://pypi-mirror.example/simple" assert target.db.setting("pytorch_index_url") == "https://torch-mirror.example/cpu" assert target.db.setting("profile_nickname") == "Backup Administrator" assert target.settings.model_hf_endpoint == "https://hf-mirror.example" assert target.settings.pip_index_url == "https://pypi-mirror.example/simple" assert target.settings.pytorch_index_url == "https://torch-mirror.example/cpu" artifact.cleanup() def test_full_backup_restores_collection_membership_cover_and_order(tmp_path: Path): source = make_services(tmp_path / "old") seed_full(source, tmp_path / "media") now = utcnow() with source.db.transaction() as conn: conn.execute( "INSERT INTO collections(id,name,description,cover_video_id,created_at,updated_at) " "VALUES(?,?,?,?,?,?)", ("collection-1", "Weekend", "Saved for the weekend", "video-1", now, now), ) conn.execute( "INSERT INTO collection_videos(collection_id,video_id,position,added_at) VALUES(?,?,?,?)", ("collection-1", "video-1", 7, now), ) conn.execute( "INSERT INTO collection_items(id,collection_id,parent_id,kind,name,video_id,position," "created_at,updated_at) " "VALUES('chapter-1','collection-1',NULL,'group','第一章',NULL,0,?,?)", (now, now), ) conn.execute( "INSERT INTO collection_items(id,collection_id,parent_id,kind,name,video_id,position," "created_at,updated_at) " "VALUES('chapter-video','collection-1','chapter-1','video',NULL,'video-1',0,?,?)", (now, now), ) conn.execute( "INSERT INTO collection_tags(collection_id,tag_id,created_at) VALUES(?,?,?)", ("collection-1", "tag-1", now), ) conn.execute( "INSERT INTO video_markers(id,video_id,position_ms,title,created_at,updated_at) " "VALUES(?,?,?,?,?,?)", ("marker-1", "video-1", 12_345, "精彩片段", now, now), ) conn.execute( "INSERT INTO video_tombstones(source_id,source_key,video_id,display_name,source_deleted,deleted_at) " "VALUES(?,?,?,?,?,?)", ("local-source", "removed.mp4", "removed-video", "removed.mp4", 0, now), ) source.db.set_setting( "webdav_server", {"enabled": True, "source_id": "local-source", "relative_path": "WebDAV 上传"}, ) artifact = source.backups.export("full", PASSWORD) target = make_services(tmp_path / "new") result = target.backups.restore(artifact.path, PASSWORD, True) assert result["counts"]["collections"] == 1 assert result["counts"]["collection_videos"] == 1 assert result["counts"]["collection_items"] == 2 assert result["counts"]["collection_tags"] == 1 assert result["counts"]["video_markers"] == 1 assert result["counts"]["video_tombstones"] == 1 with target.db.read() as conn: collection = conn.execute("SELECT * FROM collections WHERE id='collection-1'").fetchone() membership = conn.execute( "SELECT * FROM collection_videos WHERE collection_id='collection-1' AND video_id='video-1'" ).fetchone() assert collection["name"] == "Weekend" assert collection["description"] == "Saved for the weekend" assert collection["cover_video_id"] == "video-1" assert membership["position"] == 7 restored_item = conn.execute( "SELECT parent_id,kind,video_id FROM collection_items WHERE id='chapter-video'" ).fetchone() assert dict(restored_item) == { "parent_id": "chapter-1", "kind": "video", "video_id": "video-1", } assert conn.execute( "SELECT tag_id FROM collection_tags WHERE collection_id='collection-1'" ).fetchone()[0] == "tag-1" marker = conn.execute("SELECT * FROM video_markers WHERE id='marker-1'").fetchone() assert marker["position_ms"] == 12_345 assert marker["title"] == "精彩片段" tombstone = conn.execute( "SELECT * FROM video_tombstones WHERE source_id='local-source' AND source_key='removed.mp4'" ).fetchone() assert tombstone["video_id"] == "removed-video" assert tombstone["source_deleted"] == 0 assert target.db.setting("webdav_server") == { "enabled": True, "source_id": "local-source", "relative_path": "WebDAV 上传", } artifact.cleanup() @pytest.mark.parametrize("mutation", ["password", "tampered", "truncated", "version"]) def test_invalid_encrypted_backup_never_changes_target(tmp_path: Path, mutation: str): source = make_services(tmp_path / f"old-{mutation}") seed_source(source, tmp_path) artifact = source.backups.export("keys", PASSWORD) candidate = tmp_path / f"{mutation}.ifbackup" data = bytearray(artifact.path.read_bytes()) password = PASSWORD if mutation == "password": password = "the incorrect backup password" elif mutation == "tampered": data[HEADER.size + 1] ^= 1 elif mutation == "truncated": data = data[:-20] else: data[8:10] = (999).to_bytes(2, "big") candidate.write_bytes(data) target = make_services(tmp_path / f"new-{mutation}") target.auth.setup("new administrator password") with pytest.raises(BackupError): target.backups.restore(candidate, password, True) assert target.backups.status()["empty"] is True assert target.auth.login("new administrator password") artifact.cleanup() def corrupt_logical_backup( service: BackupService, artifact_path: Path, destination: Path, statement: str, ) -> None: work = destination.parent / f"work-{destination.stem}" work.mkdir() compressed = work / "payload.gz" logical = work / "logical.sqlite3" recompressed = work / "changed.gz" service._decrypt(artifact_path, compressed, PASSWORD) service._decompress(compressed, logical) connection = sqlite3.connect(logical) connection.execute(statement) connection.commit() connection.close() service._compress(logical, recompressed) service._encrypt(recompressed, destination, PASSWORD) shutil.rmtree(work) def downgrade_to_v1_logical_backup( service: BackupService, artifact_path: Path, destination: Path, ) -> None: work = destination.parent / f"work-{destination.stem}" work.mkdir() compressed = work / "payload.gz" logical = work / "logical.sqlite3" recompressed = work / "legacy.gz" service._decrypt(artifact_path, compressed, PASSWORD) service._decompress(compressed, logical) connection = sqlite3.connect(logical) counts = json.loads(connection.execute("SELECT counts_json FROM manifest WHERE id=1").fetchone()[0]) counts.pop("collections") counts.pop("collection_videos") counts.pop("collection_items") counts.pop("collection_tags") counts.pop("video_markers") counts.pop("video_tombstones") connection.execute("DROP TABLE video_tombstones") connection.execute("DROP TABLE collection_tags") connection.execute("DROP TABLE video_markers") connection.execute("DROP TABLE collection_items") connection.execute("DROP TABLE collection_videos") connection.execute("DROP TABLE collections") connection.execute( "CREATE TABLE videos_v1(id TEXT PRIMARY KEY,source_id TEXT NOT NULL REFERENCES sources(id) " "ON DELETE CASCADE,source_key TEXT NOT NULL,display_name TEXT NOT NULL,created_at TEXT NOT NULL," "updated_at TEXT NOT NULL,UNIQUE(source_id,source_key))" ) connection.execute( "INSERT INTO videos_v1 SELECT id,source_id,source_key,display_name,created_at,updated_at FROM videos" ) connection.execute("DROP TABLE videos") connection.execute("ALTER TABLE videos_v1 RENAME TO videos") connection.execute( "UPDATE manifest SET logical_schema_version=1,counts_json=? WHERE id=1", (json.dumps(counts, sort_keys=True),), ) connection.commit() connection.close() service._compress(logical, recompressed) service._encrypt(recompressed, destination, PASSWORD) shutil.rmtree(work) def test_v1_full_backup_migrates_legacy_series_to_collection(tmp_path: Path): source = make_services(tmp_path / "old") seed_full(source, tmp_path / "media") artifact = source.backups.export("full", PASSWORD) legacy = tmp_path / "legacy-v1.ifbackup" downgrade_to_v1_logical_backup(source.backups, artifact.path, legacy) target = make_services(tmp_path / "new") result = target.backups.restore(legacy, PASSWORD, True) assert result["counts"].get("collections") is None with target.db.read() as conn: collection = conn.execute("SELECT id,name FROM collections").fetchone() membership = conn.execute("SELECT collection_id,video_id,position FROM collection_videos").fetchone() assert collection["name"] == "Series" assert membership["collection_id"] == collection["id"] assert membership["video_id"] == "video-1" assert membership["position"] == 0 artifact.cleanup() @pytest.mark.parametrize( "statement", [ "UPDATE videos SET source_id='missing-source'", "UPDATE manifest SET counts_json='{}'", ], ) def test_logical_relationship_or_count_corruption_is_rejected_atomically(tmp_path: Path, statement: str): source = make_services(tmp_path / "old") seed_full(source, tmp_path / "media") artifact = source.backups.export("full", PASSWORD) corrupted = tmp_path / f"corrupted-{abs(hash(statement))}.ifbackup" corrupt_logical_backup(source.backups, artifact.path, corrupted, statement) target = make_services(tmp_path / "new") target.auth.setup("new administrator password") with pytest.raises(BackupError): target.backups.restore(corrupted, PASSWORD, True) assert target.backups.status()["empty"] is True artifact.cleanup() def test_nonempty_system_limits_storage_warning_and_scan_relink(tmp_path: Path, monkeypatch): media_root = tmp_path / "media" source = make_services(tmp_path / "old") seed_full(source, media_root) artifact = source.backups.export("full", PASSWORD) nonempty = make_services(tmp_path / "nonempty") seed_source(nonempty, tmp_path, source_id="existing") with pytest.raises(BackupNotEmptyError): nonempty.backups.restore(artifact.path, PASSWORD, True) limited = make_services(tmp_path / "limited", backup_upload_gb=0.0000001) with pytest.raises(BackupLimitError): limited.backups.restore(artifact.path, PASSWORD, True) extract_limited = make_services(tmp_path / "extract-limited", backup_extract_gb=0.000001) with pytest.raises(BackupLimitError): extract_limited.backups.restore(artifact.path, PASSWORD, True) target = make_services(tmp_path / "new") result = target.backups.restore(artifact.path, PASSWORD, True) assert result["warnings"] == [] target.scanner.scan(result["scan_jobs"][0]["job_id"], "local-source") with target.db.read() as conn: video = conn.execute("SELECT id,available,location FROM videos WHERE source_key='movie.mp4'").fetchone() assert video["id"] == "video-1" assert video["available"] == 1 assert video["location"] == str(media_root / "movie.mp4") monkeypatch.setattr(shutil, "disk_usage", lambda _path: shutil._ntuple_diskusage(100, 100, 0)) with pytest.raises(BackupStorageError): source.backups.ensure_free_space(1) artifact.cleanup() def test_restore_database_transaction_rolls_back_after_partial_insert(tmp_path: Path, monkeypatch): source = make_services(tmp_path / "old") seed_full(source, tmp_path / "media") artifact = source.backups.export("full", PASSWORD) target = make_services(tmp_path / "new") target.auth.setup("current administrator password") target.auth.create_api_token("current token") original = target.backups._insert_rows def fail_during_metadata(source_conn, destination_conn, table): if table == "actors": raise sqlite3.IntegrityError("injected transaction failure") return original(source_conn, destination_conn, table) monkeypatch.setattr(target.backups, "_insert_rows", fail_during_metadata) with pytest.raises(sqlite3.IntegrityError, match="injected"): target.backups.restore(artifact.path, PASSWORD, True) assert target.backups.status()["empty"] is True assert target.auth.login("current administrator password") with target.db.read() as conn: assert conn.execute("SELECT count(*) FROM sources").fetchone()[0] == 0 assert conn.execute("SELECT count(*) FROM tags").fetchone()[0] == 0 assert conn.execute("SELECT count(*) FROM api_tokens").fetchone()[0] == 1 artifact.cleanup() def test_unavailable_local_source_restores_with_warning(tmp_path: Path): source = make_services(tmp_path / "old") now = utcnow() with source.db.transaction() as conn: conn.execute( "INSERT INTO sources(id,kind,name,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("missing-local", "local", "Missing disk", json.dumps({"path": str(tmp_path / "missing")}), now, now), ) artifact = source.backups.export("keys", PASSWORD) target = make_services(tmp_path / "new") result = target.backups.restore(artifact.path, PASSWORD, True) assert "路径暂不可用" in result["warnings"][0] artifact.cleanup() def test_backup_api_download_restore_and_nonempty_conflict(tmp_path: Path): source_settings = Settings( data_dir=tmp_path / "source-api", embedding_backend="hash", scan_interval_seconds=86400, backup_reserve_mb=0, ) source_settings.prepare() source_app = create_app(source_settings) seed_source(source_app.state.services, tmp_path) async def export_scenario() -> bytes: transport = httpx.ASGITransport(app=source_app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: setup = await client.post("/api/v1/setup", json={"password": "source administrator"}) csrf = setup.json()["csrf_token"] status = await client.get("/api/v1/backups/status") assert status.status_code == 200 assert status.json()["empty"] is False response = await client.post( "/api/v1/backups/export", headers={"X-CSRF-Token": csrf}, json={"scope": "keys", "password": PASSWORD}, ) assert response.status_code == 200 assert response.headers["content-type"].startswith("application/vnd.imagefind.backup") assert "imagefind-backup-" in response.headers["content-disposition"] assert response.headers["x-imagefind-backup-scope"] == "keys" return response.content payload = asyncio.run(export_scenario()) target_settings = Settings( data_dir=tmp_path / "target-api", embedding_backend="hash", scan_interval_seconds=86400, backup_reserve_mb=0, ) target_settings.prepare() target_app = create_app(target_settings) async def restore_scenario() -> None: transport = httpx.ASGITransport(app=target_app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: setup = await client.post("/api/v1/setup", json={"password": "target administrator"}) csrf = setup.json()["csrf_token"] status = await client.get("/api/v1/backups/status") assert status.json()["can_restore"] is True restored = await client.post( "/api/v1/backups/restore", headers={"X-CSRF-Token": csrf}, files={"file": ("portable.ifbackup", payload, "application/octet-stream")}, data={"password": PASSWORD, "confirmed": "true"}, ) assert restored.status_code == 200 assert restored.json()["counts"]["sources"] == 1 again = await client.post( "/api/v1/backups/restore", headers={"X-CSRF-Token": csrf}, files={"file": ("portable.ifbackup", payload, "application/octet-stream")}, data={"password": PASSWORD, "confirmed": "true"}, ) assert again.status_code == 409 asyncio.run(restore_scenario()) def test_async_backup_api_tracks_job_resource_download_and_clears_secret(tmp_path: Path, monkeypatch): settings = Settings( data_dir=tmp_path / "async-backup-api", embedding_backend="hash", scan_interval_seconds=86400, backup_reserve_mb=0, ) settings.prepare() app = create_app(settings) seed_source(app.state.services, tmp_path) async def scenario() -> None: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: setup = await client.post("/api/v1/setup", json={"password": "source administrator"}) csrf = setup.json()["csrf_token"] queued = await client.post( "/api/v1/backups", headers={"X-CSRF-Token": csrf}, json={"scope": "keys", "password": PASSWORD}, ) assert queued.status_code == 202 export_id = queued.json()["id"] job_id = queued.json()["job_id"] with app.state.services.db.read() as conn: row = conn.execute( "SELECT status,secret_blob FROM backup_exports WHERE id=?", (export_id,) ).fetchone() resource = conn.execute( "SELECT resource_type,resource_id FROM job_resources WHERE job_id=?", (job_id,) ).fetchone() assert row["status"] == "queued" assert row["secret_blob"] and PASSWORD not in row["secret_blob"] assert tuple(resource) == ("backup", export_id) app.state.services.backups.run_export(job_id, export_id) records = await client.get("/api/v1/backups") completed = next(item for item in records.json() if item["id"] == export_id) assert completed["status"] == "completed" assert completed["size_bytes"] > 0 assert "secret_blob" not in completed download = await client.get(f"/api/v1/backups/{export_id}/download") assert download.status_code == 200 assert download.content.startswith(b"IFBACKUP") assert download.headers["content-length"] == str(len(download.content)) failed = await client.post( "/api/v1/backups", headers={"X-CSRF-Token": csrf}, json={"scope": "keys", "password": PASSWORD}, ) failed_id = failed.json()["id"] failed_job = failed.json()["job_id"] def fail_export(*_args, **_kwargs): raise BackupError("injected asynchronous export failure") monkeypatch.setattr(app.state.services.backups, "export", fail_export) with pytest.raises(BackupError, match="injected"): app.state.services.backups.run_export(failed_job, failed_id) with app.state.services.db.read() as conn: failed_row = conn.execute( "SELECT status,secret_blob,error FROM backup_exports WHERE id=?", (failed_id,) ).fetchone() assert failed_row["status"] == "failed" assert failed_row["secret_blob"] is None assert "injected" in failed_row["error"] unavailable = await client.get(f"/api/v1/backups/{failed_id}/download") assert unavailable.status_code == 409 cancelled = app.state.services.backups.queue_export("keys", PASSWORD) app.state.services.jobs.request_cancel(cancelled["job_id"]) with app.state.services.db.read() as conn: cancelled_row = conn.execute( "SELECT status,secret_blob,error FROM backup_exports WHERE id=?", (cancelled["id"],) ).fetchone() assert cancelled_row["status"] == "failed" assert cancelled_row["secret_blob"] is None assert cancelled_row["error"] == "备份任务已取消" asyncio.run(scenario())