Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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: LTI additional Python tests. LTI must use HTTPS for
lis_outcome_service_url. BLD-564.

Blades: Fix bug when Image mapping problems are not working for students in IE. BLD-413.

Blades: Add template that displays the most up-to-date features of
drag-and-drop. BLD-479.

Blades: LTI additional Python tests. LTI fix bug e-reader error when popping
out window. BLD-465.
Blades: LTI fix bug e-reader error when popping out window. BLD-465.

Common: Switch from mitx.db to edx.db for sqlite databases. This will effectively
reset state for local instances of the code, unless you manually rename your
Expand Down
44 changes: 18 additions & 26 deletions common/lib/xmodule/xmodule/lti_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,37 +259,22 @@ def get_context(self):
'element_class': self.category,
'open_in_a_new_page': self.open_in_a_new_page,
'display_name': self.display_name,
'form_url': self.get_form_path(),
'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'),
}


def get_form_path(self):
return self.runtime.handler_url(self, 'preview_handler').rstrip('/?')

def get_html(self):
"""
Renders parameters to template.
"""
return self.system.render_template('lti.html', self.get_context())

def get_form(self):
"""
Renders parameters to form template.
"""
return self.system.render_template('lti_form.html', self.get_context())

@XBlock.handler
def preview_handler(self, request, dispatch):
def preview_handler(self, _, __):
"""
Ajax handler.

Args:
dispatch: string request slug

Returns:
json string
This is called to get context with new oauth params to iframe.
"""
return Response(self.get_form(), content_type='text/html')
template = self.system.render_template('lti_form.html', self.get_context())
return Response(template, content_type='text/html')

def get_user_id(self):
user_id = self.runtime.anonymous_student_id
Expand All @@ -299,11 +284,18 @@ def get_user_id(self):
def get_outcome_service_url(self):
"""
Return URL for storing grades.

To test LTI on sandbox we must use http scheme.

While testing locally and on Jenkins, mock_lti_server use http.referer
to obtain scheme, so it is ok to have http(s) anyway.
"""
uri = 'http://{host}{path}'.format(
host=self.system.hostname,
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
)
scheme = 'http' if 'sandbox' in self.system.hostname else 'https'
uri = '{scheme}://{host}{path}'.format(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you leave the scheme off the URL, the browser will use either http or https, as appropriate for the page. http://stackoverflow.com/questions/4831741/can-i-change-all-my-http-links-to-just

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scheme is used in oauth signing, and current oauthlib implementation throws Exception, if scheme is not specified (https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136)

scheme=scheme,
host=self.system.hostname,
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
)
return uri

def get_resource_link_id(self):
Expand Down Expand Up @@ -449,7 +441,7 @@ def grade_handler(self, request, dispatch):

Example of correct/incorrect answer XML body:: see response_xml_template.
"""
response_xml_template = textwrap.dedent("""
response_xml_template = textwrap.dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader>
Expand Down Expand Up @@ -578,7 +570,7 @@ def verify_oauth_body_sign(self, request):

sha1 = hashlib.sha1()
sha1.update(request.body)
oauth_body_hash = base64.b64encode(sha1.hexdigest())
oauth_body_hash = base64.b64encode(sha1.digest())

oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
oauth_headers =dict(oauth_params)
Expand Down
151 changes: 131 additions & 20 deletions common/lib/xmodule/xmodule/tests/test_lti_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
"""Test for LTI Xmodule functional logic."""

from mock import Mock, patch, PropertyMock
import mock
import textwrap
import json
from lxml import etree
import json
from webob.request import Request
from copy import copy
from collections import OrderedDict
import urllib
import oauthlib
import hashlib
import base64


from xmodule.lti_module import LTIDescriptor
from xmodule.lti_module import LTIDescriptor, LTIError

from . import LogicTest

Expand Down Expand Up @@ -48,7 +55,6 @@ def setUp(self):
</imsx_POXEnvelopeRequest>
""")
self.system.get_real_user = Mock()
self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret'))
self.system.publish = Mock()

self.user_id = self.xmodule.runtime.anonymous_student_id
Expand Down Expand Up @@ -96,6 +102,7 @@ def get_response_values(self, response):
def test_authorization_header_not_present(self):
"""
Request has no Authorization header.

This is an unknown service request, i.e., it is not a part of the original service specification.
"""
request = Request(self.environ)
Expand All @@ -115,6 +122,7 @@ def test_authorization_header_not_present(self):
def test_authorization_header_empty(self):
"""
Request Authorization header has no value.

