test_reset_password.py 15.1 KB
Newer Older
1 2 3 4 5 6 7
"""
Test the various password reset flows
"""
import json
import re
import unittest

8
import ddt
9
from django.conf import settings
10
from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
11
from django.contrib.auth.models import User
12
from django.contrib.auth.tokens import default_token_generator
13 14 15
from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
16
from django.test.utils import override_settings
17
from django.utils.http import int_to_base36
18
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory
19
from mock import Mock, patch
20 21
from oauth2_provider import models as dot_models
from provider.oauth2 import models as dop_models
22

23
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
24
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
25
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
26 27
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string
28
from student.views import SETTING_CHANGE_INITIATED, password_reset, password_reset_confirm_wrapper
29
from util.testing import EventTestMixin
30

31
from .test_configuration_overrides import fake_get_value
32

33

34 35 36 37
@unittest.skipUnless(
    settings.ROOT_URLCONF == "lms.urls",
    "reset password tests should only run in LMS"
)
38
@ddt.ddt
39
class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
40 41 42 43
    """ Tests that clicking reset password sends email, and doesn't activate the user
    """
    request_factory = RequestFactory()

44 45
    ENABLED_CACHES = ['default']

46
    def setUp(self):
47
        super(ResetPasswordTests, self).setUp('student.views.tracker')
48 49 50 51 52 53 54 55
        self.user = UserFactory.create()
        self.user.is_active = False
        self.user.save()
        self.token = default_token_generator.make_token(self.user)
        self.uidb36 = int_to_base36(self.user.id)

        self.user_bad_passwd = UserFactory.create()
        self.user_bad_passwd.is_active = False
56
        self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX
57 58 59 60
        self.user_bad_passwd.save()

    @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
    def test_user_bad_password_reset(self):
61
        """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD_PREFIX"""
62 63 64 65 66 67 68 69 70 71

        bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
        bad_pwd_resp = password_reset(bad_pwd_req)
        # If they've got an unusable password, we return a successful response code
        self.assertEquals(bad_pwd_resp.status_code, 200)
        obj = json.loads(bad_pwd_resp.content)
        self.assertEquals(obj, {
            'success': True,
            'value': "('registration/password_reset_done.html', [])",
        })
