Skip to content

Commit 5c69fc1

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 c7f6a2b commit 5c69fc1

3 files changed

Lines changed: 283 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.test import TestCase
1010

1111
from openedx_content.applets.collections import api as collection_api
@@ -724,3 +724,61 @@ def test_get_or_create_component_type_by_entity_key_invalid_format(self):
724724
components_api.get_or_create_component_type_by_entity_key("not-enough-parts")
725725

726726
self.assertIn("Invalid entity_key format", str(ctx.exception))
727+
728+
729+
class CrossPackageMediaTestCase(ComponentTestCase):
730+
"""
731+
Tests for validation gaps around cross-LearningPackage media references.
732+
"""
733+
learning_package_2: LearningPackage
734+
735+
@classmethod
736+
def setUpTestData(cls) -> None:
737+
super().setUpTestData()
738+
cls.learning_package_2 = publishing_api.create_learning_package(
739+
key="CrossPackageMediaTestCase-lp2",
740+
title="Second Learning Package for Cross-Package Media Test",
741+
)
742+
743+
def test_create_component_version_media_rejects_cross_package_media(self) -> None:
744+
"""
745+
create_component_version_media() should reject Media that belongs
746+
to a different LearningPackage than the ComponentVersion.
747+
748+
If this validation is missing, a ComponentVersion in LP 1 can
749+
reference Media data from LP 2. This violates the documented invariant
750+
that Media is associated with a specific LearningPackage and breaks
751+
the assumption that all content within a LearningPackage is
752+
self-contained (e.g. for import/export, deletion, storage accounting).
753+
"""
754+
# Create a component + version in LP 1
755+
_component, component_version = components_api.create_component_and_version(
756+
self.learning_package.id,
757+
component_type=self.problem_type,
758+
local_key="component_in_lp1",
759+
title="Component in LP 1",
760+
created=self.now,
761+
created_by=None,
762+
)
763+
764+
# Create media in LP 2
765+
text_media_type = media_api.get_or_create_media_type("text/plain")
766+
media_in_lp2 = media_api.get_or_create_text_media(
767+
self.learning_package_2.id,
768+
text_media_type.id,
769+
text="This media belongs to LP 2",
770+
created=self.now,
771+
)
772+
773+
# Confirm the media is in LP 2, not LP 1
774+
assert media_in_lp2.learning_package_id == self.learning_package_2.id
775+
assert media_in_lp2.learning_package_id != self.learning_package.id
776+
777+
# This should raise an error because media_in_lp2 is from a different
778+
# LearningPackage than the ComponentVersion.
779+
with self.assertRaises((ValidationError, ValueError)):
780+
components_api.create_component_version_media(
781+
component_version.pk,
782+
media_in_lp2.pk,
783+
key="cross_package_file.txt",
784+
)

tests/openedx_content/applets/containers/test_api.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,3 +1646,59 @@ def test_soft_delete_container(lp: LearningPackage, parent_of_two: TestContainer
16461646
child_entity1.refresh_from_db()
16471647
assert child_entity1.versioning.draft == child_entity1.versioning.published
16481648
assert child_entity1.versioning.draft is not None
1649+
1650+
1651+
def test_publish_container_without_children_should_fail(lp: LearningPackage):
1652+
"""
1653+
Publishing a container with publish_dependencies=False when its unpinned
1654+
children have never been published should either fail at publish time or
1655+
produce a readable published state.
1656+
1657+
If this validation is missing, the published container references child
1658+
entities that have no Published row. Reading the published container's
1659+
contents via get_entities_in_container(published=True) will crash with
1660+
RelatedObjectDoesNotExist because row.entity.published doesn't exist for
1661+
never-published children.
1662+
"""
1663+
# Create child entities (draft-only, never published)
1664+
child_1 = create_test_entity(lp, key="unpublished_child_1", title="Unpublished Child 1")
1665+
child_2 = create_test_entity(lp, key="unpublished_child_2", title="Unpublished Child 2")
1666+
1667+
# Create a container with unpinned references to these children
1668+
container = create_test_container(
1669+
lp,
1670+
key="container_with_unpublished_children",
1671+
title="Container with Unpublished Children",
1672+
entities=[child_1, child_2], # unpinned references
1673+
)
1674+
1675+
# Publish ONLY the container, skipping its dependencies (children).
1676+
# This should either:
1677+
# (a) raise an error at publish time (preventing the bad state), or
1678+
# (b) produce a published state where get_entities_in_container works
1679+
# gracefully (e.g. returns an empty list for unpublished children).
1680+
container_drafts = publishing_api.get_all_drafts(lp.id).filter(
1681+
entity=container.publishable_entity,
1682+
)
1683+
publish_log = publishing_api.publish_from_drafts(
1684+
lp.id,
1685+
container_drafts,
1686+
publish_dependencies=False,
1687+
)
1688+
assert publish_log is not None
1689+
1690+
# The children were never published, so reading the published container
1691+
# should not crash. It should either raise a clear error, or gracefully
1692+
# handle the missing children.
1693+
container.refresh_from_db()
1694+
assert container.versioning.published is not None
1695+
1696+
# This is the call that currently crashes with RelatedObjectDoesNotExist
1697+
# because the unpinned children have no Published row.
1698+
entries = containers_api.get_entities_in_container(
1699+
container,
1700+
published=True,
1701+
)
1702+
# If we get here without crashing, the children should be excluded since
1703+
# they were never published.
1704+
assert len(entries) == 0

tests/openedx_content/applets/publishing/test_api.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
DraftSideEffect,
2020
LearningPackage,
2121
PublishableEntity,
22+
PublishableEntityVersion,
2223
PublishLog,
24+
Published,
2325
)
2426

