|
25 | 25 | LIBRARY_COLLECTION_UPDATED, |
26 | 26 | LIBRARY_CONTAINER_UPDATED, |
27 | 27 | ) |
| 28 | +from openedx_authz.api.users import get_user_role_assignments_in_scope |
28 | 29 | from openedx_learning.api import authoring as authoring_api |
29 | 30 |
|
| 31 | +from common.djangoapps.student.tests.factories import UserFactory |
30 | 32 | from .. import api |
31 | 33 | from ..models import ContentLibrary |
32 | 34 | from .base import ContentLibrariesRestApiTest |
@@ -859,8 +861,304 @@ def test_delete_component_and_revert(self): |
859 | 861 | api.revert_changes(self.lib1.library_key) |
860 | 862 |
|
861 | 863 | assert container_event_receiver.call_count == 1 |
862 | | - assert { |
863 | | - "signal": LIBRARY_CONTAINER_UPDATED, |
864 | | - "sender": None, |
865 | | - "library_container": LibraryContainerData(container_key=self.unit1.container_key), |
866 | | - }.items() <= container_event_receiver.call_args_list[0].kwargs.items() |
| 864 | + self.assertDictContainsEntries( |
| 865 | + container_event_receiver.call_args_list[0].kwargs, |
| 866 | + { |
| 867 | + "signal": LIBRARY_CONTAINER_UPDATED, |
| 868 | + "sender": None, |
| 869 | + "library_container": LibraryContainerData( |
| 870 | + container_key=self.unit3.container_key |
| 871 | + ), |
| 872 | + }, |
| 873 | + ) |
| 874 | + |
| 875 | + def test_copy_and_paste_container_same_library(self) -> None: |
| 876 | + # Copy a section with children |
| 877 | + api.copy_container(self.section1.container_key, self.user.id) |
| 878 | + # Paste the container |
| 879 | + new_container: api.ContainerMetadata = ( |
| 880 | + api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user) # type: ignore[assignment] |
| 881 | + ) |
| 882 | + |
| 883 | + # Verify that the container is copied |
| 884 | + assert new_container.container_type == self.section1.container_type |
| 885 | + assert new_container.display_name == self.section1.display_name |
| 886 | + |
| 887 | + # Verify that the children are linked |
| 888 | + subsections = api.get_container_children(new_container.container_key) |
| 889 | + assert len(subsections) == 2 |
| 890 | + assert isinstance(subsections[0], api.ContainerMetadata) |
| 891 | + assert subsections[0].container_key == self.subsection1.container_key |
| 892 | + assert isinstance(subsections[1], api.ContainerMetadata) |
| 893 | + assert subsections[1].container_key == self.subsection2.container_key |
| 894 | + |
| 895 | + def test_copy_and_paste_container_another_library(self) -> None: |
| 896 | + # Copy a section with children |
| 897 | + api.copy_container(self.section1.container_key, self.user.id) |
| 898 | + |
| 899 | + self._create_library("test-lib-cont-2", "Test Library 2") |
| 900 | + lib2 = ContentLibrary.objects.get(slug="test-lib-cont-2") |
| 901 | + # Paste the container |
| 902 | + new_container: api.ContainerMetadata = ( |
| 903 | + api.import_staged_content_from_user_clipboard(lib2.library_key, self.user) # type: ignore[assignment] |
| 904 | + ) |
| 905 | + |
| 906 | + # Verify that the container is copied |
| 907 | + assert new_container.container_type == self.section1.container_type |
| 908 | + assert new_container.display_name == self.section1.display_name |
| 909 | + |
| 910 | + # Verify that the children are copied |
| 911 | + subsections = api.get_container_children(new_container.container_key) |
| 912 | + assert len(subsections) == 2 |
| 913 | + assert isinstance(subsections[0], api.ContainerMetadata) |
| 914 | + assert subsections[0].container_key != self.subsection1.container_key # This subsection was copied |
| 915 | + assert subsections[0].display_name == self.subsection1.display_name |
| 916 | + units_subsection1 = api.get_container_children(subsections[0].container_key) |
| 917 | + assert len(units_subsection1) == 2 |
| 918 | + assert isinstance(units_subsection1[0], api.ContainerMetadata) |
| 919 | + assert units_subsection1[0].container_key != self.unit1.container_key # This unit was copied |
| 920 | + assert units_subsection1[0].display_name == self.unit1.display_name == "Unit 1" |
| 921 | + unit1_components = api.get_container_children(units_subsection1[0].container_key) |
| 922 | + assert len(unit1_components) == 2 |
| 923 | + assert isinstance(unit1_components[0], api.LibraryXBlockMetadata) |
| 924 | + assert unit1_components[0].usage_key != self.problem_block_usage_key # This component was copied |
| 925 | + assert isinstance(unit1_components[1], api.LibraryXBlockMetadata) |
| 926 | + assert unit1_components[1].usage_key != self.html_block_usage_key # This component was copied |
| 927 | + |
| 928 | + assert isinstance(units_subsection1[1], api.ContainerMetadata) |
| 929 | + assert units_subsection1[1].container_key != self.unit2.container_key # This unit was copied |
| 930 | + assert units_subsection1[1].display_name == self.unit2.display_name == "Unit 2" |
| 931 | + unit2_components = api.get_container_children(units_subsection1[1].container_key) |
| 932 | + assert len(unit2_components) == 1 |
| 933 | + assert isinstance(unit2_components[0], api.LibraryXBlockMetadata) |
| 934 | + assert unit2_components[0].usage_key != self.html_block_usage_key |
| 935 | + |
| 936 | + # This is the same component, so it should not be duplicated |
| 937 | + assert unit1_components[1].usage_key == unit2_components[0].usage_key |
| 938 | + |
| 939 | + assert isinstance(subsections[1], api.ContainerMetadata) |
| 940 | + assert subsections[1].container_key != self.subsection2.container_key # This subsection was copied |
| 941 | + assert subsections[1].display_name == self.subsection2.display_name |
| 942 | + units_subsection2 = api.get_container_children(subsections[1].container_key) |
| 943 | + assert len(units_subsection2) == 1 |
| 944 | + assert isinstance(units_subsection2[0], api.ContainerMetadata) |
| 945 | + assert units_subsection2[0].container_key != self.unit1.container_key # This unit was copied |
| 946 | + assert units_subsection2[0].display_name == self.unit1.display_name |
| 947 | + |
| 948 | + # This is the same unit, so it should not be duplicated |
| 949 | + assert units_subsection1[0].container_key == units_subsection2[0].container_key |
| 950 | + |
| 951 | + |
| 952 | +class ContentLibraryExportTest(ContentLibrariesRestApiTest): |
| 953 | + """ |
| 954 | + Tests for Content Library API export methods. |
| 955 | + """ |
| 956 | + |
| 957 | + def setUp(self) -> None: |
| 958 | + super().setUp() |
| 959 | + |
| 960 | + # Create Content Libraries |
| 961 | + self._create_library("test-lib-exp-1", "Test Library Export 1") |
| 962 | + |
| 963 | + # Fetch the created ContentLibrary objects so we can access their learning_package.id |
| 964 | + self.lib1 = ContentLibrary.objects.get(slug="test-lib-exp-1") |
| 965 | + self.wrong_task_id = '11111111-1111-1111-1111-111111111111' |
| 966 | + |
| 967 | + def test_get_backup_task_status_no_task(self) -> None: |
| 968 | + status = api.get_backup_task_status(self.user.id, "") |
| 969 | + assert status is None |
| 970 | + |
| 971 | + def test_get_backup_task_status_wrong_task_id(self) -> None: |
| 972 | + status = api.get_backup_task_status(self.user.id, task_id=self.wrong_task_id) |
| 973 | + assert status is None |
| 974 | + |
| 975 | + def test_get_backup_task_status_in_progress(self) -> None: |
| 976 | + # Create a mock UserTaskStatus in IN_PROGRESS state |
| 977 | + task_id = str(uuid.uuid4()) |
| 978 | + mock_task = UserTaskStatus( |
| 979 | + task_id=task_id, |
| 980 | + user_id=self.user.id, |
| 981 | + name=f"Export of {self.lib1.library_key}", |
| 982 | + state=UserTaskStatus.IN_PROGRESS |
| 983 | + ) |
| 984 | + |
| 985 | + with mock.patch( |
| 986 | + 'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get' |
| 987 | + ) as mock_get: |
| 988 | + mock_get.return_value = mock_task |
| 989 | + |
| 990 | + status = api.get_backup_task_status(self.user.id, task_id=task_id) |
| 991 | + assert status is not None |
| 992 | + assert status['state'] == UserTaskStatus.IN_PROGRESS |
| 993 | + assert status['file'] is None |
| 994 | + |
| 995 | + def test_get_backup_task_status_succeeded(self) -> None: |
| 996 | + # Create a mock UserTaskStatus in SUCCEEDED state |
| 997 | + task_id = str(uuid.uuid4()) |
| 998 | + mock_task = UserTaskStatus( |
| 999 | + task_id=task_id, |
| 1000 | + user_id=self.user.id, |
| 1001 | + name=f"Export of {self.lib1.library_key}", |
| 1002 | + state=UserTaskStatus.SUCCEEDED |
| 1003 | + ) |
| 1004 | + |
| 1005 | + # Create a mock UserTaskArtifact |
| 1006 | + mock_artifact = mock.Mock() |
| 1007 | + mock_artifact.file.url = "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip" |
| 1008 | + |
| 1009 | + with mock.patch( |
| 1010 | + 'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get' |
| 1011 | + ) as mock_get, mock.patch( |
| 1012 | + 'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskArtifact.objects.get' |
| 1013 | + ) as mock_artifact_get: |
| 1014 | + |
| 1015 | + mock_get.return_value = mock_task |
| 1016 | + mock_artifact_get.return_value = mock_artifact |
| 1017 | + |
| 1018 | + status = api.get_backup_task_status(self.user.id, task_id=task_id) |
| 1019 | + assert status is not None |
| 1020 | + assert status['state'] == UserTaskStatus.SUCCEEDED |
| 1021 | + assert status['file'].url == "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip" |
| 1022 | + |
| 1023 | + def test_get_backup_task_status_failed(self) -> None: |
| 1024 | + # Create a mock UserTaskStatus in FAILED state |
| 1025 | + task_id = str(uuid.uuid4()) |
| 1026 | + mock_task = UserTaskStatus( |
| 1027 | + task_id=task_id, |
| 1028 | + user_id=self.user.id, |
| 1029 | + name=f"Export of {self.lib1.library_key}", |
| 1030 | + state=UserTaskStatus.FAILED |
| 1031 | + ) |
| 1032 | + |
| 1033 | + with mock.patch( |
| 1034 | + 'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get' |
| 1035 | + ) as mock_get: |
| 1036 | + mock_get.return_value = mock_task |
| 1037 | + |
| 1038 | + status = api.get_backup_task_status(self.user.id, task_id=task_id) |
| 1039 | + assert status is not None |
| 1040 | + assert status['state'] == UserTaskStatus.FAILED |
| 1041 | + assert status['file'] is None |
| 1042 | + |
| 1043 | + |
| 1044 | +class ContentLibraryAuthZRoleAssignmentTest(ContentLibrariesRestApiTest): |
| 1045 | + """ |
| 1046 | + Tests for Content Library role assignment via the AuthZ Authorization Framework. |
| 1047 | +
|
| 1048 | + These tests verify that library roles are correctly assigned to users through |
| 1049 | + the openedx-authz (AuthZ) Authorization Framework when libraries are created or when |
| 1050 | + explicit role assignments are made. |
| 1051 | +
|
| 1052 | + See: https://github.com/openedx/openedx-authz/ |
| 1053 | + """ |
| 1054 | + |
| 1055 | + def setUp(self) -> None: |
| 1056 | + super().setUp() |
| 1057 | + |
| 1058 | + # Create Content Libraries |
| 1059 | + self._create_library("test-lib-role-1", "Test Library Role 1") |
| 1060 | + |
| 1061 | + # Fetch the created ContentLibrary objects so we can access their learning_package.id |
| 1062 | + self.lib1 = ContentLibrary.objects.get(slug="test-lib-role-1") |
| 1063 | + |
| 1064 | + def test_assign_library_admin_role_to_user_via_authz(self) -> None: |
| 1065 | + """ |
| 1066 | + Test assigning a library admin role to a user via the AuthZ Authorization Framework. |
| 1067 | +
|
| 1068 | + This test verifies that the openedx-authz Authorization Framework correctly |
| 1069 | + assigns the library_admin role to a user when explicitly called. |
| 1070 | + """ |
| 1071 | + api.assign_library_role_to_user(self.lib1.library_key, self.user, api.AccessLevel.ADMIN_LEVEL) |
| 1072 | + |
| 1073 | + roles = get_user_role_assignments_in_scope(self.user.username, str(self.lib1.library_key)) |
| 1074 | + assert len(roles) == 1 |
| 1075 | + assert "library_admin" in repr(roles[0].roles[0]) |
| 1076 | + |
| 1077 | + def test_assign_library_author_role_to_user_via_authz(self) -> None: |
| 1078 | + """ |
| 1079 | + Test assigning a library author role to a user via the AuthZ Authorization Framework. |
| 1080 | +
|
| 1081 | + This test verifies that the openedx-authz Authorization Framework correctly |
| 1082 | + assigns the library_author role to a user when explicitly called. |
| 1083 | + """ |
| 1084 | + # Create a new user to avoid conflicts with roles assigned during library creation |
| 1085 | + author_user = UserFactory. create( username="Author", email="[email protected]") |
| 1086 | + |
| 1087 | + api.assign_library_role_to_user(self.lib1.library_key, author_user, api.AccessLevel.AUTHOR_LEVEL) |
| 1088 | + |
| 1089 | + roles = get_user_role_assignments_in_scope(author_user.username, str(self.lib1.library_key)) |
| 1090 | + assert len(roles) == 1 |
| 1091 | + assert "library_author" in repr(roles[0].roles[0]) |
| 1092 | + |
| 1093 | + @mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope") |
| 1094 | + def test_library_creation_assigns_admin_role_via_authz( |
| 1095 | + self, |
| 1096 | + mock_assign_role |
| 1097 | + ) -> None: |
| 1098 | + """ |
| 1099 | + Test that creating a library via REST API assigns admin role via AuthZ. |
| 1100 | +
|
| 1101 | + This test verifies that when a library is created via the REST API, |
| 1102 | + the creator is automatically assigned the library_admin role through |
| 1103 | + the openedx-authz Authorization Framework. |
| 1104 | + """ |
| 1105 | + mock_assign_role.return_value = True |
| 1106 | + |
| 1107 | + # Create a new library (this should trigger role assignment in the REST API) |
| 1108 | + self._create_library("test-lib-role-2", "Test Library Role 2") |
| 1109 | + |
| 1110 | + # Verify that assign_role_to_user_in_scope was called |
| 1111 | + mock_assign_role.assert_called_once() |
| 1112 | + call_args = mock_assign_role.call_args |
| 1113 | + assert call_args[0][0] == self.user.username # username |
| 1114 | + assert call_args[0][1] == "library_admin" # role |
| 1115 | + assert "test-lib-role-2" in call_args[0][2] # library_key (contains slug) |
| 1116 | + |
| 1117 | + @mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope") |
| 1118 | + def test_library_creation_handles_authz_failure_gracefully( |
| 1119 | + self, |
| 1120 | + mock_assign_role |
| 1121 | + ) -> None: |
| 1122 | + """ |
| 1123 | + Test that library creation succeeds even if AuthZ role assignment fails. |
| 1124 | +
|
| 1125 | + This test verifies that if the openedx-authz Authorization Framework fails to assign |
| 1126 | + a role (returns False), the library creation still succeeds. This ensures that |
| 1127 | + the system degrades gracefully and doesn't break library creation if there are |
| 1128 | + issues with the Authorization Framework. |
| 1129 | + """ |
| 1130 | + # Simulate openedx-authz failing to assign the role |
| 1131 | + mock_assign_role.return_value = False |
| 1132 | + |
| 1133 | + # Library creation should still succeed |
| 1134 | + result = self._create_library("test-lib-role-3", "Test Library Role 3") |
| 1135 | + assert result is not None |
| 1136 | + assert result["slug"] == "test-lib-role-3" |
| 1137 | + |
| 1138 | + # Verify that the library was created successfully |
| 1139 | + lib3 = ContentLibrary.objects.get(slug="test-lib-role-3") |
| 1140 | + assert lib3 is not None |
| 1141 | + assert lib3.slug == "test-lib-role-3" |
| 1142 | + |
| 1143 | + @mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope") |
| 1144 | + def test_library_creation_handles_authz_exception( |
| 1145 | + self, |
| 1146 | + mock_assign_role |
| 1147 | + ) -> None: |
| 1148 | + """ |
| 1149 | + Test that library creation succeeds even if AuthZ raises an exception. |
| 1150 | +
|
| 1151 | + This test verifies that if the openedx-authz Authorization Framework raises an |
| 1152 | + exception during role assignment, the library creation still succeeds. This ensures |
| 1153 | + robust error handling when the Authorization Framework is unavailable or misconfigured. |
| 1154 | + """ |
| 1155 | + # Simulate openedx-authz raising an exception for unknown issues |
| 1156 | + mock_assign_role.side_effect = Exception("AuthZ unavailable") |
| 1157 | + |
| 1158 | + # Library creation should still succeed (the exception should be caught/handled) |
| 1159 | + # Note: Currently, the code doesn't catch this exception, so we expect it to propagate. |
| 1160 | + # This test documents the current behavior and can be updated if error handling is added. |
| 1161 | + with self.assertRaises(Exception) as context: |
| 1162 | + self._create_library("test-lib-role-4", "Test Library Role 4") |
| 1163 | + |
| 1164 | + assert "AuthZ unavailable" in str(context.exception) |
0 commit comments