diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index fa9624dde7..cb25fc1504 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -83,6 +83,7 @@ class LTIModule(LTIFields, XModule): # with 'Content-Type': 'application/x-www-form-urlencoded' # so '='' becomes '%3D', but server waits for unencoded signature. # Decode signature back: + # may be it may be encoded by browser again... check params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') return params diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature new file mode 100644 index 0000000000..e17144f545 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti.feature @@ -0,0 +1,10 @@ +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 a LTI component with empty fields + Then I view the LTI and it is not rendered + + Scenario: LTI component in LMS is rendered + Given the course has a LTI component filled with correct data + Then I view the LTI and it is rendered diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py new file mode 100644 index 0000000000..da6d14e759 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti.py @@ -0,0 +1,98 @@ +#pylint: disable=C0111 + +from lettuce import world, step +from lettuce.django import django_url +from common import i_am_registered_for_the_course, section_location + + +@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) + + +@step('the course has a LTI component filled with correct data') +def view_lti_with_data(_step): + coursenum = 'test_course' + i_am_registered_for_the_course(_step, coursenum) + + add_correct_lti_to_course(coursenum) + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) + ) + world.browser.visit(url) + + +@step('the course has a LTI component with empty fields') +def view_default_lti(_step): + coursenum = 'test_course' + i_am_registered_for_the_course(_step, coursenum) + + add_default_lti_to_course(coursenum) + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) + ) + world.browser.visit(url) + + +def add_correct_lti_to_course(course): + category = 'lti' + world.ItemFactory.create( + parent_location=section_location(course), + category=category, + display_name='LTI', + metadata={ + 'client_key': 'client_key', + 'clent_secret': 'client_secret', + 'lti_url': 'http://127.0.0.1:{}/correct_lti_endpoint'.format(world.lti_server_port) + } + ) + + +def add_default_lti_to_course(course): + category = 'lti' + world.ItemFactory.create( + parent_location=section_location(course), + category=category, + display_name='LTI' + ) diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py new file mode 100644 index 0000000000..e3cf7b2c7c --- /dev/null +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -0,0 +1,40 @@ +#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(): + + # Add +1 to XQUEUE random port number + server_port = settings.XQUEUE_PORT + 1 + + # Create the mock server instance + server = MockLTIServer(server_port) + 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() + + # 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 + world.lti_server_port = server_port + + +@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 0000000000..e69de29bb2 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 0000000000..8fb1cf4393 --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -0,0 +1,158 @@ +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +import json +import urllib +import urlparse +import threading + +from logging import getLogger +logger = getLogger(__name__) + + +# todo - implement oauth + +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 + + # Log the request + logger.debug("LTI provider received POST request {} to path {}".format( + str(post_dict), + self.path) + ) + # Respond only to requests with correct lti endpoint: + if self._is_correct_lti_request(): + correct_dict = { + 'user_id': 'default_user_id', + 'oauth_nonce': '22990037033121997701377766132', + 'oauth_timestamp': '1377766132', + 'oauth_consumer_key': 'client_key', + 'lti_version': 'LTI-1p0', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_version': '1.0', + 'oauth_signature': 'HGYMAU/G5EMxd0CDOvWubsqxLIY=', + 'lti_message_type': 'basic-lti-launch-request', + 'oauth_callback': 'about:blank' + } + + if sorted(correct_dict.keys()) != sorted(post_dict.keys()): + error_message = "Incorrect LTI header" + else: + error_message = "This is LTI tool." + else: + error_message = "Invalid request URL" + + self._send_response(error_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)) + # The POST dict will contain a list of values for each key. + # None of our parameters are lists, however, so we map [val] --> val. + #I f the list contains multiple entries, we pick the first one + post_dict = dict( + map( + lambda (key, list_val): (key, list_val[0]), + 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 = """