Commit 9e8a24bf by e0d

Merge pull request #1979 from edx/alex/lti_fixes_to_release

Alex/lti fixes to release
parents a78400a8 cc2e4bfe
...@@ -5,13 +5,16 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,13 +5,16 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Blades: Make LTI module not send grade_back_url if has_score=False. BLD-561.
Blades: LTI additional Python tests. LTI must use HTTPS for
lis_outcome_service_url. BLD-564.
Blades: Fix bug when Image mapping problems are not working for students in IE. BLD-413. Blades: Fix bug when Image mapping problems are not working for students in IE. BLD-413.
Blades: Add template that displays the most up-to-date features of Blades: Add template that displays the most up-to-date features of
drag-and-drop. BLD-479. drag-and-drop. BLD-479.
Blades: LTI additional Python tests. LTI fix bug e-reader error when popping Blades: LTI fix bug e-reader error when popping out window. BLD-465.
out window. BLD-465.
Common: Switch from mitx.db to edx.db for sqlite databases. This will effectively Common: Switch from mitx.db to edx.db for sqlite databases. This will effectively
reset state for local instances of the code, unless you manually rename your reset state for local instances of the code, unless you manually rename your
......
...@@ -46,8 +46,8 @@ class AnonymousUserId(models.Model): ...@@ -46,8 +46,8 @@ class AnonymousUserId(models.Model):
Purpose of this table is to provide user by anonymous_user_id. Purpose of this table is to provide user by anonymous_user_id.
We are generating anonymous_user_id using md5 algorithm, so resulting length will always be 16 bytes. We generate anonymous_user_id using md5 algorithm,
http://docs.python.org/2/library/md5.html#md5.digest_size and use result in hex form, so its length is equal to 32 bytes.
""" """
user = models.ForeignKey(User, db_index=True) user = models.ForeignKey(User, db_index=True)
anonymous_user_id = models.CharField(unique=True, max_length=32) anonymous_user_id = models.CharField(unique=True, max_length=32)
......
...@@ -259,37 +259,22 @@ class LTIModule(LTIFields, XModule): ...@@ -259,37 +259,22 @@ class LTIModule(LTIFields, XModule):
'element_class': self.category, 'element_class': self.category,
'open_in_a_new_page': self.open_in_a_new_page, 'open_in_a_new_page': self.open_in_a_new_page,
'display_name': self.display_name, 'display_name': self.display_name,
'form_url': self.get_form_path(), 'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'),
} }
def get_form_path(self):
return self.runtime.handler_url(self, 'preview_handler').rstrip('/?')
def get_html(self): def get_html(self):
""" """
Renders parameters to template. Renders parameters to template.
""" """
return self.system.render_template('lti.html', self.get_context()) return self.system.render_template('lti.html', self.get_context())
def get_form(self):
"""
Renders parameters to form template.
"""
return self.system.render_template('lti_form.html', self.get_context())
@XBlock.handler @XBlock.handler
def preview_handler(self, request, dispatch): def preview_handler(self, _, __):
""" """
Ajax handler. This is called to get context with new oauth params to iframe.
Args:
dispatch: string request slug
Returns:
json string
""" """
return Response(self.get_form(), content_type='text/html') template = self.system.render_template('lti_form.html', self.get_context())
return Response(template, content_type='text/html')
def get_user_id(self): def get_user_id(self):
user_id = self.runtime.anonymous_student_id user_id = self.runtime.anonymous_student_id
...@@ -299,11 +284,18 @@ class LTIModule(LTIFields, XModule): ...@@ -299,11 +284,18 @@ class LTIModule(LTIFields, XModule):
def get_outcome_service_url(self): def get_outcome_service_url(self):
""" """
Return URL for storing grades. Return URL for storing grades.
To test LTI on sandbox we must use http scheme.
While testing locally and on Jenkins, mock_lti_server use http.referer
to obtain scheme, so it is ok to have http(s) anyway.
""" """
uri = 'http://{host}{path}'.format( scheme = 'http' if 'sandbox' in self.system.hostname else 'https'
host=self.system.hostname, uri = '{scheme}://{host}{path}'.format(
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?') scheme=scheme,
) host=self.system.hostname,
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
)
return uri return uri
def get_resource_link_id(self): def get_resource_link_id(self):
...@@ -363,11 +355,15 @@ class LTIModule(LTIFields, XModule): ...@@ -363,11 +355,15 @@ class LTIModule(LTIFields, XModule):
# Parameters required for grading: # Parameters required for grading:
u'resource_link_id': self.get_resource_link_id(), u'resource_link_id': self.get_resource_link_id(),
u'lis_outcome_service_url': self.get_outcome_service_url(),
u'lis_result_sourcedid': self.get_lis_result_sourcedid(), u'lis_result_sourcedid': self.get_lis_result_sourcedid(),
} }
if self.has_score:
body.update({
u'lis_outcome_service_url': self.get_outcome_service_url()
})
# Appending custom parameter for signing. # Appending custom parameter for signing.
body.update(custom_parameters) body.update(custom_parameters)
...@@ -449,7 +445,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -449,7 +445,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
Example of correct/incorrect answer XML body:: see response_xml_template. Example of correct/incorrect answer XML body:: see response_xml_template.
""" """
response_xml_template = textwrap.dedent(""" response_xml_template = textwrap.dedent("""\
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> <imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader> <imsx_POXHeader>
...@@ -491,6 +487,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -491,6 +487,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
try: try:
imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body)
except Exception: except Exception:
log.debug("[LTI]: Request body XML parsing error.")
failure_values['imsx_description'] = 'Request body XML parsing error.'
return Response(response_xml_template.format(**failure_values), content_type="application/xml") return Response(response_xml_template.format(**failure_values), content_type="application/xml")
# Verify OAuth signing. # Verify OAuth signing.
...@@ -498,10 +496,15 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -498,10 +496,15 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
self.verify_oauth_body_sign(request) self.verify_oauth_body_sign(request)
except (ValueError, LTIError): except (ValueError, LTIError):
failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
failure_values['imsx_description'] = 'OAuth verification error.'
return Response(response_xml_template.format(**failure_values), content_type="application/xml") return Response(response_xml_template.format(**failure_values), content_type="application/xml")
real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1])) real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1]))
if not real_user: # that means we can't save to database, as we do not have real user id.
failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
failure_values['imsx_description'] = 'User not found.'
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
if action == 'replaceResultRequest': if action == 'replaceResultRequest':
self.system.publish( self.system.publish(
event={ event={
...@@ -518,9 +521,11 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -518,9 +521,11 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
'imsx_messageIdentifier': escape(imsx_messageIdentifier), 'imsx_messageIdentifier': escape(imsx_messageIdentifier),
'response': '<replaceResultResponse/>' 'response': '<replaceResultResponse/>'
} }
log.debug("[LTI]: Grade is saved.")
return Response(response_xml_template.format(**values), content_type="application/xml") return Response(response_xml_template.format(**values), content_type="application/xml")
unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
log.debug("[LTI]: Incorrect action.")
return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') return Response(response_xml_template.format(**unsupported_values), content_type='application/xml')
...@@ -549,6 +554,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -549,6 +554,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
# Raise exception if score is not float or not in range 0.0-1.0 regarding spec. # Raise exception if score is not float or not in range 0.0-1.0 regarding spec.
score = float(score) score = float(score)
if not 0 <= score <= 1: if not 0 <= score <= 1:
log.debug("[LTI]: Score not in range.")
raise LTIError raise LTIError
return imsx_messageIdentifier, sourcedId, score, action return imsx_messageIdentifier, sourcedId, score, action
...@@ -578,7 +584,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -578,7 +584,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
sha1 = hashlib.sha1() sha1 = hashlib.sha1()
sha1.update(request.body) sha1.update(request.body)
oauth_body_hash = base64.b64encode(sha1.hexdigest()) oauth_body_hash = base64.b64encode(sha1.digest())
oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
oauth_headers =dict(oauth_params) oauth_headers =dict(oauth_params)
...@@ -590,8 +596,11 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} ...@@ -590,8 +596,11 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
params=oauth_headers.items(), params=oauth_headers.items(),
signature=oauth_signature signature=oauth_signature
) )
if (oauth_body_hash != oauth_headers.get('oauth_body_hash') or if oauth_body_hash != oauth_headers.get('oauth_body_hash'):
not signature.verify_hmac_sha1(mock_request, client_secret)): log.debug("[LTI]: OAuth body hash verification is failed.")
raise LTIError
if not signature.verify_hmac_sha1(mock_request, client_secret):
log.debug("[LTI]: OAuth signature verification is failed.")
raise LTIError raise LTIError
def get_client_key_secret(self): def get_client_key_secret(self):
......
...@@ -1016,6 +1016,9 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs ...@@ -1016,6 +1016,9 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
error_descriptor_class - The class to use to render XModules with errors error_descriptor_class - The class to use to render XModules with errors
get_real_user - function that takes `anonymous_student_id` and returns real user_id,
associated with `anonymous_student_id`.
""" """
# Right now, usage_store is unused, and field_data is always supplanted # Right now, usage_store is unused, and field_data is always supplanted
......
...@@ -38,6 +38,10 @@ def setup_mock_lti_server(): ...@@ -38,6 +38,10 @@ def setup_mock_lti_server():
'lti_endpoint': 'correct_lti_endpoint' 'lti_endpoint': 'correct_lti_endpoint'
} }
# Flag for acceptance tests used for creating right callback_url and sending
# graded result. Used in MockLTIRequestHandler.
server.test_mode = True
# Store the server instance in lettuce's world # Store the server instance in lettuce's world
# so that other steps can access it # so that other steps can access it
# (and we can shut it down later) # (and we can shut it down later)
......
""" """
Mock LTI server for manual testing. Mock LTI server for manual testing.
Used for manual testing and testing on sandbox.
""" """
import threading import threading
...@@ -18,6 +20,10 @@ server.oauth_settings = { ...@@ -18,6 +20,10 @@ server.oauth_settings = {
} }
server.server_host = server_host server.server_host = server_host
# If in test mode mock lti server will make callback url using referer host.
# Used in MockLTIRequestHandler when sending graded result.
server.test_mode = True
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
......
...@@ -11,7 +11,6 @@ import requests ...@@ -11,7 +11,6 @@ import requests
from mock_lti_server import MockLTIServer from mock_lti_server import MockLTIServer
class MockLTIServerTest(unittest.TestCase): class MockLTIServerTest(unittest.TestCase):
''' '''
A mock version of the LTI provider server that listens on a local A mock version of the LTI provider server that listens on a local
...@@ -33,6 +32,10 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -33,6 +32,10 @@ class MockLTIServerTest(unittest.TestCase):
'lti_base': 'http://{}:{}/'.format(server_host, server_port), 'lti_base': 'http://{}:{}/'.format(server_host, server_port),
'lti_endpoint': 'correct_lti_endpoint' 'lti_endpoint': 'correct_lti_endpoint'
} }
self.server.run_inside_unittest_flag = True
#flag for creating right callback_url
self.server.test_mode = True
# Start the server in a separate daemon thread # Start the server in a separate daemon thread
server_thread = threading.Thread(target=self.server.serve_forever) server_thread = threading.Thread(target=self.server.serve_forever)
server_thread.daemon = True server_thread.daemon = True
...@@ -43,6 +46,23 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -43,6 +46,23 @@ class MockLTIServerTest(unittest.TestCase):
# Stop the server, freeing up the port # Stop the server, freeing up the port
self.server.shutdown() self.server.shutdown()
def test_wrong_header(self):
"""
Tests that LTI server processes request with right program path but with wrong header.
"""
#wrong number of params and no signature
payload = {
'user_id': 'default_user_id',
'role': 'student',
'oauth_nonce': '',
'oauth_timestamp': '',
}
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
headers = {'referer': 'http://localhost:8000/'}
response = requests.post(uri, data=payload, headers=headers)
self.assertIn('Wrong LTI signature', response.content)
def test_wrong_signature(self): def test_wrong_signature(self):
""" """
Tests that LTI server processes request with right program Tests that LTI server processes request with right program
...@@ -53,7 +73,7 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -53,7 +73,7 @@ class MockLTIServerTest(unittest.TestCase):
'role': 'student', 'role': 'student',
'oauth_nonce': '', 'oauth_nonce': '',
'oauth_timestamp': '', 'oauth_timestamp': '',
'oauth_consumer_key': 'client_key', 'oauth_consumer_key': 'test_client_key',
'lti_version': 'LTI-1p0', 'lti_version': 'LTI-1p0',
'oauth_signature_method': 'HMAC-SHA1', 'oauth_signature_method': 'HMAC-SHA1',
'oauth_version': '1.0', 'oauth_version': '1.0',
...@@ -65,25 +85,48 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -65,25 +85,48 @@ class MockLTIServerTest(unittest.TestCase):
'lis_result_sourcedid': '', 'lis_result_sourcedid': '',
'resource_link_id':'', 'resource_link_id':'',
} }
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'] uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
headers = {'referer': 'http://localhost:8000/'} headers = {'referer': 'http://localhost:8000/'}
response = requests.post(uri, data=payload, headers=headers) response = requests.post(uri, data=payload, headers=headers)
self.assertIn('Wrong LTI signature', response.content)
self.assertTrue('Wrong LTI signature' in response.content)
def test_success_response_launch_lti(self): def test_success_response_launch_lti(self):
""" """
Success lti launch. Success lti launch.
""" """
payload = {
'user_id': 'default_user_id',
'role': 'student',
'oauth_nonce': '',
'oauth_timestamp': '',
'oauth_consumer_key': 'test_client_key',
'lti_version': 'LTI-1p0',
'oauth_signature_method': 'HMAC-SHA1',
'oauth_version': '1.0',
'oauth_signature': '',
'lti_message_type': 'basic-lti-launch-request',
'oauth_callback': 'about:blank',
'launch_presentation_return_url': '',
'lis_outcome_service_url': '',
'lis_result_sourcedid': '',
'resource_link_id':'',
}
self.server.check_oauth_signature = Mock(return_value=True)
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
headers = {'referer': 'http://localhost:8000/'}
response = requests.post(uri, data=payload, headers=headers)
self.assertIn('This is LTI tool. Success.', response.content)
def test_send_graded_result(self):
payload = { payload = {
'user_id': 'default_user_id', 'user_id': 'default_user_id',
'role': 'student', 'role': 'student',
'oauth_nonce': '', 'oauth_nonce': '',
'oauth_timestamp': '', 'oauth_timestamp': '',
'oauth_consumer_key': 'client_key', 'oauth_consumer_key': 'test_client_key',
'lti_version': 'LTI-1p0', 'lti_version': 'LTI-1p0',
'oauth_signature_method': 'HMAC-SHA1', 'oauth_signature_method': 'HMAC-SHA1',
'oauth_version': '1.0', 'oauth_version': '1.0',
...@@ -94,14 +137,18 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -94,14 +137,18 @@ class MockLTIServerTest(unittest.TestCase):
'lis_outcome_service_url': '', 'lis_outcome_service_url': '',
'lis_result_sourcedid': '', 'lis_result_sourcedid': '',
'resource_link_id':'', 'resource_link_id':'',
"lis_outcome_service_url": '',
} }
self.server.check_oauth_signature = Mock(return_value=True) self.server.check_oauth_signature = Mock(return_value=True)
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'] uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
#this is the uri for sending grade from lti
headers = {'referer': 'http://localhost:8000/'} headers = {'referer': 'http://localhost:8000/'}
response = requests.post(uri, data=payload, headers=headers) response = requests.post(uri, data=payload, headers=headers)
self.assertIn('This is LTI tool. Success.', response.content)
self.assertTrue('This is LTI tool. Success.' in response.content)
self.server.grade_data['TC answer'] = "Test response"
graded_response = requests.post('http://127.0.0.1:8034/grade')
self.assertIn('Test response', graded_response.content)
...@@ -33,7 +33,7 @@ class TestLTI(BaseTestXmodule): ...@@ -33,7 +33,7 @@ class TestLTI(BaseTestXmodule):
sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id))
lis_outcome_service_url = 'http://{host}{path}'.format( lis_outcome_service_url = 'https://{host}{path}'.format(
host=self.item_descriptor.xmodule_runtime.hostname, host=self.item_descriptor.xmodule_runtime.hostname,
path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?') path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?')
) )
...@@ -46,7 +46,6 @@ class TestLTI(BaseTestXmodule): ...@@ -46,7 +46,6 @@ class TestLTI(BaseTestXmodule):
u'role': u'student', u'role': u'student',
u'resource_link_id': module_id, u'resource_link_id': module_id,
u'lis_outcome_service_url': lis_outcome_service_url,
u'lis_result_sourcedid': sourcedId, u'lis_result_sourcedid': sourcedId,
u'oauth_nonce': mocked_nonce, u'oauth_nonce': mocked_nonce,
...@@ -59,6 +58,16 @@ class TestLTI(BaseTestXmodule): ...@@ -59,6 +58,16 @@ class TestLTI(BaseTestXmodule):
saved_sign = oauthlib.oauth1.Client.sign saved_sign = oauthlib.oauth1.Client.sign
self.expected_context = {
'display_name': self.item_module.display_name,
'input_fields': self.correct_headers,
'element_class': self.item_module.category,
'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True,
'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'preview_handler').rstrip('/?'),
}
def mocked_sign(self, *args, **kwargs): def mocked_sign(self, *args, **kwargs):
""" """
Mocked oauth1 sign function. Mocked oauth1 sign function.
...@@ -79,21 +88,11 @@ class TestLTI(BaseTestXmodule): ...@@ -79,21 +88,11 @@ class TestLTI(BaseTestXmodule):
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
def test_lti_constructor(self): def test_lti_constructor(self):
""" generated_content = self.item_module.render('student_view').content
Makes sure that all parameters extracted. expected_content = self.runtime.render_template('lti.html', self.expected_context)
""" self.assertEqual(generated_content, expected_content)
generated_context = self.item_module.render('student_view').content
expected_context = {
'display_name': self.item_module.display_name,
'input_fields': self.correct_headers,
'element_class': self.item_module.category,
'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True,
'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'preview_handler').rstrip('/?'),
}
self.assertEqual( def test_lti_preview_handler(self):
generated_context, generated_content = self.item_module.preview_handler(None, None).body
self.runtime.render_template('lti.html', expected_context), expected_content = self.runtime.render_template('lti_form.html', self.expected_context)
) self.assertEqual(generated_content, expected_content)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment