Skip to content

Commit 34a6794

Browse files
test: add tests for several places where our content APIs lack validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c22cc1b commit 34a6794

3 files changed

Lines changed: 317 additions & 1 deletion

File tree

tests/openedx_content/applets/components/test_api.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from django.contrib.auth import get_user_model
77
from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user
8-
from django.core.exceptions import ObjectDoesNotExist
8+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
99
from django.db import IntegrityError
1010
from django.test import TestCase
1111

@@ -744,3 +744,61 @@ def test_get_or_create_component_type_existing(self):
744744
assert comp_type.namespace == "video"
745745
assert comp_type.name == "youtube"
746746
assert ComponentType.objects.count() == 1
747+
748+
749+
class CrossPackageMediaTestCase(ComponentTestCase):
750+
"""
751+
Tests for validation gaps around cross-LearningPackage media references.
752+
"""
753+
learning_package_2: LearningPackage
754+
755+
@classmethod
756+
def setUpTestData(cls) -> None:
757+
super().setUpTestData()
758+
cls.learning_package_2 = publishing_api.create_learning_package(
759+
key="CrossPackageMediaTestCase-lp2",
760+
title="Second Learning Package for Cross-Package Media Test",
761+
)
762+
763+
def test_create_component_version_media_rejects_cross_package_media(self) -> None:
764+
"""
765+
create_component_version_media() should reject Media that belongs
766+
to a different LearningPackage than the ComponentVersion.
767+
768+
If this validation is missing, a ComponentVersion in LP 1 can
769+
reference Media data from LP 2. This violates the documented invariant
770+
that Media is associated with a specific LearningPackage and breaks
771+
the assumption that all content within a LearningPackage is
772+
self-contained (e.g. for import/export, deletion, storage accounting).
773+
"""
774+
# Create a component + version in LP 1
775+
_component, component_version = components_api.create_component_and_version(
776+
self.learning_package.id,
777+
component_type=self.problem_type,
778+
local_key="component_in_lp1",
779+
title="Component in LP 1",
780+
created=self.now,
781+
created_by=None,
782+
)
783+
784+
# Create media in LP 2
785+
text_media_type = media_api.get_or_create_media_type("text/plain")
786+
media_in_lp2 = media_api.get_or_create_text_media(
787+
self.learning_package_2.id,
788+
text_media_type.id,
789+
text="This media belongs to LP 2",
790+
created=self.now,
791+
)
792+
793+
# Confirm the media is in LP 2, not LP 1
794+
assert media_in_lp2.learning_package_id == self.learning_package_2.id
795+
assert media_in_lp2.learning_package_id != self.learning_package.id
796+
797+
# This should raise an error because media_in_lp2 is from a different
798+
# LearningPackage than the ComponentVersion.
799+
with self.assertRaises((ValidationError, ValueError)):
800+
components_api.create_component_version_media(
801+
component_version.pk,
802+
media_in_lp2.pk,
803+
key="cross_package_file.txt",
804+
)

