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
28 changes: 26 additions & 2 deletions lms/djangoapps/instructor/tests/test_api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1122,14 +1122,26 @@ def test_get_graded_subsections_as_staff(self):
def test_get_graded_subsections_nonexistent_course(self):
"""
Test error handling for non-existent course.

The permission check does not load the course, so only a user that passes it
(global staff) reaches the view and gets the 404.
"""
self.client.force_authenticate(user=self.instructor)
self.client.force_authenticate(user=AdminFactory.create())
nonexistent_course_id = 'course-v1:NonExistent+Course+2024'
nonexistent_url = self._get_url(nonexistent_course_id)
response = self.client.get(nonexistent_url)

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # noqa: PT009

def test_get_graded_subsections_nonexistent_course_without_access(self):
"""
A user without access to a non-existent course gets 403, as for any other course.
"""
self.client.force_authenticate(user=self.instructor)
response = self.client.get(self._get_url('course-v1:NonExistent+Course+2024'))

assert response.status_code == status.HTTP_403_FORBIDDEN

def test_get_graded_subsections_empty_course(self):
"""
Test graded subsections for course without due dates.
Expand Down Expand Up @@ -1639,13 +1651,25 @@ def test_get_unit_extensions_unauthenticated(self):
def test_get_unit_extensions_nonexistent_course(self):
"""
Test error handling for non-existent course.

