Skip to content

Commit

Permalink
feat: Add endpoint to request password reset.
Browse files Browse the repository at this point in the history
Added an endpoint for users to request a password reset token using any
of their verified email addresses.

Refs #39
  • Loading branch information
cdriehuys committed Oct 12, 2019
1 parent 10d8649 commit a70b314
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Features
interface.
* :issue:`38`: Add endpoint to resend a verification email to the provided REST
interface.
* :issue:`39`: Add endpoint to request a password reset.

******
v0.3.1
Expand Down
31 changes: 31 additions & 0 deletions email_auth/interfaces/rest/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,34 @@ def validate_token(self, token):
)

return token


class PasswordResetRequestSerializer(serializers.Serializer):
"""
Serializer to create and send a password reset token to a verified
email address.
"""

email = serializers.EmailField()

def save(self, **kwargs) -> Optional[models.PasswordReset]:
"""
Send a new password reset token to the provided email address if
the email has already been verified. If the provided email has
not been verified, no action is taken.
Returns:
The created :py:class:`PasswordReset` instance if one was
created or else ``None``.
"""
try:
email = models.EmailAddress.objects.get(
address__iexact=self.validated_data["email"], is_verified=True
)
except models.EmailAddress.DoesNotExist:
return None

reset = models.PasswordReset(email=email)
reset.send_email()

return reset
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from unittest import mock

import pytest

from email_auth import models


pytest.importorskip("rest_framework")

# Imports that require the presence of "rest_framework"
from email_auth.interfaces.rest import serializers # noqa


def test_save_unregistered_email(mock_email_address_qs):
"""
If the provided email address doesn't exist in the system, saving
should do nothing.
"""
address = "test@example.com"
mock_email_address_qs.get.side_effect = models.EmailAddress.DoesNotExist

data = {"email": address}
serializer = serializers.PasswordResetRequestSerializer(data=data)

assert serializer.is_valid()
result = serializer.save()

assert result is None
assert serializer.data == data
assert mock_email_address_qs.get.call_args[1] == {
"address__iexact": address,
"is_verified": True,
}


def test_save_unverified_email(mock_email_address_qs):
"""
If the provided email address has not been verified yet, saving the
serializer should do nothing.
"""
address = "test@example.com"
mock_email_address_qs.get.side_effect = models.EmailAddress.DoesNotExist

data = {"email": address}
serializer = serializers.PasswordResetRequestSerializer(data=data)

assert serializer.is_valid()
result = serializer.save()

assert result is None
assert serializer.data == data
assert mock_email_address_qs.get.call_args[1] == {
"address__iexact": address,
"is_verified": True,
}


@mock.patch("email_auth.models.PasswordReset.send_email")
def test_save_verified_email(_, mock_email_address_qs):
"""
If a verified email is provided, saving the serializer should send
a new password reset token to the provided address.
"""
email = models.EmailAddress(address="test@example.com")
mock_email_address_qs.get.return_value = email

data = {"email": email.address}
serializer = serializers.PasswordResetRequestSerializer(data=data)

assert serializer.is_valid()
result = serializer.save()

assert serializer.data == data
assert result.send_email.call_count == 1
assert mock_email_address_qs.get.call_args[1] == {
"address__iexact": email.address,
"is_verified": True,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import pytest
import requests
from django.contrib.auth import get_user_model

from email_auth import models


pytest.importorskip("rest_framework")

from email_auth.interfaces.rest import serializers, views # noqa


def test_get_serializer_class():
"""
Test the serializer class used by the view.
"""
view = views.PasswordResetRequestView()
expected = serializers.PasswordResetRequestSerializer

assert view.get_serializer_class() == expected


@pytest.mark.functional_test
def test_request_password_reset(live_server, mailoutbox, settings):
"""
If the user provides a verified email address to the endpoint, an
email containing a link to the password reset page should be sent.
"""
reset_url_template = "http://localhost/reset-password/{key}"
settings.EMAIL_AUTH = {"PASSWORD_RESET_URL": reset_url_template}

user = get_user_model().objects.create_user(username="Test User")
email = models.EmailAddress.objects.create(
address="test@example.com", is_verified=True, user=user
)

data = {"email": email.address}
url = f"{live_server}/rest/password-reset-requests/"
response = requests.post(url, data)

assert response.status_code == 201
assert response.json() == data
assert len(mailoutbox) == 1

msg = mailoutbox[0]
reset = models.PasswordReset.objects.get()

assert msg.to == [data["email"]]
assert reset_url_template.format(key=reset.token) in msg.body


@pytest.mark.functional_test
def test_request_password_reset_missing_email(live_server, mailoutbox):
"""
If the user provides an email address that does not exist in the
system, no action should be taken.
"""
data = {"email": "test@example.com"}
url = f"{live_server}/rest/password-reset-requests/"
response = requests.post(url, data)

assert response.status_code == 201
assert response.json() == data
assert len(mailoutbox) == 0


@pytest.mark.functional_test
def test_request_password_reset_unverified_email(live_server, mailoutbox):
"""
If the user provides an email address that does not exist in the
system, no action should be taken.
"""
user = get_user_model().objects.create_user(username="Test User")
email = models.EmailAddress.objects.create(
address="test@example.com", is_verified=False, user=user
)

data = {"email": email.address}
url = f"{live_server}/rest/password-reset-requests/"
response = requests.post(url, data)

assert response.status_code == 201
assert response.json() == data
assert len(mailoutbox) == 0
5 changes: 5 additions & 0 deletions email_auth/interfaces/rest/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@
views.EmailVerificationView.as_view(),
name="email-verification-create",
),
path(
"password-reset-requests/",
views.PasswordResetRequestView.as_view(),
name="password-reset-request-list",
),
]
18 changes: 18 additions & 0 deletions email_auth/interfaces/rest/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,21 @@ class EmailVerificationView(generics.CreateAPIView):
"""

serializer_class = serializers.EmailVerificationSerializer


class PasswordResetRequestView(generics.CreateAPIView):
"""
post:
Request a password reset token be sent to the provided email
address.
If the provided email address exists and is verified, a new password
reset token will be generated and sent to the provided address. In
any other case, no email will be sent. Regardless of the outcome, a
`201` response is returned.
If the provided email address is not a valid email address, a `400`
response is returned.
"""

serializer_class = serializers.PasswordResetRequestSerializer

0 comments on commit a70b314

Please sign in to comment.