From e36351d21ef5d60be8a4d96579b4dfd9c446bd71 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 14 Dec 2023 07:44:31 -0500 Subject: [PATCH 1/2] lowercase emails on add user to domain; case insensitive match on matching DomainInvitations on login; test cases --- src/registrar/forms/domain.py | 11 +++++++++++ src/registrar/models/user.py | 2 +- src/registrar/tests/test_models.py | 14 ++++++++++++++ src/registrar/tests/test_views.py | 26 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 8b55aa29d..ac96393b4 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -28,6 +28,17 @@ class DomainAddUserForm(forms.Form): email = forms.EmailField(label="Email") + def clean(self): + """clean form data by lowercasing email""" + cleaned_data = super().clean() + + # Lowercase the value of the 'email' field + email_value = cleaned_data.get("email") + if email_value: + cleaned_data["email"] = email_value.lower() + + return cleaned_data + class DomainNameserverForm(forms.Form): """Form for changing nameservers.""" diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index ec2d06c70..d79e4c9ee 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -101,7 +101,7 @@ class User(AbstractUser): """When a user first arrives on the site, we need to retrieve any domain invitations that match their email address.""" for invitation in DomainInvitation.objects.filter( - email=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED + email__iexact=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED ): try: invitation.retrieve() diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 83126ab7c..ca221a18f 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -654,3 +654,17 @@ class TestUser(TestCase): """A new user who's neither transitioned nor invited should return True when tested with class method needs_identity_verification""" self.assertTrue(User.needs_identity_verification(self.user.email, self.user.username)) + + def test_check_domain_invitations_on_login_caps_email(self): + """A DomainInvitation with an email address with capital letters should match + a User record whose email address is not in caps""" + # create DomainInvitation with CAPS email that matches User email + # on a case-insensitive match + CAPS_EMAIL = "MAYOR@igorville.gov" + # mock the domain invitation save routine + with patch("registrar.models.DomainInvitation.save") as save_mock: + DomainInvitation.objects.get_or_create(email=CAPS_EMAIL, domain=self.domain) + self.user.check_domain_invitations_on_login() + # if check_domain_invitations_on_login properly matches exactly one + # Domain Invitation, then save routine should be called exactly once + save_mock.assert_called_once diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index da6fe6205..89ef0e35e 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1372,6 +1372,32 @@ class TestDomainManagers(TestDomainOverview): self.assertContains(success_page, "Cancel") # link to cancel invitation self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists()) + @boto3_mocking.patching + def test_domain_invitation_created_for_caps_email(self): + """Add user on a nonexistent email with CAPS creates an invitation to lowercase email. + + Adding a non-existent user sends an email as a side-effect, so mock + out the boto3 SES email sending here. + """ + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + CAPS_EMAIL = "MAYOR@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) + + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = CAPS_EMAIL + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result = add_page.form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + + self.assertContains(success_page, EMAIL) + self.assertContains(success_page, "Cancel") # link to cancel invitation + self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists()) + @boto3_mocking.patching def test_domain_invitation_email_sent(self): """Inviting a non-existent user sends them an email.""" From 7e4f500f941f640b7b39193e55796b337d49f7e6 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 14 Dec 2023 11:06:43 -0500 Subject: [PATCH 2/2] updated formatting and variable names in tests --- src/registrar/tests/test_models.py | 6 ++-- src/registrar/tests/test_views.py | 46 +++++++++++++++--------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index ca221a18f..8642b1d15 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -660,11 +660,11 @@ class TestUser(TestCase): a User record whose email address is not in caps""" # create DomainInvitation with CAPS email that matches User email # on a case-insensitive match - CAPS_EMAIL = "MAYOR@igorville.gov" + caps_email = "MAYOR@igorville.gov" # mock the domain invitation save routine with patch("registrar.models.DomainInvitation.save") as save_mock: - DomainInvitation.objects.get_or_create(email=CAPS_EMAIL, domain=self.domain) + DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain) self.user.check_domain_invitations_on_login() # if check_domain_invitations_on_login properly matches exactly one # Domain Invitation, then save routine should be called exactly once - save_mock.assert_called_once + save_mock.assert_called_once() diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 89ef0e35e..17641636e 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1355,22 +1355,22 @@ class TestDomainManagers(TestDomainOverview): out the boto3 SES email sending here. """ # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + User.objects.filter(email=email_address).delete() self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = EMAIL + add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_result = add_page.form.submit() self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() - self.assertContains(success_page, EMAIL) + self.assertContains(success_page, email_address) self.assertContains(success_page, "Cancel") # link to cancel invitation - self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists()) + self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) @boto3_mocking.patching def test_domain_invitation_created_for_caps_email(self): @@ -1380,30 +1380,30 @@ class TestDomainManagers(TestDomainOverview): out the boto3 SES email sending here. """ # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - CAPS_EMAIL = "MAYOR@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + caps_email_address = "MAYOR@igorville.gov" + User.objects.filter(email=email_address).delete() self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = CAPS_EMAIL + add_page.form["email"] = caps_email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_result = add_page.form.submit() self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() - self.assertContains(success_page, EMAIL) + self.assertContains(success_page, email_address) self.assertContains(success_page, "Cancel") # link to cancel invitation - self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists()) + self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) @boto3_mocking.patching def test_domain_invitation_email_sent(self): """Inviting a non-existent user sends them an email.""" # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + User.objects.filter(email=email_address).delete() self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) @@ -1412,28 +1412,28 @@ class TestDomainManagers(TestDomainOverview): with boto3_mocking.clients.handler_for("sesv2", mock_client): add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = EMAIL + add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) add_page.form.submit() # check the mock instance to see if `send_email` was called right mock_client_instance.send_email.assert_called_once_with( FromEmailAddress=settings.DEFAULT_FROM_EMAIL, - Destination={"ToAddresses": [EMAIL]}, + Destination={"ToAddresses": [email_address]}, Content=ANY, ) def test_domain_invitation_cancel(self): """Posting to the delete view deletes an invitation.""" - EMAIL = "mayor@igorville.gov" - invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=EMAIL) + email_address = "mayor@igorville.gov" + invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) with self.assertRaises(DomainInvitation.DoesNotExist): DomainInvitation.objects.get(id=invitation.id) def test_domain_invitation_cancel_no_permissions(self): """Posting to the delete view as a different user should fail.""" - EMAIL = "mayor@igorville.gov" - invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=EMAIL) + email_address = "mayor@igorville.gov" + invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) other_user = User() other_user.save() @@ -1445,20 +1445,20 @@ class TestDomainManagers(TestDomainOverview): @boto3_mocking.patching def test_domain_invitation_flow(self): """Send an invitation to a new user, log in and load the dashboard.""" - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + User.objects.filter(email=email_address).delete() add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = EMAIL + add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) add_page.form.submit() # user was invited, create them - new_user = User.objects.create(username=EMAIL, email=EMAIL) + new_user = User.objects.create(username=email_address, email=email_address) # log them in to `self.app` self.app.set_user(new_user.username) # and manually call the on each login callback