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
2 changes: 1 addition & 1 deletion lms/djangoapps/courseware/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def test_success_enrolled_student(self):
self.setup_user(admin=False, enroll=True, login=True)
self.verify_response()

def test_fail_unauthenticated(self):
def test_unauthenticated(self):
self.setup_course()
self.setup_user(admin=False, enroll=True, login=False)
self.verify_response(expected_response_code=302)
Expand Down
108 changes: 108 additions & 0 deletions lms/djangoapps/lti_provider/migrations/0003_create_lti_user_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models


class Migration(SchemaMigration):

def forwards(self, orm):
# Adding model 'LtiUser'
db.create_table('lti_provider_ltiuser', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('lti_consumer', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['lti_provider.LtiConsumer'])),
('lti_user_id', self.gf('django.db.models.fields.CharField')(max_length=255)),
('edx_user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], unique=True)),
))
db.send_create_signal('lti_provider', ['LtiUser'])

# Adding unique constraint on 'LtiUser', fields ['lti_consumer', 'lti_user_id']
db.create_unique('lti_provider_ltiuser', ['lti_consumer_id', 'lti_user_id'])

# Adding unique constraint on 'LtiConsumer', fields ['instance_guid']
db.create_unique('lti_provider_lticonsumer', ['instance_guid'])


def backwards(self, orm):
# Removing unique constraint on 'LtiConsumer', fields ['instance_guid']
db.delete_unique('lti_provider_lticonsumer', ['instance_guid'])

# Removing unique constraint on 'LtiUser', fields ['lti_consumer', 'lti_user_id']
db.delete_unique('lti_provider_ltiuser', ['lti_consumer_id', 'lti_user_id'])

# Deleting model 'LtiUser'
db.delete_table('lti_provider_ltiuser')


