From b5dc03ec36945ea90270db97ed086ed877bb72cc Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 29 Aug 2013 19:13:27 +0300 Subject: [PATCH] Acceptance test for LTI module (not finished), but working --- common/lib/xmodule/xmodule/lti_module.py | 1 + .../courseware/features/lti.feature | 10 ++ lms/djangoapps/courseware/features/lti.py | 98 +++++++++++ .../courseware/features/lti_setup.py | 40 +++++ .../courseware/mock_lti_server/__init__.py | 0 .../mock_lti_server/mock_lti_server.py | 158 ++++++++++++++++++ .../mock_lti_server/test_mock_lti_server.py | 72 ++++++++ 7 files changed, 379 insertions(+) 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 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 = """TEST TITLE + +

IFrame loaded

\ +

Server response is:

\ +

{}

+ """.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 get LTI is correct.''' + return 'correct_lti_endpoint' in self.path + + +class MockLTIServer(HTTPServer): + ''' + A mock LTI provider server that responds + to POST requests to localhost. + ''' + + def __init__(self, port_num, oauth={}): + ''' + Initialize the mock XQueue server instance. + + *port_num* is the localhost port to listen to + + *grade_response_dict* is a dictionary that will be JSON-serialized + and sent in response to XQueue grading requests. + ''' + + self.clent_key = oauth.get('client_key', '') + self.clent_secret = oauth.get('client_secret', '') + self.check_oauth() + + handler = MockLTIRequestHandler + address = ('', port_num) + 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 get_oauth_signature(self): + '''test''' + return self._signature + + def check_oauth(self): + ''' generate oauth signature ''' + self._signature = '12345' + 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 0000000000..8d556286c3 --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -0,0 +1,72 @@ +import mock +import unittest +import threading +import json +import urllib +import time +from mock_lti_server import MockLTIServer, MockLTIRequestHandler + +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 + self.server_url = 'http://127.0.0.1:%d' % server_port + self.server = MockLTIServer(server_port, {'client_key': '', 'client_secret': ''}) + + # 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_oauth_request(self): + + # Send a grade request + header = { + 'Content-Type': 'application/x-www-form-urlencoded', + u'Authorization': u'OAuth oauth_nonce="151177408427657509491377691584", \ +oauth_timestamp="1377691584", oauth_version="1.0", \ +oauth_signature_method="HMAC-SHA1", oauth_consumer_key="", \ +oauth_signature="wc1unKXxsX5e4HXJu%2FuiQ1KbrVo%3D"', + 'launch_presentation_return_url': '', + 'user_id': 'default_user_id', + 'lis_result_sourcedid': '', + 'lti_version': 'LTI-1p0', + 'lis_outcome_service_url': '', + 'lti_message_type': 'basic-lti-launch-request', + 'oauth_callback': 'about:blank' + } + body = {} + request = { + 'header': json.dumps(header), + 'body': json.dumps(body)} + response_handle = urllib.urlopen( + self.server_url + '/correct_lti_endpoint', + urllib.urlencode(request) + ) + + response_dict = json.loads(response_handle.read()) + # Expect that the response is success + self.assertEqual(response_dict['return_code'], 0) + # self.assertEqual(response_dict['return_code'], 0) +