Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 217 additions & 6 deletions cms/djangoapps/contentstore/tests/test_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,33 @@
Content library unit tests that require the CMS runtime.
"""
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
from contentstore.utils import reverse_usage_url
from contentstore.utils import reverse_url, reverse_usage_url, reverse_library_url
from contentstore.views.access import has_read_access, has_write_access
from contentstore.views.tests.test_library import LIBRARY_REST_URL
from fs.memoryfs import MemoryFS
from student.roles import (
CourseInstructorRole, CourseStaffRole, CourseCreatorRole, LibraryUserRole,
OrgStaffRole, OrgInstructorRole, OrgLibraryUserRole,
)
from xmodule.library_content_module import LibraryVersionReference
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.tests import get_test_system
from mock import Mock
from mock import Mock, patch
from opaque_keys.edx.locator import CourseKey, LibraryLocator
import ddt

LIBRARY_REST_URL = '/library/' # URL for GET/POST requests involving libraries themselves

@ddt.ddt
class TestLibraries(ModuleStoreTestCase):

class LibraryTestCase(ModuleStoreTestCase):
"""
High-level tests for libraries
Common functionality for content libraries tests
"""
def setUp(self):
user_password = super(TestLibraries, self).setUp()
user_password = super(LibraryTestCase, self).setUp()

self.client = AjaxEnabledTestClient()
self.client.login(username=self.user.username, password=user_password)
Expand Down Expand Up @@ -80,6 +86,14 @@ def _add_library_content_block(self, course, library_key, other_settings=None):
publish_item=False,
)

def _list_libraries(self):
"""
Use the REST API to get a list of libraries visible to the current user.
"""
response = self.client.get_json(LIBRARY_REST_URL)
self.assertEqual(response.status_code, 200)
return parse_json(response)

def _refresh_children(self, lib_content_block):
"""
Helper method: Uses the REST API to call the 'refresh_children' handler
Expand All @@ -92,6 +106,13 @@ def _refresh_children(self, lib_content_block):
self.assertEqual(response.status_code, 200)
return modulestore().get_item(lib_content_block.location)


@ddt.ddt
class TestLibraries(LibraryTestCase):
"""
High-level tests for libraries
"""

@ddt.data(
(2, 1, 1),
(2, 2, 2),
Expand Down Expand Up @@ -272,3 +293,193 @@ def test_block_with_children(self):

self.assertEqual(course_child_block.data, data_value)
self.assertEqual(course_child_block.display_name, name_value)


@ddt.ddt
class TestLibraryAccess(LibraryTestCase):
"""
Test Roles and Permissions related to Content Libraries
"""
def setUp(self):
""" Create a library, staff user, and non-staff user """
super(TestLibraryAccess, self).setUp()
self.ns_user, self.ns_user_password = self.create_non_staff_user()

def _login_as_non_staff_user(self, logout_first=True):
""" Login as a user that starts out with no roles/permissions granted. """
if logout_first:
self.client.logout() # We start logged in as a staff user
self.client.login(username=self.ns_user.username, password=self.ns_user_password)

def _assert_cannot_create_library(self, org="org", library="libfail", expected_code=403):
""" Ensure the current user is not able to create a library. """
self.assertTrue(expected_code >= 300)
response = self.client.ajax_post(LIBRARY_REST_URL, {'org': org, 'library': library, 'display_name': "Irrelevant"})
self.assertEqual(response.status_code, expected_code)
key = LibraryLocator(org=org, library=library)
self.assertEqual(modulestore().get_library(key), None)

def _can_access_library(self, lib_key):
""" Use the normal studio library URL to check if we have access """
if not isinstance(lib_key, (basestring, LibraryLocator)):
lib_key = lib_key.location.library_key
response = self.client.get(reverse_library_url('library_handler', unicode(lib_key)))
self.assertIn(response.status_code, (200, 302, 403))
return response.status_code == 200

def tearDown(self):
"""
Log out when done each test
"""
self.client.logout()
super(TestLibraryAccess, self).tearDown()

def test_creation(self):
"""
The user that creates a library should have instructor (admin) and staff permissions
"""
# self.library has been auto-created by the staff user.
self.assertTrue(has_write_access(self.user, self.lib_key))
self.assertTrue(has_read_access(self.user, self.lib_key))
# Make sure the user was actually assigned the instructor role and not just using is_staff superpowers:
self.assertTrue(CourseInstructorRole(self.lib_key).has_user(self.user))

# Now log out and ensure we are forbidden from creating a library:
self.client.logout()
self._assert_cannot_create_library(expected_code=302) # 302 redirect to login expected

# Now create a non-staff user with no permissions:
self._login_as_non_staff_user(logout_first=False)
self.assertFalse(CourseCreatorRole().has_user(self.ns_user))

# Now check that logged-in users without any permissions cannot create libraries
with patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}):
self._assert_cannot_create_library()

