coulomb-social/tests/test_content_revisions.py
tegwick dd83c90d73
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m43s
Implement space sharing, member portability and recoverable content edits
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b
2026-10-08 09:17:31 +02:00

285 lines
13 KiB
Python

"""Conflict/failure and attachment fidelity checks with synthetic local content."""
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
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, save_space_page
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 test_simultaneous_edits_have_one_winner_and_durable_old_and_new_versions(trees):
before = pageops.write_page("source", "notes", title="Notes", body="Original")
original = Path(before.path).read_bytes()
def save(body):
try:
pageops.write_page("source", "notes", title="Notes", body=body,
expected_revision=before.revision)
return "saved"
except pageops.PageOpsError:
return "conflict"
with ThreadPoolExecutor(max_workers=2) as pool:
assert sorted(pool.map(save, ("First edit", "Second edit"))) == ["conflict", "saved"]
versions = [p.read_bytes() for p in (trees / ".revisions/source/notes").glob("*.md")]
assert original in versions
assert Path(before.path).read_bytes() in versions
assert len(versions) == 2
pageops.delete_page("source", "notes")
assert not Path(before.path).exists()
assert original in versions
def test_failed_atomic_publish_leaves_original_readable_and_history_retained(trees, monkeypatch):
doc = pageops.write_page("source", "notes", title="Notes", body="Original")
original = Path(doc.path).read_bytes()
replace = pageops.os.replace
def fail_page(source, destination):
if Path(destination) == Path(doc.path):
raise OSError("synthetic disk failure")
return replace(source, destination)
monkeypatch.setattr(pageops.os, "replace", fail_page)
with pytest.raises(OSError):
pageops.write_page("source", "notes", title="Notes", body="New")
assert Path(doc.path).read_bytes() == original
assert sorted(p.name for p in Path(doc.path).parent.iterdir()) == ["index.md", "notes.md"]
def test_empty_imported_page_has_a_real_revision_and_can_be_edited(trees):
pageops.page_file("source", "empty").write_bytes(b"")
source = pageops.read_page("source", "empty")
assert source.exists and source.revision
pageops.write_page("source", "empty", body="New notes", expected_revision=source.revision)
assert pageops.read_page("source", "empty").body == "New notes\n"
assert any(p.read_bytes() == b"" for p in (trees / ".revisions/source/empty").glob("*.md"))
@pytest.mark.parametrize("operation", ["copy", "move"])
def test_body_assets_reference_links_visual_and_nested_equal_names_survive(trees, operation):
assets = pageops.assets_dir("source")
(assets / "nested").mkdir()
(assets / "diagram.png").write_bytes(b"synthetic image one")
(assets / "nested/diagram.png").write_bytes(b"synthetic image two")
body = ('![Diagram](../assets/diagram.png)\n'
'![Nested][picture]\n\n[picture]: assets/nested/diagram.png\n'
'[File](assets/diagram.png#detail)\n')
source = pageops.write_page("source", "notes", title="Notes", body=body,
visual="assets/diagram.png")
original = Path(source.path).read_bytes()
if operation == "copy":
doc = pageops.copy_page("source", "notes", "destination", "new-notes")
assert Path(source.path).read_bytes() == original
else:
doc = pageops.transfer_page("source", "notes", "destination", "new-notes")
assert not Path(source.path).exists()
assert doc.visual == "assets/new-notes-diagram.png"
assert "![Diagram](assets/new-notes-diagram.png)" in doc.body
assert "[File](assets/new-notes-diagram.png#detail)" in doc.body
assert "assets/nested/" not in doc.body
assert sorted(p.read_bytes() for p in pageops.assets_dir("destination").iterdir()) == [
b"synthetic image one", b"synthetic image two",
]
def test_attachment_failure_rolls_back_partial_copy_and_does_not_move_source(trees):
(pageops.assets_dir("source") / "present.png").write_bytes(b"synthetic image")
source = pageops.write_page("source", "notes", title="Notes",
body="![One](assets/present.png)\n![Two](assets/missing.png)")
before = Path(source.path).read_bytes()
with pytest.raises(pageops.PageOpsError, match="attachment safely"):
pageops.transfer_page("source", "notes", "destination", "notes")
assert Path(source.path).read_bytes() == before
assert list(pageops.assets_dir("destination").iterdir()) == []
assert not pageops.read_page("destination", "notes").exists
@pytest.mark.django_db
def test_stale_rename_and_missing_revision_preserve_user_input_and_current_page(trees, client):
owner = make_member("owner")
space = create_space(owner, title="Conflict", slug="conflict")
before = pageops.write_page(space.slug, "notes", title="Notes", body="Original")
pageops.write_page(space.slug, "notes", title="Notes", body="Other writer")
client.force_login(owner.user)
url = reverse("spaces:page_edit", args=[space.slug, "notes"])
for revision in (None, before.revision):
fields = {"title": "Mine", "slug": "renamed", "body": "My unsaved notes"}
if revision is not None:
fields["revision"] = revision
response = client.post(url, fields)
assert response.status_code == 200
assert b"Reopen it and merge" in response.content
assert b"My unsaved notes" in response.content
assert pageops.read_page(space.slug, "notes").body == "Other writer\n"
assert not pageops.read_page(space.slug, "renamed").exists
current = pageops.read_page(space.slug, "notes")
save_space_page(owner, space, "notes", "renamed", expected_revision=current.revision,
title="Merged", body="Other writer plus my notes")
assert not pageops.read_page(space.slug, "notes").exists
@pytest.mark.django_db
def test_space_edit_conflict_does_not_overwrite_new_index_body_or_metadata(trees, client):
owner = make_member("owner")
space = create_space(owner, title="Conflict", slug="conflict")
before = pageops.read_page(space.slug, "index")
pageops.write_page(space.slug, "index", title="Conflict", body="Another writer's index",
extra_meta={"source_id": "synthetic-source"})
client.force_login(owner.user)
fields = {"title": "My title", "abstractor": "My abstract", "revision": before.revision}
url = reverse("spaces:edit", args=[space.slug])
response = client.post(url, fields)
assert b"space changed" in response.content and b"My title" in response.content
space.refresh_from_db()
assert space.title == "Conflict"
current = pageops.read_page(space.slug, "index")
assert current.body == "Another writer's index\n"
fields["revision"] = current.revision
assert client.post(url, fields).status_code == 302
assert pageops.read_page(space.slug, "index").meta["source_id"] == "synthetic-source"
def test_page_symlink_cannot_read_or_overwrite_another_space(trees):
target = pageops.page_file("destination", "index")
pageops.page_file("source", "linked").symlink_to(target)
before = target.read_bytes()
with pytest.raises(pageops.PageOpsError, match="symlink"):
pageops.read_page("source", "linked")
with pytest.raises(pageops.PageOpsError, match="symlink"):
pageops.write_page("source", "linked", body="Tampered")
assert target.read_bytes() == before
@pytest.mark.django_db
def test_incoming_page_addresses_survive_rename_move_and_enforce_every_grant(trees, client):
from coulomb_social.apps.spaces.services import set_space_access, transfer_space_page
owner, reader = make_member("owner"), make_member("reader")
source = create_space(owner, title="Shared", slug="shared")
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")
save_space_page(owner, source, "notes", "renamed", expected_revision=doc.revision,
title="Notes", body=doc.body)
client.force_login(reader.user)
old_url = reverse("spaces:detail", args=[source.slug]) + "?page=notes"
response = client.get(old_url, follow=True)
assert response.status_code == 200
assert b"Retained content" in response.content
transfer_space_page(owner, source, "renamed", target, dest_slug="moved")
assert client.get(old_url).status_code == 404
assert b"Private" not in client.get(old_url).content
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
assert response.redirect_chain == [
(reverse("spaces:detail", args=[target.slug]) + "?page=moved", 302),
]
with pytest.raises(pageops.PageOpsError, match="reserved"):
pageops.write_page(source.slug, "notes", body="Hijack previous address")
set_space_access(owner, source, reader.pk, "remove")
assert client.get(old_url).status_code == 404
def test_failed_destination_publish_rolls_back_assets_and_keeps_source(trees, monkeypatch):
assets = pageops.assets_dir("source")
(assets / "diagram.png").write_bytes(b"synthetic image")
source = pageops.write_page("source", "notes", body="![Diagram](assets/diagram.png)")
before = Path(source.path).read_bytes()
original = pageops.os.replace
def fail_target(old, new):
if Path(new) == pageops.page_file("destination", "notes"):
raise OSError("synthetic destination failure")
return original(old, new)
monkeypatch.setattr(pageops.os, "replace", fail_target)
with pytest.raises(OSError):
pageops.transfer_page("source", "notes", "destination", "notes")
assert Path(source.path).read_bytes() == before
assert not pageops.read_page("destination", "notes").exists
assert list(pageops.assets_dir("destination").iterdir()) == []
assert not pageops.page_aliases("source")
@pytest.mark.django_db
def test_destination_disk_failure_is_visible_without_losing_source(trees, client, monkeypatch):
owner = make_member("owner")
space = create_space(owner, title="Work", slug="work")
source = pageops.write_page(space.slug, "notes", title="Notes", body="Original notes")
original = pageops.os.replace
def fail_target(old, new):
if Path(new) == pageops.page_file(space.slug, "copy"):
raise OSError("synthetic private storage path must not be disclosed")
return original(old, new)
monkeypatch.setattr(pageops.os, "replace", fail_target)
client.force_login(owner.user)
response = client.post(reverse("spaces:page_copy", args=[space.slug, source.slug]), {
"dest_slug": "copy", "title": "Copy",
})
assert response.status_code == 200
assert b"could not be confirmed" in response.content
assert b"private storage path" not in response.content
assert pageops.read_page(space.slug, "notes").body == "Original notes\n"
assert not pageops.read_page(space.slug, "copy").exists
def test_separate_processes_share_the_writer_lock_and_reject_stale_revision(trees):
doc = pageops.write_page("source", "notes", title="Notes", body="Original")
code = '''
import sys
from django.conf import settings
settings.configure(CONTENT_ROOT=sys.argv[1])
from coulomb_social.apps.spaces import pageops
if sys.argv[3] == "hold":
with pageops.content_write_lock():
print("locked", flush=True)
sys.stdin.readline()
pageops.write_page("source", "notes", body="First process", expected_revision=sys.argv[2])
else:
print("attempting", flush=True)
try:
pageops.write_page("source", "notes", body="Second process", expected_revision=sys.argv[2])
except pageops.PageOpsError:
print("conflict", flush=True)
'''
children = []
try:
for mode in ("hold", "write"):
child = subprocess.Popen(
[sys.executable, "-c", code, str(trees), doc.revision, mode],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
children.append(child)
assert child.stdout.readline().strip() == ("locked" if mode == "hold" else "attempting")
with pytest.raises(subprocess.TimeoutExpired):
children[1].communicate(timeout=0.25)
_, errors = children[0].communicate(input="release\n", timeout=10)
assert children[0].returncode == 0, errors
output, errors = children[1].communicate(timeout=10)
assert children[1].returncode == 0, errors
assert output.strip() == "conflict"
assert pageops.read_page("source", "notes").body == "First process\n"
finally:
for child in children:
if child.poll() is None:
child.terminate()
child.communicate(timeout=10)