Commit 2894b21c by Alexander Kryklia

Merge pull request #804 from edx/valera/lti_module

LTI module with tests
parents 34d0fe15 ecc37227
......@@ -52,7 +52,8 @@ NOTE_COMPONENT_TYPES = ['notes']
ADVANCED_COMPONENT_TYPES = [
'annotatable',
'word_cloud',
'graphical_slider_tool'
'graphical_slider_tool',
'lti',
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
ADVANCED_COMPONENT_CATEGORY = 'advanced'
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
......
......@@ -81,7 +81,6 @@ def preview_component(request, location):
component,
'xmodule_edit.html'
)
return render_to_response('component.html', {
'preview': get_preview_html(request, component, 0),
'editor': component.runtime.render(component, None, 'studio_view').content,
......@@ -104,7 +103,6 @@ def preview_module_system(request, preview_id, descriptor):
return lms_field_data(descriptor._field_data, student_data)
course_id = get_course_for_item(descriptor.location).location.course_id
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
......@@ -118,6 +116,8 @@ def preview_module_system(request, preview_id, descriptor):
xblock_field_data=preview_field_data,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
mixins=settings.XBLOCK_MIXINS,
course_id=course_id,
anonymous_student_id='student'
)
......
......@@ -56,6 +56,7 @@ setup(
"hidden = xmodule.hidden_module:HiddenDescriptor",
"raw = xmodule.raw_module:RawDescriptor",
"crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
"lti = xmodule.lti_module:LTIModuleDescriptor"
],
'console_scripts': [
'xmodule_assets = xmodule.static_content:main',
......
......@@ -153,6 +153,7 @@ class TextbookList(List):
class CourseFields(object):
lti_passports = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings)
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course",
default=[], scope=Scope.content)
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
......
div.lti {
// align center
margin: 0 auto;
h3.error_message {
display: block;
}
form.ltiLaunchForm {
display: none;
}
iframe.ltiLaunchFrame {
width: 100%;
height: 800px;
display: none;
border: 0px;
overflow-x: hidden;
}
&.rendered {
iframe.ltiLaunchFrame {
display: block;
}
h3.error_message {
display: none;
}
}
}
<div id="lti_id" class="lti">
<form
action=""
name="ltiLaunchForm"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame"
encType="application/x-www-form-urlencoded"
>
<input type="hidden" name="launch_presentation_return_url" value="">
<input type="hidden" name="lis_outcome_service_url" value="">
<input type="hidden" name="lis_result_sourcedid" value="">
<input type="hidden" name="lti_message_type" value="basic-lti-launch-request">
<input type="hidden" name="lti_version" value="LTI-1p0">
<input type="hidden" name="oauth_callback" value="about:blank">
<input type="hidden" name="oauth_consumer_key" value=""/>
<input type="hidden" name="oauth_nonce" value=""/>
<input type="hidden" name="oauth_signature_method" value="HMAC-SHA1"/>
<input type="hidden" name="oauth_timestamp" value=""/>
<input type="hidden" name="oauth_version" value="1.0"/>
<input type="hidden" name="user_id" value="default_user_id">
<input type="hidden" name="oauth_signature" value=""/>
<input type="submit" value="Press to Launch" />
</form>
<h3 class="error_message">
Please provide launch_url. Click "Edit", and fill in the
required fields.
</h3>
<iframe
name="ltiLaunchFrame"
class="ltiLaunchFrame"
src=""
></iframe>
</div>
/**
* File: constructor.js
*
* Purpose: Jasmine tests for LTI module (front-end part).
*
*
* The front-end part of the LTI module is really simple. If an action
* is set for the hidden LTI form, then it is submited, and the results are
* redirected to an iframe.
*
* We will test that the form is only submited when the action is set (i.e.
* not empty).
*
* Other aspects of LTI module will be covered by Python unit tests and
* acceptance tests.
*
*/
/*
* "Hence that general is skilful in attack whose opponent does not know what
* to defend; and he is skilful in defense whose opponent does not know what
* to attack."
*
* ~ Sun Tzu
*/
(function () {
describe('LTI', function () {
describe('constructor', function () {
describe('before settings were filled in', function () {
var element, errorMessage, frame;
// This function will be executed before each of the it() specs
// in this suite.
beforeEach(function () {
loadFixtures('lti.html');
element = $('#lti_id');
errorMessage = element.find('.error_message');
form = element.find('.ltiLaunchForm');
frame = element.find('.ltiLaunchFrame');
spyOnEvent(form, 'submit');
LTI(element);
});
it(
'when URL setting is filled form is not submited',
function () {
expect('submit').not.toHaveBeenTriggeredOn(form);
});
});
describe('After the settings were filled in', function () {
var element, errorMessage, frame;
// This function will be executed before each of the it() specs
// in this suite.
beforeEach(function () {
loadFixtures('lti.html');
element = $('#lti_id');
errorMessage = element.find('.error_message');
form = element.find('.ltiLaunchForm');
frame = element.find('.ltiLaunchFrame');
spyOnEvent(form, 'submit');
// The user "fills in" the necessary settings, and the
// form will get an action URL.
form.attr('action', 'http://www.example.com/');
LTI(element);
});
it('when URL setting is filled form is submited', function () {
expect('submit').toHaveBeenTriggeredOn(form);
});
});
});
});
}());
window.LTI = (function () {
// Function initialize(element)
//
// Initialize the LTI iframe.
function initialize(element) {
var form;
// In cms (Studio) the element is already a jQuery object. In lms it is
// a DOM object.
//
// To make sure that there is no error, we pass it through the $()
// function. This will make it a jQuery object if it isn't already so.
element = $(element);
form = element.find('.ltiLaunchForm');
// If the Form's action attribute is set (i.e. we can perform a normal
// submit), then we submit the form and make the frame shown.
if (form.attr('action')) {
form.submit();
element.find('.lti').addClass('rendered')
}
}
return initialize;
}());
"""
Module that allows to insert LTI tools to page.
Module uses current edx-platform 0.14.2 version of requests (oauth part).
Please update code when upgrading requests.
Protocol is oauth1, LTI version is 1.1.1:
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
"""
import logging
import requests
import urllib
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xmodule.x_module import XModule
from xmodule.course_module import CourseDescriptor
from pkg_resources import resource_string
from xblock.core import String, Scope, List
log = logging.getLogger(__name__)
class LTIError(Exception):
pass
class LTIFields(object):
"""
Fields to define and obtain LTI tool from provider are set here,
except credentials, which should be set in course settings::
`lti_id` is id to connect tool with credentials in course settings.
`launch_url` is launch url of tool.
`custom_parameters` are additional parameters to navigate to proper book and book page.
For example, for Vitalsource provider, `launch_url` should be
*https://bc-staging.vitalsource.com/books/book*,
and to get to proper book and book page, you should set custom parameters as::
vbid=put_book_id_here
book_location=page/put_page_number_here
"""
lti_id = String(help="Id of the tool", default='', scope=Scope.settings)
launch_url = String(help="URL of the tool", default='', scope=Scope.settings)
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
class LTIModule(LTIFields, XModule):
'''
Module provides LTI integration to course.
Except usual xmodule structure it proceeds with oauth signing.
How it works::
1. Get credentials from course settings.
2. There is minimal set of parameters need to be signed (presented for Vitalsource)::
user_id
oauth_callback
lis_outcome_service_url
lis_result_sourcedid
launch_presentation_return_url
lti_message_type
lti_version
role
*+ all custom parameters*
These parameters should be encoded and signed by *oauth1* together with
`launch_url` and *POST* request type.
3. Signing proceeds with client key/secret pair obtained from course settings.
That pair should be obtained from LTI provider and set into course settings by course author.
After that signature and other oauth data are generated.
Oauth data which is generated after signing is usual::
oauth_callback
oauth_nonce
oauth_consumer_key
oauth_signature_method
oauth_timestamp
oauth_version
4. All that data is passed to form and sent to LTI provider server by browser via
autosubmit via javascript.
Form example::
<form
action="${launch_url}"
name="ltiLaunchForm"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame"
encType="application/x-www-form-urlencoded"
>
<input name="launch_presentation_return_url" value="" />
<input name="lis_outcome_service_url" value="" />
<input name="lis_result_sourcedid" value="" />
<input name="lti_message_type" value="basic-lti-launch-request" />
<input name="lti_version" value="LTI-1p0" />
<input name="oauth_callback" value="about:blank" />
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
<input name="oauth_nonce" value="${oauth_nonce}" />
<input name="oauth_signature_method" value="HMAC-SHA1" />
<input name="oauth_timestamp" value="${oauth_timestamp}" />
<input name="oauth_version" value="1.0" />
<input name="user_id" value="${user_id}" />
<input name="role" value="student" />
<input name="oauth_signature" value="${oauth_signature}" />
<input name="custom_1" value="${custom_param_1_value}" />
<input name="custom_2" value="${custom_param_2_value}" />
<input name="custom_..." value="${custom_param_..._value}" />
<input type="submit" value="Press to Launch" />
</form>
5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures.
If signatures are correct, LTI provider redirects iframe source to LTI tool web page,
and LTI tool is rendered to iframe inside course.
Otherwise error message from LTI provider is generated.
'''
js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]}
css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]}
js_module_name = "LTI"
def get_html(self):
"""
Renders parameters to template.
"""
# Obtains client_key and client_secret credentials from current course:
course_id = self.runtime.course_id
course_location = CourseDescriptor.id_to_location(course_id)
course = self.descriptor.runtime.modulestore.get_item(course_location)
client_key = client_secret = ''
for lti_passport in course.lti_passports:
try:
lti_id, key, secret = lti_passport.split(':')
except ValueError:
raise LTIError('Could not parse LTI passport: {0!r}. \
Should be "id:key:secret" string.'.format(lti_passport))
if lti_id == self.lti_id:
client_key, client_secret = key, secret
break
# parsing custom parameters to dict
custom_parameters = {}
for custom_parameter in self.custom_parameters:
try:
param_name, param_value = custom_parameter.split('=', 1)
except ValueError:
raise LTIError('Could not parse custom parameter: {0!r}. \
Should be "x=y" string.'.format(custom_parameter))
# LTI specs: 'custom_' should be prepended before each custom parameter
custom_parameters[u'custom_' + unicode(param_name)] = unicode(param_value)
input_fields = self.oauth_params(
custom_parameters,
client_key,
client_secret
)
context = {
'input_fields': input_fields,
# these params do not participate in oauth signing
'launch_url': self.launch_url,
'element_id': self.location.html_id(),
'element_class': self.location.category,
}
return self.system.render_template('lti.html', context)
def oauth_params(self, custom_parameters, client_key, client_secret):
"""
Signs request and returns signature and oauth parameters.
`custom_paramters` is dict of parsed `custom_parameter` field
`client_key` and `client_secret` are LTI tool credentials.
Also *anonymous student id* is passed to template and therefore to LTI provider.
"""
client = requests.auth.Client(
client_key=unicode(client_key),
client_secret=unicode(client_secret)
)
user_id = self.runtime.anonymous_student_id
assert user_id is not None
# must have parameters for correct signing from LTI:
body = {
u'user_id': user_id,
u'oauth_callback': u'about:blank',
u'lis_outcome_service_url': '',
u'lis_result_sourcedid': '',
u'launch_presentation_return_url': '',
u'lti_message_type': u'basic-lti-launch-request',
u'lti_version': 'LTI-1p0',
u'role': u'student'
}
# appending custom parameter for signing
body.update(custom_parameters)
# This is needed for body encoding:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
__, headers, __ = client.sign(
unicode(self.launch_url),
http_method=u'POST',
body=body,
headers=headers)
params = headers['Authorization']
# parse headers to pass to template as part of context:
params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')])
params[u'oauth_nonce'] = params[u'OAuth oauth_nonce']
del params[u'OAuth oauth_nonce']
# 0.14.2 (current) version of requests oauth library encodes signature,
# with 'Content-Type': 'application/x-www-form-urlencoded'
# so '='' becomes '%3D'.
# We send form via browser, so browser will encode it again,
# So we need to decode signature back:
params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8')
# add lti parameters to oauth parameters for sending in form
params.update(body)
return params
class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor):
"""
LTIModuleDescriptor provides no export/import to xml.
"""
module_class = LTIModule
......@@ -27,7 +27,7 @@ class XModuleCourseFactory(Factory):
store = editable_modulestore('direct')
# Write the data to the mongo datastore
new_course = store.create_xmodule(location)
new_course = store.create_xmodule(location, metadata=kwargs.get('metadata', None))
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
......
......@@ -40,7 +40,7 @@ open_ended_grading_interface = {
}
def get_test_system():
def get_test_system(course_id=''):
"""
Construct a test ModuleSystem instance.
......@@ -66,7 +66,8 @@ def get_test_system():
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
xblock_field_data=lambda descriptor: descriptor._field_data,
anonymous_student_id='student',
open_ended_grading_interface=open_ended_grading_interface
open_ended_grading_interface=open_ended_grading_interface,
course_id=course_id,
)
......
......@@ -95,6 +95,14 @@ Html
:members:
:show-inheritance:
LTI
===
.. automodule:: xmodule.lti_module
:members:
:show-inheritance:
Mako
====
......
Feature: LTI component
As a student, I want to view LTI component in LMS.
Scenario: LTI component in LMS is not rendered
Given the course has correct LTI credentials
And the course has an LTI component with incorrect fields
Then I view the LTI and it is not rendered
Scenario: LTI component in LMS is rendered
Given the course has correct LTI credentials
And the course has an LTI component filled with correct fields
Then I view the LTI and it is rendered
Scenario: LTI component in LMS is rendered incorrectly
Given the course has incorrect LTI credentials
And the course has an LTI component filled with correct fields
Then I view the LTI but incorrect_signature warning is rendered
\ No newline at end of file
#pylint: disable=C0111
from django.contrib.auth.models import User
from lettuce import world, step
from lettuce.django import django_url
from common import course_id
from student.models import CourseEnrollment
@step('I view the LTI and it is not rendered$')
def lti_is_not_rendered(_step):
# lti div has no class rendered
assert world.is_css_not_present('div.lti.rendered')
# error is shown
assert world.css_visible('.error_message')
# iframe is not visible
assert not world.css_visible('iframe')
#inside iframe test content is not presented
with world.browser.get_iframe('ltiLaunchFrame') as iframe:
# iframe does not contain functions from terrain/ui_helpers.py
assert iframe.is_element_not_present_by_css('.result', wait_time=5)
@step('I view the LTI and it is rendered$')
def lti_is_rendered(_step):
# lti div has class rendered
assert world.is_css_present('div.lti.rendered')
# error is hidden
assert not world.css_visible('.error_message')
# iframe is visible
assert world.css_visible('iframe')
#inside iframe test content is presented
with world.browser.get_iframe('ltiLaunchFrame') as iframe:
# iframe does not contain functions from terrain/ui_helpers.py
assert iframe.is_element_present_by_css('.result', wait_time=5)
assert ("This is LTI tool. Success." == world.retry_on_exception(
lambda: iframe.find_by_css('.result')[0].text,
max_attempts=5
))
@step('I view the LTI but incorrect_signature warning is rendered$')
def incorrect_lti_is_rendered(_step):
# lti div has class rendered
assert world.is_css_present('div.lti.rendered')
# error is hidden
assert not world.css_visible('.error_message')
# iframe is visible
assert world.css_visible('iframe')
#inside iframe test content is presented
with world.browser.get_iframe('ltiLaunchFrame') as iframe:
# iframe does not contain functions from terrain/ui_helpers.py
assert iframe.is_element_present_by_css('.result', wait_time=5)
assert ("Wrong LTI signature" == world.retry_on_exception(
lambda: iframe.find_by_css('.result')[0].text,
max_attempts=5
))
@step('the course has correct LTI credentials$')
def set_correct_lti_passport(_step):
coursenum = 'test_course'
metadata = {
'lti_passports': ["correct_lti_id:{}:{}".format(
world.lti_server.oauth_settings['client_key'],
world.lti_server.oauth_settings['client_secret']
)]
}
i_am_registered_for_the_course(coursenum, metadata)
@step('the course has incorrect LTI credentials$')
def set_incorrect_lti_passport(_step):
coursenum = 'test_course'
metadata = {
'lti_passports': ["test_lti_id:{}:{}".format(
world.lti_server.oauth_settings['client_key'],
"incorrect_lti_secret_key"
)]
}
i_am_registered_for_the_course(coursenum, metadata)
@step('the course has an LTI component filled with correct fields$')
def add_correct_lti_to_course(_step):
category = 'lti'
world.ItemFactory.create(
# parent_location=section_location(course),
parent_location=world.scenario_dict['SEQUENTIAL'].location,
category=category,
display_name='LTI',
metadata={
'lti_id': 'correct_lti_id',
'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint']
}
)
course = world.scenario_dict["COURSE"]
chapter_name = world.scenario_dict['SECTION'].display_name.replace(
" ", "_")
section_name = chapter_name
path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format(
org=course.org,
num=course.number,
name=course.display_name.replace(' ', '_'),
chapter=chapter_name,
section=section_name)
url = django_url(path)
world.browser.visit(url)
@step('the course has an LTI component with incorrect fields$')
def add_incorrect_lti_to_course(_step):
category = 'lti'
world.ItemFactory.create(
parent_location=world.scenario_dict['SEQUENTIAL'].location,
category=category,
display_name='LTI',
metadata={
'lti_id': 'incorrect_lti_id',
'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint']
}
)
course = world.scenario_dict["COURSE"]
chapter_name = world.scenario_dict['SECTION'].display_name.replace(
" ", "_")
section_name = chapter_name
path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format(
org=course.org,
num=course.number,
name=course.display_name.replace(' ', '_'),
chapter=chapter_name,
section=section_name)
url = django_url(path)
world.browser.visit(url)
def create_course(course, metadata):
# First clear the modulestore so we don't try to recreate
# the same course twice
# This also ensures that the necessary templates are loaded
world.clear_courses()
# Create the course
# We always use the same org and display name,
# but vary the course identifier (e.g. 600x or 191x)
world.scenario_dict['COURSE'] = world.CourseFactory.create(
org='edx',
number=course,
display_name='Test Course',
metadata=metadata
)
# Add a section to the course to contain problems
world.scenario_dict['SECTION'] = world.ItemFactory.create(
parent_location=world.scenario_dict['COURSE'].location,
display_name='Test Section'
)
world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create(
parent_location=world.scenario_dict['SECTION'].location,
category='sequential',
display_name='Test Section')
def i_am_registered_for_the_course(course, metadata):
# Create the course
create_course(course, metadata)
# Create the user
world.create_user('robot', 'test')
usr = User.objects.get(username='robot')
# If the user is not already enrolled, enroll the user.
CourseEnrollment.enroll(usr, course_id(course))
world.log_in(username='robot', password='test')
#pylint: disable=C0111
#pylint: disable=W0621
from courseware.mock_lti_server.mock_lti_server import MockLTIServer
from lettuce import before, after, world
from django.conf import settings
import threading
from logging import getLogger
logger = getLogger(__name__)
@before.all
def setup_mock_lti_server():
server_host = '127.0.0.1'
# Add +1 to XQUEUE random port number
server_port = settings.XQUEUE_PORT + 1
address = (server_host, server_port)
# Create the mock server instance
server = MockLTIServer(address)
logger.debug("LTI server started at {} port".format(str(server_port)))
# Start the server running in a separate daemon thread
# Because the thread is a daemon, it will terminate
# when the main thread terminates.
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
server.oauth_settings = {
'client_key': 'test_client_key',
'client_secret': 'test_client_secret',
'lti_base': 'http://{}:{}/'.format(server_host, server_port),
'lti_endpoint': 'correct_lti_endpoint'
}
# Store the server instance in lettuce's world
# so that other steps can access it
# (and we can shut it down later)
world.lti_server = server
@after.all
def teardown_mock_lti_server(total):
# Stop the LTI server and free up the port
world.lti_server.shutdown()
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
import urlparse
from requests.packages.oauthlib.oauth1.rfc5849 import signature
import mock
from logging import getLogger
logger = getLogger(__name__)
class MockLTIRequestHandler(BaseHTTPRequestHandler):
'''
A handler for LTI POST requests.
'''
protocol = "HTTP/1.0"
def do_HEAD(self):
self._send_head()
def do_POST(self):
'''
Handle a POST request from the client and sends response back.
'''
self._send_head()
post_dict = self._post_dict() # Retrieve the POST data
logger.debug("LTI provider received POST request {} to path {}".format(
str(post_dict),
self.path)
) # Log the request
# Respond only to requests with correct lti endpoint:
if self._is_correct_lti_request():
correct_keys = [
'user_id',
'role',
'oauth_nonce',
'oauth_timestamp',
'oauth_consumer_key',
'lti_version',
'oauth_signature_method',
'oauth_version',
'oauth_signature',
'lti_message_type',
'oauth_callback',
'lis_outcome_service_url',
'lis_result_sourcedid',
'launch_presentation_return_url'
]
if sorted(correct_keys) != sorted(post_dict.keys()):
status_message = "Incorrect LTI header"
else:
params = {k: v for k, v in post_dict.items() if k != 'oauth_signature'}
if self.server.check_oauth_signature(params, post_dict['oauth_signature']):
status_message = "This is LTI tool. Success."
else:
status_message = "Wrong LTI signature"
else:
status_message = "Invalid request URL"
self._send_response(status_message)
def _send_head(self):
'''
Send the response code and MIME headers
'''
if self._is_correct_lti_request():
self.send_response(200)
else:
self.send_response(500)
self.send_header('Content-type', 'text/html')
self.end_headers()
def _post_dict(self):
'''
Retrieve the POST parameters from the client as a dictionary
'''
try:
length = int(self.headers.getheader('content-length'))
post_dict = urlparse.parse_qs(self.rfile.read(length), keep_blank_values=True)
# The POST dict will contain a list of values for each key.
# None of our parameters are lists, however, so we map [val] --> val.
# If the list contains multiple entries, we pick the first one
post_dict = {key: val[0] for key, val in post_dict.items()}
except:
# We return an empty dict here, on the assumption
# that when we later check that the request has
# the correct fields, it won't find them,
# and will therefore send an error response
return {}
return post_dict
def _send_response(self, message):
'''
Send message back to the client
'''
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
</body></html>""".format(message)
# Log the response
logger.debug("LTI: sent response {}".format(response_str))
self.wfile.write(response_str)
def _is_correct_lti_request(self):
'''If url to LTI tool is correct.'''
return self.server.oauth_settings['lti_endpoint'] in self.path
class MockLTIServer(HTTPServer):
'''
A mock LTI provider server that responds
to POST requests to localhost.
'''
def __init__(self, address):
'''
Initialize the mock XQueue server instance.
*address* is the (host, host's port to listen to) tuple.
'''
handler = MockLTIRequestHandler
HTTPServer.__init__(self, address, handler)
def shutdown(self):
'''
Stop the server and free up the port
'''
# First call superclass shutdown()
HTTPServer.shutdown(self)
# We also need to manually close the socket
self.socket.close()
def check_oauth_signature(self, params, client_signature):
'''
Checks oauth signature from client.
`params` are params from post request except signature,
`client_signature` is signature from request.
Builds mocked request and verifies hmac-sha1 signing::
1. builds string to sign from `params`, `url` and `http_method`.
2. signs it with `client_secret` which comes from server settings.
3. obtains signature after sign and then compares it with request.signature
(request signature comes form client in request)
Returns `True` if signatures are correct, otherwise `False`.
'''
client_secret = unicode(self.oauth_settings['client_secret'])
url = self.oauth_settings['lti_base'] + self.oauth_settings['lti_endpoint']
request = mock.Mock()
request.params = [(unicode(k), unicode(v)) for k, v in params.items()]
request.uri = unicode(url)
request.http_method = u'POST'
request.signature = unicode(client_signature)
return signature.verify_hmac_sha1(request, client_secret)
"""
Test for Mock_LTI_Server
"""
import unittest
import threading
import urllib
from mock_lti_server import MockLTIServer
from nose.plugins.skip import SkipTest
class MockLTIServerTest(unittest.TestCase):
'''
A mock version of the LTI provider server that listens on a local
port and responds with pre-defined grade messages.
Used for lettuce BDD tests in lms/courseware/features/lti.feature
'''
def setUp(self):
# This is a test of the test setup,
# so it does not need to run as part of the unit test suite
# You can re-enable it by commenting out the line below
# raise SkipTest
# Create the server
server_port = 8034
server_host = '127.0.0.1'
address = (server_host, server_port)
self.server = MockLTIServer(address)
self.server.oauth_settings = {
'client_key': 'test_client_key',
'client_secret': 'test_client_secret',
'lti_base': 'http://{}:{}/'.format(server_host, server_port),
'lti_endpoint': 'correct_lti_endpoint'
}
# Start the server in a separate daemon thread
server_thread = threading.Thread(target=self.server.serve_forever)
server_thread.daemon = True
server_thread.start()
def tearDown(self):
# Stop the server, freeing up the port
self.server.shutdown()
def test_request(self):
"""
Tests that LTI server processes request with right program
path, and responses with incorrect signature.
"""
request = {
'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': ''
}
response_handle = urllib.urlopen(
self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'],
urllib.urlencode(request)
)
response = response_handle.read()
self.assertTrue('Wrong LTI signature' in response)
......@@ -86,7 +86,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
data=self.DATA
)
self.runtime = get_test_system()
self.runtime = get_test_system(course_id=self.course.id)
# Allow us to assert that the template was called in the same way from
# different code paths while maintaining the type returned by render_template
self.runtime.render_template = lambda template, context: u'{!r}, {!r}'.format(template, sorted(context.items()))
......
"""LTI integration tests"""
import requests
from . import BaseTestXmodule
from collections import OrderedDict
import mock
class TestLTI(BaseTestXmodule):
"""
Integration test for lti xmodule.
It checks overall code, by assuring that context that goes to template is correct.
As part of that, checks oauth signature generation by mocking signing function of `requests` library.
"""
CATEGORY = "lti"
def setUp(self):
"""
Mock oauth1 signing of requests library for testing.
"""
super(TestLTI, self).setUp()
mocked_nonce = u'135685044251684026041377608307'
mocked_timestamp = u'1234567890'
mocked_signature_after_sign = u'my_signature%3D'
mocked_decoded_signature = u'my_signature='
self.correct_headers = {
u'oauth_callback': u'about:blank',
u'lis_outcome_service_url': '',
u'lis_result_sourcedid': '',
u'launch_presentation_return_url': '',
u'lti_message_type': u'basic-lti-launch-request',
u'lti_version': 'LTI-1p0',
u'oauth_nonce': mocked_nonce,
u'oauth_timestamp': mocked_timestamp,
u'oauth_consumer_key': u'',
u'oauth_signature_method': u'HMAC-SHA1',
u'oauth_version': u'1.0',
u'user_id': self.runtime.anonymous_student_id,
u'role': u'student',
u'oauth_signature': mocked_decoded_signature
}
saved_sign = requests.auth.Client.sign
def mocked_sign(self, *args, **kwargs):
"""
Mocked oauth1 sign function.
"""
# self is <oauthlib.oauth1.rfc5849.Client object> here:
__, headers, __ = saved_sign(self, *args, **kwargs)
# we should replace nonce, timestamp and signed_signature in headers:
old = headers[u'Authorization']
old_parsed = OrderedDict([param.strip().replace('"', '').split('=') for param in old.split(',')])
old_parsed[u'OAuth oauth_nonce'] = mocked_nonce
old_parsed[u'oauth_timestamp'] = mocked_timestamp
old_parsed[u'oauth_signature'] = mocked_signature_after_sign
headers[u'Authorization'] = ', '.join([k+'="'+v+'"' for k, v in old_parsed.items()])
return None, headers, None
patcher = mock.patch.object(requests.auth.Client, "sign", mocked_sign)
patcher.start()
self.addCleanup(patcher.stop)
def test_lti_constructor(self):
"""
Makes sure that all parameters extracted.
"""
self.runtime.render_template = lambda template, context: context
generated_context = self.item_module.get_html()
expected_context = {
'input_fields': self.correct_headers,
'element_class': self.item_module.location.category,
'element_id': self.item_module.location.html_id(),
'launch_url': '', # default value
}
self.assertDictEqual(generated_context, expected_context)
<div id="${element_id}" class="${element_class}">
## This form will be hidden. Once available on the client, the LTI
## module JavaScript will trigget a "submit" on the form, and the
## result will be rendered to the below iFrame.
<form
action="${launch_url}"
name="ltiLaunchForm"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame"
encType="application/x-www-form-urlencoded"
>
% for param_name, param_value in input_fields.items():
<input name="${param_name}" value="${param_value}" />
%endfor
<input type="submit" value="Press to Launch" />
</form>
<h3 class="error_message">
Please provide launch_url. Click "Edit", and fill in the
required fields.
</h3>
## The result of the form submit will be rendered here.
<iframe
name="ltiLaunchFrame"
class="ltiLaunchFrame"
src=""
></iframe>
</div>
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