Files
dodo/tests/test_backup_v2.py
T
bboysoul 4973d56a64
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m17s
refactor: remove manual refresh and search controls
2026-09-19 21:15:23 +08:00

764 lines
33 KiB
Python

import asyncio
import hashlib
import io
import json
import zipfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from datetime import UTC, date, datetime
from pathlib import Path
from uuid import UUID
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.auth import COOKIE_NAME, hash_token
from backend.models import (
BackupImport,
BackupPreflight,
Habit,
HabitLog,
HabitPause,
RecurrenceException,
Session,
Task,
User,
)
from tests.test_mvp_backend import boot
def _zip_entries(content: bytes) -> dict[str, bytes]:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
return {name: archive.read(name) for name in archive.namelist()}
def _make_zip(entries: dict[str, bytes], *, backup_id: str = "11111111-1111-4111-8111-111111111111") -> bytes:
entity_names = {
"folders", "lists", "tasks", "recurrences", "recurrence_exceptions", "habits",
"habit_logs", "habit_pauses", "countdowns", "memos", "attachments",
}
complete_entries = {f"data/{name}.json": b"[]" for name in entity_names}
complete_entries["data/lists.json"] = json.dumps([{
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"name": "Inbox", "is_inbox": True, "position": 0,
}]).encode()
complete_entries.update(entries)
entries = complete_entries
checksums = {name: hashlib.sha256(value).hexdigest() for name, value in entries.items()}
manifest = {
"format": "dodo-backup",
"version": 2,
"backup_id": backup_id,
"entities": {
name: len(json.loads(entries[f"data/{name}.json"])) for name in entity_names
},
"checksums": checksums,
}
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", json.dumps(manifest))
for name, value in entries.items():
archive.writestr(name, value)
return output.getvalue()
def _preflight(client, content: bytes, mode: str = "merge"):
return client.post(
"/api/v1/backup/preflight",
params={"mode": mode},
files={"file": ("backup.zip", content, "application/zip")},
)
def _replace_entities(content: bytes, replacements: dict[str, list[dict]]) -> bytes:
entries = _zip_entries(content)
manifest = json.loads(entries.pop("manifest.json"))
for entity, rows in replacements.items():
name = f"data/{entity}.json"
entries[name] = json.dumps(rows, allow_nan=True).encode()
manifest["entities"][entity] = len(rows)
manifest["checksums"][name] = hashlib.sha256(entries[name]).hexdigest()
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", json.dumps(manifest))
for name, value in entries.items():
archive.writestr(name, value)
return output.getvalue()
def _archive_rows(content: bytes, entity: str) -> list[dict]:
return json.loads(_zip_entries(content)[f"data/{entity}.json"])
def test_zip_v2_round_trip_includes_history_exception_and_attachment_bytes(client, tmp_path):
inbox = boot(client)
from backend.config import get_settings
get_settings().attachment_dir = str(tmp_path / "attachments")
task = client.post(
"/api/v1/tasks",
json={"title": "完整备份任务", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY"}
).json()
habit = client.post(
"/api/v1/habits", json={"name": "备份习惯", "kind": "numeric", "target": 2, "schedule_type": "daily"}
).json()
today = datetime.now(UTC).date().isoformat()
assert client.put(f"/api/v1/habits/{habit['id']}/logs/{today}", json={"value": 2}).status_code == 200
assert client.post(
f"/api/v1/habits/{habit['id']}/pauses",
json={"start_date": "2026-09-18", "end_date": "2026-09-19"},
).status_code == 201
attachment_bytes = b"\x00real attachment bytes\xff"
uploaded = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.bin", attachment_bytes, "application/zip")},
)
assert uploaded.status_code == 201
async def seed_exception():
from backend.db import get_engine
async with AsyncSession(get_engine()) as db:
db.add(RecurrenceException(
template_id=UUID(recurrence["id"]), occurrence_at=datetime(2026, 9, 14, 9, tzinfo=UTC),
title="例外标题", completed=True,
))
await db.commit()
asyncio.run(seed_exception())
exported = client.get("/api/v1/backup/export.zip")
assert exported.status_code == 200
assert exported.headers["content-type"] == "application/zip"
assert "dodo-backup-v2.zip" in exported.headers["content-disposition"]
entries = _zip_entries(exported.content)
manifest = json.loads(entries["manifest.json"])
assert manifest["version"] == 2
assert set(manifest["entities"]) >= {
"folders", "lists", "tasks", "recurrences", "recurrence_exceptions",
"habits", "habit_logs", "habit_pauses", "countdowns", "memos", "attachments",
}
attachment_row = json.loads(entries["data/attachments.json"])[0]
assert entries[attachment_row["archive_path"]] == attachment_bytes
assert "password_hash" not in exported.content.decode("utf-8", errors="ignore")
assert "token_hash" not in exported.content.decode("utf-8", errors="ignore")
merge_preflight = _preflight(client, exported.content)
merged = client.post(
"/api/v1/backup/restore",
json={"preflight_token": merge_preflight.json()["preflight_token"], "mode": "merge"},
)
assert merged.status_code == 200, merged.text
preflight = _preflight(client, exported.content, "replace")
assert preflight.status_code == 200
assert preflight.json()["valid"] is True
restored = client.post(
"/api/v1/backup/restore",
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
)
assert restored.status_code == 200, restored.text
restored_items = client.get("/api/v1/tasks", params={"limit": 100}).json()["items"]
restored_task = next(item for item in restored_items if item["title"] == "完整备份任务")
restored_attachment = client.get(f"/api/v1/tasks/{restored_task['id']}/attachments").json()[0]
assert client.get(f"/api/v1/attachments/{restored_attachment['id']}").content == attachment_bytes
async def assert_history():
from backend.db import get_engine
async with AsyncSession(get_engine()) as db:
assert await db.scalar(select(func.count()).select_from(HabitLog)) == 1
assert await db.scalar(select(func.count()).select_from(HabitPause)) == 1
assert await db.scalar(select(func.count()).select_from(RecurrenceException)) == 1
asyncio.run(assert_history())
def test_merge_same_backup_is_idempotent_via_import_ledger(client):
inbox = boot(client)
client.post("/api/v1/tasks", json={"title": "只导入一次", "list_id": inbox["id"]})
content = client.get("/api/v1/backup/export.zip").content
first_token = _preflight(client, content).json()["preflight_token"]
second_token = _preflight(client, content).json()["preflight_token"]
assert client.post("/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}).status_code == 200
second = client.post(
"/api/v1/backup/restore",
json={"preflight_token": second_token, "mode": "merge"},
)
assert second.status_code == 200
assert second.json()["already_imported"] is True
async def counts():
from backend.db import get_engine
async with AsyncSession(get_engine()) as db:
return (
await db.scalar(select(func.count()).select_from(Task).where(Task.title == "只导入一次")),
await db.scalar(select(func.count()).select_from(BackupImport)),
)
assert asyncio.run(counts()) == (1, 1)
def test_invalid_zip_variants_are_rejected_before_any_write(client):
inbox = boot(client)
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
valid_data = json.dumps([]).encode()
cases = []
cases.append(_make_zip({"../escape": b"x", "data/tasks.json": valid_data}))
duplicate = io.BytesIO()
with zipfile.ZipFile(duplicate, "w") as archive:
archive.writestr("manifest.json", "{}")
archive.writestr("data/tasks.json", "[]")
archive.writestr("data/tasks.json", "[]")
cases.append(duplicate.getvalue())
bad_checksum = _make_zip({"data/tasks.json": valid_data})
entries = _zip_entries(bad_checksum)
manifest = json.loads(entries["manifest.json"])
manifest["checksums"]["data/tasks.json"] = "0" * 64
cases.append(_make_zip({"data/tasks.json": valid_data}, backup_id=manifest["backup_id"]))
# Replace the checksum after helper generation.
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("manifest.json", json.dumps(manifest))
archive.writestr("data/tasks.json", valid_data)
cases[-1] = output.getvalue()
dangling = {"data/tasks.json": json.dumps([{
"id": "22222222-2222-4222-8222-222222222222", "list_id": "missing", "title": "bad"
}]).encode()}
cases.append(_make_zip(dangling))
missing_attachment = {"data/attachments.json": json.dumps([{
"id": "33333333-3333-4333-8333-333333333333",
"task_id": "22222222-2222-4222-8222-222222222222",
"filename": "x", "mime_type": "text/plain", "size": 1,
"archive_path": "attachments/33333333-3333-4333-8333-333333333333/content",
}]).encode()}
cases.append(_make_zip(missing_attachment))
for content in cases:
response = _preflight(client, content)
assert response.status_code == 422
assert response.json()["detail"]["code"].startswith("backup_")
after = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
assert after == before
def test_legacy_json_and_csv_restore_remain_supported(client):
inbox = boot(client)
client.post("/api/v1/tasks", json={"title": "legacy", "list_id": inbox["id"]})
exported_json = client.get("/api/v1/export")
exported_csv = client.get("/api/v1/export.csv")
assert client.post("/api/v1/restore?mode=merge", json=exported_json.json()).status_code == 200
assert client.post(
"/api/v1/restore.csv?mode=merge",
files={"file": ("backup.csv", exported_csv.content, "text/csv")},
).status_code == 200
def test_backup_routes_require_auth_csrf_bind_tokens_and_consume_once(client):
boot(client)
exported = client.get("/api/v1/backup/export.zip")
token = _preflight(client, exported.content).json()["preflight_token"]
anonymous = client.__class__(client.app)
with anonymous:
assert anonymous.get("/api/v1/backup/export.zip").status_code == 401
assert _preflight(anonymous, exported.content).status_code == 401
assert anonymous.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
).status_code == 401
csrf = client.post(
"/api/v1/backup/restore",
json={"preflight_token": token, "mode": "merge"},
headers={"origin": "https://dodo.example", "x-csrf-token": "wrong"},
)
assert csrf.status_code == 403
async def add_other_user_session():
from backend.db import get_engine
async with AsyncSession(get_engine()) as db:
other = User(username="other", password_hash="unused")
db.add(other)
await db.flush()
session_token = "other-user-session-token"
db.add(Session(
token_hash=hash_token(session_token), user_id=other.id,
expires_at=datetime(2099, 1, 1, tzinfo=UTC),
))
await db.commit()
return session_token
other_session = asyncio.run(add_other_user_session())
other = client.__class__(client.app)
with other:
other.cookies.set(COOKIE_NAME, other_session)
wrong_user = other.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
)
assert wrong_user.status_code == 409
restored = client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
)
assert restored.status_code == 200
reused = client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
)
assert reused.status_code == 409
def test_preflight_binds_mode_and_persists_only_staged_metadata(client):
boot(client)
content = client.get("/api/v1/backup/export.zip").content
token = _preflight(client, content, "replace").json()["preflight_token"]
wrong_mode = client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
)
assert wrong_mode.status_code == 409
restored = client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
)
assert restored.status_code == 200
async def assert_persisted_consumption():
from backend.db import get_engine
from backend.models import BackupPreflight
async with AsyncSession(get_engine()) as db:
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None)))
assert row is not None
assert row.consumed_at is not None
assert row.archive_sha256 == hashlib.sha256(content).hexdigest()
asyncio.run(assert_persisted_consumption())
def test_preflight_rejects_upload_over_compressed_limit_without_unbounded_read(client, monkeypatch):
boot(client)
import importlib
router_module = importlib.import_module("backend.backup.router")
monkeypatch.setattr(router_module, "MAX_ARCHIVE_BYTES", 32)
response = _preflight(client, b"x" * 33)
assert response.status_code == 422
assert response.json()["detail"]["code"] == "backup_size_invalid"
def test_quarantine_compensates_value_error_after_first_move(tmp_path, monkeypatch):
from backend.backup import storage
root = tmp_path / "attachments"
root.mkdir()
first = root / "first"
first.write_bytes(b"first")
original = storage.contained_file
def fail_second(storage_root, name):
if name == "bad":
raise ValueError("bad path")
return original(storage_root, name)
monkeypatch.setattr(storage, "contained_file", fail_second)
with pytest.raises(ValueError):
storage.quarantine_files(root, ["first", "bad"], tmp_path / "quarantine")
assert first.read_bytes() == b"first"
def test_preflight_rejects_unique_and_invalid_task_graph_constraints(client):
boot(client)
list_id = "11111111-aaaa-4111-8111-111111111111"
parent_id = "22222222-aaaa-4222-8222-222222222222"
child_id = "33333333-aaaa-4333-8333-333333333333"
base_list = {"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}
cases = [
{"data/lists.json": json.dumps([base_list, {**base_list, "id": "44444444-aaaa-4444-8444-444444444444"}]).encode()},
{"data/lists.json": json.dumps([base_list]).encode(), "data/tasks.json": json.dumps([
{"id": parent_id, "list_id": list_id, "parent_id": child_id, "title": "p", "external_id": "same"},
{"id": child_id, "list_id": list_id, "parent_id": parent_id, "title": "c", "external_id": "same"},
]).encode()},
]
for entries in cases:
response = _preflight(client, _make_zip(entries))
assert response.status_code == 422
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
def test_preflight_rejects_per_user_pending_quota(client, monkeypatch):
boot(client)
import importlib
router_module = importlib.import_module("backend.backup.router")
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
content = client.get("/api/v1/backup/export.zip").content
assert _preflight(client, content).status_code == 200
response = _preflight(client, content)
assert response.status_code == 429
assert response.json()["detail"]["code"] == "backup_preflight_quota"
def test_replace_cleanup_failure_is_retryable_and_not_reported_as_success(client, tmp_path, monkeypatch):
boot(client)
from backend.backup import service
from backend.config import get_settings
get_settings().attachment_dir = str(tmp_path / "attachments")
content = client.get("/api/v1/backup/export.zip").content
token = _preflight(client, content, "replace").json()["preflight_token"]
real_remove = service.remove_quarantine
monkeypatch.setattr(service, "remove_quarantine", lambda _: (_ for _ in ()).throw(OSError("busy")))
response = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"})
assert response.status_code == 500
assert response.json()["detail"]["code"] == "backup_cleanup_pending"
monkeypatch.setattr(service, "remove_quarantine", real_remove)
retried = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"})
assert retried.status_code == 200
assert retried.json()["cleanup_retried"] is True
def test_preflight_rejects_malformed_rows_without_writing_or_leaking_details(client):
boot(client)
malformed = _make_zip({
"data/folders.json": json.dumps([{
"id": "44444444-4444-4444-8444-444444444444",
"name": "bad position",
"position": "not-an-integer",
}]).encode(),
})
preflight = _preflight(client, malformed)
assert preflight.status_code == 422
assert preflight.json()["detail"]["code"] == "backup_entity_invalid"
def test_preflight_rejects_invalid_habit_graph_without_any_write(client):
boot(client)
habit = client.post(
"/api/v1/habits",
json={"name": "基准", "kind": "numeric", "target": 2, "max_value": 4,
"schedule_type": "weekly", "weekdays": [1, 3], "start_date": "2026-09-01"},
).json()
content = client.get("/api/v1/backup/export.zip").content
base_habit = _archive_rows(content, "habits")[0]
base_log = {
"id": "91919191-9191-4191-8191-919191919191", "habit_id": habit["id"],
"day": "2026-09-01", "value": 2, "updated_at": "2026-09-01T08:00:00+00:00",
}
base_pause = {
"id": "92929292-9292-4292-8292-929292929292", "habit_id": habit["id"],
"start_date": "2026-09-10", "end_date": "2026-09-12",
}
invalid_graphs = [
({"habits": [{**base_habit, "kind": "counter"}]}, "kind enum"),
({"habits": [{**base_habit, "kind": "boolean", "target": 2, "max_value": 1}]}, "boolean target"),
({"habits": [{**base_habit, "target": float("inf")}]}, "finite target"),
({"habits": [{**base_habit, "target": 5, "max_value": 4}]}, "numeric range"),
({"habits": [{**base_habit, "schedule_type": "sometimes"}]}, "schedule enum"),
({"habits": [{**base_habit, "weekdays": "99"}]}, "weekday range"),
({"habits": [{**base_habit, "weekdays": "1,1"}]}, "weekday uniqueness"),
({"habits": [{**base_habit, "weekdays": "1, 3"}]}, "weekday storage"),
({"habits": [{**base_habit, "schedule_type": "monthly", "weekdays": None,
"month_days": "0", "interval_days": None}]}, "month range"),
({"habits": [{**base_habit, "schedule_type": "interval", "weekdays": None,
"month_days": None, "interval_days": 0}]}, "interval range"),
({"habits": [{**base_habit, "start_date": "2026-02-30"}]}, "start date"),
({"habit_logs": [{**base_log, "value": float("inf")}]}, "finite log"),
({"habits": [{**base_habit, "kind": "boolean", "target": 1, "max_value": 1}],
"habit_logs": [{**base_log, "value": 2}]}, "boolean log"),
({"habit_logs": [{**base_log, "value": 5}]}, "numeric log max"),
({"habit_logs": [{**base_log, "value": -1}]}, "numeric log minimum"),
({"habit_pauses": [{**base_pause, "start_date": "2026-09-13"}]}, "pause order"),
({"habit_pauses": [base_pause, {
**base_pause, "id": "93939393-9393-4393-8393-939393939393",
"start_date": "2026-09-12", "end_date": "2026-09-14",
}]}, "pause overlap"),
]
async def counts():
from backend.db import get_engine
async with AsyncSession(get_engine()) as db:
values = []
for model in (Habit, HabitLog, HabitPause, BackupPreflight):
values.append(await db.scalar(select(func.count()).select_from(model)))
return tuple(values)
before = asyncio.run(counts())
for replacements, label in invalid_graphs:
response = _preflight(client, _replace_entities(content, replacements), "replace")
assert response.status_code == 422, (label, response.text)
assert response.json()["detail"]["code"] in {
"backup_entity_invalid", "backup_habit_invalid",
}, label
assert asyncio.run(counts()) == before, label
def test_four_habit_schedules_and_history_round_trip(client):
boot(client)
definitions = [
{"name": "每天", "kind": "boolean", "schedule_type": "daily"},
{"name": "每周", "kind": "numeric", "target": 2, "max_value": 4,
"schedule_type": "weekly", "weekdays": [1, 3]},
{"name": "每月", "kind": "numeric", "target": 3, "max_value": 5,
"schedule_type": "monthly", "month_days": [1, 15, 31]},
{"name": "间隔", "kind": "numeric", "target": 1.5, "max_value": 2.5,
"schedule_type": "interval", "interval_days": 3},
]
habits = [client.post("/api/v1/habits", json={**item, "start_date": "2026-09-01"}).json()
for item in definitions]
async def add_history():
from backend.db import get_engine
async with AsyncSession(get_engine()) as db:
for index, habit in enumerate(habits):
db.add(HabitLog(
habit_id=UUID(habit["id"]), day=date(2026, 9, index + 1),
value=1 if habit["kind"] == "boolean" else habit["target"],
))
db.add_all([
HabitPause(habit_id=UUID(habits[1]["id"]), start_date=date(2026, 9, 20),
end_date=date(2026, 9, 21)),
HabitPause(habit_id=UUID(habits[1]["id"]), start_date=date(2026, 9, 23),
end_date=date(2026, 9, 24)),
])
row = await db.get(Habit, UUID(habits[3]["id"]))
row.archived_at = datetime(2026, 9, 30, tzinfo=UTC)
await db.commit()
asyncio.run(add_history())
content = client.get("/api/v1/backup/export.zip").content
expected_habits = _archive_rows(content, "habits")
expected_logs = _archive_rows(content, "habit_logs")
expected_pauses = _archive_rows(content, "habit_pauses")
preflight = _preflight(client, content, "replace")
assert preflight.status_code == 200, preflight.text
restored = client.post(
"/api/v1/backup/restore",
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
)
assert restored.status_code == 200, restored.text
after = client.get("/api/v1/backup/export.zip").content
def stable(rows):
return sorted(
[{key: value for key, value in row.items() if key not in {"created_at", "updated_at"}}
for row in rows], key=lambda row: row["id"]
)
assert stable(_archive_rows(after, "habits")) == stable(expected_habits)
assert stable(_archive_rows(after, "habit_logs")) == stable(expected_logs)
assert stable(_archive_rows(after, "habit_pauses")) == stable(expected_pauses)
def test_merge_rejects_backup_id_reuse_with_different_archive(client):
boot(client)
backup_id = "55555555-5555-4555-8555-555555555555"
first = _make_zip({"data/folders.json": b"[]"}, backup_id=backup_id)
first_token = _preflight(client, first).json()["preflight_token"]
assert client.post(
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
).status_code == 200
changed = _make_zip({"data/folders.json": json.dumps([{
"id": "66666666-6666-4666-8666-666666666666", "name": "different", "position": 0,
}]).encode()}, backup_id=backup_id)
second_token = _preflight(client, changed).json()["preflight_token"]
second = client.post(
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
)
assert second.status_code == 409
assert second.json()["detail"]["code"] == "backup_id_conflict"
def test_replace_rebuilds_backup_identity_ledger_after_prior_merge(client):
boot(client)
backup_id = "77777777-7777-4777-8777-777777777777"
original = _make_zip({"data/folders.json": b"[]"}, backup_id=backup_id)
merge_token = _preflight(client, original, "merge").json()["preflight_token"]
assert client.post(
"/api/v1/backup/restore", json={"preflight_token": merge_token, "mode": "merge"}
).status_code == 200
replace_token = _preflight(client, original, "replace").json()["preflight_token"]
assert client.post(
"/api/v1/backup/restore", json={"preflight_token": replace_token, "mode": "replace"}
).status_code == 200
changed = _make_zip({"data/folders.json": json.dumps([{
"id": "88888888-8888-4888-8888-888888888888", "name": "new entity", "position": 0,
}]).encode()}, backup_id=backup_id)
changed_token = _preflight(client, changed, "merge").json()["preflight_token"]
conflict = client.post(
"/api/v1/backup/restore", json={"preflight_token": changed_token, "mode": "merge"}
)
assert conflict.status_code == 409
assert conflict.json()["detail"]["code"] == "backup_id_conflict"
def test_restore_cleans_parsed_staging_dir_when_preflight_identity_changed(client, monkeypatch):
boot(client)
content = client.get("/api/v1/backup/export.zip").content
token = _preflight(client, content, "merge").json()["preflight_token"]
import importlib
router_module = importlib.import_module("backend.backup.router")
real_parse = router_module.parse_archive_path
parsed_dirs: list[Path] = []
def parse_with_changed_identity(path, **kwargs):
archive = real_parse(path, **kwargs)
parsed_dirs.append(archive.staging_dir)
return replace(archive, archive_sha256="0" * 64)
monkeypatch.setattr(router_module, "parse_archive_path", parse_with_changed_identity)
response = client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
)
assert response.status_code == 409
assert response.json()["detail"]["code"] == "backup_preflight_invalid"
assert parsed_dirs
assert not parsed_dirs[0].exists()
def test_restore_handles_child_before_parent_task_order(client):
inbox = boot(client)
parent = client.post(
"/api/v1/tasks", json={"title": "parent", "list_id": inbox["id"]}
).json()
client.post(
"/api/v1/tasks",
json={"title": "child", "list_id": inbox["id"], "parent_id": parent["id"]},
)
entries = _zip_entries(client.get("/api/v1/backup/export.zip").content)
manifest = json.loads(entries.pop("manifest.json"))
tasks = json.loads(entries["data/tasks.json"])
entries["data/tasks.json"] = json.dumps(list(reversed(tasks))).encode()
manifest["checksums"]["data/tasks.json"] = hashlib.sha256(entries["data/tasks.json"]).hexdigest()
rebuilt = io.BytesIO()
with zipfile.ZipFile(rebuilt, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", json.dumps(manifest))
for name, value in entries.items():
archive.writestr(name, value)
token = _preflight(client, rebuilt.getvalue(), "replace").json()["preflight_token"]
restored = client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
)
assert restored.status_code == 200, restored.text
items = client.get("/api/v1/tasks", params={"limit": 100}).json()["items"]
restored_parent = next(item for item in items if item["title"] == "parent")
assert restored_parent["subtasks"][0]["title"] == "child"
def test_replace_restores_quarantined_files_when_database_write_fails(client, tmp_path, monkeypatch):
inbox = boot(client)
from backend.backup import service
from backend.config import get_settings
root = tmp_path / "attachments"
get_settings().attachment_dir = str(root)
old_bytes = b"keep me"
task = client.post("/api/v1/tasks", json={"title": "old", "list_id": inbox["id"]}).json()
uploaded = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("old.txt", old_bytes, "text/plain")},
).json()
async def old_storage_path():
from backend.db import get_engine
from backend.models import Attachment
async with AsyncSession(get_engine()) as db:
row = await db.get(Attachment, UUID(uploaded["id"]))
return root / row.storage_name
old_path = asyncio.run(old_storage_path())
content = client.get("/api/v1/backup/export.zip").content
token = _preflight(client, content, "replace").json()["preflight_token"]
def fail_new_attachment_write(storage_root: Path, storage_name: str):
raise OSError("simulated write failure")
monkeypatch.setattr(service, "contained_file", fail_new_attachment_write)
with pytest.raises(OSError, match="simulated write failure"):
client.post(
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
)
assert old_path.read_bytes() == old_bytes
restored_items = client.get("/api/v1/tasks", params={"limit": 100}).json()["items"]
assert any(item["title"] == "old" for item in restored_items)
def test_archive_blob_is_never_read_whole(client, tmp_path, monkeypatch):
inbox = boot(client)
from backend.config import get_settings
get_settings().attachment_dir = str(tmp_path / "attachments")
task = client.post("/api/v1/tasks", json={"title": "stream", "list_id": inbox["id"]}).json()
blob = bytes(range(256)) * 64
assert client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("blob.bin", blob, "application/zip")},
).status_code == 201
content = client.get("/api/v1/backup/export.zip").content
real_read = zipfile.ZipFile.read
def reject_blob_read(self, name, *args, **kwargs):
filename = name.filename if isinstance(name, zipfile.ZipInfo) else name
if str(filename).startswith("attachments/"):
raise AssertionError("blob entry was loaded with ZipFile.read")
return real_read(self, name, *args, **kwargs)
monkeypatch.setattr(zipfile.ZipFile, "read", reject_blob_read)
preflight = _preflight(client, content)
assert preflight.status_code == 200, preflight.text
restored = client.post(
"/api/v1/backup/restore",
json={"preflight_token": preflight.json()["preflight_token"], "mode": "merge"},
)
assert restored.status_code == 200, restored.text
def test_pending_quota_reservation_is_atomic_across_workers(client, monkeypatch):
boot(client)
import importlib
router_module = importlib.import_module("backend.backup.router")
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
content = client.get("/api/v1/backup/export.zip").content
def upload(_):
with client.__class__(client.app) as worker:
worker.cookies.update(client.cookies)
return _preflight(worker, content).status_code
with ThreadPoolExecutor(max_workers=2) as executor:
statuses = list(executor.map(upload, range(2)))
assert sorted(statuses) == [200, 429]
def test_entity_ledger_survives_partial_retry_and_normalizes_relationships(client):
inbox = boot(client)
task = client.post("/api/v1/tasks", json={"title": "ledger-parent", "list_id": inbox["id"]}).json()
child = client.post(
"/api/v1/tasks",
json={"title": "ledger-child", "list_id": inbox["id"], "parent_id": task["id"]},
).json()
content = client.get("/api/v1/backup/export.zip").content
token = _preflight(client, content).json()["preflight_token"]
restored = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"})
assert restored.status_code == 200, restored.text
async def verify_ledger():
from backend.db import get_engine
from backend.models import BackupImportEntity
async with AsyncSession(get_engine()) as db:
rows = list((await db.scalars(select(BackupImportEntity).where(
BackupImportEntity.entity_type == "tasks"
))).all())
by_source = {str(row.source_id): row for row in rows}
assert by_source[task["id"]].target_id
assert by_source[child["id"]].target_id
assert len(by_source[child["id"]].content_digest) == 64
asyncio.run(verify_ledger())