This is an unknown service request, i.e., it is not a part of the original service specification.
"""
request = Request(self.environ)
Expand All @@ -128,7 +136,6 @@ def test_authorization_header_empty(self):
'description': 'The request has failed.',
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
}

self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)

Expand All @@ -147,7 +154,6 @@ def test_grade_not_in_range(self):
'description': 'The request has failed.',
'messageIdentifier': 'unknown',
}

self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)

Expand All @@ -166,7 +172,6 @@ def test_bad_grade_decimal(self):
'description': 'The request has failed.',
'messageIdentifier': 'unknown',
}

self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)

Expand All @@ -186,7 +191,6 @@ def test_unsupported_action(self):
'description': 'Target does not support the requested operation.',
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
}

self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)

Expand All @@ -199,7 +203,6 @@ def test_good_request(self):
request = Request(self.environ)
request.body = self.get_request_body()
response = self.xmodule.grade_handler(request, '')
code_major, description, messageIdentifier, action = self.get_response_values(response)
description_expected = 'Score for {sourcedId} is now {score}'.format(
sourcedId=self.DEFAULTS['sourcedId'],
score=self.DEFAULTS['grade'],
Expand All @@ -221,28 +224,20 @@ def test_user_id(self):
self.assertEqual(real_user_id, expected_user_id)

def test_outcome_service_url(self):
expected_outcome_service_url = 'http://{host}{path}'.format(
expected_outcome_service_url = 'https://{host}{path}'.format(
host=self.xmodule.runtime.hostname,
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
)

real_outcome_service_url = self.xmodule.get_outcome_service_url()
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)

def test_get_form_path(self):
expected_form_path = self.xmodule.runtime.handler_url(self.xmodule, 'preview_handler').rstrip('/?')

real_form_path = self.xmodule.get_form_path()
self.assertEqual(real_form_path, expected_form_path)

def test_resource_link_id(self):
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
mock_id.return_value = self.module_id
expected_resource_link_id = unicode(urllib.quote(self.module_id))
real_resource_link_id = self.xmodule.get_resource_link_id()
self.assertEqual(real_resource_link_id, expected_resource_link_id)


def test_lis_result_sourcedid(self):
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
mock_id.return_value = self.module_id
Expand All @@ -251,11 +246,127 @@ def test_lis_result_sourcedid(self):
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)


def test_verify_oauth_body_sign(self):
pass
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
def test_client_key_secret(self, test):
"""
LTI module gets client key and secret provided.
"""
#this adds lti passports to system
mocked_course = Mock(lti_passports = ['lti_id:test_client:test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
self.xmodule.lti_id = "lti_id"
key, secret = self.xmodule.get_client_key_secret()
expected = ('test_client', 'test_secret')
self.assertEqual(expected, (key, secret))

@patch('xmodule.course_module.CourseDescriptor.id_to_location')
def test_client_key_secret_not_provided(self, test):
"""
LTI module attempts to get client key and secret provided in cms.

There are key and secret but not for specific LTI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enter empty line

"""

#this adds lti passports to system
mocked_course = Mock(lti_passports = ['test_id:test_client:test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
#set another lti_id
self.xmodule.lti_id = "another_lti_id"
key_secret = self.xmodule.get_client_key_secret()
expected = ('','')
self.assertEqual(expected, key_secret)

@patch('xmodule.course_module.CourseDescriptor.id_to_location')
def test_bad_client_key_secret(self, test):
"""
LTI module attempts to get client key and secret provided in cms.

def test_client_key_secret(self):
pass
There are key and secret provided in wrong format.
"""
#this adds lti passports to system
mocked_course = Mock(lti_passports = ['test_id_test_client_test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
self.xmodule.lti_id = 'lti_id'
with self.assertRaises(LTIError):
self.xmodule.get_client_key_secret()

@patch('xmodule.lti_module.signature.verify_hmac_sha1', return_value=True)
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', return_value=('test_client_key', u'test_client_secret'))
def test_successful_verify_oauth_body_sign(self, get_key_secret, mocked_verify):
"""
Test if OAuth signing was successful.
"""
try:
self.xmodule.verify_oauth_body_sign(self.get_signed_grade_mock_request())
except LTIError:
self.fail("verify_oauth_body_sign() raised LTIError unexpectedly!")

@patch('xmodule.lti_module.signature.verify_hmac_sha1', return_value=False)
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', return_value=('test_client_key', u'test_client_secret'))
def test_failed_verify_oauth_body_sign(self, get_key_secret, mocked_verify):
"""
Oauth signing verify fail.
"""
with self.assertRaises(LTIError):
req = self.get_signed_grade_mock_request()
self.xmodule.verify_oauth_body_sign(req)

def get_signed_grade_mock_request(self):
"""
Example of signed request from LTI Provider.
"""
mock_request = Mock()
mock_request.headers = {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/xml',
'Authorization': u'OAuth oauth_nonce="135685044251684026041377608307", \
oauth_timestamp="1234567890", oauth_version="1.0", \
oauth_signature_method="HMAC-SHA1", \
oauth_consumer_key="test_client_key", \
oauth_signature="my_signature%3D", \
oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="'
}
mock_request.url = u'http://testurl'
mock_request.http_method = u'POST'
mock_request.body = textwrap.dedent("""
<?xml version = "1.0" encoding = "UTF-8"?>
<imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
</imsx_POXEnvelopeRequest>
""")
return mock_request

def test_good_custom_params(self):
"""
Custom parameters are presented in right format.
"""
self.xmodule.custom_parameters = ['test_custom_params=test_custom_param_value']
self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret'))
self.xmodule.oauth_params = Mock()
self.xmodule.get_input_fields()
self.xmodule.oauth_params.assert_called_with(
{u'custom_test_custom_params': u'test_custom_param_value'},
'test_client_key', 'test_client_secret'
)

def test_bad_custom_params(self):
"""
Custom parameters are presented in wrong format.
"""
bad_custom_params = ['test_custom_params: test_custom_param_value']
self.xmodule.custom_parameters = bad_custom_params
self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret'))
self.xmodule.oauth_params = Mock()
with self.assertRaises(LTIError):
self.xmodule.get_input_fields()

def test_max_score(self):
self.xmodule.weight = 100.0
Expand Down
4 changes: 4 additions & 0 deletions lms/djangoapps/courseware/features/lti_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ def setup_mock_lti_server():
'lti_endpoint': 'correct_lti_endpoint'
}

# Flag for acceptance tests used for creating right callback_url and sending
# graded result. Used in MockLTIRequestHandler.
server.test_mode = True

# Store the server instance in lettuce's world
# so that other steps can access it
# (and we can shut it down later)
Expand Down
Loading