LTI additional Python tests. LTI must use HTTPS for lis_outcome_service_url.
BLD-564.
This commit is contained in:
committed by
Oleg Marshev
parent
11080f2872
commit
0079243746
@@ -5,13 +5,15 @@ 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: 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
|
||||||
|
|||||||
@@ -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,8 +284,15 @@ 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'
|
||||||
|
uri = '{scheme}://{host}{path}'.format(
|
||||||
|
scheme=scheme,
|
||||||
host=self.system.hostname,
|
host=self.system.hostname,
|
||||||
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
|
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||||
)
|
)
|
||||||
@@ -449,7 +441,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>
|
||||||
@@ -578,7 +570,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)
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
"""Test for LTI Xmodule functional logic."""
|
"""Test for LTI Xmodule functional logic."""
|
||||||
|
|
||||||
from mock import Mock, patch, PropertyMock
|
from mock import Mock, patch, PropertyMock
|
||||||
|
import mock
|
||||||
import textwrap
|
import textwrap
|
||||||
import json
|
import json
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
import json
|
||||||
from webob.request import Request
|
from webob.request import Request
|
||||||
from copy import copy
|
from copy import copy
|
||||||
|
from collections import OrderedDict
|
||||||
import urllib
|
import urllib
|
||||||
|
import oauthlib
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
|
||||||
from xmodule.lti_module import LTIDescriptor
|
|
||||||
|
from xmodule.lti_module import LTIDescriptor, LTIError
|
||||||
|
|
||||||
from . import LogicTest
|
from . import LogicTest
|
||||||
|
|
||||||
@@ -48,7 +55,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
</imsx_POXEnvelopeRequest>
|
</imsx_POXEnvelopeRequest>
|
||||||
""")
|
""")
|
||||||
self.system.get_real_user = Mock()
|
self.system.get_real_user = Mock()
|
||||||
self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret'))
|
|
||||||
self.system.publish = Mock()
|
self.system.publish = Mock()
|
||||||
|
|
||||||
self.user_id = self.xmodule.runtime.anonymous_student_id
|
self.user_id = self.xmodule.runtime.anonymous_student_id
|
||||||
@@ -96,6 +102,7 @@ class LTIModuleTest(LogicTest):
|
|||||||
def test_authorization_header_not_present(self):
|
def test_authorization_header_not_present(self):
|
||||||
"""
|
"""
|
||||||
Request has no Authorization header.
|
Request has no Authorization header.
|
||||||
|
|
||||||
This is an unknown service request, i.e., it is not a part of the original service specification.
|
This is an unknown service request, i.e., it is not a part of the original service specification.
|
||||||
"""
|
"""
|
||||||
request = Request(self.environ)
|
request = Request(self.environ)
|
||||||
@@ -115,6 +122,7 @@ class LTIModuleTest(LogicTest):
|
|||||||
def test_authorization_header_empty(self):
|
def test_authorization_header_empty(self):
|
||||||
"""
|
"""
|
||||||
Request Authorization header has no value.
|
Request Authorization header has no value.
|
||||||
|
|
||||||
This is an unknown service request, i.e., it is not a part of the original service specification.
|
This is an unknown service request, i.e., it is not a part of the original service specification.
|
||||||
"""
|
"""
|
||||||
request = Request(self.environ)
|
request = Request(self.environ)
|
||||||
@@ -128,7 +136,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
'description': 'The request has failed.',
|
'description': 'The request has failed.',
|
||||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertDictEqual(expected_response, real_response)
|
self.assertDictEqual(expected_response, real_response)
|
||||||
|
|
||||||
@@ -147,7 +154,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
'description': 'The request has failed.',
|
'description': 'The request has failed.',
|
||||||
'messageIdentifier': 'unknown',
|
'messageIdentifier': 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertDictEqual(expected_response, real_response)
|
self.assertDictEqual(expected_response, real_response)
|
||||||
|
|
||||||
@@ -166,7 +172,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
'description': 'The request has failed.',
|
'description': 'The request has failed.',
|
||||||
'messageIdentifier': 'unknown',
|
'messageIdentifier': 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertDictEqual(expected_response, real_response)
|
self.assertDictEqual(expected_response, real_response)
|
||||||
|
|
||||||
@@ -186,7 +191,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
'description': 'Target does not support the requested operation.',
|
'description': 'Target does not support the requested operation.',
|
||||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertDictEqual(expected_response, real_response)
|
self.assertDictEqual(expected_response, real_response)
|
||||||
|
|
||||||
@@ -199,7 +203,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
request = Request(self.environ)
|
request = Request(self.environ)
|
||||||
request.body = self.get_request_body()
|
request.body = self.get_request_body()
|
||||||
response = self.xmodule.grade_handler(request, '')
|
response = self.xmodule.grade_handler(request, '')
|
||||||
code_major, description, messageIdentifier, action = self.get_response_values(response)
|
|
||||||
description_expected = 'Score for {sourcedId} is now {score}'.format(
|
description_expected = 'Score for {sourcedId} is now {score}'.format(
|
||||||
sourcedId=self.DEFAULTS['sourcedId'],
|
sourcedId=self.DEFAULTS['sourcedId'],
|
||||||
score=self.DEFAULTS['grade'],
|
score=self.DEFAULTS['grade'],
|
||||||
@@ -221,20 +224,13 @@ class LTIModuleTest(LogicTest):
|
|||||||
self.assertEqual(real_user_id, expected_user_id)
|
self.assertEqual(real_user_id, expected_user_id)
|
||||||
|
|
||||||
def test_outcome_service_url(self):
|
def test_outcome_service_url(self):
|
||||||
expected_outcome_service_url = 'http://{host}{path}'.format(
|
expected_outcome_service_url = 'https://{host}{path}'.format(
|
||||||
host=self.xmodule.runtime.hostname,
|
host=self.xmodule.runtime.hostname,
|
||||||
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
|
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||||
)
|
)
|
||||||
|
|
||||||
real_outcome_service_url = self.xmodule.get_outcome_service_url()
|
real_outcome_service_url = self.xmodule.get_outcome_service_url()
|
||||||
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
|
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
|
||||||
|
|
||||||
def test_get_form_path(self):
|
|
||||||
expected_form_path = self.xmodule.runtime.handler_url(self.xmodule, 'preview_handler').rstrip('/?')
|
|
||||||
|
|
||||||
real_form_path = self.xmodule.get_form_path()
|
|
||||||
self.assertEqual(real_form_path, expected_form_path)
|
|
||||||
|
|
||||||
def test_resource_link_id(self):
|
def test_resource_link_id(self):
|
||||||
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
||||||
mock_id.return_value = self.module_id
|
mock_id.return_value = self.module_id
|
||||||
@@ -242,7 +238,6 @@ class LTIModuleTest(LogicTest):
|
|||||||
real_resource_link_id = self.xmodule.get_resource_link_id()
|
real_resource_link_id = self.xmodule.get_resource_link_id()
|
||||||
self.assertEqual(real_resource_link_id, expected_resource_link_id)
|
self.assertEqual(real_resource_link_id, expected_resource_link_id)
|
||||||
|
|
||||||
|
|
||||||
def test_lis_result_sourcedid(self):
|
def test_lis_result_sourcedid(self):
|
||||||
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
||||||
mock_id.return_value = self.module_id
|
mock_id.return_value = self.module_id
|
||||||
@@ -251,11 +246,127 @@ class LTIModuleTest(LogicTest):
|
|||||||
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
|
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
|
||||||
|
|
||||||
|
|
||||||
def test_verify_oauth_body_sign(self):
|
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
|
||||||
pass
|
def test_client_key_secret(self, test):
|
||||||
|
"""
|
||||||
|
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_item.return_value = mocked_course
|
||||||
|
runtime = Mock(modulestore=modulestore)
|
||||||
|
self.xmodule.descriptor.runtime = runtime
|
||||||
|
self.xmodule.lti_id = "lti_id"
|
||||||
|
key, secret = self.xmodule.get_client_key_secret()
|
||||||
|
expected = ('test_client', 'test_secret')
|
||||||
|
self.assertEqual(expected, (key, secret))
|
||||||
|
|
||||||
def test_client_key_secret(self):
|
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
|
||||||
pass
|
def test_client_key_secret_not_provided(self, test):
|
||||||
|
"""
|
||||||
|
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_item.return_value = mocked_course
|
||||||
|
runtime = Mock(modulestore=modulestore)
|
||||||
|
self.xmodule.descriptor.runtime = runtime
|
||||||
|
#set another lti_id
|
||||||
|
self.xmodule.lti_id = "another_lti_id"
|
||||||
|
key_secret = self.xmodule.get_client_key_secret()
|
||||||
|
expected = ('','')
|
||||||
|
self.assertEqual(expected, key_secret)
|
||||||
|
|
||||||
|
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
|
||||||
|
def test_bad_client_key_secret(self, test):
|
||||||
|
"""
|
||||||
|
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_item.return_value = mocked_course
|
||||||
|
runtime = Mock(modulestore=modulestore)
|
||||||
|
self.xmodule.descriptor.runtime = runtime
|
||||||
|
self.xmodule.lti_id = 'lti_id'
|
||||||
|
with self.assertRaises(LTIError):
|
||||||
|
self.xmodule.get_client_key_secret()
|
||||||
|
|
||||||
|
@patch('xmodule.lti_module.signature.verify_hmac_sha1', return_value=True)
|
||||||
|
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', return_value=('test_client_key', u'test_client_secret'))
|
||||||
|
def test_successful_verify_oauth_body_sign(self, get_key_secret, mocked_verify):
|
||||||
|
"""
|
||||||
|
Test if OAuth signing was successful.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.xmodule.verify_oauth_body_sign(self.get_signed_grade_mock_request())
|
||||||
|
except LTIError:
|
||||||
|
self.fail("verify_oauth_body_sign() raised LTIError unexpectedly!")
|
||||||
|
|
||||||
|
@patch('xmodule.lti_module.signature.verify_hmac_sha1', return_value=False)
|
||||||
|
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', return_value=('test_client_key', u'test_client_secret'))
|
||||||
|
def test_failed_verify_oauth_body_sign(self, get_key_secret, mocked_verify):
|
||||||
|
"""
|
||||||
|
Oauth signing verify fail.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(LTIError):
|
||||||
|
req = self.get_signed_grade_mock_request()
|
||||||
|
self.xmodule.verify_oauth_body_sign(req)
|
||||||
|
|
||||||
|
def get_signed_grade_mock_request(self):
|
||||||
|
"""
|
||||||
|
Example of signed request from LTI Provider.
|
||||||
|
"""
|
||||||
|
mock_request = Mock()
|
||||||
|
mock_request.headers = {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
'Authorization': u'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 = u'http://testurl'
|
||||||
|
mock_request.http_method = u'POST'
|
||||||
|
mock_request.body = textwrap.dedent("""
|
||||||
|
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||||
|
<imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
|
||||||
|
</imsx_POXEnvelopeRequest>
|
||||||
|
""")
|
||||||
|
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(
|
||||||
|
{u'custom_test_custom_params': u'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 self.assertRaises(LTIError):
|
||||||
|
self.xmodule.get_input_fields()
|
||||||
|
|
||||||
def test_max_score(self):
|
def test_max_score(self):
|
||||||
self.xmodule.weight = 100.0
|
self.xmodule.weight = 100.0
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
"""
|
||||||
|
LTI Server
|
||||||
|
|
||||||
|
What is supported:
|
||||||
|
------------------
|
||||||
|
|
||||||
|
1.) This LTI Provider can service only one Tool Consumer at the same time. It is
|
||||||
|
not possible to have this LTI multiple times on a single page in LMS.
|
||||||
|
|
||||||
|
"""
|
||||||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import textwrap
|
import textwrap
|
||||||
@@ -23,6 +33,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
protocol = "HTTP/1.0"
|
protocol = "HTTP/1.0"
|
||||||
callback_url = None
|
callback_url = None
|
||||||
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
"""Log an arbitrary message."""
|
"""Log an arbitrary message."""
|
||||||
# Code copied from BaseHTTPServer.py. Changed to write to sys.stdout
|
# Code copied from BaseHTTPServer.py. Changed to write to sys.stdout
|
||||||
@@ -35,6 +46,8 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
'''
|
'''
|
||||||
Handle a GET request from the client and sends response back.
|
Handle a GET request from the client and sends response back.
|
||||||
|
|
||||||
|
Used for checking LTI Provider started correctly.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
self.send_response(200, 'OK')
|
self.send_response(200, 'OK')
|
||||||
@@ -42,29 +55,20 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
response_str = """<html><head><title>TEST TITLE</title></head>
|
response_str = """<html><head><title>TEST TITLE</title></head>
|
||||||
<body>I have stored grades.</body></html>"""
|
<body>This is LTI Provider.</body></html>"""
|
||||||
|
|
||||||
self.wfile.write(response_str)
|
self.wfile.write(response_str)
|
||||||
|
|
||||||
self._send_graded_result()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
'''
|
'''
|
||||||
Handle a POST request from the client and sends response back.
|
Handle a POST request from the client and sends response back.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
'''
|
|
||||||
logger.debug("LTI provider received POST request {} to path {}".format(
|
|
||||||
str(self.post_dict),
|
|
||||||
self.path)
|
|
||||||
) # Log the request
|
|
||||||
'''
|
|
||||||
# Respond to grade request
|
|
||||||
if 'grade' in self.path and self._send_graded_result().status_code == 200:
|
if 'grade' in self.path and self._send_graded_result().status_code == 200:
|
||||||
status_message = 'LTI consumer (edX) responded with XML content:<br>' + self.server.grade_data['TC answer']
|
status_message = 'LTI consumer (edX) responded with XML content:<br>' + self.server.grade_data['TC answer']
|
||||||
self.server.grade_data['callback_url'] = None
|
self.server.grade_data['callback_url'] = None
|
||||||
|
self._send_response(status_message, 200)
|
||||||
# Respond to request with correct lti endpoint:
|
# Respond to request with correct lti endpoint:
|
||||||
elif self._is_correct_lti_request():
|
elif self._is_correct_lti_request():
|
||||||
self.post_dict = self._post_dict()
|
self.post_dict = self._post_dict()
|
||||||
@@ -97,26 +101,19 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
# set data for grades
|
# set data for grades
|
||||||
# what need to be stored as server data
|
# what need to be stored as server data
|
||||||
self.server.grade_data = {
|
self.server.grade_data = {
|
||||||
'callback_url': self.post_dict["lis_outcome_service_url"],
|
'callback_url': self.post_dict.get('lis_outcome_service_url'),
|
||||||
'sourcedId': self.post_dict['lis_result_sourcedid']
|
'sourcedId': self.post_dict.get('lis_result_sourcedid')
|
||||||
}
|
}
|
||||||
|
self._send_response(status_message, 200)
|
||||||
else:
|
else:
|
||||||
status_message = "Invalid request URL"
|
status_message = "Invalid request URL"
|
||||||
|
self._send_response(status_message, 500)
|
||||||
|
|
||||||
self._send_head()
|
def _send_head(self, status_code):
|
||||||
self._send_response(status_message)
|
|
||||||
|
|
||||||
def _send_head(self):
|
|
||||||
'''
|
'''
|
||||||
Send the response code and MIME headers
|
Send the response code and MIME headers
|
||||||
'''
|
'''
|
||||||
self.send_response(200)
|
self.send_response(status_code)
|
||||||
'''
|
|
||||||
if self._is_correct_lti_request():
|
|
||||||
self.send_response(200)
|
|
||||||
else:
|
|
||||||
self.send_response(500)
|
|
||||||
'''
|
|
||||||
self.send_header('Content-type', 'text/html')
|
self.send_header('Content-type', 'text/html')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@@ -182,15 +179,22 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
</imsx_POXEnvelopeRequest>
|
</imsx_POXEnvelopeRequest>
|
||||||
""")
|
""")
|
||||||
data = payload.format(**values)
|
data = payload.format(**values)
|
||||||
# temporarily changed to get for easy view in browser
|
|
||||||
# get relative part, because host name is different in a) manual tests b) acceptance tests c) demos
|
# get relative part, because host name is different in a) manual tests b) acceptance tests c) demos
|
||||||
|
if getattr(self.server, 'test_mode', None):
|
||||||
relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path
|
relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path
|
||||||
url = self.server.referer_host + relative_url
|
url = self.server.referer_host + relative_url
|
||||||
|
else:
|
||||||
|
url = self.server.grade_data['callback_url']
|
||||||
|
|
||||||
headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'}
|
headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
|
||||||
headers['Authorization'] = self.oauth_sign(url, data)
|
headers['Authorization'] = self.oauth_sign(url, data)
|
||||||
|
|
||||||
|
# We can't mock requests in unit tests, because we use them, but we need
|
||||||
|
# them to be mocked only for this one case.
|
||||||
|
if getattr(self.server, 'run_inside_unittest_flag', None):
|
||||||
|
response = mock.Mock(status_code=200, url=url, data=data, headers=headers)
|
||||||
|
return response
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url,
|
url,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -199,11 +203,11 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
self.server.grade_data['TC answer'] = response.content
|
self.server.grade_data['TC answer'] = response.content
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _send_response(self, message):
|
def _send_response(self, message, status_code):
|
||||||
'''
|
'''
|
||||||
Send message back to the client
|
Send message back to the client
|
||||||
'''
|
'''
|
||||||
|
self._send_head(status_code)
|
||||||
if self.server.grade_data['callback_url']:
|
if self.server.grade_data['callback_url']:
|
||||||
response_str = """<html><head><title>TEST TITLE</title></head>
|
response_str = """<html><head><title>TEST TITLE</title></head>
|
||||||
<body>
|
<body>
|
||||||
@@ -250,7 +254,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
|||||||
#Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
|
#Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
|
||||||
sha1 = hashlib.sha1()
|
sha1 = hashlib.sha1()
|
||||||
sha1.update(body)
|
sha1.update(body)
|
||||||
oauth_body_hash = base64.b64encode(sha1.hexdigest())
|
oauth_body_hash = base64.b64encode(sha1.digest())
|
||||||
__, headers, __ = client.sign(
|
__, headers, __ = client.sign(
|
||||||
unicode(url.strip()),
|
unicode(url.strip()),
|
||||||
http_method=u'POST',
|
http_method=u'POST',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
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 = {
|
|||||||
}
|
}
|
||||||
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
|
|||||||
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):
|
|||||||
'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,24 @@ 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 and responses with wrong header.
|
||||||
|
"""
|
||||||
|
#wrong number of params
|
||||||
|
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('Incorrect LTI header', 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
|
||||||
@@ -65,19 +86,16 @@ 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 = {
|
payload = {
|
||||||
'user_id': 'default_user_id',
|
'user_id': 'default_user_id',
|
||||||
'role': 'student',
|
'role': 'student',
|
||||||
@@ -100,8 +118,40 @@ class MockLTIServerTest(unittest.TestCase):
|
|||||||
|
|
||||||
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)
|
||||||
|
self.assertIn('This is LTI tool. Success.', response.content)
|
||||||
|
|
||||||
|
def test_send_graded_result(self):
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'user_id': 'default_user_id',
|
||||||
|
'role': 'student',
|
||||||
|
'oauth_nonce': '',
|
||||||
|
'oauth_timestamp': '',
|
||||||
|
'oauth_consumer_key': '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':'',
|
||||||
|
"lis_outcome_service_url": '',
|
||||||
|
}
|
||||||
|
self.server.check_oauth_signature = Mock(return_value=True)
|
||||||
|
|
||||||
|
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/'}
|
||||||
response = requests.post(uri, data=payload, headers=headers)
|
response = requests.post(uri, data=payload, headers=headers)
|
||||||
|
|
||||||
self.assertTrue('This is LTI tool. Success.' in 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):
|
|||||||
|
|
||||||
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('/?')
|
||||||
)
|
)
|
||||||
@@ -59,6 +59,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 +89,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user