coulomb-social/tests/test_content_recovery.py
tegwick f94ef6a9d1
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Define revision purge and copy/move crash recovery
Page saves keep every revision. An explicit purge drops only inactive
revisions older than a timezone-aware cutoff, keeps live bytes and deleted
page history, and stops if a stored hash does not match. Interrupted copies
and moves resume from a journal without deleting a changed source or granting
destination access. Private corpus review remains open.

Assistant: grok
Assistant-Session: 01a12cc2-e3b2-77a3-8aef-9cc60e577fee
2026-10-11 23:24:23 +02:00

158 lines
6.7 KiB
Python

"""Revision retention and crash recovery for copy/move. Synthetic content only."""
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from django.urls import reverse
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.services import create_space, set_space_access
from tests.test_space_permissions import make_member
@pytest.fixture
def trees(tmp_path, settings):
settings.CONTENT_ROOT = str(tmp_path / "content")
for slug in ("source", "destination"):
pageops.init_space_content(slug, title=slug)
return tmp_path / "content"
def _notes(body="Retained content"):
asset = pageops.assets_dir("source") / "diagram.png"
asset.write_bytes(b"synthetic image")
return pageops.write_page(
"source", "notes", title="Notes", body=f"![Diagram](assets/diagram.png)\n\n{body}\n",
)
def test_restart_drops_unconfirmed_destination_and_keeps_source(trees):
source = _notes()
before = Path(source.path).read_bytes()
partial = pageops.assets_dir("destination") / "notes-diagram.png"
partial.write_bytes(b"synthetic image")
record = pageops._start_record(
"transfer", "source", "notes", "destination", "notes", source.revision,
)
pageops._note_asset(record, "notes-diagram.png")
pageops.write_page("source", "other", title="Other", body="Unrelated")
assert not partial.exists()
assert Path(source.path).read_bytes() == before
assert pageops.page_aliases("source") == {}
assert not pageops.read_page("destination", "notes").exists
assert not list((trees / ".recovery").glob("*.json"))
def test_restart_finishes_a_published_move_without_changing_bytes(trees):
source = _notes()
copied = pageops.copy_page(
"source", "notes", "destination", "notes", title="Notes", journal=True,
)
record = pageops._start_record(
"transfer", "source", "notes", "destination", "notes", source.revision,
)
record["dest_revision"] = copied.revision
record["phase"] = "published"
pageops._write_record(record)
result = pageops.recover_interrupted_content()
assert result == {"cleared": 1, "held": 0}
assert not Path(source.path).exists()
assert pageops.page_aliases("source")["notes"] == ["destination", "notes"]
assert pageops.read_page("destination", "notes").revision == copied.revision
assert not list((trees / ".recovery").glob("*.json"))
def test_restart_keeps_a_source_that_changed_and_drops_its_alias(trees):
source = _notes()
copied = pageops.copy_page(
"source", "notes", "destination", "notes", title="Notes", journal=True,
)
pageops.remember_page_address("source", "notes", "destination", "notes")
record = pageops._start_record(
"transfer", "source", "notes", "destination", "notes", source.revision,
)
record["dest_revision"] = copied.revision
record["phase"] = "aliased"
pageops._write_record(record)
Path(source.path).write_bytes(b"changed after the crash window\n")
pageops.recover_interrupted_content()
assert Path(source.path).read_bytes() == b"changed after the crash window\n"
assert pageops.page_aliases("source") == {}
assert pageops.read_page("destination", "notes").revision == copied.revision
@pytest.mark.django_db
def test_recovered_move_still_requires_the_destination_grant(trees, client):
owner, reader = make_member("owner"), make_member("reader")
source = create_space(owner, title="Shared", slug="source")
target = create_space(owner, title="Private", slug="private")
set_space_access(owner, source, reader.pk, "viewer")
doc = pageops.write_page(source.slug, "notes", title="Notes", body="Retained content")
copied = pageops.copy_page(
source.slug, "notes", target.slug, "moved", title="Notes", journal=True,
)
record = pageops._start_record(
"transfer", source.slug, "notes", target.slug, "moved", doc.revision,
)
record["dest_revision"] = copied.revision
record["phase"] = "published"
pageops._write_record(record)
pageops.recover_interrupted_content()
client.force_login(reader.user)
old_url = reverse("spaces:detail", args=[source.slug]) + "?page=notes"
assert client.get(old_url).status_code == 404
set_space_access(owner, target, reader.pk, "viewer")
response = client.get(old_url, follow=True)
assert response.status_code == 200
assert b"Retained content" in response.content
def test_existing_destination_is_refused_without_a_journal(trees):
source = _notes()
pageops.write_page("destination", "notes", title="Keep", body="Already here")
before = pageops.page_file("destination", "notes").read_bytes()
with pytest.raises(pageops.PageOpsError, match="already exists"):
pageops.transfer_page("source", "notes", "destination", "notes")
assert pageops.page_file("destination", "notes").read_bytes() == before
assert Path(source.path).read_bytes()
assert not list((trees / ".recovery").glob("*.json"))
def test_purge_keeps_live_bytes_and_deleted_page_history(trees):
first = pageops.write_page("source", "notes", title="Notes", body="First")
pageops.write_page(
"source", "notes", title="Notes", body="Second", expected_revision=first.revision,
)
removed = pageops.write_page("source", "gone", title="Gone", body="Recover me")
pageops.delete_page("source", "gone")
revision_dir = trees / ".revisions" / "source" / "notes"
old = next(path for path in revision_dir.glob("*.md") if path.stem == first.revision)
live = next(path for path in revision_dir.glob("*.md") if path.stem != first.revision)
deleted = next((trees / ".revisions" / "source" / "gone").glob("*.md"))
past = (datetime.now(UTC) - timedelta(days=30)).timestamp()
for path in (old, live, deleted):
os.utime(path, (past, past))
removed_count = pageops.purge_revisions(
older_than=datetime.now(UTC) - timedelta(days=7),
)
assert removed_count == 1
assert not old.exists()
assert live.exists()
assert deleted.exists()
assert pageops.read_page("source", "notes").body == "Second\n"
assert removed.body == "Recover me\n"
def test_purge_stops_before_deleting_when_a_revision_hash_does_not_match(trees):
doc = pageops.write_page("source", "notes", title="Notes", body="First")
pageops.write_page(
"source", "notes", title="Notes", body="Second", expected_revision=doc.revision,
)
stored = list((trees / ".revisions" / "source" / "notes").glob("*.md"))
stored[0].write_bytes(b"tampered")
past = datetime.now(UTC) - timedelta(days=1)
with pytest.raises(pageops.PageOpsError, match="integrity"):
pageops.purge_revisions(older_than=past)
assert len(list((trees / ".revisions" / "source" / "notes").glob("*.md"))) == 2