feat(6846): replace_from_backup rolls the store back to a full backup by id
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
One transaction: - Refuse unless the backup is a full one and, on a live instance, carries this instance's id. A fresh instance (no records, at most the restoring account) takes any full backup and adopts its id. - Clear first: search vectors and the CSS consumer map (derived); every backed-up table nothing outside the backup points at, whole; the settings the backup carries; and the identity-table rows the backup lacks. A share granted by a removed user goes before the user. - Write the backup through the same _build_* helpers as the copy-in restore, now _load_v2(keep_ids=...). Identity tables (users, projects, milestones, notes, rulebooks, topics, rules, systems, design systems, code shapes, family decisions) go through _place. That keeps the backup's id and merges, so API keys, shares and forge connections of a surviving row stay. - Reset id sequences past the restored max, and sign every session out. reindex_after_restore runs the four embedding backfills afterwards. A unit guard checks that no row kept for outside references cascades from a table replaced whole. Integration tests cover edits, deletions and new rows put back, a surviving API key, sessions ending, fresh ids afterwards, foreign or id-less backups refused, a user export refused, a failure part-way changing nothing, and a fresh instance adopting the id. Milestone 582, step 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+638
-413
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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,
|
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
||||||
)
|
)
|
||||||
assert backup._rule_rows([row])[0]["kind"] == "preference"
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user