feat: permanently delete archived lists
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-09 21:34:49 +08:00
parent 0e291ddb9c
commit d49c81212d
9 changed files with 957 additions and 6 deletions
+545
View File
@@ -0,0 +1,545 @@
import asyncio
import os
import shutil
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.config import get_settings
from backend.db import get_engine
from backend.models import (
Attachment,
PurgeOperation,
RecurrenceException,
RecurrenceTemplate,
Task,
TaskList,
User,
)
def boot(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
def create_list(client, name="待永久删除"):
response = client.post("/api/v1/lists", json={"name": name})
assert response.status_code == 201
return response.json()
def attachment_storage_name(attachment_id: str) -> str:
async def load():
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
return (await db.get(Attachment, UUID(attachment_id))).storage_name
return asyncio.run(load())
def test_purge_requires_archive_protects_inbox_and_hides_other_users_lists(client):
boot(client)
inbox = client.get("/api/v1/lists").json()[0]
active = create_list(client, "活动清单")
assert client.delete(f"/api/v1/lists/{inbox['id']}/purge").status_code == 409
active_response = client.delete(f"/api/v1/lists/{active['id']}/purge")
assert active_response.status_code == 409
assert active_response.json()["detail"] == "请先归档再永久删除"
async def seed_foreign_archived_list():
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
foreign = User(username="other", password_hash="unused")
db.add(foreign)
await db.flush()
foreign_list = TaskList(
user_id=foreign.id,
name="别人的归档清单",
deleted_at=datetime.now(UTC),
)
db.add(foreign_list)
await db.commit()
return foreign_list.id
foreign_list_id = asyncio.run(seed_foreign_archived_list())
assert client.delete(f"/api/v1/lists/{foreign_list_id}/purge").status_code == 404
assert asyncio.run(row_counts(foreign_list_id))["lists"] == 1
def test_repeated_purge_is_not_found_and_does_not_touch_other_lists(client):
boot(client)
target = create_list(client, "目标")
survivor = create_list(client, "保留")
survivor_task = client.post(
"/api/v1/tasks", json={"title": "必须保留", "list_id": survivor["id"]}
).json()
assert client.delete(f"/api/v1/lists/{target['id']}").status_code == 204
assert client.delete(f"/api/v1/lists/{target['id']}/purge").status_code == 204
assert client.delete(f"/api/v1/lists/{target['id']}/purge").status_code == 404
assert client.get(f"/api/v1/tasks/{survivor_task['id']}").status_code == 200
def test_purge_uses_unique_operation_directory_and_preserves_legacy_collision(
client, tmp_path: Path
):
boot(client)
attachment_root = tmp_path / "attachments"
get_settings().attachment_dir = str(attachment_root)
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
).json()
upload = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
assert upload.status_code == 201
legacy_collision = attachment_root / ".purge-trash" / task_list["id"]
legacy_collision.mkdir(parents=True)
sentinel = legacy_collision / "owned-by-another-request"
sentinel.write_text("keep", encoding="utf-8")
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert response.status_code == 204
assert sentinel.read_text(encoding="utf-8") == "keep"
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 0
def test_operation_directory_collision_does_not_delete_unowned_directory(
client, tmp_path: Path, monkeypatch
):
import backend.main as main_module
boot(client)
attachment_root = tmp_path / "attachments"
get_settings().attachment_dir = str(attachment_root)
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
).json()
client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
operation_id = UUID("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa")
collision = attachment_root / ".purge-trash" / str(operation_id)
collision.mkdir(parents=True)
sentinel = collision / "owned-by-another-request"
sentinel.write_text("keep", encoding="utf-8")
operation_ids = iter(
[operation_id, UUID("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb")]
)
monkeypatch.setattr(main_module, "uuid4", lambda: next(operation_ids))
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert response.status_code == 204
assert sentinel.read_text(encoding="utf-8") == "keep"
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 0
def test_concurrent_quarantine_allocation_uses_distinct_owned_directories(tmp_path: Path):
from backend.main import _create_unique_quarantine
attachment_root = tmp_path / "attachments"
with ThreadPoolExecutor(max_workers=8) as pool:
allocations = list(pool.map(lambda _: _create_unique_quarantine(attachment_root), range(20)))
operation_ids = [operation_id for operation_id, _ in allocations]
trash_dirs = [trash_dir for _, trash_dir in allocations]
assert len(set(operation_ids)) == 20
assert len(set(trash_dirs)) == 20
assert all(trash_dir.is_dir() for trash_dir in trash_dirs)
def test_attachment_pre_move_failure_keeps_database_and_file(
client, tmp_path: Path, monkeypatch
):
boot(client)
get_settings().attachment_dir = str(tmp_path / "attachments")
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
).json()
upload = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
attachment_path = Path(get_settings().attachment_dir) / attachment_storage_name(upload.json()["id"])
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
def fail_replace(source, destination):
raise OSError("disk unavailable")
monkeypatch.setattr(os, "replace", fail_replace)
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert response.status_code == 500
assert response.json()["detail"] == "附件隔离失败,清单未删除"
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 1
assert attachment_path.read_bytes() == b"proof"
def test_pre_move_restore_failure_preserves_quarantined_copy(
client, tmp_path: Path, monkeypatch
):
boot(client)
attachment_root = tmp_path / "attachments"
get_settings().attachment_dir = str(attachment_root)
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "两个附件", "list_id": task_list["id"]}
).json()
first = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("one.txt", b"one", "text/plain")},
).json()
second = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("two.txt", b"two", "text/plain")},
).json()
first_name = attachment_storage_name(first["id"])
second_name = attachment_storage_name(second["id"])
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
original_replace = os.replace
calls = 0
def fail_second_move_and_restore(source, destination):
nonlocal calls
calls += 1
if calls >= 2:
raise OSError("disk unavailable")
return original_replace(source, destination)
monkeypatch.setattr(os, "replace", fail_second_move_and_restore)
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert response.status_code == 500
assert "部分附件恢复失败" in response.json()["detail"]
trash_dirs = list((attachment_root / ".purge-trash").iterdir())
assert len(trash_dirs) == 1
assert (trash_dirs[0] / first_name).read_bytes() == b"one"
assert (attachment_root / second_name).read_bytes() == b"two"
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 2
def test_database_commit_failure_restores_quarantined_file_and_rows(
client, tmp_path: Path, monkeypatch
):
boot(client)
get_settings().attachment_dir = str(tmp_path / "attachments")
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
).json()
upload = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
attachment_path = Path(get_settings().attachment_dir) / attachment_storage_name(upload.json()["id"])
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
original_commit = AsyncSession.commit
async def fail_commit(db):
raise OSError("database unavailable")
monkeypatch.setattr(AsyncSession, "commit", fail_commit)
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
monkeypatch.setattr(AsyncSession, "commit", original_commit)
assert response.status_code == 500
assert response.json()["detail"] == "数据库提交失败,清单未删除"
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 1
assert attachment_path.read_bytes() == b"proof"
assert not (Path(get_settings().attachment_dir) / ".purge-trash").exists()
def test_cleanup_failure_can_be_retried_by_original_list_id(
client, tmp_path: Path, monkeypatch
):
boot(client)
attachment_root = tmp_path / "attachments"
get_settings().attachment_dir = str(attachment_root)
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
).json()
upload = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
storage_name = attachment_storage_name(upload.json()["id"])
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
original_rmtree = shutil.rmtree
cleanup_attempts = 0
def fail_cleanup_once(path, *args, **kwargs):
nonlocal cleanup_attempts
if Path(path).parent.name == ".purge-trash" and cleanup_attempts == 0:
cleanup_attempts += 1
raise OSError("disk unavailable")
return original_rmtree(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", fail_cleanup_once)
first = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert first.status_code == 500
operation_id = first.json()["detail"].split("操作ID: ", 1)[1].split(",", 1)[0]
trash_dir = attachment_root / ".purge-trash" / operation_id
assert (trash_dir / storage_name).read_bytes() == b"proof"
second = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert second.status_code == 204
assert not trash_dir.exists()
assert asyncio.run(purge_operation_count(task_list["id"])) == 0
async def purge_operation_count(list_id):
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
return await db.scalar(
select(func.count()).select_from(PurgeOperation).where(
PurgeOperation.list_id == UUID(str(list_id))
)
)
def test_final_quarantine_cleanup_failure_is_reported_and_keeps_residue_isolated(
client, tmp_path: Path, monkeypatch, caplog
):
boot(client)
attachment_root = tmp_path / "attachments"
get_settings().attachment_dir = str(attachment_root)
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
).json()
upload = client.post(
f"/api/v1/tasks/{task['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
storage_name = attachment_storage_name(upload.json()["id"])
attachment_path = attachment_root / storage_name
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
original_rmtree = shutil.rmtree
def fail_trash_cleanup(path, *args, **kwargs):
if Path(path).parent.name == ".purge-trash":
raise OSError("disk unavailable")
return original_rmtree(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", fail_trash_cleanup)
with caplog.at_level("ERROR", logger="backend.main"):
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
trash_dirs = [path for path in (attachment_root / ".purge-trash").iterdir()]
assert len(trash_dirs) == 1
trash_dir = trash_dirs[0]
assert response.status_code == 500
assert "清单数据已删除,但附件清理未完成" in response.json()["detail"]
assert f"操作ID: {trash_dir.name}" in response.json()["detail"]
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 0
assert not attachment_path.exists()
assert (trash_dir / storage_name).read_bytes() == b"proof"
assert str(trash_dir) in caplog.text
def test_malicious_attachment_path_rejects_purge_without_touching_file_or_database(
client, tmp_path: Path
):
boot(client)
attachment_root = tmp_path / "attachments"
get_settings().attachment_dir = str(attachment_root)
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "恶意附件", "list_id": task_list["id"]}
).json()
outside_path = tmp_path / "outside.txt"
outside_path.write_text("keep", encoding="utf-8")
async def seed_malicious_attachment():
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
owner_id = await db.scalar(select(TaskList.user_id).where(TaskList.id == UUID(task_list["id"])))
db.add(
Attachment(
user_id=owner_id,
task_id=UUID(task["id"]),
filename="outside.txt",
storage_name="../outside.txt",
mime_type="text/plain",
size=4,
)
)
await db.commit()
asyncio.run(seed_malicious_attachment())
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert response.status_code == 409
assert response.json()["detail"] == "附件存储路径无效,无法永久删除清单"
assert outside_path.read_text(encoding="utf-8") == "keep"
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 1
@pytest.mark.parametrize(
"storage_name",
[
"/tmp/dodo-outside.txt",
"nested/../proof.txt",
"nested/./proof.txt",
"nested//proof.txt",
"C:\\temp\\proof.txt",
],
)
def test_absolute_navigation_or_empty_attachment_path_segment_is_rejected(
client, tmp_path: Path, storage_name: str
):
boot(client)
get_settings().attachment_dir = str(tmp_path / "attachments")
task_list = create_list(client)
task = client.post(
"/api/v1/tasks", json={"title": "恶意附件", "list_id": task_list["id"]}
).json()
async def seed_malicious_attachment():
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
owner_id = await db.scalar(select(TaskList.user_id).where(TaskList.id == UUID(task_list["id"])))
db.add(
Attachment(
user_id=owner_id,
task_id=UUID(task["id"]),
filename="proof.txt",
storage_name=storage_name,
mime_type="text/plain",
size=4,
)
)
await db.commit()
asyncio.run(seed_malicious_attachment())
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
assert client.delete(f"/api/v1/lists/{task_list['id']}/purge").status_code == 409
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
async def row_counts(list_id):
list_id = UUID(str(list_id))
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
task_ids = list((await db.scalars(select(Task.id).where(Task.list_id == list_id))).all())
template_ids = list(
(await db.scalars(select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id.in_(task_ids)))).all()
) if task_ids else []
return {
"lists": await db.scalar(select(func.count()).select_from(TaskList).where(TaskList.id == list_id)),
"tasks": len(task_ids),
"templates": len(template_ids),
"exceptions": await db.scalar(
select(func.count()).select_from(RecurrenceException).where(
RecurrenceException.template_id.in_(template_ids)
)
) if template_ids else 0,
"attachments": await db.scalar(
select(func.count()).select_from(Attachment).where(Attachment.task_id.in_(task_ids))
) if task_ids else 0,
}
def test_purge_archived_list_removes_all_task_data_and_attachment_files(client, tmp_path: Path):
boot(client)
get_settings().attachment_dir = str(tmp_path / "attachments")
task_list = create_list(client)
parent = client.post(
"/api/v1/tasks",
json={
"title": "重复父任务",
"list_id": task_list["id"],
"due_at": "2030-01-01T09:00:00Z",
"rrule": "FREQ=DAILY",
},
).json()
child = client.post(
"/api/v1/tasks",
json={"title": "子任务", "list_id": task_list["id"], "parent_id": parent["id"]},
).json()
completed = client.post(
"/api/v1/tasks", json={"title": "已完成", "list_id": task_list["id"]}
).json()
completed = client.patch(
f"/api/v1/tasks/{completed['id']}", json={"completed": True, "version": completed["version"]}
).json()
soft_deleted = client.post(
"/api/v1/tasks", json={"title": "软删除", "list_id": task_list["id"]}
).json()
assert client.delete(f"/api/v1/tasks/{soft_deleted['id']}").status_code == 204
upload = client.post(
f"/api/v1/tasks/{child['id']}/attachments",
files={"file": ("proof.txt", b"proof", "text/plain")},
)
assert upload.status_code == 201
attachment_id = upload.json()["id"]
async def seed_exception_and_get_storage_name():
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
template = await db.scalar(
select(RecurrenceTemplate).where(RecurrenceTemplate.task_id == UUID(parent["id"]))
)
db.add(
RecurrenceException(
template_id=template.id,
occurrence_at=datetime(2030, 1, 2, 9, tzinfo=UTC),
completed=True,
)
)
attachment = await db.get(Attachment, UUID(attachment_id))
storage_name = attachment.storage_name
await db.commit()
return storage_name
storage_name = asyncio.run(seed_exception_and_get_storage_name())
attachment_path = Path(get_settings().attachment_dir) / storage_name
assert attachment_path.is_file()
assert asyncio.run(row_counts(task_list["id"])) == {
"lists": 1,
"tasks": 4,
"templates": 1,
"exceptions": 1,
"attachments": 1,
}
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
assert response.status_code == 204
assert asyncio.run(row_counts(task_list["id"])) == {
"lists": 0,
"tasks": 0,
"templates": 0,
"exceptions": 0,
"attachments": 0,
}
assert not attachment_path.exists()