test_lti_integration.py 8.92 KB
Newer Older
1 2 3
"""LTI integration tests"""

from collections import OrderedDict
4
import json
5
import mock
6
import oauthlib
7
import urllib
8

9
from django.conf import settings
10
from django.core.urlresolvers import reverse
11 12 13

from courseware.tests import BaseTestXmodule
from courseware.views import get_course_lti_endpoints
14
from lms.djangoapps.lms_xblock.runtime import quote_slashes
15 16 17
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.x_module import STUDENT_VIEW
18

19

20 21 22 23 24
class TestLTI(BaseTestXmodule):
    """
    Integration test for lti xmodule.

    It checks overall code, by assuring that context that goes to template is correct.
25 26
    As part of that, checks oauth signature generation by mocking signing function
    of `oauthlib` library.
27 28 29 30 31 32 33 34 35 36 37 38 39
    """
    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='

40
        # Note: this course_id is actually a course_key
41
        context_id = self.item_descriptor.course_id.to_deprecated_string()
42
        user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id)
43 44
        hostname = self.item_descriptor.xmodule_runtime.hostname
        resource_link_id = unicode(urllib.quote('{}-{}'.format(hostname, self.item_descriptor.location.html_id())))
45

Oleg Marshev committed
46 47
        sourcedId = "{context}:{resource_link}:{user_id}".format(
            context=urllib.quote(context_id),
Oleg Marshev committed
48 49
            resource_link=resource_link_id,
            user_id=user_id
50
        )
51

52
        self.correct_headers = {
53
            u'user_id': user_id,
54 55 56 57
            u'oauth_callback': u'about:blank',
            u'launch_presentation_return_url': '',
            u'lti_message_type': u'basic-lti-launch-request',
            u'lti_version': 'LTI-1p0',
58
            u'roles': u'Student',
Oleg Marshev committed
59
            u'context_id': context_id,
60

61
            u'resource_link_id': resource_link_id,
62
            u'lis_result_sourcedid': sourcedId,
63 64 65 66 67 68 69 70 71

            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'oauth_signature': mocked_decoded_signature
        }

72
        saved_sign = oauthlib.oauth1.Client.sign
73

74
        self.expected_context = {
75
            'display_name': self.item_descriptor.display_name,
76
            'input_fields': self.correct_headers,
77 78
            'element_class': self.item_descriptor.category,
            'element_id': self.item_descriptor.location.html_id(),
79 80
            'launch_url': 'http://www.example.com',  # default value
            'open_in_a_new_page': True,
81 82 83 84 85 86 87
            'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_descriptor,
                                                                         'preview_handler').rstrip('/?'),
            'hide_launch': False,
            'has_score': False,
            'module_score': None,
            'comment': u'',
            'weight': 1.0,
88 89 90 91
            'ask_to_send_username': self.item_descriptor.ask_to_send_username,
            'ask_to_send_email': self.item_descriptor.ask_to_send_email,
            'description': self.item_descriptor.description,
            'button_text': self.item_descriptor.button_text,
92
            'accept_grades_past_due': self.item_descriptor.accept_grades_past_due,
93 94
        }

95 96 97 98 99 100 101 102 103 104 105 106
        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
107
            headers[u'Authorization'] = ', '.join([k + '="' + v + '"' for k, v in old_parsed.items()])
108 109
            return None, headers, None

110
        patcher = mock.patch.object(oauthlib.oauth1.Client, "sign", mocked_sign)
111 112 113 114
        patcher.start()
        self.addCleanup(patcher.stop)

    def test_lti_constructor(self):
115
        generated_content = self.item_descriptor.render(STUDENT_VIEW).content
116
        expected_content = self.runtime.render_template('lti.html', self.expected_context)
117
        self.assertEqual(generated_content, expected_content)
118

119
    def test_lti_preview_handler(self):
120
        generated_content = self.item_descriptor.preview_handler(None, None).body
