712 lines
30 KiB
Python
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())
|