The permission check does not load the course, so only a user that passes it
(global staff) reaches the view and gets the 404.
"""
self.client.force_authenticate(user=self.instructor)
self.client.force_authenticate(user=AdminFactory.create())
nonexistent_course_id = 'course-v1:edX+NonExistent+2024'
response = self.client.get(self._get_url(course_id=nonexistent_course_id))

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # noqa: PT009

def test_get_unit_extensions_nonexistent_course_without_access(self):
"""
A user without access to a non-existent course gets 403, as for any other course.
"""
self.client.force_authenticate(user=self.instructor)
response = self.client.get(self._get_url(course_id='course-v1:edX+NonExistent+2024'))

assert response.status_code == status.HTTP_403_FORBIDDEN

def test_get_unit_extensions(self):
"""
Test retrieving unit extensions.
Expand Down
130 changes: 129 additions & 1 deletion lms/djangoapps/instructor/tests/test_api_v2_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
They cover the tabs, ``authz_enabled`` and the migrated endpoints.
"""

from datetime import datetime
from unittest.mock import patch

import ddt
Expand All @@ -17,20 +18,23 @@
)
from openedx_authz.applets.learner_administration.constants import COURSES_VIEW_COURSE_INFO
from openedx_authz.authz import COURSE_ADMIN, COURSE_STAFF
from pytz import UTC
from rest_framework import status
from rest_framework.test import APIClient

from common.djangoapps.student.roles import CourseDataResearcherRole, CourseLimitedStaffRole
from common.djangoapps.student.tests.factories import (
CourseEnrollmentFactory,
InstructorFactory,
StaffFactory,
UserFactory,
)
from lms.djangoapps.courseware.tabs import get_course_tab_list
from lms.djangoapps.instructor.authz import constants
from lms.djangoapps.instructor.authz.tests.mixins import InstructorAuthzTestMixin
from openedx.core.djangoapps.course_date_signals.handlers import extract_dates
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory

API = "lms.djangoapps.instructor.authz.api"

Expand Down Expand Up @@ -312,3 +316,127 @@ def test_legacy_without_role_hidden(self):
self.set_authz_enabled(False)

assert not self._has_instructor_tab(UserFactory())


@ddt.ddt
class DateExtensionsAuthzTest(InstructorAuthzTestMixin, SharedModuleStoreTestCase):
"""
AuthZ enforcement of the Date Extensions tab endpoints: ``GradedSubsectionsView``
and ``UnitExtensionsView`` (``courses.view_learner_extensions``), and
``ChangeDueDateView`` (``courses.manage_learner_extensions``).
"""

# (url_name, "get" or "post")
VIEW_ENDPOINTS = (("graded_subsections", "get"), ("unit_extensions", "get"))
MANAGE_ENDPOINTS = (("change_due_date", "post"),)

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
cls.course_key = cls.course.id
cls.chapter = BlockFactory.create(parent=cls.course, category="chapter")
cls.sequential = BlockFactory.create(
parent=cls.chapter,
category="sequential",
due=datetime(2030, 1, 1, tzinfo=UTC),
metadata={"graded": True, "format": "Homework"},
)
# ChangeDueDateView extends a date through edx-when, which needs the course's
# dates extracted into its own store before it recognizes this due date exists.
extract_dates(None, cls.course_key)

def setUp(self):
super().setUp()
self.client = APIClient()
self.learner = UserFactory()
CourseEnrollmentFactory.create(user=self.learner, course_id=self.course_key)

def _request(self, user, url_name, method):
"""Call the given endpoint as ``user``, GET or POST depending on ``method``."""
self.client.force_authenticate(user=user)
url = reverse(f"instructor_api_v2:{url_name}", kwargs={"course_id": str(self.course_key)})
if method == "get":
return self.client.get(url)
return self.client.post(
url,
{
"block_id": str(self.sequential.location),
"due_datetime": "01/01/2030 00:00",
"email_or_username": self.learner.username,
},
format="json",
)

@ddt.data(*VIEW_ENDPOINTS, *MANAGE_ENDPOINTS)
@ddt.unpack
def test_authz_course_admin_has_access(self, url_name, method):
"""A Course Admin can view and manage date extensions."""
user = self.create_user_with_authz_role(COURSE_ADMIN.external_key, self.course_key)

response = self._request(user, url_name, method)

assert response.status_code == status.HTTP_200_OK

@ddt.data(*VIEW_ENDPOINTS, *MANAGE_ENDPOINTS)
@ddt.unpack
def test_authz_course_staff_has_access(self, url_name, method):
"""A Course Staff can view and manage date extensions."""
user = self.create_user_with_authz_role(COURSE_STAFF.external_key, self.course_key)

response = self._request(user, url_name, method)

assert response.status_code == status.HTTP_200_OK

@ddt.data(*VIEW_ENDPOINTS, *MANAGE_ENDPOINTS)
@ddt.unpack
def test_authz_user_without_permission_is_denied(self, url_name, method):
"""A user with no AuthZ role in the course is denied."""
user = UserFactory()

response = self._request(user, url_name, method)

assert response.status_code == status.HTTP_403_FORBIDDEN

@ddt.data(*VIEW_ENDPOINTS, *MANAGE_ENDPOINTS)
@ddt.unpack
def test_authz_denied_overrides_legacy_roles(self, url_name, method):
"""With the flag on, AuthZ denying a legacy instructor means no access."""
instructor = InstructorFactory(course_key=self.course_key)

with patch(f"{API}.authz_api.is_user_allowed", return_value=False):
response = self._request(instructor, url_name, method)

assert response.status_code == status.HTTP_403_FORBIDDEN

@ddt.data(*VIEW_ENDPOINTS, *MANAGE_ENDPOINTS)
@ddt.unpack
def test_legacy_instructor_keeps_access(self, url_name, method):
"""With the flag off, a legacy instructor keeps the access it always had."""
self.set_authz_enabled(False)
instructor = InstructorFactory(course_key=self.course_key)

response = self._request(instructor, url_name, method)

assert response.status_code == status.HTTP_200_OK

@ddt.data(*VIEW_ENDPOINTS, *MANAGE_ENDPOINTS)
@ddt.unpack
def test_legacy_student_is_denied(self, url_name, method):
"""With the flag off, an enrolled learner with no staff role is denied."""
self.set_authz_enabled(False)

response = self._request(self.learner, url_name, method)

assert response.status_code == status.HTTP_403_FORBIDDEN

def test_legacy_does_not_query_authz(self):
"""With the flag off, AuthZ is not queried."""
self.set_authz_enabled(False)
instructor = InstructorFactory(course_key=self.course_key)

with patch(f"{API}.authz_api.is_user_allowed") as is_user_allowed:
response = self._request(instructor, "graded_subsections", "get")

assert response.status_code == status.HTTP_200_OK
is_user_allowed.assert_not_called()
9 changes: 6 additions & 3 deletions lms/djangoapps/instructor/views/api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@ class ChangeDueDateView(APIView):
Grants a due date extension to a student for a particular unit.
this version works with a new payload that is JSON and more up to date.
"""
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_classes = (IsAuthenticated, InstructorAuthzPermission)
authz_permissions = (authz_constants.COURSES_MANAGE_LEARNER_EXTENSIONS,)
permission_name = permissions.GIVE_STUDENT_EXTENSION
serializer_class = BlockDueDateSerializerV2

Expand Down Expand Up @@ -527,7 +528,8 @@ def post(self, request, course_id):

class GradedSubsectionsView(APIView):
"""View to retrieve graded subsections with due dates"""
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_classes = (IsAuthenticated, InstructorAuthzPermission)
authz_permissions = (authz_constants.COURSES_VIEW_LEARNER_EXTENSIONS,)
permission_name = permissions.VIEW_DASHBOARD

def get(self, request, course_id):
Expand Down Expand Up @@ -628,7 +630,8 @@ class UnitExtensionsView(ListAPIView):
* 403: Forbidden - User lacks instructor permissions
* 404: Not Found - Course does not exist
"""
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_classes = (IsAuthenticated, InstructorAuthzPermission)
authz_permissions = (authz_constants.COURSES_VIEW_LEARNER_EXTENSIONS,)
permission_name = permissions.VIEW_DASHBOARD
serializer_class = UnitExtensionSerializer
filter_backends = []
Expand Down
Loading