Protect published snapshots and concurrent edits

This commit is contained in:
2026-09-27 10:20:38 +03:30
parent b8e79c950a
commit f529f85bf0
3 changed files with 59 additions and 1 deletions

View File

@@ -1,10 +1,13 @@
import io
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection, connections, close_old_connections
from PIL import Image
from apps.content.models import EditorialPage, MediaAsset, PageRevision, PublicationAudit, SiteIdentity
@@ -103,6 +106,34 @@ def test_stale_publish_is_rejected_without_swapping_pointer():
assert second.status == "draft"
@pytest.mark.django_db(transaction=True)
@pytest.mark.skipif(connection.vendor != "postgresql", reason="SQLite has no row-level select_for_update locking")
def test_concurrent_publish_only_one_revision_wins():
page = EditorialPage.objects.create(slug="home", page_kind="sections")
user = actor("chief", superuser=True)
drafts = [save_page_draft(user, page, page_fields(title), home_sections()) for title in ("First", "Second")]
barrier = Barrier(3)
def attempt(revision_id):
close_old_connections()
barrier.wait()
try:
publish_page(user, PageRevision.objects.get(pk=revision_id), expected_published_id=None)
return "published"
except PublishConflict:
return "conflict"
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(attempt, draft.pk) for draft in drafts]
barrier.wait()
outcomes = [future.result(timeout=15) for future in futures]
assert sorted(outcomes) == ["conflict", "published"]
page.refresh_from_db()
assert page.published_revision_id in {draft.pk for draft in drafts}
def test_publish_rejects_incomplete_page_and_leaves_no_public_content():
page = EditorialPage.objects.create(slug="home", page_kind="sections")
user = actor("chief", superuser=True)
@@ -181,3 +212,7 @@ def test_revision_sections_and_audit_are_immutable():
audit.operation = "publish"
with pytest.raises(ValidationError):
audit.save()
published = publish_page(user, PageRevision.objects.get(pk=draft.pk), expected_published_id=None)
published.status = "draft"
with pytest.raises(ValidationError):
published.save()