"""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)