2527
User = get_user_model()
@@ -1483,3 +1485,169 @@ def test_container_next_version(self) -> None:
14831485
# Test that I can get a [PublishLog] history of a given container and its children, that includes changes made to the
14841486
# child components while they were part of the container but excludes changes made to those children while they were
14851487
# not part of the container. 🫣
1488+
1489+
1490+
class CrossEntityValidationTestCase(TestCase):
1491+
"""
1492+
Tests for validation gaps where API calls can corrupt state by mixing
1493+
entities/versions/packages that shouldn't be combined.
1494+
"""
1495+
now: datetime
1496+
learning_package_1: LearningPackage
1497+
learning_package_2: LearningPackage
1498+
1499+
@classmethod
1500+
def setUpTestData(cls) -> None:
1501+
cls.now = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
1502+
cls.learning_package_1 = publishing_api.create_learning_package(
1503+
"cross_entity_validation_lp_1",
1504+
"Cross-Entity Validation LP 1",
1505+
created=cls.now,
1506+
)
1507+
cls.learning_package_2 = publishing_api.create_learning_package(
1508+
"cross_entity_validation_lp_2",
1509+
"Cross-Entity Validation LP 2",
1510+
created=cls.now,
1511+
)
1512+
1513+
def test_set_draft_version_rejects_version_from_different_entity(self) -> None:
1514+
"""
1515+
set_draft_version() should reject a PublishableEntityVersion that
1516+
belongs to a different PublishableEntity.
1517+
1518+
If this validation is missing, entity_a's Draft will point to a version
1519+
that was defined for entity_b. This corrupts the publishing state:
1520+
component_a.versioning.draft would return component_b's data, and
1521+
publishing would propagate the wrong content.
1522+
"""
1523+
entity_a = publishing_api.create_publishable_entity(
1524+
self.learning_package_1.id,
1525+
"entity_a",
1526+
created=self.now,
1527+
created_by=None,
1528+
)
1529+
entity_b = publishing_api.create_publishable_entity(
1530+
self.learning_package_1.id,
1531+
"entity_b",
1532+
created=self.now,
1533+
created_by=None,
1534+
)
1535+
1536+
# Create v1 for entity_a (draft_a -> v1)
1537+
publishing_api.create_publishable_entity_version(
1538+
entity_a.id,
1539+
version_num=1,
1540+
title="Entity A v1",
1541+
created=self.now,
1542+
created_by=None,
1543+
)
1544+
1545+
# Create v1 and v2 for entity_b. After v2 is created, entity_b's
1546+
# draft points to v2, so v1 is "free" (no Draft points to it) and
1547+
# won't trigger a OneToOne constraint violation.
1548+
version_b_v1 = publishing_api.create_publishable_entity_version(
1549+
entity_b.id,
1550+
version_num=1,
1551+
title="Entity B v1",
1552+
created=self.now,
1553+
created_by=None,
1554+
)
1555+
publishing_api.create_publishable_entity_version(
1556+
entity_b.id,
1557+
version_num=2,
1558+
title="Entity B v2",
1559+
created=self.now,
1560+
created_by=None,
1561+
)
1562+
1563+
# Confirm version_b_v1 belongs to entity_b, not entity_a.
1564+
assert version_b_v1.entity_id == entity_b.id
1565+
assert version_b_v1.entity_id != entity_a.id
1566+
1567+
# This should raise an error because version_b_v1 belongs to entity_b,
1568+
# not entity_a. Without validation, this silently corrupts entity_a's
1569+
# draft to point to entity_b's content.
1570+
with pytest.raises((ValidationError, ValueError)):
1571+
publishing_api.set_draft_version(entity_a.id, version_b_v1.pk)
1572+
1573+
def test_publish_from_drafts_rejects_cross_package_drafts(self) -> None:
1574+
"""
1575+
publish_from_drafts() should reject drafts that don't belong to the
1576+
specified LearningPackage.
1577+
1578+
If this validation is missing, a PublishLog is created for LP 1 but
1579+
with PublishLogRecords referencing entities from LP 2. The Published
1580+
rows for LP 2's entities would point to records in LP 1's PublishLog,
1581+
corrupting the publish history for both packages.
1582+
"""
1583+
# Create an entity in LP 2
1584+
entity_in_lp2 = publishing_api.create_publishable_entity(
1585+
self.learning_package_2.id,
1586+
"entity_in_lp2",
1587+
created=self.now,
1588+
created_by=None,
1589+
)
1590+
publishing_api.create_publishable_entity_version(
1591+
entity_in_lp2.id,
1592+
version_num=1,
1593+
title="Entity in LP2",
1594+
created=self.now,
1595+
created_by=None,
1596+
)
1597+
1598+
# Get drafts from LP 2
1599+
drafts_from_lp2 = Draft.objects.filter(
1600+
entity__learning_package_id=self.learning_package_2.id
1601+
)
1602+
assert drafts_from_lp2.exists()
1603+
1604+
# This should raise an error because we're trying to publish LP 2's
1605+
# drafts under LP 1's PublishLog.
1606+
with pytest.raises((ValidationError, ValueError)):
1607+
publishing_api.publish_from_drafts(
1608+
self.learning_package_1.id,
1609+
drafts_from_lp2,
1610+
)
1611+
1612+
def test_create_version_rejects_cross_package_dependencies(self) -> None:
1613+
"""
1614+
create_publishable_entity_version() should reject dependencies that
1615+
are from a different LearningPackage.
1616+
1617+
If this validation is missing, PublishableEntityVersionDependency rows
1618+
are created linking entities across packages. The side-effect machinery
1619+
would then propagate draft/publish changes across LearningPackage
1620+
boundaries, creating DraftChangeLogRecords and PublishLogRecords in the
1621+
wrong package's logs.
1622+
"""
1623+
entity_in_lp1 = publishing_api.create_publishable_entity(
1624+
self.learning_package_1.id,
1625+
"entity_in_lp1",
1626+
created=self.now,
1627+
created_by=None,
1628+
)
1629+
entity_in_lp2 = publishing_api.create_publishable_entity(
1630+
self.learning_package_2.id,
1631+
"dep_entity_in_lp2",
1632+
created=self.now,
1633+
created_by=None,
1634+
)
1635+
publishing_api.create_publishable_entity_version(
1636+
entity_in_lp2.id,
1637+
version_num=1,
1638+
title="Dependency in LP2",
1639+
created=self.now,
1640+
created_by=None,
1641+
)
1642+
1643+
# This should raise an error because entity_in_lp2 is from a
1644+
# different LearningPackage than entity_in_lp1.
1645+
with pytest.raises((ValidationError, ValueError)):
1646+
publishing_api.create_publishable_entity_version(
1647+
entity_in_lp1.id,
1648+
version_num=1,
1649+
title="Entity in LP1 with cross-package dep",
1650+
created=self.now,
1651+
created_by=None,
1652+
dependencies=[entity_in_lp2.id],
1653+
)

0 commit comments

Comments
 (0)