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
Exemple #2
0
    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
Exemple #5
0
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 &lt;script&gt;alert(3)&lt;/script&gt;"),  # 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