718 lines
29 KiB
Python
718 lines
29 KiB
Python
import asyncio
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.models import Attachment, BackupImportEntity
|
|
from tests.test_backup_v2 import _preflight
|
|
from tests.test_mvp_backend import boot
|
|
|
|
|
|
async def _attachment_state(attachment_id: str) -> tuple[Attachment, int]:
|
|
from backend.db import get_engine
|
|
|
|
async with AsyncSession(get_engine()) as db:
|
|
attachment = await db.get(Attachment, UUID(attachment_id))
|
|
ledger_count = await db.scalar(
|
|
select(func.count()).select_from(BackupImportEntity).where(
|
|
BackupImportEntity.entity_type == "attachments",
|
|
BackupImportEntity.source_id == UUID(attachment_id),
|
|
)
|
|
)
|
|
return attachment, ledger_count or 0
|
|
|
|
|
|
@pytest.mark.parametrize("damage", ["missing", "corrupt"])
|
|
def test_merge_existing_attachment_without_ledger_never_certifies_bad_file(
|
|
client, tmp_path, damage
|
|
):
|
|
from backend.config import get_settings
|
|
|
|
inbox = boot(client)
|
|
root = tmp_path / "attachments"
|
|
get_settings().attachment_dir = str(root)
|
|
task = client.post(
|
|
"/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]}
|
|
).json()
|
|
payload = b"trusted attachment bytes"
|
|
attachment = client.post(
|
|
f"/api/v1/tasks/{task['id']}/attachments",
|
|
files={"file": ("proof.txt", payload, "text/plain")},
|
|
).json()
|
|
archive = client.get("/api/v1/backup/export.zip").content
|
|
row, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
|
assert ledger_count == 0
|
|
stored = root / row.storage_name
|
|
if damage == "missing":
|
|
stored.unlink()
|
|
else:
|
|
stored.write_bytes(b"x" * len(payload))
|
|
|
|
token = _preflight(client, archive, "merge").json()["preflight_token"]
|
|
response = client.post(
|
|
"/api/v1/backup/restore",
|
|
json={"preflight_token": token, "mode": "merge"},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"]["code"] in {
|
|
"backup_entity_missing",
|
|
"backup_entity_conflict",
|
|
}
|
|
_, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
|
assert ledger_count == 0
|
|
|
|
|
|
def test_merge_existing_attachment_without_ledger_hashes_equal_bytes_before_ledger(
|
|
client, tmp_path
|
|
):
|
|
from backend.config import get_settings
|
|
|
|
inbox = boot(client)
|
|
root = tmp_path / "attachments"
|
|
get_settings().attachment_dir = str(root)
|
|
task = client.post(
|
|
"/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]}
|
|
).json()
|
|
payload = b"trusted attachment bytes"
|
|
attachment = client.post(
|
|
f"/api/v1/tasks/{task['id']}/attachments",
|
|
files={"file": ("proof.txt", payload, "text/plain")},
|
|
).json()
|
|
archive = client.get("/api/v1/backup/export.zip").content
|
|
|
|
token = _preflight(client, archive, "merge").json()["preflight_token"]
|
|
response = client.post(
|
|
"/api/v1/backup/restore",
|
|
json={"preflight_token": token, "mode": "merge"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
row, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
|
assert ledger_count == 1
|
|
assert Path(root / row.storage_name).read_bytes() == payload
|
|
|
|
|
|
async def _business_counts() -> tuple[int, ...]:
|
|
from backend.backup.service import ENTITY_MODELS
|
|
from backend.db import get_engine
|
|
|
|
async with AsyncSession(get_engine()) as db:
|
|
counts = []
|
|
for model in ENTITY_MODELS.values():
|
|
counts.append((await db.scalar(select(func.count()).select_from(model))) or 0)
|
|
return tuple(counts)
|
|
|
|
|
|
def _base_graph() -> dict[str, list[dict]]:
|
|
list_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
task_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
|
recurrence_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
|
return {
|
|
"lists": [{"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}],
|
|
"tasks": [{
|
|
"id": task_id,
|
|
"list_id": list_id,
|
|
"parent_id": None,
|
|
"title": "valid task",
|
|
"description": "",
|
|
"priority": 0,
|
|
"completed": False,
|
|
"completed_at": None,
|
|
"due_at": "2026-09-20T08:00:00+00:00",
|
|
"due_has_time": True,
|
|
"version": 1,
|
|
"position": 0,
|
|
"external_id": None,
|
|
}],
|
|
"recurrences": [{
|
|
"id": recurrence_id,
|
|
"task_id": task_id,
|
|
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
|
"starts_at": "2026-09-20T08:00:00+00:00",
|
|
"ends_at": None,
|
|
"trigger_mode": "scheduled",
|
|
"after_completion_days": None,
|
|
"last_completed_at": None,
|
|
}],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutate", "label"),
|
|
[
|
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=None), "after completion days required"),
|
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule="FREQ=DAILY", after_completion_days=1), "after completion excludes rrule"),
|
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=0), "after completion range"),
|
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", rrule=None, after_completion_days=None), "scheduled requires rrule"),
|
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", after_completion_days=1), "scheduled excludes days"),
|
|
(lambda graph: graph["recurrences"][0].update(rrule="FREQ=NOPE"), "rrule parses"),
|
|
(lambda graph: graph["recurrences"][0].update(ends_at="2026-09-19T08:00:00+00:00"), "ends after starts"),
|
|
(lambda graph: graph["recurrences"][0].update(last_completed_at="2026-09-21T08:00:00+00:00"), "last completion before start"),
|
|
(lambda graph: graph["tasks"][0].update(due_at=None, due_has_time=False), "recurring task has due"),
|
|
(lambda graph: graph["tasks"][0].update(parent_id="dddddddd-dddd-4ddd-8ddd-dddddddddddd"), "recurring task top level"),
|
|
],
|
|
)
|
|
def test_recurrence_preflight_rejects_invalid_contract_without_business_writes(client, mutate, label):
|
|
boot(client)
|
|
graph = _base_graph()
|
|
if label == "recurring task top level":
|
|
graph["tasks"].append({
|
|
**graph["tasks"][0],
|
|
"id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
"title": "parent",
|
|
"parent_id": None,
|
|
})
|
|
mutate(graph)
|
|
before = asyncio.run(_business_counts())
|
|
|
|
content = _make_archive_from_graph(graph)
|
|
response = _preflight(client, content, "replace")
|
|
|
|
assert response.status_code == 422, (label, response.text)
|
|
assert response.json()["detail"]["code"] == "backup_recurrence_invalid"
|
|
assert asyncio.run(_business_counts()) == before
|
|
|
|
|
|
def _make_archive_from_graph(
|
|
graph: dict[str, list[dict]],
|
|
files: dict[str, bytes] | None = None,
|
|
*,
|
|
backup_id: str = "11111111-1111-4111-8111-111111111111",
|
|
) -> bytes:
|
|
from tests.test_backup_v2 import _make_zip
|
|
|
|
entries = {
|
|
f"data/{entity}.json": __import__("json").dumps(rows).encode()
|
|
for entity, rows in graph.items()
|
|
}
|
|
entries.update(files or {})
|
|
return _make_zip(entries, backup_id=backup_id)
|
|
|
|
|
|
def _task_tree_graph(parent_ids: list[str | None]) -> dict[str, list[dict]]:
|
|
graph = _base_graph()
|
|
graph["recurrences"] = []
|
|
template = graph["tasks"][0]
|
|
task_ids = [
|
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
]
|
|
graph["tasks"] = [
|
|
{
|
|
**template,
|
|
"id": task_id,
|
|
"parent_id": parent_id,
|
|
"title": f"tree task {index}",
|
|
"position": index,
|
|
}
|
|
for index, (task_id, parent_id) in enumerate(zip(task_ids, parent_ids, strict=True))
|
|
]
|
|
return graph
|
|
|
|
|
|
def test_preflight_rejects_three_level_task_tree_without_business_writes(client):
|
|
boot(client)
|
|
graph = _task_tree_graph([
|
|
None,
|
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
])
|
|
before = asyncio.run(_business_counts())
|
|
|
|
response = _preflight(client, _make_archive_from_graph(graph), "replace")
|
|
|
|
assert response.status_code == 422, response.text
|
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
|
assert asyncio.run(_business_counts()) == before
|
|
|
|
|
|
def test_parent_with_multiple_children_round_trips_in_arbitrary_zip_order(client):
|
|
boot(client)
|
|
parent_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
|
graph = _task_tree_graph([parent_id, None, parent_id])
|
|
graph["tasks"] = [graph["tasks"][2], graph["tasks"][1], graph["tasks"][0]]
|
|
|
|
preflight = _preflight(client, _make_archive_from_graph(graph), "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
|
|
|
|
from tests.test_backup_v2 import _archive_rows
|
|
|
|
rows = _archive_rows(client.get("/api/v1/backup/export.zip").content, "tasks")
|
|
by_id = {row["id"]: row for row in rows}
|
|
assert by_id[parent_id]["parent_id"] is None
|
|
assert {
|
|
row["id"] for row in rows if row["parent_id"] == parent_id
|
|
} == {
|
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
}
|
|
items = client.get("/api/v1/tasks", params={"limit": 100}).json()["items"]
|
|
restored_parent = next(item for item in items if item["id"] == parent_id)
|
|
assert {child["id"] for child in restored_parent["subtasks"]} == {
|
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"recurrence",
|
|
[
|
|
{
|
|
"rrule": "FREQ=MONTHLY;BYMONTHDAY=1,15;COUNT=8",
|
|
"trigger_mode": "scheduled",
|
|
"after_completion_days": None,
|
|
},
|
|
{
|
|
"rrule": None,
|
|
"trigger_mode": "after_completion",
|
|
"after_completion_days": 30,
|
|
},
|
|
],
|
|
)
|
|
def test_both_recurrence_modes_round_trip_through_replace(client, recurrence):
|
|
boot(client)
|
|
graph = _base_graph()
|
|
graph["recurrences"][0].update(recurrence)
|
|
content = _make_archive_from_graph(graph)
|
|
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
|
|
exported = client.get("/api/v1/backup/export.zip").content
|
|
from tests.test_backup_v2 import _archive_rows
|
|
|
|
row = _archive_rows(exported, "recurrences")[0]
|
|
assert row["rrule"] == recurrence["rrule"]
|
|
assert row["trigger_mode"] == recurrence["trigger_mode"]
|
|
assert row["after_completion_days"] == recurrence["after_completion_days"]
|
|
|
|
|
|
def _entity_case(entity: str, row: dict, files: dict[str, bytes] | None = None) -> bytes:
|
|
graph = _base_graph()
|
|
graph[entity] = [row]
|
|
return _make_archive_from_graph(graph, files)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("entity", "row", "files"),
|
|
[
|
|
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": " ", "position": 0}, None),
|
|
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x" * 121, "position": 0}, None),
|
|
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x", "position": -1}, None),
|
|
("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "x" * 121, "is_inbox": True, "position": 0}, None),
|
|
("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "Inbox", "is_inbox": True, "position": -1}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "title": " "}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "priority": 4}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "version": 0}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "position": -1}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "due_at": None, "due_has_time": True}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "completed": False, "completed_at": "2026-09-20T08:00:00+00:00"}, None),
|
|
("tasks", {**_base_graph()["tasks"][0], "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None),
|
|
("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "bad", "lunar_month": None, "lunar_day": None, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None),
|
|
("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "solar", "lunar_month": 1, "lunar_day": 1, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None),
|
|
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": " ", "content": "", "version": 1}, None),
|
|
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 0}, None),
|
|
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 1, "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None),
|
|
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x" * 256, "mime_type": "text/plain", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
|
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "application/x-danger", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
|
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "text/plain", "size": -1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
|
],
|
|
)
|
|
def test_all_entity_contracts_fail_preflight_without_business_writes(client, entity, row, files):
|
|
boot(client)
|
|
before = asyncio.run(_business_counts())
|
|
|
|
response = _preflight(client, _entity_case(entity, row, files), "replace")
|
|
|
|
assert response.status_code == 422, (entity, response.text)
|
|
assert asyncio.run(_business_counts()) == before
|
|
|
|
|
|
def test_legacy_replace_is_rejected_without_mutating_data(client):
|
|
inbox = boot(client)
|
|
task = client.post(
|
|
"/api/v1/tasks", json={"title": "must survive", "list_id": inbox["id"]}
|
|
).json()
|
|
payload = client.get("/api/v1/export").json()
|
|
|
|
response = client.post("/api/v1/restore?mode=replace", json=payload)
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"] == {
|
|
"code": "legacy_replace_unsupported",
|
|
"message": "旧版备份仅支持合并恢复",
|
|
}
|
|
assert client.get(f"/api/v1/tasks/{task['id']}").status_code == 200
|
|
|
|
|
|
def test_legacy_csv_upload_has_streaming_hard_limit(client, monkeypatch):
|
|
boot(client)
|
|
from backend import mvp
|
|
|
|
monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_BYTES", 32)
|
|
response = client.post(
|
|
"/api/v1/restore.csv?mode=merge",
|
|
files={"file": ("backup.csv", b"entity,data\n" + b"x" * 33, "text/csv")},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"] == {
|
|
"code": "legacy_backup_too_large",
|
|
"message": "旧版备份文件过大",
|
|
}
|
|
|
|
|
|
def test_legacy_json_rejects_excessive_records(client, monkeypatch):
|
|
boot(client)
|
|
from backend import mvp
|
|
|
|
monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_RECORDS", 1)
|
|
payload = {"version": 1, "folders": [], "lists": [], "tasks": [
|
|
{"id": "11111111-1111-4111-8111-111111111111"},
|
|
{"id": "22222222-2222-4222-8222-222222222222"},
|
|
]}
|
|
|
|
response = client.post("/api/v1/restore?mode=merge", json=payload)
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"]["code"] == "legacy_backup_too_many_records"
|
|
|
|
|
|
def test_v2_preflight_rejects_multiple_active_pinned_countdowns(client):
|
|
boot(client)
|
|
graph = _base_graph()
|
|
graph["recurrences"] = []
|
|
graph["countdowns"] = [
|
|
{
|
|
"id": f"{index:08d}-2020-4020-8020-202020202020",
|
|
"title": f"pinned {index}", "event_date": "2026-09-20",
|
|
"calendar_mode": "solar", "lunar_month": None, "lunar_day": None,
|
|
"ignore_year": False, "kind": "countdown", "repeat_rule": "none",
|
|
"icon": "x", "pinned": True, "archived_at": None,
|
|
}
|
|
for index in (1, 2)
|
|
]
|
|
|
|
response = _preflight(client, _make_archive_from_graph(graph), "merge")
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
|
|
|
|
|
def test_reaper_removes_failed_and_stranded_staging_and_repairs_cleanup(client, tmp_path):
|
|
boot(client)
|
|
from backend.backup.router import _prune
|
|
from backend.config import get_settings
|
|
from backend.db import get_engine
|
|
from backend.models import BackupPreflight, utcnow
|
|
|
|
staging_root = tmp_path / "staging"
|
|
staging_root.mkdir()
|
|
get_settings().backup_staging_dir = str(staging_root)
|
|
failed_file = staging_root / "failed.zip"
|
|
consuming_file = staging_root / "consuming.zip"
|
|
failed_file.write_bytes(b"failed")
|
|
consuming_file.write_bytes(b"consuming")
|
|
cleanup = tmp_path / "cleanup"
|
|
cleanup.mkdir()
|
|
(cleanup / "old").write_bytes(b"old")
|
|
|
|
async def exercise():
|
|
async with AsyncSession(get_engine()) as db:
|
|
user_id = await db.scalar(select(__import__("backend.models", fromlist=["User"]).User.id))
|
|
now = utcnow() - timedelta(hours=1)
|
|
rows = [
|
|
BackupPreflight(
|
|
token_hash=str(index) * 64, user_id=user_id,
|
|
backup_id=UUID(f"00000000-0000-4000-8000-00000000000{index}"),
|
|
archive_sha256="0" * 64, archive_size=10,
|
|
staging_path=str(path), mode="merge", status=status,
|
|
expires_at=now, consumed_at=now,
|
|
cleanup_path=str(cleanup) if status == "cleanup_pending" else None,
|
|
)
|
|
for index, (status, path) in enumerate(
|
|
[("failed", failed_file), ("consuming", consuming_file),
|
|
("cleanup_pending", staging_root / "cleanup.zip")], start=1
|
|
)
|
|
]
|
|
db.add_all(rows)
|
|
await db.flush()
|
|
row_ids = [row.id for row in rows]
|
|
await db.commit()
|
|
await _prune(db)
|
|
statuses = {
|
|
str(row_id): await db.scalar(
|
|
select(BackupPreflight.status).where(BackupPreflight.id == row_id)
|
|
)
|
|
for row_id in row_ids
|
|
}
|
|
return row_ids, statuses
|
|
|
|
row_ids, statuses = asyncio.run(exercise())
|
|
assert not failed_file.exists()
|
|
assert not consuming_file.exists()
|
|
assert not cleanup.exists()
|
|
assert statuses[str(row_ids[2])] == "consumed"
|
|
|
|
|
|
def test_expired_repair_pending_is_repaired_not_deleted_with_quarantine(client, tmp_path):
|
|
boot(client)
|
|
from backend.backup.router import _prune
|
|
from backend.config import get_settings
|
|
from backend.db import get_engine
|
|
from backend.models import BackupPreflight, User, utcnow
|
|
|
|
staging_root = tmp_path / "staging"
|
|
attachment_root = tmp_path / "attachments"
|
|
quarantine = tmp_path / "quarantine"
|
|
staging_root.mkdir()
|
|
attachment_root.mkdir()
|
|
quarantine.mkdir()
|
|
staging = staging_root / "repair.zip"
|
|
staging.write_bytes(b"staged")
|
|
(quarantine / "restored.bin").write_bytes(b"original")
|
|
get_settings().backup_staging_dir = str(staging_root)
|
|
get_settings().attachment_dir = str(attachment_root)
|
|
|
|
async def exercise():
|
|
async with AsyncSession(get_engine()) as db:
|
|
user_id = await db.scalar(select(User.id))
|
|
row = BackupPreflight(
|
|
token_hash="9" * 64,
|
|
user_id=user_id,
|
|
backup_id=UUID("99999999-9999-4999-8999-999999999999"),
|
|
archive_sha256="0" * 64,
|
|
archive_size=7,
|
|
staging_path=str(staging),
|
|
mode="replace",
|
|
status="repair_pending",
|
|
expires_at=utcnow() - timedelta(hours=1),
|
|
cleanup_path=str(quarantine),
|
|
)
|
|
db.add(row)
|
|
await db.flush()
|
|
row_id = row.id
|
|
await db.commit()
|
|
await _prune(db)
|
|
repaired = await db.get(BackupPreflight, row_id)
|
|
return repaired.status, repaired.cleanup_path
|
|
|
|
status, cleanup_path = asyncio.run(exercise())
|
|
assert (attachment_root / "restored.bin").read_bytes() == b"original"
|
|
assert not quarantine.exists()
|
|
assert not staging.exists()
|
|
assert status == "failed"
|
|
assert cleanup_path is None
|
|
|
|
|
|
def test_repair_pending_counts_against_preflight_quota(client, tmp_path, monkeypatch):
|
|
boot(client)
|
|
import importlib
|
|
|
|
from backend.config import get_settings
|
|
from backend.db import get_engine
|
|
from backend.models import BackupPreflight, User, utcnow
|
|
|
|
router_module = importlib.import_module("backend.backup.router")
|
|
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
|
staging_root = tmp_path / "staging"
|
|
staging_root.mkdir()
|
|
get_settings().backup_staging_dir = str(staging_root)
|
|
staged = staging_root / "pending.zip"
|
|
staged.write_bytes(b"pending")
|
|
|
|
async def seed():
|
|
async with AsyncSession(get_engine()) as db:
|
|
user_id = await db.scalar(select(User.id))
|
|
db.add(BackupPreflight(
|
|
token_hash="8" * 64,
|
|
user_id=user_id,
|
|
backup_id=UUID("88888888-8888-4888-8888-888888888888"),
|
|
archive_sha256="0" * 64,
|
|
archive_size=7,
|
|
staging_path=str(staged),
|
|
mode="replace",
|
|
status="repair_pending",
|
|
expires_at=utcnow() + timedelta(hours=1),
|
|
cleanup_path=str(tmp_path / "quarantine"),
|
|
))
|
|
await db.commit()
|
|
|
|
asyncio.run(seed())
|
|
content = client.get("/api/v1/backup/export.zip").content
|
|
response = _preflight(client, content, "merge")
|
|
assert response.status_code == 429
|
|
assert response.json()["detail"]["code"] == "backup_preflight_quota"
|
|
|
|
|
|
def test_repair_pending_exception_survives_router_and_same_token_only_repairs(
|
|
client, tmp_path, monkeypatch
|
|
):
|
|
inbox = boot(client)
|
|
import importlib
|
|
|
|
backup_router = importlib.import_module("backend.backup.router")
|
|
from backend.backup import service
|
|
from backend.config import get_settings
|
|
from backend.db import get_engine
|
|
from backend.models import Attachment, BackupPreflight
|
|
|
|
root = tmp_path / "attachments"
|
|
get_settings().attachment_dir = str(root)
|
|
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", b"old", "text/plain")},
|
|
).json()
|
|
content = client.get("/api/v1/backup/export.zip").content
|
|
token = _preflight(client, content, "replace").json()["preflight_token"]
|
|
|
|
real_restore = service.restore_quarantine
|
|
monkeypatch.setattr(service, "contained_file", lambda *_: (_ for _ in ()).throw(OSError("write")))
|
|
monkeypatch.setattr(service, "restore_quarantine", lambda *_: (_ for _ in ()).throw(OSError("repair")))
|
|
response = client.post(
|
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
|
)
|
|
assert response.status_code == 500
|
|
assert response.json()["detail"]["code"] == "backup_repair_pending"
|
|
|
|
async def state():
|
|
async with AsyncSession(get_engine()) as db:
|
|
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.status == "repair_pending"))
|
|
attachment = await db.get(Attachment, UUID(uploaded["id"]))
|
|
return row.status, row.cleanup_path, root / attachment.storage_name
|
|
|
|
status, cleanup_path, old_path = asyncio.run(state())
|
|
assert status == "repair_pending"
|
|
assert cleanup_path
|
|
assert not old_path.exists()
|
|
|
|
monkeypatch.setattr(service, "restore_quarantine", real_restore)
|
|
monkeypatch.setattr(
|
|
backup_router,
|
|
"restore_v2",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("DB restore repeated")),
|
|
)
|
|
retried = client.post(
|
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
|
)
|
|
assert retried.status_code == 409
|
|
assert retried.json()["detail"]["code"] == "backup_restore_retry"
|
|
assert old_path.read_bytes() == b"old"
|
|
|
|
async def repaired_state():
|
|
async with AsyncSession(get_engine()) as db:
|
|
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None)))
|
|
return row.status, row.cleanup_path
|
|
|
|
assert asyncio.run(repaired_state()) == ("failed", None)
|
|
|
|
|
|
def test_merge_preflight_rejects_archive_pin_when_user_has_different_active_pin(client):
|
|
boot(client)
|
|
existing = client.post(
|
|
"/api/v1/countdowns",
|
|
json={"title": "existing pin", "event_date": "2026-09-20", "pinned": True},
|
|
).json()
|
|
graph = _base_graph()
|
|
graph["recurrences"] = []
|
|
graph["countdowns"] = [{
|
|
"id": "77777777-7777-4777-8777-777777777777",
|
|
"title": "incoming pin",
|
|
"event_date": "2026-09-21",
|
|
"calendar_mode": "solar",
|
|
"lunar_month": None,
|
|
"lunar_day": None,
|
|
"ignore_year": False,
|
|
"kind": "countdown",
|
|
"repeat_rule": "none",
|
|
"icon": "x",
|
|
"pinned": True,
|
|
"archived_at": None,
|
|
}]
|
|
|
|
response = _preflight(client, _make_archive_from_graph(graph), "merge")
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
|
active = client.get("/api/v1/countdowns").json()
|
|
assert [row["id"] for row in active if row["pinned"]] == [existing["id"]]
|
|
|
|
|
|
def test_restore_target_ids_are_unique_across_entity_tables(client):
|
|
boot(client)
|
|
shared = "66666666-6666-4666-8666-666666666666"
|
|
|
|
async def seed_folder():
|
|
from backend.db import get_engine
|
|
from backend.models import Folder, User
|
|
async with AsyncSession(get_engine()) as db:
|
|
user_id = await db.scalar(select(User.id))
|
|
db.add(Folder(
|
|
id=UUID(shared), user_id=user_id, name="existing entity id", position=0
|
|
))
|
|
await db.commit()
|
|
|
|
asyncio.run(seed_folder())
|
|
current = client.get("/api/v1/backup/export.zip").content
|
|
from tests.test_backup_v2 import _archive_rows, _replace_entities
|
|
|
|
lists = _archive_rows(current, "lists")
|
|
lists.append({
|
|
"id": shared,
|
|
"name": "cross-table collision",
|
|
"is_inbox": False,
|
|
"position": 1,
|
|
})
|
|
second_content = _replace_entities(current, {"lists": lists})
|
|
entries = __import__("tests.test_backup_v2", fromlist=["_zip_entries"])._zip_entries(second_content)
|
|
entities = {
|
|
name.removeprefix("data/").removesuffix(".json"): __import__("json").loads(value)
|
|
for name, value in entries.items()
|
|
if name.startswith("data/") and name.endswith(".json")
|
|
}
|
|
second = _preflight(
|
|
client,
|
|
_make_archive_from_graph(
|
|
entities,
|
|
{
|
|
name: value
|
|
for name, value in entries.items()
|
|
if name.startswith("attachments/")
|
|
},
|
|
backup_id="55555555-5555-4555-8555-555555555555",
|
|
),
|
|
"merge",
|
|
)
|
|
assert second.status_code == 200, second.text
|
|
restored = client.post(
|
|
"/api/v1/backup/restore",
|
|
json={"preflight_token": second.json()["preflight_token"], "mode": "merge"},
|
|
)
|
|
assert restored.status_code == 200, restored.text
|
|
|
|
async def ids():
|
|
from backend.db import get_engine
|
|
from backend.models import Folder, TaskList
|
|
async with AsyncSession(get_engine()) as db:
|
|
folder_id = await db.scalar(select(Folder.id).where(Folder.name == "existing entity id"))
|
|
list_id = await db.scalar(select(TaskList.id).where(TaskList.name == "cross-table collision"))
|
|
return folder_id, list_id
|
|
|
|
folder_id, list_id = asyncio.run(ids())
|
|
assert folder_id == UUID(shared)
|
|
assert list_id != folder_id
|