Files
imagefind/tests/test_backups.py
T

712 lines
30 KiB
Python

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())