Files
edx-platform/xmodule/tests/test_lti20_unit.py
2026-01-09 11:43:33 -05:00

388 lines
16 KiB
Python

"""Tests for LTI Xmodule LTIv2.0 functional logic."""
import datetime
import textwrap
import unittest
from unittest.mock import Mock
from zoneinfo import ZoneInfo
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 <script>alert(3)</script>", "a "), # drops scripts
("<b>bold 包</b>", "<b>bold 包</b>"), # unicode, and <b> 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/<anon_id>
"""
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/<anon_id>
"""
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(ZoneInfo("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