coulomb-social/tests/test_content_recovery.py
tegwick d9da608e65 Add private author-managed attachments with retained revisions
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
2026-10-31 19:38:10 +01:00

283 lines
12 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
@pytest.mark.parametrize('operation,phase', [
(operation, phase)
for operation in ('copy_page', 'transfer_page')
for phase in ('before_asset_create', 'during_first_asset', 'during_second_asset',
'after_assets', 'after_page', 'published')
] + [('transfer_page', phase) for phase in ('aliased', 'source_deleted')])
def test_process_death_during_copy_or_move_is_recoverable(trees, operation, phase):
import json
import subprocess
import sys
source = _notes('Retained content [attachment](assets/second.txt)')
(pageops.assets_dir('source') / 'second.txt').write_bytes(b'synthetic attachment')
(pageops.assets_dir('source') / 'managed.txt').write_bytes(b'managed attachment')
source = pageops.write_page('source', 'notes', title=source.title, body=source.body,
extra_meta={'coulomb_attachments': json.dumps([
{'name': 'Managed file', 'path': 'assets/managed.txt'}])})
original = Path(source.path).read_bytes()
child = r"""
import os, sys
from django.conf import settings
settings.configure(CONTENT_ROOT=sys.argv[1])
from coulomb_social.apps.spaces import pageops, assets
phase = sys.argv[3]
def stop():
os._exit(86)
original_open = assets.open_local_asset
def opening(*args, **kwargs):
if kwargs.get('create') and phase == 'before_asset_create':
stop()
return original_open(*args, **kwargs)
assets.open_local_asset = opening
original_copy, count = pageops.shutil.copyfileobj, 0
def copying(source, destination):
global count
count += 1
if phase == 'during_first_asset' or (phase == 'during_second_asset' and count == 2):
destination.write(source.read(4))
destination.flush()
os.fsync(destination.fileno())
stop()
original_copy(source, destination)
pageops.shutil.copyfileobj = copying
original_write = pageops.write_page
def writing(*args, **kwargs):
if phase == 'after_assets':
stop()
result = original_write(*args, **kwargs)
if phase == 'after_page':
stop()
return result
pageops.write_page = writing
original_record = pageops._write_record
def recording(record):
result = original_record(record)
if record['phase'] == phase:
stop()
return result
pageops._write_record = recording
original_delete = pageops.delete_page
def deleting(*args, **kwargs):
original_delete(*args, **kwargs)
if phase == 'source_deleted':
stop()
pageops.delete_page = deleting
getattr(pageops, sys.argv[2])('source', 'notes', 'destination', 'notes')
"""
result = subprocess.run([sys.executable, '-c', child, str(trees), operation, phase],
capture_output=True, timeout=15)
assert result.returncode == 86, result.stderr.decode()
assert pageops.recover_interrupted_content() == {'cleared': 1, 'held': 0}
published = phase in {'published', 'aliased', 'source_deleted'}
if published and operation == 'transfer_page':
assert not Path(source.path).exists()
assert pageops.page_aliases('source')['notes'] == ['destination', 'notes']
else:
assert Path(source.path).read_bytes() == original
assert pageops.page_aliases('source') == {}
assert pageops.read_page('destination', 'notes').exists == published
if not published:
assert not list(pageops.assets_dir('destination').iterdir())
copied = getattr(pageops, operation)('source', 'notes', 'destination', 'notes')
assert copied.exists
destination = pageops.assets_dir('destination')
assert (destination / 'notes-diagram.png').read_bytes() == b'synthetic image'
assert (destination / 'notes-second.txt').read_bytes() == b'synthetic attachment'
assert (destination / 'notes-managed.txt').read_bytes() == b'managed attachment'
from coulomb_social.apps.spaces.attachments import entries
assert entries(pageops.read_page('destination', 'notes').meta) == [
{'name': 'Managed file', 'path': 'assets/notes-managed.txt'}]
assert not list((trees / '.recovery').glob('*.json'))
assert pageops.recover_interrupted_content() == {'cleared': 0, 'held': 0}
@pytest.mark.parametrize('operation', ['copy_page', 'transfer_page'])
@pytest.mark.parametrize('kind', ['file', 'directory', 'symlink', 'dangling_symlink'])
def test_existing_attachment_never_becomes_recovery_cleanup_authority(trees, operation, kind):
source = _notes()
original = Path(source.path).read_bytes()
destination = pageops.assets_dir('destination') / 'notes-diagram.png'
if kind == 'file':
destination.write_bytes(b'existing attachment')
elif kind == 'directory':
destination.mkdir()
else:
target = (pageops.assets_dir('source') / 'diagram.png' if kind == 'symlink'
else trees / 'missing')
destination.symlink_to(target)
before = destination.lstat()
with pytest.raises(pageops.PageOpsError, match='Cannot copy'):
getattr(pageops, operation)('source', 'notes', 'destination', 'notes')
assert destination.lstat() == before
if kind == 'file':
assert destination.read_bytes() == b'existing attachment'
assert Path(source.path).read_bytes() == original
assert not pageops.read_page('destination', 'notes').exists
assert pageops.recover_interrupted_content() == {'cleared': 0, 'held': 0}