@ddt.data(
CourseInstructorRole,
CourseStaffRole,
LibraryUserRole,
)
def test_acccess(self, access_role):
"""
Test the various roles that allow viewing libraries are working correctly.
"""
# At this point, one library exists, created by the currently-logged-in staff user.
# Create another library as staff:
library2_key = self._create_library(library="lib2")
# Login as ns_user:
self._login_as_non_staff_user()

# ns_user shouldn't be able to access any libraries:
lib_list = self._list_libraries()
self.assertEqual(len(lib_list), 0)
self.assertFalse(self._can_access_library(self.library))
self.assertFalse(self._can_access_library(library2_key))

# Now manually intervene to give ns_user access to library2_key:
access_role(library2_key).add_users(self.ns_user)

# Now ns_user should be able to access library2_key only:
lib_list = self._list_libraries()
self.assertEqual(len(lib_list), 1)
self.assertEqual(lib_list[0]["library_key"], unicode(library2_key))
self.assertTrue(self._can_access_library(library2_key))
self.assertFalse(self._can_access_library(self.library))

@ddt.data(
OrgStaffRole,
OrgInstructorRole,
OrgLibraryUserRole,
)
def test_org_based_access(self, org_access_role):
"""
Test the various roles that allow viewing all of an organization's
libraries are working correctly.
"""
# Create some libraries as the staff user:
lib_key_pacific = self._create_library(org="PacificX", library="libP")
lib_key_atlantic = self._create_library(org="AtlanticX", library="libA")

# Login as a non-staff:
self._login_as_non_staff_user()

# Now manually intervene to give ns_user access to all "PacificX" libraries:
org_access_role(lib_key_pacific.org).add_users(self.ns_user)

# Now ns_user should be able to access lib_key_pacific only:
lib_list = self._list_libraries()
self.assertEqual(len(lib_list), 1)
self.assertEqual(lib_list[0]["library_key"], unicode(lib_key_pacific))
self.assertTrue(self._can_access_library(lib_key_pacific))
self.assertFalse(self._can_access_library(lib_key_atlantic))
self.assertFalse(self._can_access_library(self.lib_key))

@ddt.data(True, False)
def test_read_only_role(self, use_org_level_role):
"""
Test the read-only role (LibraryUserRole and its org-level equivalent)
"""
# As staff user, add a block to self.library:
block = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)

# Login as a ns_user:
self._login_as_non_staff_user()
self.assertFalse(self._can_access_library(self.library))

block_url = reverse_usage_url('xblock_handler', block.location)

def can_read_block():
""" Check if studio lets us view the XBlock in the library """
response = self.client.get_json(block_url)
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
return response.status_code == 200

def can_edit_block():
""" Check if studio lets us edit the XBlock in the library """
response = self.client.ajax_post(block_url)
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
return response.status_code == 200

def can_delete_block():
""" Check if studio lets us delete the XBlock in the library """
response = self.client.delete(block_url)
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
return response.status_code == 200

def can_copy_block():
""" Check if studio lets us duplicate the XBlock in the library """
response = self.client.ajax_post(reverse_url('xblock_handler'), {
'parent_locator': unicode(self.library.location),
'duplicate_source_locator': unicode(block.location),
})
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
return response.status_code == 200

