Acceptance test for LTI module (not finished), but working
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
10
lms/djangoapps/courseware/features/lti.feature
Normal file
10
lms/djangoapps/courseware/features/lti.feature
Normal file
@@ -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
|
||||
98
lms/djangoapps/courseware/features/lti.py
Normal file
98
lms/djangoapps/courseware/features/lti.py
Normal file
@@ -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'
|
||||
)
|
||||
40
lms/djangoapps/courseware/features/lti_setup.py
Normal file
40
lms/djangoapps/courseware/features/lti_setup.py
Normal file
@@ -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()
|
||||
158
lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
Normal file
158
lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
Normal file
@@ -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 = """<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 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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user