Protect published snapshots and concurrent edits
This commit is contained in:
@@ -31,6 +31,9 @@ class MediaAsset(models.Model):
|
|||||||
approved = models.BooleanField(default=False)
|
approved = models.BooleanField(default=False)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.file.name or f"Media asset {self.pk}"
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
for field in ("alt", "source", "license"):
|
for field in ("alt", "source", "license"):
|
||||||
@@ -60,6 +63,11 @@ class MediaAsset(models.Model):
|
|||||||
self.bytes = size
|
self.bytes = size
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
if self.pk:
|
||||||
|
previous = type(self).objects.get(pk=self.pk)
|
||||||
|
protected = ("file", "alt", "source", "license", "width", "height", "bytes")
|
||||||
|
if previous.approved and (not self.approved or any(getattr(self, field) != getattr(previous, field) for field in protected)):
|
||||||
|
raise ValidationError("Approved media is immutable.")
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -77,6 +85,9 @@ class SiteIdentity(models.Model):
|
|||||||
("restore_content", "Can restore published content"),
|
("restore_content", "Can restore published content"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Association site identity"
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.published_revision_id and self.published_revision.site_id != self.pk:
|
if self.published_revision_id and self.published_revision.site_id != self.pk:
|
||||||
raise ValidationError("Published revision belongs to another site.")
|
raise ValidationError("Published revision belongs to another site.")
|
||||||
@@ -126,6 +137,8 @@ class SiteRevision(models.Model):
|
|||||||
protected = ("site_id", "number", "official_name", "short_name", "faculty_subline", "description", "logo_id", "logo_alt", "navigation", "contacts", "footer_copy", "seo_title", "seo_description", "seo_image_id", "created_by_id", "source_seed_key", "source_seed_version", "content_checksum")
|
protected = ("site_id", "number", "official_name", "short_name", "faculty_subline", "description", "logo_id", "logo_alt", "navigation", "contacts", "footer_copy", "seo_title", "seo_description", "seo_image_id", "created_by_id", "source_seed_key", "source_seed_version", "content_checksum")
|
||||||
if any(getattr(self, field) != getattr(previous, field) for field in protected):
|
if any(getattr(self, field) != getattr(previous, field) for field in protected):
|
||||||
raise ValidationError("Site revision content is immutable.")
|
raise ValidationError("Site revision content is immutable.")
|
||||||
|
if previous.status == "published" and (self.status != previous.status or self.published_by_id != previous.published_by_id or self.published_at != previous.published_at):
|
||||||
|
raise ValidationError("Published site revision metadata is immutable.")
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +147,9 @@ class EditorialPage(models.Model):
|
|||||||
page_kind = models.CharField(max_length=20, choices=[("sections", "Sections"), ("listing_intro", "Listing intro")])
|
page_kind = models.CharField(max_length=20, choices=[("sections", "Sections"), ("listing_intro", "Listing intro")])
|
||||||
published_revision = models.ForeignKey("PageRevision", null=True, blank=True, on_delete=models.PROTECT, related_name="published_for")
|
published_revision = models.ForeignKey("PageRevision", null=True, blank=True, on_delete=models.PROTECT, related_name="published_for")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.slug
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.slug not in PAGE_KINDS or self.page_kind != PAGE_KINDS[self.slug]:
|
if self.slug not in PAGE_KINDS or self.page_kind != PAGE_KINDS[self.slug]:
|
||||||
@@ -177,6 +193,8 @@ class PageRevision(models.Model):
|
|||||||
raise ValidationError("Page revision content is immutable.")
|
raise ValidationError("Page revision content is immutable.")
|
||||||
if self.content_checksum != previous.content_checksum and previous.content_checksum:
|
if self.content_checksum != previous.content_checksum and previous.content_checksum:
|
||||||
raise ValidationError("Page revision checksum is immutable.")
|
raise ValidationError("Page revision checksum is immutable.")
|
||||||
|
if previous.status == "published" and (self.status != previous.status or self.published_by_id != previous.published_by_id or self.published_at != previous.published_at):
|
||||||
|
raise ValidationError("Published page revision metadata is immutable.")
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +236,8 @@ class PageSection(models.Model):
|
|||||||
if self.key == "contact" and not self.visible:
|
if self.key == "contact" and not self.visible:
|
||||||
raise ValidationError("About contact must be visible.")
|
raise ValidationError("About contact must be visible.")
|
||||||
validators.text(self.heading, 120 if self.key in {"hero", "identity"} else 90, required=True)
|
validators.text(self.heading, 120 if self.key in {"hero", "identity"} else 90, required=True)
|
||||||
validators.text(self.body, 1200 if slug == "about" else 600)
|
body_limit = 500 if self.key == "hero" else 600 if self.key in {"participation", "identity"} else 1200
|
||||||
|
validators.text(self.body, body_limit)
|
||||||
validators.text(self.intro, 300)
|
validators.text(self.intro, 300)
|
||||||
validators.text(self.all_link_label, 40)
|
validators.text(self.all_link_label, 40)
|
||||||
validators.text(self.image_alt, 180)
|
validators.text(self.image_alt, 180)
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ def save_page_draft(actor, page: EditorialPage, fields: dict, sections: list[dic
|
|||||||
base_revision = PageRevision.objects.get(pk=base_revision.pk)
|
base_revision = PageRevision.objects.get(pk=base_revision.pk)
|
||||||
if base_revision.page_id != page.pk:
|
if base_revision.page_id != page.pk:
|
||||||
raise ValidationError("Base revision belongs to another page.")
|
raise ValidationError("Base revision belongs to another page.")
|
||||||
|
if base_revision.pk != page.revisions.order_by("-number").values_list("pk", flat=True).first():
|
||||||
|
raise PublishConflict("A newer revision exists. Reload the draft before editing.")
|
||||||
if not isinstance(sections, list):
|
if not isinstance(sections, list):
|
||||||
raise ValidationError("Sections must be a list.")
|
raise ValidationError("Sections must be a list.")
|
||||||
revision = PageRevision(page=page, number=_next_number(page), created_by=actor, **_fields(fields, PAGE_FIELDS))
|
revision = PageRevision(page=page, number=_next_number(page), created_by=actor, **_fields(fields, PAGE_FIELDS))
|
||||||
@@ -132,6 +134,8 @@ def save_site_draft(actor, site: SiteIdentity, fields: dict, *, base_revision=No
|
|||||||
base_revision = SiteRevision.objects.get(pk=base_revision.pk)
|
base_revision = SiteRevision.objects.get(pk=base_revision.pk)
|
||||||
if base_revision.site_id != site.pk:
|
if base_revision.site_id != site.pk:
|
||||||
raise ValidationError("Base revision belongs to another site.")
|
raise ValidationError("Base revision belongs to another site.")
|
||||||
|
if base_revision.pk != site.revisions.order_by("-number").values_list("pk", flat=True).first():
|
||||||
|
raise PublishConflict("A newer revision exists. Reload the draft before editing.")
|
||||||
revision = SiteRevision(site=site, number=_next_number(site), created_by=actor, **_fields(fields, SITE_FIELDS))
|
revision = SiteRevision(site=site, number=_next_number(site), created_by=actor, **_fields(fields, SITE_FIELDS))
|
||||||
revision.full_clean()
|
revision.full_clean()
|
||||||
revision.content_checksum = _site_checksum(revision)
|
revision.content_checksum = _site_checksum(revision)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import io
|
import io
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.db import connection, connections, close_old_connections
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from apps.content.models import EditorialPage, MediaAsset, PageRevision, PublicationAudit, SiteIdentity
|
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"
|
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():
|
def test_publish_rejects_incomplete_page_and_leaves_no_public_content():
|
||||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||||
user = actor("chief", superuser=True)
|
user = actor("chief", superuser=True)
|
||||||
@@ -181,3 +212,7 @@ def test_revision_sections_and_audit_are_immutable():
|
|||||||
audit.operation = "publish"
|
audit.operation = "publish"
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
audit.save()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user