121 122
        expected_content = self.runtime.render_template('lti_form.html', self.expected_context)
        self.assertEqual(generated_content, expected_content)
123 124 125 126 127 128 129 130 131 132 133 134


class TestLTIModuleListing(ModuleStoreTestCase):
    """
    a test for the rest endpoint that lists LTI modules in a course
    """
    # arbitrary constant
    COURSE_SLUG = "100"
    COURSE_NAME = "test_course"

    def setUp(self):
        """Create course, 2 chapters, 2 sections"""
135
        super(TestLTIModuleListing, self).setUp()
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
        self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
        self.chapter1 = ItemFactory.create(
            parent_location=self.course.location,
            display_name="chapter1",
            category='chapter')
        self.section1 = ItemFactory.create(
            parent_location=self.chapter1.location,
            display_name="section1",
            category='sequential')
        self.chapter2 = ItemFactory.create(
            parent_location=self.course.location,
            display_name="chapter2",
            category='chapter')
        self.section2 = ItemFactory.create(
            parent_location=self.chapter2.location,
            display_name="section2",
            category='sequential')

        # creates one draft and one published lti module, in different sections
        self.lti_published = ItemFactory.create(
            parent_location=self.section1.location,
            display_name="lti published",
            category="lti",
159
            location=self.course.id.make_usage_key('lti', 'lti_published'),
160 161 162 163 164
        )
        self.lti_draft = ItemFactory.create(
            parent_location=self.section2.location,
            display_name="lti draft",
            category="lti",
jsa committed
165
            location=self.course.id.make_usage_key('lti', 'lti_draft'),
166
            publish_item=False,
167 168 169 170 171 172 173
        )

    def expected_handler_url(self, handler):
        """convenience method to get the reversed handler urls"""
        return "https://{}{}".format(settings.SITE_NAME, reverse(
            'courseware.module_render.handle_xblock_callback_noauth',
            args=[
174
                self.course.id.to_deprecated_string(),
Calen Pennington committed
175
                quote_slashes(unicode(self.lti_published.scope_ids.usage_id.to_deprecated_string()).encode('utf-8')),
176 177 178 179 180 181
                handler
            ]
        ))

    def test_lti_rest_bad_course(self):
        """Tests what happens when the lti listing rest endpoint gets a bad course_id"""
182
        bad_ids = [u"sf", u"dne/dne/dne", u"fo/ey/\\u5305"]
183
        for bad_course_id in bad_ids:
184 185
            lti_rest_endpoints_url = 'courses/{}/lti_rest_endpoints/'.format(bad_course_id)
            response = self.client.get(lti_rest_endpoints_url)
186 187 188
            self.assertEqual(404, response.status_code)

    def test_lti_rest_listing(self):
189
        """tests that the draft lti module is part of the endpoint response"""
190 191
        request = mock.Mock()
        request.method = 'GET'
192
        response = get_course_lti_endpoints(request, course_id=self.course.id.to_deprecated_string())
193 194 195 196 197 198 199 200

        self.assertEqual(200, response.status_code)
        self.assertEqual('application/json', response['Content-Type'])

        expected = {
            "lti_1_1_result_service_xml_endpoint": self.expected_handler_url('grade_handler'),
            "lti_2_0_result_service_json_endpoint":
            self.expected_handler_url('lti_2_0_result_rest_handler') + "/user/{anon_user_id}",
jsa committed
201
            "display_name": self.lti_published.display_name,
202 203 204 205 206 207 208 209 210
        }
        self.assertEqual([expected], json.loads(response.content))

    def test_lti_rest_non_get(self):
        """tests that the endpoint returns 404 when hit with NON-get"""
        DISALLOWED_METHODS = ("POST", "PUT", "DELETE", "HEAD", "OPTIONS")  # pylint: disable=invalid-name
        for method in DISALLOWED_METHODS:
            request = mock.Mock()
            request.method = method
211
            response = get_course_lti_endpoints(request, self.course.id.to_deprecated_string())
212
            self.assertEqual(405, response.status_code)