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