@@ -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