models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'lti_provider.gradedassignment': {
'Meta': {'unique_together': "(('outcome_service', 'lis_result_sourcedid'),)", 'object_name': 'GradedAssignment'},
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lis_result_sourcedid': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'outcome_service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['lti_provider.OutcomeService']"}),
'usage_key': ('xmodule_django.models.UsageKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'lti_provider.lticonsumer': {
'Meta': {'object_name': 'LtiConsumer'},
'consumer_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'consumer_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'consumer_secret': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'instance_guid': ('django.db.models.fields.CharField', [], {'max_length': '255', 'unique': 'True', 'null': 'True'})
},
'lti_provider.ltiuser': {
'Meta': {'unique_together': "(('lti_consumer', 'lti_user_id'),)", 'object_name': 'LtiUser'},
'edx_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lti_consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['lti_provider.LtiConsumer']"}),
'lti_user_id': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'lti_provider.outcomeservice': {
'Meta': {'object_name': 'OutcomeService'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lis_outcome_service_url': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'lti_consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['lti_provider.LtiConsumer']"})
}
}

complete_apps = ['lti_provider']
18 changes: 18 additions & 0 deletions lms/djangoapps/lti_provider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,21 @@ class Meta(object):
Uniqueness constraints.
"""
unique_together = ('outcome_service', 'lis_result_sourcedid')


class LtiUser(models.Model):
"""
Model mapping the identity of an LTI user to an account on the edX platform.
The LTI user_id field is guaranteed to be unique per LTI consumer (per
to the LTI spec), so we guarantee a unique mapping from LTI to edX account
by using the lti_consumer/lti_user_id tuple.
"""
lti_consumer = models.ForeignKey(LtiConsumer)
lti_user_id = models.CharField(max_length=255)
edx_user = models.ForeignKey(User, unique=True)

class Meta(object):
"""
Uniqueness constraints.
"""
unique_together = ('lti_consumer', 'lti_user_id')
157 changes: 157 additions & 0 deletions lms/djangoapps/lti_provider/tests/test_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""
Tests for the LTI user management functionality
"""

import string

from django.contrib.auth.models import User
from django.test import TestCase
from django.test.client import RequestFactory
from mock import patch, MagicMock
from lti_provider.models import LtiConsumer, LtiUser
import lti_provider.users as users
from student.tests.factories import UserFactory


class UserManagementHelperTest(TestCase):
"""
Tests for the helper functions in users.py
"""

def setUp(self):
super(UserManagementHelperTest, self).setUp()
self.request = RequestFactory().post('/')
self.old_user = UserFactory.create()
self.new_user = UserFactory.create()
self.new_user.save()
self.request.user = self.old_user
self.lti_user = LtiUser(
lti_user_id='lti_user_id',
edx_user=self.new_user
)

@patch('lti_provider.users.login')
def test_new_user_logged_in_by_switch_user(self, login_mock):
with patch('lti_provider.users.User.objects.get', return_value=self.new_user):
users.switch_user(self.request, self.lti_user)
login_mock.assert_called_with(self.request, self.new_user)

@patch('lti_provider.users.login')
def test_backend_set_in_switch_user(self, _login_mock):
users.switch_user(self.request, self.lti_user)
self.assertIsNotNone(self.new_user.backend, 'Backend not set on user')

def test_random_username_generator(self):
for _idx in range(1000):
username = users.generate_random_edx_username()
self.assertTrue(len(username) <= 30, 'Username too long')
# Check that the username contains only allowable characters
for char in range(len(username)):
self.assertTrue(
username[char] in string.ascii_letters + string.digits,
"Username has forbidden character '{}'".format(username[char])
)


@patch('lti_provider.users.switch_user')
@patch('lti_provider.users.create_lti_user')
class AuthenticateLtiUserTest(TestCase):
"""
Tests for the authenticate_lti_user function in users.py
"""
def setUp(self):
super(AuthenticateLtiUserTest, self).setUp()
self.lti_consumer = LtiConsumer(
consumer_name='TestConsumer',
consumer_key='TestKey',
consumer_secret='TestSecret'
)
self.lti_consumer.save()
self.lti_user_id = 'lti_user_id'
self.edx_user_id = 'edx_user_id'
self.old_user = UserFactory.create()
self.request = RequestFactory().post('/')
self.request.user = self.old_user

def create_lti_user_model(self):
"""
Generate and save a User and an LTI user model
"""
edx_user = User(username=self.edx_user_id)
edx_user.save()
lti_user = LtiUser(
lti_consumer=self.lti_consumer,
lti_user_id=self.lti_user_id,
edx_user=edx_user
)
lti_user.save()
return lti_user

def test_authentication_with_new_user(self, _create_user, switch_user):
lti_user = MagicMock()
lti_user.edx_user_id = self.edx_user_id
with patch('lti_provider.users.create_lti_user', return_value=lti_user) as create_user:
users.authenticate_lti_user(self.request, self.lti_user_id, self.lti_consumer)
create_user.assert_called_with(self.lti_user_id, self.lti_consumer)
switch_user.assert_called_with(self.request, lti_user)

def test_authentication_with_authenticated_user(self, create_user, switch_user):
lti_user = self.create_lti_user_model()
self.request.user = lti_user.edx_user
self.request.user.is_authenticated = MagicMock(return_value=True)
users.authenticate_lti_user(self.request, self.lti_user_id, self.lti_consumer)
self.assertFalse(create_user.called)
self.assertFalse(switch_user.called)

def test_authentication_with_unauthenticated_user(self, create_user, switch_user):
lti_user = self.create_lti_user_model()
self.request.user = lti_user.edx_user
self.request.user.is_authenticated = MagicMock(return_value=False)
users.authenticate_lti_user(self.request, self.lti_user_id, self.lti_consumer)
self.assertFalse(create_user.called)
switch_user.assert_called_with(self.request, lti_user)

def test_authentication_with_wrong_user(self, create_user, switch_user):
lti_user = self.create_lti_user_model()
self.request.user = self.old_user
self.request.user.is_authenticated = MagicMock(return_value=True)
users.authenticate_lti_user(self.request, self.lti_user_id, self.lti_consumer)
self.assertFalse(create_user.called)
switch_user.assert_called_with(self.request, lti_user)


class CreateLtiUserTest(TestCase):
"""
Tests for the create_lti_user function in users.py
"""

def setUp(self):
super(CreateLtiUserTest, self).setUp()
self.lti_consumer = LtiConsumer(
consumer_name='TestConsumer',
consumer_key='TestKey',
consumer_secret='TestSecret'
)
self.lti_consumer.save()

def test_create_lti_user_creates_auth_user_model(self):
users.create_lti_user('lti_user_id', self.lti_consumer)
self.assertEqual(User.objects.count(), 1)

@patch('uuid.uuid4', return_value='random_uuid')
@patch('lti_provider.users.generate_random_edx_username', return_value='edx_id')
def test_create_lti_user_creates_correct_user(self, uuid_mock, _username_mock):
users.create_lti_user('lti_user_id', self.lti_consumer)
self.assertEqual(User.objects.count(), 1)
user = User.objects.get(username='edx_id')
self.assertEqual(user.email, 'edx_id@lti.example.com')
uuid_mock.assert_called_with()

@patch('lti_provider.users.generate_random_edx_username', side_effect=['edx_id', 'new_edx_id'])
def test_unique_username_created(self, username_mock):
User(username='edx_id').save()
users.create_lti_user('lti_user_id', self.lti_consumer)
self.assertEqual(username_mock.call_count, 2)
self.assertEqual(User.objects.count(), 2)
user = User.objects.get(username='new_edx_id')
self.assertEqual(user.email, 'new_edx_id@lti.example.com')
36 changes: 19 additions & 17 deletions lms/djangoapps/lti_provider/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
'oauth_signature_method': u'HMAC-SHA1',
'oauth_timestamp': u'OAuth Timestamp',
'oauth_nonce': u'OAuth Nonce',
'user_id': u'LTI_User',
}

LTI_OPTIONAL_PARAMS = {
Expand Down Expand Up @@ -89,17 +90,19 @@ class LtiLaunchTest(LtiTestMixin, TestCase):
Tests for the lti_launch view
"""
@patch('lti_provider.views.render_courseware')
def test_valid_launch(self, render):
@patch('lti_provider.views.authenticate_lti_user')
def test_valid_launch(self, _authenticate, render):
"""
Verifies that the LTI launch succeeds when passed a valid request.
"""
request = build_launch_request()
views.lti_launch(request, unicode(COURSE_KEY), unicode(USAGE_KEY))
render.assert_called_with(request, ALL_PARAMS['usage_key'])
render.assert_called_with(request, USAGE_KEY)

@patch('lti_provider.views.render_courseware')
@patch('lti_provider.views.store_outcome_parameters')
def test_outcome_service_registered(self, store_params, _render):
@patch('lti_provider.views.authenticate_lti_user')
def test_outcome_service_registered(self, _authenticate, store_params, _render):
"""
Verifies that the LTI launch succeeds when passed a valid request.
"""
Expand Down Expand Up @@ -142,7 +145,8 @@ def test_launch_with_disabled_feature_flag(self):
self.assertEqual(response.status_code, 403)

@patch('lti_provider.views.lti_run')
def test_session_contents_after_launch(self, _run):
@patch('lti_provider.views.authenticate_lti_user')
def test_session_contents_after_launch(self, _authenticate, _run):
"""
Verifies that the LTI parameters and the course and usage IDs are
properly stored in the session
Expand All @@ -156,7 +160,8 @@ def test_session_contents_after_launch(self, _run):
self.assertEqual(session[key], request.POST[key], key + ' not set in the session')

@patch('lti_provider.views.lti_run')
def test_optional_parameters_in_session(self, _run):
@patch('lti_provider.views.authenticate_lti_user')
def test_optional_parameters_in_session(self, _authenticate, _run):
"""
Verifies that the outcome-related optional LTI parameters are properly
stored in the session
Expand All @@ -182,17 +187,6 @@ def test_optional_parameters_in_session(self, _run):
'Consumer instance GUID not set in the session'
)

def test_redirect_for_non_authenticated_user(self):
"""
Verifies that if the lti_launch view is called by an unauthenticated
user, the response will redirect to the login page with the correct
URL
"""
request = build_launch_request(False)
response = views.lti_launch(request, unicode(COURSE_KEY), unicode(USAGE_KEY))
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/accounts/login?next=/lti_provider/lti_run')

def test_forbidden_if_signature_fails(self):
"""
Verifies that the view returns Forbidden if the LTI OAuth signature is
Expand Down Expand Up @@ -278,7 +272,10 @@ def get_response(self):
"""
lti_launch_url = reverse(
'lti_provider_launch',
kwargs={'course_id': unicode(self.course.id), 'usage_id': unicode(self.html_block.location)}
kwargs={
'course_id': unicode(self.course.id),
'usage_id': unicode(self.html_block.location)
}
)
SignatureValidator.verify = MagicMock(return_value=True)
return self.client.post(lti_launch_url, data=LTI_DEFAULT_PARAMS)
Expand All @@ -287,3 +284,8 @@ def test_unenrolled_student(self):
self.setup_course()
self.setup_user(admin=False, enroll=False, login=True)
self.verify_response()

def test_unauthenticated(self):
self.setup_course()
self.setup_user(admin=False, enroll=True, login=False)
self.verify_response()
Loading