Files
edx-platform/common/lib/xmodule/xmodule/tests/test_lti20_unit.py
Feanil Patel 2df8b8226b Merge pull request #22643 from edx/feanil/2to3_asserts
Run `2to3 -f asserts . -w` on edx-platform.
2019-12-30 12:13:42 -05:00

399 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for LTI Xmodule LTIv2.0 functional logic."""
import datetime
import textwrap
from mock import Mock
from pytz import UTC
from xmodule.lti_2_util import LTIError
from xmodule.lti_module import LTIDescriptor
from . import LogicTest
class LTI20RESTResultServiceTest(LogicTest):
"""Logic tests for LTI module. LTI2.0 REST ResultService"""
descriptor_class = LTIDescriptor
def setUp(self):
super(LTI20RESTResultServiceTest, self).setUp()
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.user_id = self.xmodule.runtime.anonymous_student_id
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
runtime = Mock(name='runtime', modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
self.xmodule.lti_id = "lti_id"
test_cases = ( # (before sanitize, after sanitize)
(u"plaintext", u"plaintext"),
(u"a <script>alert(3)</script>", u"a &lt;script&gt;alert(3)&lt;/script&gt;"), # encodes scripts
(u"<b>bold 包</b>", u"<b>bold 包</b>"), # unicode, and <b> tags pass through
)
for case in test_cases:
self.xmodule.score_comment = case[0]
self.assertEqual(
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={u'Content-Type': u'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={u'Content-Type': u'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={u'Content-Type': u'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
self.assertTrue(self.xmodule.verify_oauth_body_sign.called)
BAD_DISPATCH_INPUTS = [
None,
u"",
u"abcd"
u"notuser/abcd"
u"user/"
u"user//"
u"user/gbere/"
u"user/gbere/xsdf"
u"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 = [
(u"user/abcd3", u"abcd3"),
(u"user/Äbcdè2", u"Ä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:
self.assertEqual(self.xmodule.parse_lti_2_0_handler_suffix(ginput), expected)
BAD_JSON_INPUTS = [
# (bad inputs, error message expected)
([
u"kk", # ValueError
u"{{}", # ValueError
u"{}}", # ValueError
3, # TypeError
{}, # TypeError
], u"Supplied JSON string in request body could not be decoded"),
([
u"3", # valid json, not array or object
u"[]", # valid json, array too small
u"[3, {}]", # valid json, 1st element not an object
], u"Supplied JSON string is a list that does not contain an object as the first element"),
([
u'{"@type": "NOTResult"}', # @type key must have value 'Result'
], u"JSON object does not contain correct @type attribute"),
([
# @context missing
u'{"@type": "Result", "resultScore": 0.1}',
], u"JSON object does not contain required key"),
([
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 100}''' # score out of range
], u"score value outside the permitted range of 0-1."),
([
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": "1b"}''', # score ValueError
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": {}}''', # score TypeError
], u"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 = [
(u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 0.1}''', u""), # no comment means we expect ""
(u'''
[{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"resultScore": 0.1}]''', u""), # OK to have array of objects -- just take the first. @id is okay too
(u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 0.1,
"comment": "ಠ益ಠ"}''', u"ಠ益ಠ"), # 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)
self.assertEqual(score, 0.1)
self.assertEqual(comment, expected_comment)
GOOD_JSON_PUT = textwrap.dedent(u"""
{"@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(u"""
{"@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=u'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': (
u'OAuth oauth_nonce="135685044251684026041377608307", '
u'oauth_timestamp="1234567890", oauth_version="1.0", '
u'oauth_signature_method="HMAC-SHA1", '
u'oauth_consumer_key="test_client_key", '
u'oauth_signature="my_signature%3D", '
u'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="'
)
}
mock_request.url = u'http://testurl'
mock_request.http_method = method
mock_request.method = method
mock_request.body = body
return mock_request
USER_STANDIN = Mock()
USER_STANDIN.id = 999
def setup_system_xmodule_mocks_for_lti20_request_test(self):
"""
Helper fn to set up mocking for lti 2.0 request test
"""
self.system.get_real_user = Mock(return_value=self.USER_STANDIN)
self.xmodule.max_score = Mock(return_value=1.0)
self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', u'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 = u"ಠ益ಠ" # 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, u"user/abcd")
# Now assert there's no score
self.assertEqual(response.status_code, 200)
self.assertIsNone(self.xmodule.module_score)
self.assertEqual(self.xmodule.score_comment, u"")
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
self.assertEqual(
called_grade_obj,
{'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True},
)
self.assertEqual(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 = u"ಠ益ಠ" # pylint: disable=invalid-name
self.xmodule.module_score = SCORE
self.xmodule.score_comment = COMMENT
mock_request = self.get_signed_lti20_mock_request(b"", method=u'DELETE')
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, u"user/abcd")
# Now assert there's no score
self.assertEqual(response.status_code, 200)
self.assertIsNone(self.xmodule.module_score)
self.assertEqual(self.xmodule.score_comment, u"")
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
self.assertEqual(
called_grade_obj,
{'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True},
)
self.assertEqual(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, u"user/abcd")
# Now assert
self.assertEqual(response.status_code, 200)
self.assertEqual(self.xmodule.module_score, 0.1)
self.assertEqual(self.xmodule.score_comment, u"ಠ益ಠ")
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
self.assertEqual(evt_type, 'grade')
self.assertEqual(
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=u'GET')
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, u"user/abcd")
# Now assert
self.assertEqual(response.status_code, 200)
self.assertEqual(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 = u"ಠ益ಠ" # pylint: disable=invalid-name
self.xmodule.module_score = SCORE
self.xmodule.score_comment = COMMENT
mock_request = self.get_signed_lti20_mock_request(b"", method=u'GET')
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, u"user/abcd")
# Now assert
self.assertEqual(response.status_code, 200)
self.assertEqual(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, u"user/abcd")
self.assertEqual(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, u"user/abcd")
self.assertEqual(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)
self.assertEqual(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, u"user/abcd")
self.assertEqual(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.get_real_user = Mock(return_value=None)
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, u"user/abcd")
self.assertEqual(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, u"user/abcd")
self.assertEqual(response.status_code, 404)