tests/openedx_content/applets/containers/test_api.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,3 +1685,59 @@ def test_soft_delete_container(lp: LearningPackage, parent_of_two: TestContainer
16851685
child_entity1.refresh_from_db()
16861686
assert child_entity1.versioning.draft == child_entity1.versioning.published
16871687
assert child_entity1.versioning.draft is not None
1688+
1689+
1690+
def test_publish_container_without_children_should_fail(lp: LearningPackage):
1691+
"""
1692+
Publishing a container with publish_dependencies=False when its unpinned
1693+
children have never been published should either fail at publish time or
1694+
produce a readable published state.
1695+
1696+
If this validation is missing, the published container references child
1697+
entities that have no Published row. Reading the published container's
1698+
contents via get_entities_in_container(published=True) will crash with
1699+
RelatedObjectDoesNotExist because row.entity.published doesn't exist for
1700+
never-published children.
1701+
"""
1702+
# Create child entities (draft-only, never published)
1703+
child_1 = create_test_entity(lp, key="unpublished_child_1", title="Unpublished Child 1")
1704+
child_2 = create_test_entity(lp, key="unpublished_child_2", title="Unpublished Child 2")
1705+
1706+
# Create a container with unpinned references to these children
1707+
container = create_test_container(
1708+
lp,
1709+
key="container_with_unpublished_children",
1710+
title="Container with Unpublished Children",
1711+
entities=[child_1, child_2], # unpinned references
1712+
)
1713+
1714+
# Publish ONLY the container, skipping its dependencies (children).
1715+
# This should either:
1716+
# (a) raise an error at publish time (preventing the bad state), or
1717+
# (b) produce a published state where get_entities_in_container works
1718+
# gracefully (e.g. returns an empty list for unpublished children).
1719+
container_drafts = publishing_api.get_all_drafts(lp.id).filter(
1720+
entity=container.publishable_entity,
1721+
)
1722+
publish_log = publishing_api.publish_from_drafts(
1723+
lp.id,
1724+
container_drafts,
1725+
publish_dependencies=False,
1726+
)
1727+
assert publish_log is not None
1728+
1729+
# The children were never published, so reading the published container
1730+
# should not crash. It should either raise a clear error, or gracefully
1731+
# handle the missing children.
1732+
container.refresh_from_db()
1733+
assert container.versioning.published is not None
1734+
1735+
# This is the call that currently crashes with RelatedObjectDoesNotExist
1736+
# because the unpinned children have no Published row.
1737+
entries = containers_api.get_entities_in_container(
1738+
container,
1739+
published=True,
1740+
)
1741+
# If we get here without crashing, the children should be excluded since
1742+
# they were never published.
1743+
assert len(entries) == 0

tests/openedx_content/applets/publishing/test_api.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,3 +2345,205 @@ def test_container_next_version(self) -> None:
23452345
# Test that I can get a [PublishLog] history of a given container and its children, that includes changes made to the
23462346
# child components while they were part of the container but excludes changes made to those children while they were
23472347
# not part of the container. 🫣
2348+
2349+
2350+
class CrossEntityValidationTestCase(TestCase):
2351+
"""
2352+
Tests for validation gaps where API calls can corrupt state by mixing
2353+
entities/versions/packages that shouldn't be combined.
2354+
"""
2355+
now: datetime
2356+
learning_package_1: LearningPackage
2357+
learning_package_2: LearningPackage
2358+
2359+
@classmethod
2360+
def setUpTestData(cls) -> None:
2361+
cls.now = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
2362+
cls.learning_package_1 = publishing_api.create_learning_package(
2363+
"cross_entity_validation_lp_1",
2364+
"Cross-Entity Validation LP 1",
2365+
created=cls.now,
2366+
)
2367+
cls.learning_package_2 = publishing_api.create_learning_package(
2368+
"cross_entity_validation_lp_2",
2369+
"Cross-Entity Validation LP 2",
2370+
created=cls.now,
2371+
)
2372+
2373+
def test_set_draft_version_rejects_version_from_different_entity(self) -> None:
2374+
"""
2375+
set_draft_version() should reject a PublishableEntityVersion that
2376+
belongs to a different PublishableEntity.
2377+
2378+
If this validation is missing, entity_a's Draft will point to a version
2379+
that was defined for entity_b. This corrupts the publishing state:
2380+
component_a.versioning.draft would return component_b's data, and
2381+
publishing would propagate the wrong content.
2382+
"""
2383+
entity_a = publishing_api.create_publishable_entity(
2384+
self.learning_package_1.id,
2385+
"entity_a",
2386+
created=self.now,
2387+
created_by=None,
2388+
)
2389+
entity_b = publishing_api.create_publishable_entity(
2390+
self.learning_package_1.id,
2391+
"entity_b",
2392+
created=self.now,
2393+
created_by=None,
2394+
)
2395+
2396+
# Create v1 for entity_a (draft_a -> v1)
2397+
publishing_api.create_publishable_entity_version(
2398+
entity_a.id,
2399+
version_num=1,
2400+
title="Entity A v1",
2401+
created=self.now,
2402+
created_by=None,
2403+
)
2404+
2405+
# Create v1 and v2 for entity_b. After v2 is created, entity_b's
2406+
# draft points to v2, so v1 is "free" (no Draft points to it) and
2407+
# won't trigger a OneToOne constraint violation.
2408+
version_b_v1 = publishing_api.create_publishable_entity_version(
2409+
entity_b.id,
2410+
version_num=1,
2411+
title="Entity B v1",
2412+
created=self.now,
2413+
created_by=None,
2414+
)
2415+
publishing_api.create_publishable_entity_version(
2416+
entity_b.id,
2417+
version_num=2,
2418+
title="Entity B v2",
2419+
created=self.now,
2420+
created_by=None,
2421+
)
2422+
2423+
# Confirm version_b_v1 belongs to entity_b, not entity_a.
2424+
assert version_b_v1.entity_id == entity_b.id
2425+
assert version_b_v1.entity_id != entity_a.id
2426+
2427+
# This should raise an error because version_b_v1 belongs to entity_b,
2428+
# not entity_a. Without validation, this silently corrupts entity_a's
2429+
# draft to point to entity_b's content.
2430+
with pytest.raises((ValidationError, ValueError)):
2431+
publishing_api.set_draft_version(entity_a.id, version_b_v1.pk)
2432+
2433+
def test_publish_from_drafts_rejects_cross_package_drafts(self) -> None:
2434+
"""
2435+
publish_from_drafts() should reject drafts that don't belong to the
2436+
specified LearningPackage.
2437+
2438+
If this validation is missing, a PublishLog is created for LP 1 but
2439+
with PublishLogRecords referencing entities from LP 2. The Published
2440+
rows for LP 2's entities would point to records in LP 1's PublishLog,
2441+
corrupting the publish history for both packages.
2442+
"""
2443+
# Create an entity in LP 2
2444+
entity_in_lp2 = publishing_api.create_publishable_entity(
2445+
self.learning_package_2.id,
2446+
"entity_in_lp2",
2447+
created=self.now,
2448+
created_by=None,
2449+
)
2450+
publishing_api.create_publishable_entity_version(
2451+
entity_in_lp2.id,
2452+
version_num=1,
2453+
title="Entity in LP2",
2454+
created=self.now,
2455+
created_by=None,
2456+
)
2457+
2458+
# Get drafts from LP 2
2459+
drafts_from_lp2 = Draft.objects.filter(
2460+
entity__learning_package_id=self.learning_package_2.id
2461+
)
2462+
assert drafts_from_lp2.exists()
2463+
2464+
# This should raise an error because we're trying to publish LP 2's
2465+
# drafts under LP 1's PublishLog.
2466+
with pytest.raises((ValidationError, ValueError)):
2467+
publishing_api.publish_from_drafts(
2468+
self.learning_package_1.id,
2469+
drafts_from_lp2,
2470+
)
2471+
2472+
def test_create_version_rejects_cross_package_dependencies(self) -> None:
2473+
"""
2474+
create_publishable_entity_version() should reject dependencies that
2475+
are from a different LearningPackage.
2476+
2477+
If this validation is missing, PublishableEntityVersionDependency rows
2478+
are created linking entities across packages. The side-effect machinery
2479+
would then propagate draft/publish changes across LearningPackage
2480+
boundaries, creating DraftChangeLogRecords and PublishLogRecords in the
2481+
wrong package's logs.
2482+
"""
2483+
entity_in_lp1 = publishing_api.create_publishable_entity(
2484+
self.learning_package_1.id,
2485+
"entity_in_lp1",
2486+
created=self.now,
2487+
created_by=None,
2488+
)
2489+
entity_in_lp2 = publishing_api.create_publishable_entity(
2490+
self.learning_package_2.id,
2491+
"dep_entity_in_lp2",
2492+
created=self.now,
2493+
created_by=None,
2494+
)
2495+
publishing_api.create_publishable_entity_version(
2496+
entity_in_lp2.id,
2497+
version_num=1,
2498+
title="Dependency in LP2",
2499+
created=self.now,
2500+
created_by=None,
2501+
)
2502+
2503+
# This should raise an error because entity_in_lp2 is from a
2504+
# different LearningPackage than entity_in_lp1.
2505+
with pytest.raises((ValidationError, ValueError)):
2506+
publishing_api.create_publishable_entity_version(
2507+
entity_in_lp1.id,
2508+
version_num=1,
2509+
title="Entity in LP1 with cross-package dep",
2510+
created=self.now,
2511+
created_by=None,
2512+
dependencies=[entity_in_lp2.id],
2513+
)
2514+
2515+
def test_publish_functions_rejected_inside_bulk_draft_changes_for(self) -> None:
2516+
"""
2517+
publish_all_drafts() and publish_from_drafts() must not be callable
2518+
from within a bulk_draft_changes_for() context.
2519+
2520+
bulk_draft_changes_for() opens a DraftChangeLog for accumulating draft
2521+
edits; running a publish inside it mixes draft-change bookkeeping with
2522+
publish bookkeeping in the same atomic block, which corrupts the
2523+
ordering of DraftChangeLog vs. PublishLog records and can leave Drafts
2524+
and Published rows out of sync if the outer context later raises.
2525+
"""
2526+
entity = publishing_api.create_publishable_entity(
2527+
self.learning_package_1.id,
2528+
"entity_for_bulk_publish_check",
2529+
created=self.now,
2530+
created_by=None,
2531+
)
2532+
publishing_api.create_publishable_entity_version(
2533+
entity.id,
2534+
version_num=1,
2535+
title="Entity v1",
2536+
created=self.now,
2537+
created_by=None,
2538+
)
2539+
2540+
with pytest.raises((ValidationError, RuntimeError)):
2541+
with publishing_api.bulk_draft_changes_for(self.learning_package_1.id):
2542+
publishing_api.publish_all_drafts(self.learning_package_1.id)
2543+
2544+
with pytest.raises((ValidationError, RuntimeError)):
2545+
with publishing_api.bulk_draft_changes_for(self.learning_package_1.id):
2546+
publishing_api.publish_from_drafts(
2547+
self.learning_package_1.id,
2548+
Draft.objects.filter(entity__learning_package_id=self.learning_package_1.id),
2549+
)

0 commit comments

Comments
 (0)