399 lines
17 KiB
Python
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 <script>alert(3)</script>"), # 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)
|