242 lines
8.9 KiB
Python
242 lines
8.9 KiB
Python
"""
|
|
Tests for the LTI provider views
|
|
"""
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from django.test import TestCase
|
|
from django.test.client import RequestFactory
|
|
from django.urls import reverse
|
|
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
|
|
|
from common.djangoapps.student.tests.factories import UserFactory
|
|
from lms.djangoapps.courseware.testutils import RenderXBlockTestMixin
|
|
from lms.djangoapps.lti_provider import models, views
|
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
|
|
|
LTI_DEFAULT_PARAMS = {
|
|
'roles': 'Instructor,urn:lti:instrole:ims/lis/Administrator',
|
|
'context_id': 'lti_launch_context_id',
|
|
'oauth_version': '1.0',
|
|
'oauth_consumer_key': 'consumer_key',
|
|
'oauth_signature': 'OAuth Signature',
|
|
'oauth_signature_method': 'HMAC-SHA1',
|
|
'oauth_timestamp': 'OAuth Timestamp',
|
|
'oauth_nonce': 'OAuth Nonce',
|
|
'user_id': 'LTI_User',
|
|
}
|
|
|
|
LTI_OPTIONAL_PARAMS = {
|
|
'context_title': 'context title',
|
|
'context_label': 'context label',
|
|
'lis_result_sourcedid': 'result sourcedid',
|
|
'lis_outcome_service_url': 'outcome service URL',
|
|
'tool_consumer_instance_guid': 'consumer instance guid'
|
|
}
|
|
|
|
COURSE_KEY = CourseLocator(org='some_org', course='some_course', run='some_run')
|
|
USAGE_KEY = BlockUsageLocator(course_key=COURSE_KEY, block_type='problem', block_id='block_id')
|
|
|
|
COURSE_PARAMS = {
|
|
'course_key': COURSE_KEY,
|
|
'usage_key': USAGE_KEY
|
|
}
|
|
|
|
|
|
ALL_PARAMS = dict(list(LTI_DEFAULT_PARAMS.items()) + list(COURSE_PARAMS.items()))
|
|
|
|
|
|
def build_launch_request(extra_post_data=None, param_to_delete=None):
|
|
"""
|
|
Helper method to create a new request object for the LTI launch.
|
|
"""
|
|
if extra_post_data is None:
|
|
extra_post_data = {}
|
|
post_data = dict(list(LTI_DEFAULT_PARAMS.items()) + list(extra_post_data.items()))
|
|
if param_to_delete:
|
|
del post_data[param_to_delete]
|
|
request = RequestFactory().post('/', data=post_data)
|
|
request.user = UserFactory.create()
|
|
request.session = {}
|
|
return request
|
|
|
|
|
|
class LtiTestMixin:
|
|
"""
|
|
Mixin for LTI tests
|
|
"""
|
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_LTI_PROVIDER': True})
|
|
def setUp(self):
|
|
super().setUp()
|
|
# Always accept the OAuth signature
|
|
self.mock_verify = MagicMock(return_value=True)
|
|
patcher = patch('lms.djangoapps.lti_provider.signature_validator.SignatureValidator.verify', self.mock_verify)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
self.consumer = models.LtiConsumer(
|
|
consumer_name='consumer',
|
|
consumer_key=LTI_DEFAULT_PARAMS['oauth_consumer_key'],
|
|
consumer_secret='secret'
|
|
)
|
|
self.consumer.save()
|
|
|
|
|
|
class LtiLaunchTest(LtiTestMixin, TestCase):
|
|
"""
|
|
Tests for the lti_launch view
|
|
"""
|
|
@patch('lms.djangoapps.lti_provider.views.render_courseware')
|
|
@patch('lms.djangoapps.lti_provider.views.authenticate_lti_user')
|
|
def test_valid_launch(self, _authenticate, render):
|
|
"""
|
|
Verifies that the LTI launch succeeds when passed a valid request.
|
|
"""
|
|
request = build_launch_request()
|
|
views.lti_launch(request, str(COURSE_KEY), str(USAGE_KEY))
|
|
render.assert_called_with(request, USAGE_KEY)
|
|
|
|
@patch('lms.djangoapps.lti_provider.views.render_courseware')
|
|
@patch('lms.djangoapps.lti_provider.views.store_outcome_parameters')
|
|
@patch('lms.djangoapps.lti_provider.views.authenticate_lti_user')
|
|
def test_valid_launch_with_optional_params(self, _authenticate, store_params, _render):
|
|
"""
|
|
Verifies that the LTI launch succeeds when passed a valid request.
|
|
"""
|
|
request = build_launch_request(extra_post_data=LTI_OPTIONAL_PARAMS)
|
|
views.lti_launch(request, str(COURSE_KEY), str(USAGE_KEY))
|
|
store_params.assert_called_with(
|
|
dict(list(ALL_PARAMS.items()) + list(LTI_OPTIONAL_PARAMS.items())),
|
|
request.user,
|
|
self.consumer
|
|
)
|
|
|
|
@patch('lms.djangoapps.lti_provider.views.render_courseware')
|
|
@patch('lms.djangoapps.lti_provider.views.store_outcome_parameters')
|
|
@patch('lms.djangoapps.lti_provider.views.authenticate_lti_user')
|
|
def test_outcome_service_registered(self, _authenticate, store_params, _render):
|
|
"""
|
|
Verifies that the LTI launch succeeds when passed a valid request.
|
|
"""
|
|
request = build_launch_request()
|
|
views.lti_launch(
|
|
request,
|
|
str(COURSE_PARAMS['course_key']),
|
|
str(COURSE_PARAMS['usage_key'])
|
|
)
|
|
store_params.assert_called_with(ALL_PARAMS, request.user, self.consumer)
|
|
|
|
def launch_with_missing_parameter(self, missing_param):
|
|
"""
|
|
Helper method to remove a parameter from the LTI launch and call the view
|
|
"""
|
|
request = build_launch_request(param_to_delete=missing_param)
|
|
return views.lti_launch(request, None, None)
|
|
|
|
def test_launch_with_missing_parameters(self):
|
|
"""
|
|
Runs through all required LTI parameters and verifies that the lti_launch
|
|
view returns Bad Request if any of them are missing.
|
|
"""
|
|
for missing_param in views.REQUIRED_PARAMETERS:
|
|
response = self.launch_with_missing_parameter(missing_param)
|
|
assert response.status_code == 400, (('Launch should fail when parameter ' + missing_param) + ' is missing')
|
|
|
|
def test_launch_with_disabled_feature_flag(self):
|
|
"""
|
|
Verifies that the LTI launch will fail if the ENABLE_LTI_PROVIDER flag
|
|
is not set
|
|
"""
|
|
with patch.dict('django.conf.settings.FEATURES', {'ENABLE_LTI_PROVIDER': False}):
|
|
request = build_launch_request()
|
|
response = views.lti_launch(request, None, None)
|
|
assert response.status_code == 403
|
|
|
|
def test_forbidden_if_signature_fails(self):
|
|
"""
|
|
Verifies that the view returns Forbidden if the LTI OAuth signature is
|
|
incorrect.
|
|
"""
|
|
self.mock_verify.return_value = False
|
|
|
|
request = build_launch_request()
|
|
response = views.lti_launch(request, None, None)
|
|
assert response.status_code == 403
|
|
assert response.status_code == 403
|
|
|
|
@patch('lms.djangoapps.lti_provider.views.render_courseware')
|
|
def test_lti_consumer_record_supplemented_with_guid(self, _render):
|
|
self.mock_verify.return_value = False
|
|
|
|
request = build_launch_request(LTI_OPTIONAL_PARAMS)
|
|
with self.assertNumQueries(3):
|
|
views.lti_launch(request, None, None)
|
|
consumer = models.LtiConsumer.objects.get(
|
|
consumer_key=LTI_DEFAULT_PARAMS['oauth_consumer_key']
|
|
)
|
|
assert consumer.instance_guid == 'consumer instance guid'
|
|
|
|
|
|
class LtiLaunchTestRender(LtiTestMixin, RenderXBlockTestMixin, ModuleStoreTestCase):
|
|
"""
|
|
Tests for the rendering returned by lti_launch view.
|
|
This class overrides the get_response method, which is used by
|
|
the tests defined in RenderXBlockTestMixin.
|
|
"""
|
|
|
|
def get_response(self, usage_key, url_encoded_params=None):
|
|
"""
|
|
Overridable method to get the response from the endpoint that is being tested.
|
|
"""
|
|
lti_launch_url = reverse(
|
|
'lti_provider_launch',
|
|
kwargs={
|
|
'course_id': str(self.course.id),
|
|
'usage_id': str(usage_key)
|
|
}
|
|
)
|
|
if url_encoded_params:
|
|
lti_launch_url += '?' + url_encoded_params
|
|
|
|
return self.client.post(lti_launch_url, data=LTI_DEFAULT_PARAMS)
|
|
|
|
# The following test methods override the base tests for verifying access
|
|
# by unenrolled and unauthenticated students, since there is a discrepancy
|
|
# of access rules between the 2 endpoints (LTI and xBlock_render).
|
|
# TODO fix this access discrepancy to the same underlying data.
|
|
|
|
def test_unenrolled_student(self):
|
|
"""
|
|
Override since LTI allows access to unenrolled students.
|
|
"""
|
|
self.setup_course()
|
|
self.setup_user(admin=False, enroll=False, login=True)
|
|
self.verify_response()
|
|
|
|
def test_unauthenticated(self):
|
|
"""
|
|
Override since LTI allows access to unauthenticated users.
|
|
"""
|
|
self.setup_course()
|
|
self.setup_user(admin=False, enroll=True, login=False)
|
|
self.verify_response()
|
|
|
|
def get_success_enrolled_staff_mongo_count(self):
|
|
"""
|
|
Override because mongo queries are higher for this
|
|
particular test. This has not been investigated exhaustively
|
|
as mongo is no longer used much, and removing user_partitions
|
|
from inheritance fixes the problem.
|
|
|
|
# The 9 mongoDB calls include calls for
|
|
# Old Mongo:
|
|
# (1) fill_in_run
|
|
# (2) get_course in get_course_with_access
|
|
# (3) get_item for HTML block in get_module_by_usage_id
|
|
# (4) get_parent when loading HTML block
|
|
# (5)-(8) calls related to the inherited user_partitions field.
|
|
# (9) edx_notes descriptor call to get_course
|
|
"""
|
|
return 9
|