diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 75c89e0a26..1096092117 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -1,33 +1,15 @@ from django import forms -from django.utils.translation import ugettext, ugettext_lazy as _ -from django.template import loader from django.contrib.auth.models import User -from django.contrib.auth.hashers import UNUSABLE_PASSWORD, is_password_usable, get_hasher -from django.contrib.auth.tokens import default_token_generator -from django.contrib.sites.models import get_current_site -from django.utils.http import int_to_base36 +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.hashers import UNUSABLE_PASSWORD - - -# This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm -# I think copy-and-paste here is somewhat better than subclassing and -# just changing the definition of clean_email, because it's less -# likely to be broken by incompatibility with a new django version. -# (If this form is good enough now, a snapshot of it ought to last a while) - -class PasswordResetFormNoActive(forms.Form): - error_messages = { - 'unknown': _("That e-mail address doesn't have an associated " - "user account. Are you sure you've registered?"), - 'unusable': _("The user account associated with this e-mail " - "address cannot reset the password."), - } - email = forms.EmailField(label=_("E-mail"), max_length=75) - +class PasswordResetFormNoActive(PasswordResetForm): def clean_email(self): """ - Validates that an active user exists with the given email address. - """ + This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm + Except removing the requirement of active users + Validates that a user exists with the given email address. + """ email = self.cleaned_data["email"] #The line below contains the only change, removing is_active=True self.users_cache = User.objects.filter(email__iexact=email) @@ -37,36 +19,3 @@ class PasswordResetFormNoActive(forms.Form): for user in self.users_cache): raise forms.ValidationError(self.error_messages['unusable']) return email - - def save(self, domain_override=None, - subject_template_name='registration/password_reset_subject.txt', - email_template_name='registration/password_reset_email.html', - use_https=False, token_generator=default_token_generator, - from_email=None, request=None): - """ - Generates a one-use only link for resetting password and sends to the - user. - """ - from django.core.mail import send_mail - for user in self.users_cache: - if not domain_override: - current_site = get_current_site(request) - site_name = current_site.name - domain = current_site.domain - else: - site_name = domain = domain_override - c = { - 'email': user.email, - 'domain': domain, - 'site_name': site_name, - 'uid': int_to_base36(user.id), - 'user': user, - 'token': token_generator.make_token(user), - 'protocol': use_https and 'https' or 'http', - } - subject = loader.render_to_string(subject_template_name, c) - # Email subject *must not* contain newlines - subject = ''.join(subject.splitlines()) - email = loader.render_to_string(email_template_name, c) - send_mail(subject, email, from_email, [user.email]) - diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 4638da44b2..10836122b8 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -5,18 +5,118 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ import logging +import json +import re +import unittest +from django import forms +from django.conf import settings from django.test import TestCase -from mock import Mock +from django.test.client import RequestFactory +from django.contrib.auth.models import User +from django.contrib.auth.hashers import UNUSABLE_PASSWORD +from django.template.loader import render_to_string, get_template, TemplateDoesNotExist +from django.core.urlresolvers import is_valid_path + +from mock import Mock, patch +from textwrap import dedent from student.models import unique_id_for_user -from student.views import process_survey_link, _cert_info - +from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper +from student.tests.factories import UserFactory +from student.tests.test_email import mock_render_to_string COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' log = logging.getLogger(__name__) +try: + get_template('registration/password_reset_email.html') + project_uses_password_reset = True +except TemplateDoesNotExist: + project_uses_password_reset = False + + +class ResetPasswordTests(TestCase): + """ Tests that clicking reset password sends email, and doesn't activate the user + """ + request_factory = RequestFactory() + + def setUp(self): + self.user = UserFactory.create() + self.user.is_active = False + self.user.save() + + self.user_bad_passwd = UserFactory.create() + self.user_bad_passwd.is_active = False + self.user_bad_passwd.password = UNUSABLE_PASSWORD + self.user_bad_passwd.save() + + + @unittest.skipUnless(project_uses_password_reset, dedent("""Skipping Test because CMS has not provided + necessary templates for password reset. If this message is in LMS tests, that is a bug and needs to be fixed.""")) + @patch('student.views.password_reset_confirm') + @patch('django.core.mail.send_mail') + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_reset_password_email(self, send_email, reset_confirm): + """Tests sending of reset password email""" + + #First test the bad password user, mainly for diff-cover sake + bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) + bad_pwd_resp = password_reset(bad_pwd_req) + self.assertEquals(bad_pwd_resp.status_code, 200) + self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, + 'error': 'Invalid e-mail or user'})) + + #Now test the exception cases with invalid email. + bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) + bad_email_resp = password_reset(bad_email_req) + self.assertEquals(bad_email_resp.status_code, 200) + self.assertEquals(bad_email_resp.content, json.dumps({'success': False, + 'error': 'Invalid e-mail or user'})) + + #Now test the legit case where email should have been sent + good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + self.assertEquals(good_resp.content, + json.dumps({'success': True, + 'value': "('registration/password_reset_done.html', [])"})) + + ((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args + self.assertIn("Password reset", subject) + self.assertIn("You're receiving this e-mail because you requested a password reset", msg) + self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) + self.assertEquals(len(to_addrs), 1) + self.assertIn(self.user.email, to_addrs) + + #test that the user is not active + #it's a bit unsettling that we have to reload the user from the db for this test to work + #but I guess the user is cached here in the instance of ResetPasswordTests + #so the update in the view does not know to update this class. + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + #now try to activate the user in the password reset phase + bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') + bad_reset_resp = password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') + (confirm_args, confirm_kwargs) = reset_confirm.call_args + self.assertEquals(confirm_kwargs['uidb36'], 'NO') + self.assertEquals(confirm_kwargs['token'], 'OP') + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(reset_match['uidb36'], + reset_match['token'])) + good_reset_resp = password_reset_confirm_wrapper(good_reset_req, reset_match['uidb36'], reset_match['token']) + (confirm_args, confirm_kwargs) = reset_confirm.call_args + self.assertEquals(confirm_kwargs['uidb36'], reset_match['uidb36']) + self.assertEquals(confirm_kwargs['token'], reset_match['token']) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) + + class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 50f6d90368..7ae460b438 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -975,7 +975,7 @@ def password_reset(request): 'value': render_to_string('registration/password_reset_done.html', {})})) else: return HttpResponse(json.dumps({'success': False, - 'error': 'Invalid e-mail'})) + 'error': 'Invalid e-mail or user'})) def password_reset_confirm_wrapper(request, uidb36=None, token=None): ''' A wrapper around django.contrib.auth.views.password_reset_confirm.