"""Tests for LTI Xmodule LTIv2.0 functional logic.""" import datetime import textwrap import unittest from unittest.mock import Mock from pytz import UTC from xblock.field_data import DictFieldData from xmodule.lti_2_util import LTIError from xmodule.lti_block import LTIBlock from xmodule.tests.helpers import StubUserService from . import get_test_system class LTI20RESTResultServiceTest(unittest.TestCase): """Logic tests for LTI block. LTI2.0 REST ResultService""" USER_STANDIN = Mock() USER_STANDIN.id = 999 def setUp(self): super().setUp() self.runtime = get_test_system(user=self.USER_STANDIN) self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} self.runtime.publish = Mock() self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access self.xblock = LTIBlock(self.runtime, DictFieldData({}), Mock()) self.lti_id = self.xblock.lti_id self.xblock.due = None self.xblock.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.xblock.runtime.modulestore = modulestore self.xblock.lti_id = "lti_id" test_cases = ( # (before sanitize, after sanitize) ("plaintext", "plaintext"), ("a ", "a "), # drops scripts ("bold 包", "bold 包"), # unicode, and tags pass through ) for case in test_cases: self.xblock.score_comment = case[0] assert case[1] == self.xblock.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.xblock.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.xblock.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.xblock.verify_lti_2_0_result_rest_headers(request) def test_lti20_rest_good_headers(self): """ Input with good oauth body hash verification """ self.xblock.verify_oauth_body_sign = Mock(return_value=True) request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) self.xblock.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.xblock.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/ """ for einput in self.BAD_DISPATCH_INPUTS: with self.assertRaisesRegex(LTIError, "No valid user id found in endpoint URL"): self.xblock.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/ """ for ginput, expected in self.GOOD_DISPATCH_INPUTS: assert self.xblock.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.xblock.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.xblock.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_xblock_mocks_for_lti20_request_test(self): """ Helper fn to set up mocking for lti 2.0 request test """ self.xblock.max_score = Mock(return_value=1.0) self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) self.xblock.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_xblock_mocks_for_lti20_request_test() SCORE = 0.55 # pylint: disable=invalid-name COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xblock.module_score = SCORE self.xblock.score_comment = COMMENT mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE) # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert there's no score assert response.status_code == 200 assert self.xblock.module_score is None assert self.xblock.score_comment == '' (_, evt_type, called_grade_obj), _ = self.runtime.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_xblock_mocks_for_lti20_request_test() SCORE = 0.55 # pylint: disable=invalid-name COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xblock.module_score = SCORE self.xblock.score_comment = COMMENT mock_request = self.get_signed_lti20_mock_request(b"", method='DELETE') # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert there's no score assert response.status_code == 200 assert self.xblock.module_score is None assert self.xblock.score_comment == '' (_, evt_type, called_grade_obj), _ = self.runtime.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_xblock_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert assert response.status_code == 200 assert self.xblock.module_score == 0.1 assert self.xblock.score_comment == 'ಠ益ಠ' (_, evt_type, called_grade_obj), _ = self.runtime.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_xblock_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(b"", method='GET') # Now call the handler response = self.xblock.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_xblock_mocks_for_lti20_request_test() SCORE = 0.55 # pylint: disable=invalid-name COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xblock.module_score = SCORE self.xblock.score_comment = COMMENT mock_request = self.get_signed_lti20_mock_request(b"", method='GET') # Now call the handler response = self.xblock.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_xblock_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.xblock.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_xblock_mocks_for_lti20_request_test() self.xblock.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.xblock.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_xblock_mocks_for_lti20_request_test() mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xblock.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_xblock_mocks_for_lti20_request_test() self.xblock.parse_lti_2_0_result_json = Mock(side_effect=LTIError()) mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xblock.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_xblock_mocks_for_lti20_request_test() self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xblock.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_xblock_mocks_for_lti20_request_test() self.xblock.due = datetime.datetime.now(UTC) self.xblock.accept_grades_past_due = False mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") assert response.status_code == 404