72
        self.assert_no_events_were_emitted()
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88

    @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
    def test_nonexist_email_password_reset(self):
        """Now test the exception cases with of reset_password called 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)
        # Note: even if the email is bad, we return a successful response code
        # This prevents someone potentially trying to "brute-force" find out which
        # emails are and aren't registered with edX
        self.assertEquals(bad_email_resp.status_code, 200)
        obj = json.loads(bad_email_resp.content)
        self.assertEquals(obj, {
            'success': True,
            'value': "('registration/password_reset_done.html', [])",
        })
89
        self.assert_no_events_were_emitted()
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106

    @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
    def test_password_reset_ratelimited(self):
        """ Try (and fail) resetting password 30 times in a row on an non-existant email address """
        cache.clear()

        for i in xrange(30):
            good_req = self.request_factory.post('/password_reset/', {
                'email': 'thisdoesnotexist{0}@foo.com'.format(i)
            })
            good_resp = password_reset(good_req)
            self.assertEquals(good_resp.status_code, 200)

        # then the rate limiter should kick in and give a HttpForbidden response
        bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'})
        bad_resp = password_reset(bad_req)
        self.assertEquals(bad_resp.status_code, 403)
107
        self.assert_no_events_were_emitted()
108 109 110

        cache.clear()

111
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
112 113 114 115 116 117
    @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):
        """Tests contents of reset password email, and that user is not active"""

        good_req = self.request_factory.post('/password_reset/', {'email': self.user.email})
118
        good_req.user = self.user
Ahsan committed
119 120 121 122 123 124
        dop_client = ClientFactory()
        dop_access_token = AccessTokenFactory(user=self.user, client=dop_client)
        RefreshTokenFactory(user=self.user, client=dop_client, access_token=dop_access_token)
        dot_application = dot_factories.ApplicationFactory(user=self.user)
        dot_access_token = dot_factories.AccessTokenFactory(user=self.user, application=dot_application)
        dot_factories.RefreshTokenFactory(user=self.user, application=dot_application, access_token=dot_access_token)
125 126
        good_resp = password_reset(good_req)
        self.assertEquals(good_resp.status_code, 200)
Ahsan committed
127 128 129 130
        self.assertFalse(dop_models.AccessToken.objects.filter(user=self.user).exists())
        self.assertFalse(dop_models.RefreshToken.objects.filter(user=self.user).exists())
        self.assertFalse(dot_models.AccessToken.objects.filter(user=self.user).exists())
        self.assertFalse(dot_models.RefreshToken.objects.filter(user=self.user).exists())
131 132 133 134 135 136 137 138 139
        obj = json.loads(good_resp.content)
        self.assertEquals(obj, {
            'success': True,
            'value': "('registration/password_reset_done.html', [])",
        })

        (subject, msg, from_addr, to_addrs) = send_email.call_args[0]
        self.assertIn("Password reset", subject)
        self.assertIn("You're receiving this e-mail because you requested a password reset", msg)
140
        self.assertEquals(from_addr, configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL))
141 142 143
        self.assertEquals(len(to_addrs), 1)
        self.assertIn(self.user.email, to_addrs)

144 145 146 147
        self.assert_event_emitted(
            SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None,
        )

148 149 150 151 152
        #test that the user is not active
        self.user = User.objects.get(pk=self.user.pk)
        self.assertFalse(self.user.is_active)
        re.search(r'password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', msg).groupdict()

153
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
154 155 156 157 158 159 160 161 162 163 164
    @patch('django.core.mail.send_mail')
    @ddt.data((False, 'http://'), (True, 'https://'))
    @ddt.unpack
    def test_reset_password_email_https(self, is_secure, protocol, send_email):
        """
        Tests that the right url protocol is included in the reset password link
        """
        req = self.request_factory.post(
            '/password_reset/', {'email': self.user.email}
        )
        req.is_secure = Mock(return_value=is_secure)
165 166
        req.user = self.user
        password_reset(req)
167 168 169 170 171
        _, msg, _, _ = send_email.call_args[0]
        expected_msg = "Please go to the following page and choose a new password:\n\n" + protocol

        self.assertIn(expected_msg, msg)

172 173 174 175
        self.assert_event_emitted(
            SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
        )

176 177
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
    @patch('django.core.mail.send_mail')
Ahsan Ulhaq committed
178
    @ddt.data(('Crazy Awesome Site', 'Crazy Awesome Site'), ('edX', 'edX'))
179
    @ddt.unpack
180
    def test_reset_password_email_site(self, site_name, platform_name, send_email):
181 182 183 184 185
        """
        Tests that the right url domain and platform name is included in
        the reset password email
        """
        with patch("django.conf.settings.PLATFORM_NAME", platform_name):
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
            with patch("django.conf.settings.SITE_NAME", site_name):
                req = self.request_factory.post(
                    '/password_reset/', {'email': self.user.email}
                )
                req.user = self.user
                password_reset(req)
                _, msg, _, _ = send_email.call_args[0]

                reset_msg = "you requested a password reset for your user account at {}"
                reset_msg = reset_msg.format(site_name)

                self.assertIn(reset_msg, msg)

                sign_off = "The {} Team".format(platform_name)
                self.assertIn(sign_off, msg)

                self.assert_event_emitted(
                    SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
                )
205

206
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
207
    @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
208
    @patch('django.core.mail.send_mail')
209
    def test_reset_password_email_configuration_override(self, send_email):
210 211 212 213 214 215 216 217
        """
        Tests that the right url domain and platform name is included in
        the reset password email
        """
        req = self.request_factory.post(
            '/password_reset/', {'email': self.user.email}
        )
        req.get_host = Mock(return_value=None)
218 219
        req.user = self.user
        password_reset(req)
220
        _, msg, from_addr, _ = send_email.call_args[0]
221

222
        reset_msg = "you requested a password reset for your user account at {}".format(fake_get_value('platform_name'))
223 224 225

        self.assertIn(reset_msg, msg)

226 227 228
        self.assert_event_emitted(
            SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
        )
229
        self.assertEqual(from_addr, "no-reply@fakeuniversity.com")
230

231 232 233 234 235 236 237
    @ddt.data(
        ('invalidUid', 'invalid_token'),
        (None, 'invalid_token'),
        ('invalidUid', None),
    )
    @ddt.unpack
    def test_reset_password_bad_token(self, uidb36, token):
238
        """Tests bad token and uidb36 in password reset"""
239 240 241 242 243 244 245 246 247 248 249 250
        if uidb36 is None:
            uidb36 = self.uidb36
        if token is None:
            token = self.token

        bad_request = self.request_factory.get(
            reverse(
                "password_reset_confirm",
                kwargs={"uidb36": uidb36, "token": token}
            )
        )
        password_reset_confirm_wrapper(bad_request, uidb36, token)
251 252 253
        self.user = User.objects.get(pk=self.user.pk)
        self.assertFalse(self.user.is_active)

254
    def test_reset_password_good_token(self):
255
        """Tests good token and uidb36 in password reset"""
256 257 258 259 260
        url = reverse(
            "password_reset_confirm",
            kwargs={"uidb36": self.uidb36, "token": self.token}
        )
        good_reset_req = self.request_factory.get(url)
261 262 263 264
        password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token)
        self.user = User.objects.get(pk=self.user.pk)
        self.assertTrue(self.user.is_active)

265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283
    def test_password_reset_fail(self):
        """Tests that if we provide mismatched passwords, user is not marked as active."""
        self.assertFalse(self.user.is_active)

        url = reverse(
            'password_reset_confirm',
            kwargs={'uidb36': self.uidb36, 'token': self.token}
        )
        request_params = {'new_password1': 'password1', 'new_password2': 'password2'}
        confirm_request = self.request_factory.post(url, data=request_params)

        # Make a password reset request with mismatching passwords.
        resp = password_reset_confirm_wrapper(confirm_request, self.uidb36, self.token)

        # Verify the response status code is: 200 with password reset fail and also verify that
        # the user is not marked as active.
        self.assertEqual(resp.status_code, 200)
        self.assertFalse(User.objects.get(pk=self.user.pk).is_active)

284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312
    @override_settings(PASSWORD_MIN_LENGTH=2)
    @override_settings(PASSWORD_MAX_LENGTH=10)
    @ddt.data(
        {
            'password': '1',
            'error_message': 'Password: Invalid Length (must be 2 characters or more)',
        },
        {
            'password': '01234567891',
            'error_message': 'Password: Invalid Length (must be 10 characters or fewer)'
        }
    )
    def test_password_reset_with_invalid_length(self, password_dict):
        """Tests that if we provide password characters less then PASSWORD_MIN_LENGTH,
        or more than PASSWORD_MAX_LENGTH, password reset will fail with error message.
        """

        url = reverse(
            'password_reset_confirm',
            kwargs={'uidb36': self.uidb36, 'token': self.token}
        )
        request_params = {'new_password1': password_dict['password'], 'new_password2': password_dict['password']}
        confirm_request = self.request_factory.post(url, data=request_params)

        # Make a password reset request with minimum/maximum passwords characters.
        response = password_reset_confirm_wrapper(confirm_request, self.uidb36, self.token)

        self.assertEqual(response.context_data['err_msg'], password_dict['error_message'])

313
    @patch('student.views.password_reset_confirm')
314 315 316
    @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
    def test_reset_password_good_token_configuration_override(self, reset_confirm):
        """Tests password reset confirmation page for site configuration override."""
317 318 319 320 321
        url = reverse(
            "password_reset_confirm",
            kwargs={"uidb36": self.uidb36, "token": self.token}
        )
        good_reset_req = self.request_factory.get(url)
322 323 324
        password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token)
        confirm_kwargs = reset_confirm.call_args[1]
        self.assertEquals(confirm_kwargs['extra_context']['platform_name'], 'Fake University')
325 326
        self.user = User.objects.get(pk=self.user.pk)
        self.assertTrue(self.user.is_active)
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344

    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
    @patch('django.core.mail.send_mail')
    @ddt.data('Crazy Awesome Site', 'edX')
    def test_reset_password_email_subject(self, platform_name, send_email):
        """
        Tests that the right platform name is included in
        the reset password email subject
        """
        with patch("django.conf.settings.PLATFORM_NAME", platform_name):
            req = self.request_factory.post(
                '/password_reset/', {'email': self.user.email}
            )
            req.user = self.user
            password_reset(req)
            subj, _, _, _ = send_email.call_args[0]

            self.assertIn(platform_name, subj)