def setUp(self): super().setUp() self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} self.request_body_xml_template = textwrap.dedent(""" <?xml version = "1.0" encoding = "UTF-8"?> <imsx_POXEnvelopeRequest xmlns = "{namespace}"> <imsx_POXHeader> <imsx_POXRequestHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>{messageIdentifier}</imsx_messageIdentifier> </imsx_POXRequestHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <{action}> <resultRecord> <sourcedGUID> <sourcedId>{sourcedId}</sourcedId> </sourcedGUID> <result> <resultScore> <language>en-us</language> <textString>{grade}</textString> </resultScore> </result> </resultRecord> </{action}> </imsx_POXBody> </imsx_POXEnvelopeRequest> """) self.system = get_test_system() self.system.get_real_user = Mock() self.system.publish = Mock() self.system.rebind_noauth_module_to_user = Mock() self.user_id = self.system.anonymous_student_id self.xmodule = LTIBlock( self.system, DictFieldData({}), ScopeIds(None, None, None, BlockUsageLocator(self.system.course_id, 'lti', 'name')) ) self.lti_id = self.xmodule.lti_id self.unquoted_resource_link_id = '{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'.format( self.xmodule.runtime.hostname ) sourced_id = ':'.join(parse.quote(i) for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id)) # lint-amnesty, pylint: disable=line-too-long self.defaults = { 'namespace': "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", 'sourcedId': sourced_id, 'action': 'replaceResultRequest', 'grade': 0.5, 'messageIdentifier': '528243ba5241b', } self.xmodule.due = None self.xmodule.graceperiod = None
def setUp(self): super().setUp() self.system = get_test_system(user=self.USER_STANDIN) self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} self.system.publish = Mock() self.system.rebind_noauth_module_to_user = Mock() self.xmodule = LTIBlock(self.system, DictFieldData({}), Mock()) self.lti_id = self.xmodule.lti_id self.xmodule.due = None self.xmodule.graceperiod = None
def setUp(self): super(LTI20RESTResultServiceTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments self.system = get_test_system() self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} self.system.get_real_user = Mock() self.system.publish = Mock() self.system.rebind_noauth_module_to_user = Mock() self.xmodule = LTIBlock(self.system, DictFieldData({}), Mock()) self.lti_id = self.xmodule.lti_id self.xmodule.due = None self.xmodule.graceperiod = None
class LTIBlockTest(unittest.TestCase): """Logic tests for LTI module.""" def setUp(self): super().setUp() self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} self.request_body_xml_template = textwrap.dedent(""" <?xml version = "1.0" encoding = "UTF-8"?> <imsx_POXEnvelopeRequest xmlns = "{namespace}"> <imsx_POXHeader> <imsx_POXRequestHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>{messageIdentifier}</imsx_messageIdentifier> </imsx_POXRequestHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <{action}> <resultRecord> <sourcedGUID> <sourcedId>{sourcedId}</sourcedId> </sourcedGUID> <result> <resultScore> <language>en-us</language> <textString>{grade}</textString> </resultScore> </result> </resultRecord> </{action}> </imsx_POXBody> </imsx_POXEnvelopeRequest> """) self.system = get_test_system() self.system.get_real_user = Mock() self.system.publish = Mock() self.system.rebind_noauth_module_to_user = Mock() self.user_id = self.system.anonymous_student_id self.xmodule = LTIBlock( self.system, DictFieldData({}), ScopeIds(None, None, None, BlockUsageLocator(self.system.course_id, 'lti', 'name')) ) self.lti_id = self.xmodule.lti_id self.unquoted_resource_link_id = '{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'.format( self.xmodule.runtime.hostname ) sourced_id = ':'.join(parse.quote(i) for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id)) # lint-amnesty, pylint: disable=line-too-long self.defaults = { 'namespace': "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", 'sourcedId': sourced_id, 'action': 'replaceResultRequest', 'grade': 0.5, 'messageIdentifier': '528243ba5241b', } self.xmodule.due = None self.xmodule.graceperiod = None def get_request_body(self, params=None): """Fetches the body of a request specified by params""" if params is None: params = {} data = copy(self.defaults) data.update(params) return self.request_body_xml_template.format(**data).encode('utf-8') def get_response_values(self, response): """Gets the values from the given response""" parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') root = etree.fromstring(response.body.strip(), parser=parser) lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" namespaces = {'def': lti_spec_namespace} code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text imsx_pox_body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] try: action = imsx_pox_body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') except Exception: # pylint: disable=broad-except action = None return { 'code_major': code_major, 'description': description, 'messageIdentifier': message_identifier, 'action': action } @patch( 'xmodule.lti_module.LTIBlock.get_client_key_secret', return_value=('test_client_key', 'test_client_secret') ) def test_authorization_header_not_present(self, _get_key_secret): """ Request has no Authorization header. This is an unknown service request, i.e., it is not a part of the original service specification. """ request = Request(self.environ) request.body = self.get_request_body() response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', 'description': 'OAuth verification error: Malformed authorization header', 'messageIdentifier': self.defaults['messageIdentifier'], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @patch( 'xmodule.lti_module.LTIBlock.get_client_key_secret', return_value=('test_client_key', 'test_client_secret') ) def test_authorization_header_empty(self, _get_key_secret): """ Request Authorization header has no value. This is an unknown service request, i.e., it is not a part of the original service specification. """ request = Request(self.environ) request.authorization = "bad authorization header" request.body = self.get_request_body() response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', 'description': 'OAuth verification error: Malformed authorization header', 'messageIdentifier': self.defaults['messageIdentifier'], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) def test_real_user_is_none(self): """ If we have no real user, we should send back failure response. """ self.xmodule.verify_oauth_body_sign = Mock() self.xmodule.has_score = True self.system.get_real_user = Mock(return_value=None) request = Request(self.environ) request.body = self.get_request_body() response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', 'description': 'User not found.', 'messageIdentifier': self.defaults['messageIdentifier'], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) def test_grade_past_due(self): """ Should fail if we do not accept past due grades, and it is past due. """ self.xmodule.accept_grades_past_due = False self.xmodule.due = datetime.datetime.now(UTC) self.xmodule.graceperiod = Timedelta().from_json("0 seconds") request = Request(self.environ) request.body = self.get_request_body() response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', 'description': 'Grade is past due', 'messageIdentifier': 'unknown', } assert response.status_code == 200 assert expected_response == real_response def test_grade_not_in_range(self): """ Grade returned from Tool Provider is outside the range 0.0-1.0. """ self.xmodule.verify_oauth_body_sign = Mock() request = Request(self.environ) request.body = self.get_request_body(params={'grade': '10'}) response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', 'description': 'Request body XML parsing error: score value outside the permitted range of 0-1.', 'messageIdentifier': 'unknown', } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) def test_bad_grade_decimal(self): """ Grade returned from Tool Provider doesn't use a period as the decimal point. """ self.xmodule.verify_oauth_body_sign = Mock() request = Request(self.environ) request.body = self.get_request_body(params={'grade': '0,5'}) response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) msg = "could not convert string to float: '0,5'" expected_response = { 'action': None, 'code_major': 'failure', 'description': f'Request body XML parsing error: {msg}', 'messageIdentifier': 'unknown', } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) def test_unsupported_action(self): """ Action returned from Tool Provider isn't supported. `replaceResultRequest` is supported only. """ self.xmodule.verify_oauth_body_sign = Mock() request = Request(self.environ) request.body = self.get_request_body({'action': 'wrongAction'}) response = self.xmodule.grade_handler(request, '') real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'unsupported', 'description': 'Target does not support the requested operation.', 'messageIdentifier': self.defaults['messageIdentifier'], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) def test_good_request(self): """ Response from Tool Provider is correct. """ self.xmodule.verify_oauth_body_sign = Mock() self.xmodule.has_score = True request = Request(self.environ) request.body = self.get_request_body() response = self.xmodule.grade_handler(request, '') description_expected = 'Score for {sourcedId} is now {score}'.format( sourcedId=self.defaults['sourcedId'], score=self.defaults['grade'], ) real_response = self.get_response_values(response) expected_response = { 'action': 'replaceResultResponse', 'code_major': 'success', 'description': description_expected, 'messageIdentifier': self.defaults['messageIdentifier'], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) assert self.xmodule.module_score == float(self.defaults['grade']) def test_user_id(self): expected_user_id = str(parse.quote(self.xmodule.runtime.anonymous_student_id)) real_user_id = self.xmodule.get_user_id() assert real_user_id == expected_user_id def test_outcome_service_url(self): mock_url_prefix = 'https://hostname/' test_service_name = "test_service" def mock_handler_url(block, handler_name, **kwargs): # pylint: disable=unused-argument """Mock function for returning fully-qualified handler urls""" return mock_url_prefix + handler_name self.xmodule.runtime.handler_url = Mock(side_effect=mock_handler_url) real_outcome_service_url = self.xmodule.get_outcome_service_url(service_name=test_service_name) assert real_outcome_service_url == (mock_url_prefix + test_service_name) def test_resource_link_id(self): with patch('xmodule.lti_module.LTIBlock.location', new_callable=PropertyMock): self.xmodule.location.html_id = lambda: 'i4x-2-3-lti-31de800015cf4afb973356dbe81496df' expected_resource_link_id = str(parse.quote(self.unquoted_resource_link_id)) real_resource_link_id = self.xmodule.get_resource_link_id() assert real_resource_link_id == expected_resource_link_id def test_lis_result_sourcedid(self): expected_sourced_id = ':'.join(parse.quote(i) for i in ( str(self.system.course_id), self.xmodule.get_resource_link_id(), self.user_id )) real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid() assert real_lis_result_sourcedid == expected_sourced_id def test_client_key_secret(self): """ LTI module gets client key and secret provided. """ #this adds lti passports to system mocked_course = Mock(lti_passports=['lti_id:test_client:test_secret']) modulestore = Mock() modulestore.get_course.return_value = mocked_course runtime = Mock(modulestore=modulestore) self.xmodule.runtime = runtime self.xmodule.lti_id = "lti_id" key, secret = self.xmodule.get_client_key_secret() expected = ('test_client', 'test_secret') assert expected == (key, secret) def test_client_key_secret_not_provided(self): """ LTI module attempts to get client key and secret provided in cms. There are key and secret but not for specific LTI. """ # this adds lti passports to system mocked_course = Mock(lti_passports=['test_id:test_client:test_secret']) modulestore = Mock() modulestore.get_course.return_value = mocked_course runtime = Mock(modulestore=modulestore) self.xmodule.runtime = runtime # set another lti_id self.xmodule.lti_id = "another_lti_id" key_secret = self.xmodule.get_client_key_secret() expected = ('', '') assert expected == key_secret def test_bad_client_key_secret(self): """ LTI module attempts to get client key and secret provided in cms. There are key and secret provided in wrong format. """ # this adds lti passports to system mocked_course = Mock(lti_passports=['test_id_test_client_test_secret']) modulestore = Mock() modulestore.get_course.return_value = mocked_course runtime = Mock(modulestore=modulestore) self.xmodule.runtime = runtime self.xmodule.lti_id = 'lti_id' with pytest.raises(LTIError): self.xmodule.get_client_key_secret() @patch('xmodule.lti_module.signature.verify_hmac_sha1', Mock(return_value=True)) @patch( 'xmodule.lti_module.LTIBlock.get_client_key_secret', Mock(return_value=('test_client_key', 'test_client_secret')) ) def test_successful_verify_oauth_body_sign(self): """ Test if OAuth signing was successful. """ self.xmodule.verify_oauth_body_sign(self.get_signed_grade_mock_request()) @patch('xmodule.lti_module.LTIBlock.get_outcome_service_url', Mock(return_value='https://testurl/')) @patch('xmodule.lti_module.LTIBlock.get_client_key_secret', Mock(return_value=('__consumer_key__', '__lti_secret__'))) def test_failed_verify_oauth_body_sign_proxy_mangle_url(self): """ Oauth signing verify fail. """ request = self.get_signed_grade_mock_request_with_correct_signature() self.xmodule.verify_oauth_body_sign(request) # we should verify against get_outcome_service_url not # request url proxy and load balancer along the way may # change url presented to the method request.url = 'http://testurl/' self.xmodule.verify_oauth_body_sign(request) def get_signed_grade_mock_request_with_correct_signature(self): """ Generate a proper LTI request object """ mock_request = Mock() mock_request.headers = { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': ( 'OAuth realm="https://testurl/", oauth_body_hash="wwzA3s8gScKD1VpJ7jMt9b%2BMj9Q%3D",' 'oauth_nonce="18821463", oauth_timestamp="1409321145", ' 'oauth_consumer_key="__consumer_key__", oauth_signature_method="HMAC-SHA1", ' 'oauth_version="1.0", oauth_signature="fHsE1hhIz76/msUoMR3Lyb7Aou4%3D"' ) } mock_request.url = 'https://testurl' mock_request.http_method = 'POST' mock_request.method = mock_request.http_method mock_request.body = ( b'<?xml version=\'1.0\' encoding=\'utf-8\'?>\n' b'<imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">' b'<imsx_POXHeader><imsx_POXRequestHeaderInfo><imsx_version>V1.0</imsx_version>' b'<imsx_messageIdentifier>edX_fix</imsx_messageIdentifier></imsx_POXRequestHeaderInfo>' b'</imsx_POXHeader><imsx_POXBody><replaceResultRequest><resultRecord><sourcedGUID>' b'<sourcedId>MITxLTI/MITxLTI/201x:localhost%3A8000-i4x-MITxLTI-MITxLTI-lti-3751833a214a4f66a0d18f63234207f2' b':363979ef768ca171b50f9d1bfb322131</sourcedId>' b'</sourcedGUID><result><resultScore><language>en</language><textString>0.32</textString></resultScore>' b'</result></resultRecord></replaceResultRequest></imsx_POXBody></imsx_POXEnvelopeRequest>' ) return mock_request def test_wrong_xml_namespace(self): """ Test wrong XML Namespace. Tests that tool provider returned grade back with wrong XML Namespace. """ with pytest.raises(IndexError): mocked_request = self.get_signed_grade_mock_request(namespace_lti_v1p1=False) self.xmodule.parse_grade_xml_body(mocked_request.body) def test_parse_grade_xml_body(self): """ Test XML request body parsing. Tests that xml body was parsed successfully. """ mocked_request = self.get_signed_grade_mock_request() message_identifier, sourced_id, grade, action = self.xmodule.parse_grade_xml_body(mocked_request.body) assert self.defaults['messageIdentifier'] == message_identifier assert self.defaults['sourcedId'] == sourced_id assert self.defaults['grade'] == grade assert self.defaults['action'] == action @patch('xmodule.lti_module.signature.verify_hmac_sha1', Mock(return_value=False)) @patch( 'xmodule.lti_module.LTIBlock.get_client_key_secret', Mock(return_value=('test_client_key', 'test_client_secret')) ) def test_failed_verify_oauth_body_sign(self): """ Oauth signing verify fail. """ with pytest.raises(LTIError): req = self.get_signed_grade_mock_request() self.xmodule.verify_oauth_body_sign(req) def get_signed_grade_mock_request(self, namespace_lti_v1p1=True): """ Example of signed request from LTI Provider. When `namespace_v1p0` is set to True then the default namespase from LTI 1.1 will be used. Otherwise fake namespace will be added to XML. """ mock_request = Mock() mock_request.headers = { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': 'OAuth oauth_nonce="135685044251684026041377608307", \ oauth_timestamp="1234567890", oauth_version="1.0", \ oauth_signature_method="HMAC-SHA1", \ oauth_consumer_key="test_client_key", \ oauth_signature="my_signature%3D", \ oauth_body_hash="JEpIArlNCeV4ceXxric8gJQCnBw="' } mock_request.url = 'http://testurl' mock_request.http_method = 'POST' params = {} if not namespace_lti_v1p1: params = { 'namespace': "http://www.fakenamespace.com/fake" } mock_request.body = self.get_request_body(params) return mock_request def test_good_custom_params(self): """ Custom parameters are presented in right format. """ self.xmodule.custom_parameters = ['test_custom_params=test_custom_param_value'] self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) self.xmodule.oauth_params = Mock() self.xmodule.get_input_fields() self.xmodule.oauth_params.assert_called_with( {'custom_test_custom_params': 'test_custom_param_value'}, 'test_client_key', 'test_client_secret' ) def test_bad_custom_params(self): """ Custom parameters are presented in wrong format. """ bad_custom_params = ['test_custom_params: test_custom_param_value'] self.xmodule.custom_parameters = bad_custom_params self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) self.xmodule.oauth_params = Mock() with pytest.raises(LTIError): self.xmodule.get_input_fields() def test_max_score(self): self.xmodule.weight = 100.0 assert not self.xmodule.has_score assert self.xmodule.max_score() is None self.xmodule.has_score = True assert self.xmodule.max_score() == 100.0 def test_context_id(self): """ Tests that LTI parameter context_id is equal to course_id. """ assert str(self.system.course_id) == self.xmodule.context_id
class LTI20RESTResultServiceTest(unittest.TestCase): """Logic tests for LTI module. LTI2.0 REST ResultService""" USER_STANDIN = Mock() USER_STANDIN.id = 999 def setUp(self): super().setUp() self.system = get_test_system(user=self.USER_STANDIN) self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} self.system.publish = Mock() self.system.rebind_noauth_module_to_user = Mock() self.xmodule = LTIBlock(self.system, DictFieldData({}), Mock()) self.lti_id = self.xmodule.lti_id self.xmodule.due = None self.xmodule.graceperiod = None def test_sanitize_get_context(self): """Tests that the get_context function does basic sanitization""" # get_context, unfortunately, requires a lot of mocking machinery mocked_course = Mock(name='mocked_course', lti_passports=['lti_id:test_client:test_secret']) modulestore = Mock(name='modulestore') modulestore.get_course.return_value = mocked_course self.xmodule.runtime.modulestore = modulestore self.xmodule.lti_id = "lti_id" test_cases = ( # (before sanitize, after sanitize) ("plaintext", "plaintext"), ("a <script>alert(3)</script>", "a <script>alert(3)</script>"), # encodes scripts ("<b>bold 包</b>", "<b>bold 包</b>"), # unicode, and <b> tags pass through ) for case in test_cases: self.xmodule.score_comment = case[0] assert case[1] == self.xmodule.get_context()['comment'] def test_lti20_rest_bad_contenttype(self): """ Input with bad content type """ with self.assertRaisesRegex(LTIError, "Content-Type must be"): request = Mock(headers={'Content-Type': 'Non-existent'}) self.xmodule.verify_lti_2_0_result_rest_headers(request) def test_lti20_rest_failed_oauth_body_verify(self): """ Input with bad oauth body hash verification """ err_msg = "OAuth body verification failed" self.xmodule.verify_oauth_body_sign = Mock( side_effect=LTIError(err_msg)) with self.assertRaisesRegex(LTIError, err_msg): request = Mock( headers={ 'Content-Type': 'application/vnd.ims.lis.v2.result+json' }) self.xmodule.verify_lti_2_0_result_rest_headers(request) def test_lti20_rest_good_headers(self): """ Input with good oauth body hash verification """ self.xmodule.verify_oauth_body_sign = Mock(return_value=True) request = Mock( headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) self.xmodule.verify_lti_2_0_result_rest_headers(request) # We just want the above call to complete without exceptions, and to have called verify_oauth_body_sign assert self.xmodule.verify_oauth_body_sign.called BAD_DISPATCH_INPUTS = [ None, "", "abcd" "notuser/abcd" "user/" "user//" "user/gbere/" "user/gbere/xsdf" "user/ಠ益ಠ" # not alphanumeric ] def test_lti20_rest_bad_dispatch(self): """ Test the error cases for the "dispatch" argument to the LTI 2.0 handler. Anything that doesn't fit the form user/<anon_id> """ for einput in self.BAD_DISPATCH_INPUTS: with self.assertRaisesRegex( LTIError, "No valid user id found in endpoint URL"): self.xmodule.parse_lti_2_0_handler_suffix(einput) GOOD_DISPATCH_INPUTS = [ ("user/abcd3", "abcd3"), ("user/Äbcdè2", "Äbcdè2"), # unicode, just to make sure ] def test_lti20_rest_good_dispatch(self): """ Test the good cases for the "dispatch" argument to the LTI 2.0 handler. Anything that does fit the form user/<anon_id> """ for ginput, expected in self.GOOD_DISPATCH_INPUTS: assert self.xmodule.parse_lti_2_0_handler_suffix( ginput) == expected BAD_JSON_INPUTS = [ # (bad inputs, error message expected) ( [ "kk", # ValueError "{{}", # ValueError "{}}", # ValueError 3, # TypeError {}, # TypeError ], "Supplied JSON string in request body could not be decoded"), ( [ "3", # valid json, not array or object "[]", # valid json, array too small "[3, {}]", # valid json, 1st element not an object ], "Supplied JSON string is a list that does not contain an object as the first element" ), ( [ '{"@type": "NOTResult"}', # @type key must have value 'Result' ], "JSON object does not contain correct @type attribute"), ( [ # @context missing '{"@type": "Result", "resultScore": 0.1}', ], "JSON object does not contain required key"), ( [ ''' {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "resultScore": 100}''' # score out of range ], "score value outside the permitted range of 0-1."), ( [ ''' {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "resultScore": "1b"}''', # score ValueError ''' {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "resultScore": {}}''', # score TypeError ], "Could not convert resultScore to float"), ] def test_lti20_bad_json(self): """ Test that bad json_str to parse_lti_2_0_result_json inputs raise appropriate LTI Error """ for error_inputs, error_message in self.BAD_JSON_INPUTS: for einput in error_inputs: with self.assertRaisesRegex(LTIError, error_message): self.xmodule.parse_lti_2_0_result_json(einput) GOOD_JSON_INPUTS = [ (''' {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "resultScore": 0.1}''', ""), # no comment means we expect "" ( ''' [{"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "@id": "anon_id:abcdef0123456789", "resultScore": 0.1}]''', "" ), # OK to have array of objects -- just take the first. @id is okay too (''' {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "resultScore": 0.1, "comment": "ಠ益ಠ"}''', "ಠ益ಠ"), # unicode comment ] def test_lti20_good_json(self): """ Test the parsing of good comments """ for json_str, expected_comment in self.GOOD_JSON_INPUTS: score, comment = self.xmodule.parse_lti_2_0_result_json(json_str) assert score == 0.1 assert comment == expected_comment GOOD_JSON_PUT = textwrap.dedent(""" {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "@id": "anon_id:abcdef0123456789", "resultScore": 0.1, "comment": "ಠ益ಠ"} """).encode('utf-8') GOOD_JSON_PUT_LIKE_DELETE = textwrap.dedent(""" {"@type": "Result", "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", "@id": "anon_id:abcdef0123456789", "comment": "ಠ益ಠ"} """).encode('utf-8') def get_signed_lti20_mock_request(self, body, method='PUT'): """ Example of signed from LTI 2.0 Provider. Signatures and hashes are example only and won't verify """ mock_request = Mock() mock_request.headers = { 'Content-Type': 'application/vnd.ims.lis.v2.result+json', 'Authorization': ('OAuth oauth_nonce="135685044251684026041377608307", ' 'oauth_timestamp="1234567890", oauth_version="1.0", ' 'oauth_signature_method="HMAC-SHA1", ' 'oauth_consumer_key="test_client_key", ' 'oauth_signature="my_signature%3D", ' 'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="') } mock_request.url = 'http://testurl' mock_request.http_method = method mock_request.method = method mock_request.body = body return mock_request def setup_system_xmodule_mocks_for_lti20_request_test(self): """ Helper fn to set up mocking for lti 2.0 request test """ self.xmodule.max_score = Mock(return_value=1.0) self.xmodule.get_client_key_secret = Mock( return_value=('test_client_key', 'test_client_secret')) self.xmodule.verify_oauth_body_sign = Mock() def test_lti20_put_like_delete_success(self): """ The happy path for LTI 2.0 PUT that acts like a delete """ self.setup_system_xmodule_mocks_for_lti20_request_test() SCORE = 0.55 # pylint: disable=invalid-name COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xmodule.module_score = SCORE self.xmodule.score_comment = COMMENT mock_request = self.get_signed_lti20_mock_request( self.GOOD_JSON_PUT_LIKE_DELETE) # Now call the handler response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") # Now assert there's no score assert response.status_code == 200 assert self.xmodule.module_score is None assert self.xmodule.score_comment == '' (_, evt_type, called_grade_obj), _ = self.system.publish.call_args # pylint: disable=unpacking-non-sequence assert called_grade_obj ==\ {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} assert evt_type == 'grade' def test_lti20_delete_success(self): """ The happy path for LTI 2.0 DELETE """ self.setup_system_xmodule_mocks_for_lti20_request_test() SCORE = 0.55 # pylint: disable=invalid-name COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xmodule.module_score = SCORE self.xmodule.score_comment = COMMENT mock_request = self.get_signed_lti20_mock_request(b"", method='DELETE') # Now call the handler response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") # Now assert there's no score assert response.status_code == 200 assert self.xmodule.module_score is None assert self.xmodule.score_comment == '' (_, evt_type, called_grade_obj), _ = self.system.publish.call_args # pylint: disable=unpacking-non-sequence assert called_grade_obj ==\ {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} assert evt_type == 'grade' def test_lti20_put_set_score_success(self): """ The happy path for LTI 2.0 PUT that sets a score """ self.setup_system_xmodule_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) # Now call the handler response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") # Now assert assert response.status_code == 200 assert self.xmodule.module_score == 0.1 assert self.xmodule.score_comment == 'ಠ益ಠ' (_, evt_type, called_grade_obj), _ = self.system.publish.call_args # pylint: disable=unpacking-non-sequence assert evt_type == 'grade' assert called_grade_obj ==\ {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0, 'score_deleted': False} def test_lti20_get_no_score_success(self): """ The happy path for LTI 2.0 GET when there's no score """ self.setup_system_xmodule_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(b"", method='GET') # Now call the handler response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") # Now assert assert response.status_code == 200 assert response.json == { '@context': 'http://purl.imsglobal.org/ctx/lis/v2/Result', '@type': 'Result' } def test_lti20_get_with_score_success(self): """ The happy path for LTI 2.0 GET when there is a score """ self.setup_system_xmodule_mocks_for_lti20_request_test() SCORE = 0.55 # pylint: disable=invalid-name COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xmodule.module_score = SCORE self.xmodule.score_comment = COMMENT mock_request = self.get_signed_lti20_mock_request(b"", method='GET') # Now call the handler response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") # Now assert assert response.status_code == 200 assert response.json ==\ {'@context': 'http://purl.imsglobal.org/ctx/lis/v2/Result', '@type': 'Result', 'resultScore': SCORE, 'comment': COMMENT} UNSUPPORTED_HTTP_METHODS = ["OPTIONS", "HEAD", "POST", "TRACE", "CONNECT"] def test_lti20_unsupported_method_error(self): """ Test we get a 404 when we don't GET or PUT """ self.setup_system_xmodule_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) for bad_method in self.UNSUPPORTED_HTTP_METHODS: mock_request.method = bad_method response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") assert response.status_code == 404 def test_lti20_request_handler_bad_headers(self): """ Test that we get a 401 when header verification fails """ self.setup_system_xmodule_mocks_for_lti20_request_test() self.xmodule.verify_lti_2_0_result_rest_headers = Mock( side_effect=LTIError()) mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") assert response.status_code == 401 def test_lti20_request_handler_bad_dispatch_user(self): """ Test that we get a 404 when there's no (or badly formatted) user specified in the url """ self.setup_system_xmodule_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xmodule.lti_2_0_result_rest_handler(mock_request, None) assert response.status_code == 404 def test_lti20_request_handler_bad_json(self): """ Test that we get a 404 when json verification fails """ self.setup_system_xmodule_mocks_for_lti20_request_test() self.xmodule.parse_lti_2_0_result_json = Mock(side_effect=LTIError()) mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") assert response.status_code == 404 def test_lti20_request_handler_bad_user(self): """ Test that we get a 404 when the supplied user does not exist """ self.setup_system_xmodule_mocks_for_lti20_request_test() self.system._services['user'] = StubUserService(user=None) # pylint: disable=protected-access mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") assert response.status_code == 404 def test_lti20_request_handler_grade_past_due(self): """ Test that we get a 404 when accept_grades_past_due is False and it is past due """ self.setup_system_xmodule_mocks_for_lti20_request_test() self.xmodule.due = datetime.datetime.now(UTC) self.xmodule.accept_grades_past_due = False mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xmodule.lti_2_0_result_rest_handler( mock_request, "user/abcd") assert response.status_code == 404