From d02ef8bc12299f46dcbb69dfc85294a90c201127 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan <valera@edx.org> Date: Mon, 19 Aug 2013 17:25:13 +0300 Subject: [PATCH] Add Learning Tools Interoperability (LTI) blade. LTI blade allows to include LTI components to courses. Python integration, Jasmine and acceptance tests are included. --- CHANGELOG.rst | 4 + .../contentstore/views/component.py | 3 +- cms/djangoapps/contentstore/views/preview.py | 4 +- common/lib/xmodule/setup.py | 1 + common/lib/xmodule/xmodule/course_module.py | 1 + common/lib/xmodule/xmodule/css/lti/lti.scss | 30 +++ .../lib/xmodule/xmodule/js/fixtures/lti.html | 40 +++ .../xmodule/js/spec/lti/constructor.js | 84 ++++++ common/lib/xmodule/xmodule/js/src/lti/lti.js | 26 ++ common/lib/xmodule/xmodule/lti_module.py | 249 ++++++++++++++++++ .../xmodule/modulestore/tests/factories.py | 2 +- common/lib/xmodule/xmodule/tests/__init__.py | 5 +- docs/developers/source/xmodule.rst | 8 + .../courseware/features/lti.feature | 17 ++ lms/djangoapps/courseware/features/lti.py | 188 +++++++++++++ .../courseware/features/lti_setup.py | 50 ++++ .../courseware/mock_lti_server/__init__.py | 0 .../mock_lti_server/mock_lti_server.py | 167 ++++++++++++ .../mock_lti_server/test_mock_lti_server.py | 75 ++++++ lms/djangoapps/courseware/tests/__init__.py | 2 +- lms/djangoapps/courseware/tests/test_lti.py | 79 ++++++ lms/templates/lti.html | 34 +++ 22 files changed, 1062 insertions(+), 7 deletions(-) create mode 100644 common/lib/xmodule/xmodule/css/lti/lti.scss create mode 100644 common/lib/xmodule/xmodule/js/fixtures/lti.html create mode 100644 common/lib/xmodule/xmodule/js/spec/lti/constructor.js create mode 100644 common/lib/xmodule/xmodule/js/src/lti/lti.js create mode 100644 common/lib/xmodule/xmodule/lti_module.py create mode 100644 lms/djangoapps/courseware/features/lti.feature create mode 100644 lms/djangoapps/courseware/features/lti.py create mode 100644 lms/djangoapps/courseware/features/lti_setup.py create mode 100644 lms/djangoapps/courseware/mock_lti_server/__init__.py create mode 100644 lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py create mode 100644 lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py create mode 100644 lms/djangoapps/courseware/tests/test_lti.py create mode 100644 lms/templates/lti.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89f084b3f45..84962963bd1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,10 @@ 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 the top. Include a label indicating the component affected. + +Blades: Added Learning Tools Interoperability (LTI) blade. Now LTI components +can be included to courses. + LMS: Added alphabetical sorting of forum categories and subcategories. It is hidden behind a false defaulted course level flag. diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 724dc439d98..deef87a4037 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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' diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index ccbb7fb5bb1..45ad7a7424a 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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' ) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 704de15ea77..6a24bf8f276 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -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', diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index aca804d5e2e..658a095d148 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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) diff --git a/common/lib/xmodule/xmodule/css/lti/lti.scss b/common/lib/xmodule/xmodule/css/lti/lti.scss new file mode 100644 index 00000000000..97a8f62d545 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/lti/lti.scss @@ -0,0 +1,30 @@ +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; + } + } +} diff --git a/common/lib/xmodule/xmodule/js/fixtures/lti.html b/common/lib/xmodule/xmodule/js/fixtures/lti.html new file mode 100644 index 00000000000..e5e7ab3f3f0 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/lti.html @@ -0,0 +1,40 @@ +<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> diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js new file mode 100644 index 00000000000..0a73496bedf --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -0,0 +1,84 @@ +/** + * 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); + }); + }); + }); + }); +}()); diff --git a/common/lib/xmodule/xmodule/js/src/lti/lti.js b/common/lib/xmodule/xmodule/js/src/lti/lti.js new file mode 100644 index 00000000000..e5b6885e1bb --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/lti/lti.js @@ -0,0 +1,26 @@ +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; +}()); diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py new file mode 100644 index 00000000000..bc07cea97eb --- /dev/null +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -0,0 +1,249 @@ +""" +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 diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 4ad801aef84..c8228c5e3eb 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -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: diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index fefa668a56a..b7e5ea8435c 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -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, ) diff --git a/docs/developers/source/xmodule.rst b/docs/developers/source/xmodule.rst index 008a1303d29..77ee2ea684f 100644 --- a/docs/developers/source/xmodule.rst +++ b/docs/developers/source/xmodule.rst @@ -95,6 +95,14 @@ Html :members: :show-inheritance: + +LTI +=== + +.. automodule:: xmodule.lti_module + :members: + :show-inheritance: + Mako ==== diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature new file mode 100644 index 00000000000..abdcfdb7046 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti.feature @@ -0,0 +1,17 @@ +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 diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py new file mode 100644 index 00000000000..0e91d5ed026 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti.py @@ -0,0 +1,188 @@ +#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') diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py new file mode 100644 index 00000000000..0a6c4590dd9 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -0,0 +1,50 @@ +#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() diff --git a/lms/djangoapps/courseware/mock_lti_server/__init__.py b/lms/djangoapps/courseware/mock_lti_server/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py new file mode 100644 index 00000000000..ba9cea84d67 --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -0,0 +1,167 @@ +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) + diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py new file mode 100644 index 00000000000..99650d5faae --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -0,0 +1,75 @@ +""" +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) diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 88129cc8d1c..0a4d3508b84 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -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())) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py new file mode 100644 index 00000000000..d2b4ea68670 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -0,0 +1,79 @@ +"""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) diff --git a/lms/templates/lti.html b/lms/templates/lti.html new file mode 100644 index 00000000000..3d97c8d8089 --- /dev/null +++ b/lms/templates/lti.html @@ -0,0 +1,34 @@ +<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> -- GitLab