def can_create_block():
""" Check if studio lets us make a new XBlock in the library """
response = self.client.ajax_post(reverse_url('xblock_handler'), {
'parent_locator': unicode(self.library.location), 'category': 'html',
})
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
return response.status_code == 200

# Check that we do not have read or write access to block:
self.assertFalse(can_read_block())
self.assertFalse(can_edit_block())
self.assertFalse(can_delete_block())
self.assertFalse(can_copy_block())
self.assertFalse(can_create_block())

# Give ns_user read-only permission:
if use_org_level_role:
OrgLibraryUserRole(self.lib_key.org).add_users(self.ns_user)
else:
LibraryUserRole(self.lib_key).add_users(self.ns_user)

self.assertTrue(self._can_access_library(self.library))
self.assertTrue(can_read_block())
self.assertFalse(can_edit_block())
self.assertFalse(can_delete_block())
self.assertFalse(can_copy_block())
self.assertFalse(can_create_block())
30 changes: 29 additions & 1 deletion cms/djangoapps/contentstore/views/access.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
""" Helper methods for determining user access permissions in Studio """

from student.roles import CourseStaffRole, GlobalStaff, CourseInstructorRole, OrgStaffRole, OrgInstructorRole
from opaque_keys.edx.locator import LibraryLocator
from student.roles import (
GlobalStaff, CourseStaffRole, CourseInstructorRole, LibraryUserRole,
OrgStaffRole, OrgInstructorRole, OrgLibraryUserRole
)
from student import auth


Expand All @@ -24,6 +28,30 @@ def has_course_access(user, course_key, role=CourseStaffRole):
return auth.has_access(user, role(course_key.for_branch(None)))


def has_write_access(user, course_key):
"""
Return True iff user is allowed to modify the given course/library.
Currently equivalent to has_course_access but less amibguously named.
"""
return has_course_access(user, course_key)


def has_read_access(user, course_key):
"""
Return True iff user is allowed to view this course/library.

There is currently no such thing as read-only course access in studio, but
there is read-only access to content libraries.
"""
if has_course_access(user, course_key):
return True # Global, Org, or Course "Instructors" and "Staff" can read and write
if isinstance(course_key, LibraryLocator):
if OrgLibraryUserRole(org=course_key.org).has_user(user):
return True # User has read-only access to all libraries in this organization
return LibraryUserRole(course_key.for_branch(None)).has_user(user) # User has read-only access this library

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be written as OrgLibraryUserRole(org=course_key.org).has_user(user) or LibraryUserRole(course_key.for_branch(None)).has_user(user)

return False


def get_user_role(user, course_id):
"""
What type of access: staff or instructor does this user have in Studio?
Expand Down
5 changes: 3 additions & 2 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from models.settings.course_metadata import CourseMetadata
from util.json_request import expect_json
from util.string_utils import _has_non_ascii_characters
from .access import has_course_access
from .access import has_course_access, has_read_access, has_write_access
from .component import (
OPEN_ENDED_COMPONENT_TYPES,
NOTE_COMPONENT_TYPES,
Expand Down Expand Up @@ -348,7 +348,7 @@ def _accessible_libraries_list(user):
List all libraries available to the logged in user by iterating through all libraries
"""
# No need to worry about ErrorDescriptors - split's get_libraries() never returns them.
return [lib for lib in modulestore().get_libraries() if has_course_access(user, lib.location)]
return [lib for lib in modulestore().get_libraries() if has_read_access(user, lib.location.library_key)]


@login_required
Expand Down Expand Up @@ -415,6 +415,7 @@ def format_library_for_view(library):
'url': reverse_library_url('library_handler', unicode(library.location.library_key)),
'org': library.display_org_with_default,
'number': library.display_number_with_default,
'can_edit': has_write_access(request.user, library.location.library_key),
}

# remove any courses in courses that are also in the in_process_course_actions list
Expand Down
Loading