Restore rolls back to a backup; a session can only file issues in another project #220

Merged
bvandeusen merged 15 commits from dev into main 2026-11-05 09:29:55 -05:00
3 changed files with 854 additions and 413 deletions
Showing only changes of commit 8806ab85b4 - Show all commits
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
"""Real-Postgres proof that a full backup rolls this instance back by id.
The lane shares one database, so every test here takes its backup of the
whole store, changes its own rows, and rolls back to that backup: what it
puts back is exactly what was there when it started.
"""
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import func, select
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.user import User
from grimoire.services import backup
from grimoire.services.api_keys import create_api_key, lookup_key
from grimoire.services.instance import adopt_instance_id, get_instance_id
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "rollback_owner"
@pytest_asyncio.fixture
async def seeded():
"""One user with a project and two tasks, committed, and a full backup
taken right after."""
async with async_session() as s:
user = await ensure_user(s, OWNER)
# A clean slate for this user: an earlier test's rows would otherwise
# be in this test's backup too.
for model in (Note, Project):
for row in (await s.execute(select(model).where(model.user_id == user.id))).scalars():
await s.delete(row)
await s.flush()
project = Project(user_id=user.id, title="Rollback drill")
s.add(project)
await s.flush()
kept = Note(user_id=user.id, project_id=project.id, title="before",
body="as backed up", note_type="note", task_kind="work", status="todo")
doomed = Note(user_id=user.id, project_id=project.id, title="deleted later",
body="", note_type="note", task_kind="work", status="todo")
s.add_all([kept, doomed])
await s.commit()
ids = {"user": user.id, "project": project.id, "kept": kept.id, "doomed": doomed.id}
key, _ = await create_api_key(ids["user"], "rollback drill", "read")
payload = await backup.export_full_backup()
return ids, payload, key
def _own(payload: dict, table: str, user_id: int) -> list[dict]:
return sorted(
(row for row in payload[table] if row.get("user_id") == user_id),
key=lambda row: row["id"],
)
async def _change_everything(ids: dict) -> int:
"""Edit one task, delete the other, add a task and a project. Returns the
new task's id."""
async with async_session() as s:
kept = await s.get(Note, ids["kept"])
kept.title = "after"
kept.body = "edited since the backup"
await s.delete(await s.get(Note, ids["doomed"]))
extra = Project(user_id=ids["user"], title="made after the backup")
s.add(extra)
await s.flush()
new = Note(user_id=ids["user"], project_id=extra.id, title="new since",
body="", note_type="note", task_kind="work", status="todo")
s.add(new)
await s.commit()
return new.id
async def test_a_rollback_puts_back_edits_and_deletions_and_removes_new_records(seeded):
ids, payload, _ = seeded
new_id = await _change_everything(ids)
result = await backup.replace_from_backup(payload)
assert result["mode"] == "rollback"
async with async_session() as s:
kept = await s.get(Note, ids["kept"])
assert (kept.title, kept.body) == ("before", "as backed up")
assert await s.get(Note, ids["doomed"]) is not None
assert await s.get(Note, new_id) is None
titles = (await s.execute(
select(Project.title).where(Project.user_id == ids["user"])
)).scalars().all()
assert titles == ["Rollback drill"]
after = await backup.export_full_backup()
for table in ("projects", "milestones", "notes"):
assert _own(after, table, ids["user"]) == _own(payload, table, ids["user"]), table
async def test_a_surviving_users_api_key_still_works_and_their_sessions_end(seeded):
ids, payload, key = seeded
async with async_session() as s:
version_before = (await s.get(User, ids["user"])).session_version
await _change_everything(ids)
await backup.replace_from_backup(payload)
found = await lookup_key(key)
assert found is not None and found.user_id == ids["user"]
async with async_session() as s:
assert (await s.get(User, ids["user"])).session_version == version_before + 1
async def test_new_records_after_a_rollback_take_fresh_ids(seeded):
ids, payload, _ = seeded
await _change_everything(ids)
await backup.replace_from_backup(payload)
async with async_session() as s:
highest = (await s.execute(select(func.max(Note.id)))).scalar_one()
note = Note(user_id=ids["user"], project_id=ids["project"], title="after rollback",
body="", note_type="note", task_kind="work", status="todo")
s.add(note)
await s.commit()
assert note.id > highest
await s.delete(note)
await s.commit()
@pytest.mark.parametrize("instance_id", ["another-instance", None])
async def test_a_backup_from_elsewhere_is_refused_on_a_live_instance(seeded, instance_id):
ids, payload, _ = seeded
foreign = {**payload, "instance_id": instance_id}
new_id = await _change_everything(ids)
with pytest.raises(backup.RestoreRefused):
await backup.replace_from_backup(foreign)
async with async_session() as s:
assert (await s.get(Note, ids["kept"])).title == "after"
assert await s.get(Note, new_id) is not None
await backup.replace_from_backup(payload)
async def test_a_users_own_export_is_refused(seeded):
_, payload, _ = seeded
with pytest.raises(backup.RestoreRefused):
await backup.replace_from_backup({**payload, "scope": "user"})
async def test_a_failure_part_way_changes_nothing(seeded):
ids, payload, _ = seeded
new_id = await _change_everything(ids)
with patch.object(backup, "_reset_sequences", AsyncMock(side_effect=RuntimeError("boom"))):
with pytest.raises(RuntimeError):
await backup.replace_from_backup(payload)
async with async_session() as s:
assert (await s.get(Note, ids["kept"])).title == "after"
assert await s.get(Note, ids["doomed"]) is None
assert await s.get(Note, new_id) is not None
await backup.replace_from_backup(payload)
async def test_a_fresh_instance_adopts_the_backups_instance_id(seeded):
_, payload, _ = seeded
own = await get_instance_id()
try:
with patch.object(backup, "_is_fresh", AsyncMock(return_value=True)):
result = await backup.replace_from_backup({**payload, "instance_id": "a-lineage"})
assert result["mode"] == "fresh"
assert await get_instance_id() == "a-lineage"
finally:
async with async_session() as s:
await adopt_instance_id(s, own)
await s.commit()
+38
View File
@@ -685,3 +685,41 @@ def test_rule_rows_carry_the_kind_so_a_preference_does_not_restore_as_a_rule():
arose_from_id=None, created_at=stamp, updated_at=stamp,
)
assert backup._rule_rows([row])[0]["kind"] == "preference"
def test_a_rollback_never_cascades_into_a_row_it_keeps_for_outside_references():
"""A rollback replaces most backed-up tables whole, and keeps an identity
table's rows in place so what points at them from OUTSIDE the backup (API
keys, shares, forge connections) survives. If such a kept row had an
ON DELETE CASCADE foreign key into a table replaced whole, clearing that
table would delete the kept row, and its keys and shares with it."""
from grimoire.models import Base
tables = Base.metadata.tables
backed_up = set(backup._BACKED_UP)
referenced_from_outside = {
fk.column.table.name
for table in Base.metadata.sorted_tables if table.name not in backed_up
for fk in table.foreign_keys if fk.column.table.name in backed_up
}
assert {"users", "projects", "notes"} <= referenced_from_outside
assert referenced_from_outside <= set(backup._IDENTITY_TABLES)
safe_targets = set(backup._IDENTITY_TABLES) | set(backup._CATALOG_TABLES) | (
set(tables) - backed_up
)
unsafe = sorted(
f"{name}.{fk.parent.name} -> {fk.column.table.name}"
for name in referenced_from_outside
for fk in tables[name].foreign_keys
if (fk.ondelete or "").upper() == "CASCADE" and fk.column.table.name not in safe_targets
)
assert unsafe == []
def test_every_rollback_table_list_names_real_backed_up_tables():
from grimoire.models import Base
assert set(backup._IDENTITY_TABLES) <= set(backup._BACKED_UP)
assert set(backup._CATALOG_TABLES) <= set(backup._BACKED_UP)
assert set(backup._DERIVED_TABLES) <= set(backup._NOT_INCLUDED)
assert set(backup._DERIVED_TABLES) <= set(Base.metadata.tables)