diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 168c27c01..a617e29d9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1492,7 +1492,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): Emails sent to requested user / email. When exceptions are raised, return without saving model. """ - if not change: # Only send email if this is a new PortfolioInvitation(creation) + if not change: # Only send email if this is a new PortfolioInvitation (creation) portfolio = obj.portfolio requested_email = obj.email requestor = request.user @@ -1537,7 +1537,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): ) else: - logger.warning("Could not send email invitation (Other Exception)", obj.portfolio, exc_info=True) + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") def response_add(self, request, obj, post_url_continue=None): diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index a259e5bef..19d547c3a 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2,6 +2,8 @@ from datetime import datetime from django.utils import timezone from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite +from registrar.utility.email import EmailSendingError +from registrar.utility.errors import MissingEmailError from waffle.testutils import override_flag from django_webtest import WebTest # type: ignore from api.tests.common import less_console_noise_decorator @@ -277,6 +279,29 @@ class TestUserPortfolioPermissionAdmin(TestCase): # Should return the forbidden permissions for member role self.assertEqual(member_only_permissions, set(member_forbidden)) + @less_console_noise_decorator + def test_has_change_form_description(self): + """Tests if this model has a model description on the change form view""" + self.client.force_login(self.superuser) + + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + response = self.client.get( + "/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk), + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains( + response, + "If you add someone to a portfolio here, it will not trigger an invitation email.", + ) + class TestPortfolioInvitationAdmin(TestCase): """Tests for the PortfolioInvitationAdmin class as super user @@ -432,6 +457,30 @@ class TestPortfolioInvitationAdmin(TestCase): ) self.assertContains(response, "Show more") + @less_console_noise_decorator + def test_has_change_form_description(self): + """Tests if this model has a model description on the change form view""" + self.client.force_login(self.superuser) + + invitation, _ = PortfolioInvitation.objects.get_or_create( + email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + response = self.client.get( + "/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk), + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains( + response, + "If you add someone to a portfolio here, it will trigger an invitation email when you click", + ) + + @less_console_noise_decorator def test_get_filters(self): """Ensures that our filters are displaying correctly""" with less_console_noise(): @@ -456,6 +505,211 @@ class TestPortfolioInvitationAdmin(TestCase): self.assertContains(response, invited_html, count=1) self.assertContains(response, retrieved_html, count=1) + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") # Mock the `messages.warning` call + def test_save_sends_email(self, mock_messages_warning, mock_send_email): + """On save_model, an email is NOT sent if an invitation already exists.""" + self.client.force_login(self.superuser) + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that send_portfolio_invitation_email is not called + mock_send_email.assert_called() + + # Get the arguments passed to send_portfolio_invitation_email + _, called_kwargs = mock_send_email.call_args + + # Assert the email content + self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov") + self.assertEqual(called_kwargs["requestor"], self.superuser) + self.assertEqual(called_kwargs["portfolio"], self.portfolio) + + # Assert that a warning message was triggered + mock_messages_warning.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.") + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.warning") # Mock the `messages.warning` call + def test_save_does_not_send_email_if_requested_user_exists(self, mock_messages_warning, mock_send_email): + """On save_model, an email is NOT sent if an the requested email belongs to an existing user. + It also throws a warning.""" + self.client.force_login(self.superuser) + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Mock the UserPortfolioPermission query to simulate the invitation already existing + existing_user = create_user() + UserPortfolioPermission.objects.create(user=existing_user, portfolio=self.portfolio) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email=existing_user.email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + + # Assert that a warning message was triggered + mock_messages_warning.assert_called_once_with(request, "User is already a member of this portfolio.") + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") # Mock the `messages.error` call + def test_save_exception_email_sending_error(self, mock_messages_error, mock_send_email): + """Handle EmailSendingError correctly when sending the portfolio invitation fails.""" + self.client.force_login(self.superuser) + + # Mock the email sending function to raise EmailSendingError + mock_send_email.side_effect = EmailSendingError("Email service unavailable") + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that messages.error was called with the correct message + mock_messages_error.assert_called_once_with( + request, "Could not send email invitation. Portfolio invitation not saved." + ) + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") # Mock the `messages.error` call + def test_save_exception_missing_email_error(self, mock_messages_error, mock_send_email): + """Handle MissingEmailError correctly when no email exists for the requestor.""" + self.client.force_login(self.superuser) + + # Mock the email sending function to raise MissingEmailError + mock_send_email.side_effect = MissingEmailError("Email-less Rachid") + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that messages.error was called with the correct message + mock_messages_error.assert_called_once_with( + request, + "Can't send invitation email. No email is associated with the account for 'Email-less Rachid'.", + ) + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") # Mock the `messages.error` call + def test_save_exception_generic_error(self, mock_messages_error, mock_send_email): + """Handle generic exceptions correctly during portfolio invitation.""" + self.client.force_login(self.superuser) + + # Mock the email sending function to raise a generic exception + mock_send_email.side_effect = Exception("Unexpected error") + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that messages.error was called with the correct message + mock_messages_error.assert_called_once_with( + request, "Could not send email invitation. Portfolio invitation not saved." + ) + + # @patch("django.contrib.messages.get_messages") + # def test_form_rerenders_if_errors_or_warnings(self, mock_get_messages): + # """Ensure the form is re-rendered when errors or warnings are present.""" + # self.client.force_login(self.superuser) + + # # Mock the presence of an error message + # mock_message = Mock() + # mock_message.level_tag = "error" + # mock_message.message = "Simulated error message" + # mock_get_messages.return_value = [mock_message] + + # # Create an instance of the admin class + # admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=AdminSite()) + + # # Create a PortfolioInvitation instance + # portfolio_invitation = PortfolioInvitation( + # email="james.gordon@gotham.gov", + # portfolio=self.portfolio, + # roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + # ) + + # # Create a request object + # request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + # request.user = self.superuser + + # # Trigger response_add + # response = admin_instance.response_add(request, portfolio_invitation) + + # # Assert that the response status code is 200 (indicating the form was re-rendered) + # self.assertEqual(response.status_code, 200, msg="Expected form to re-render due to errors.") + + # # Assert that the mocked error message is included in the response + # self.assertContains(response, "Simulated error message", msg_prefix="Expected error message not found.") + + class TestHostAdmin(TestCase): """Tests for the HostAdmin class as super user diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 12d9af8ac..bf4fba623 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -18,7 +18,13 @@ from registrar.forms.domain_request_wizard import ( AboutYourOrganizationForm, ) from registrar.forms.domain import ContactForm -from registrar.tests.common import MockEppLib +from registrar.forms.portfolio import BasePortfolioMemberForm, PortfolioInvitedMemberForm, PortfolioMemberForm, PortfolioNewMemberForm +from registrar.models.portfolio import Portfolio +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user import User +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.tests.common import MockEppLib, create_user from django.contrib.auth import get_user_model @@ -408,3 +414,191 @@ class TestContactForm(TestCase): def test_contact_form_email_invalid2(self): form = ContactForm(data={"email": "@"}) self.assertEqual(form.errors["email"], ["Enter a valid email address."]) + + +class TestBasePortfolioMemberForms(TestCase): + """We test on the child forms instead of BasePortfolioMemberForm becasue the base form + is a model form with no model bound.""" + + def setUp(self): + super().setUp() + self.user = create_user() + self.portfolio, _ = Portfolio.objects.get_or_create(creator_id=self.user.id, organization_name="Hotel California") + + def tearDown(self): + super().tearDown() + Portfolio.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + PortfolioInvitation.objects.all().delete() + User.objects.all().delete() + + def _assert_form_is_valid(self, form_class, data, instance=None): + if instance != None: + form = form_class(data=data, instance=instance) + else: + print('no instance') + form = form_class(data=data) + self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}") + return form + + def _assert_form_has_error(self, form_class, data, field_name): + form = form_class(data=data) + self.assertFalse(form.is_valid()) + self.assertIn(field_name, form.errors) + + def _assert_initial_data(self, form_class, instance, expected_initial_data): + """Helper to check if the instance data is correctly mapped to the initial form values.""" + form = form_class(instance=instance) + for field, expected_value in expected_initial_data.items(): + self.assertEqual(form.initial[field], expected_value) + + def _assert_permission_mapping(self, form_class, data, expected_permissions): + """Helper to check if permissions are correctly handled and mapped.""" + form = self._assert_form_is_valid(form_class, data) + cleaned_data = form.cleaned_data + for permission in expected_permissions: + self.assertIn(permission, cleaned_data["additional_permissions"]) + + def test_required_field_for_admin(self): + """Test that required fields are validated for an admin role.""" + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": "", # Simulate missing field + "member_permission_admin": "", # Simulate missing field + } + + # Check required fields for all forms + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioMemberForm, data, "member_permission_admin") + + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin") + + self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permission_admin") + + def test_required_field_for_member(self): + """Test that required fields are validated for a member role.""" + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": "", # Simulate missing field + } + + # Check required fields for all forms + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_member") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_member") + self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_member") + + def test_clean_validates_required_fields_for_role(self): + """Test that the `clean` method validates the correct fields for each role. + + For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form. + For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data. + + These things are handled in the views.""" + + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=self.portfolio, user=self.user) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho") + + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value, + } + + # Check form validity for all forms + form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS]) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS]) + + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value, + } + + form = self._assert_form_is_valid(PortfolioNewMemberForm, data) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS]) + + def test_clean_member_permission_edgecase(self): + """Test that the clean method correctly handles the special "no_access" value for members. + We'll need to add a portfolio, which in the app is handled by the view post.""" + + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=self.portfolio, user=self.user) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho") + + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": "no_access", # Simulate no access permission + } + + form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["domain_request_permission_member"], None) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["domain_request_permission_member"], None) + + + def test_map_instance_to_initial_admin_role(self): + """Test that instance data is correctly mapped to the initial form values for an admin role.""" + user_portfolio_permission = UserPortfolioPermission( + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + portfolio=self.portfolio, + email="hi@ho", + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS], + ) + + expected_initial_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + "member_permission_admin": UserPortfolioPermissionChoices.VIEW_MEMBERS, + } + self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) + self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) + + def test_map_instance_to_initial_member_role(self): + """Test that instance data is correctly mapped to the initial form values for a member role.""" + user_portfolio_permission = UserPortfolioPermission( + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + portfolio=self.portfolio, + email="hi@ho", + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + ) + expected_initial_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + } + self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) + self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) + + def test_invalid_data_for_admin(self): + """Test invalid form submission for an admin role with missing permissions.""" + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": "", # Missing field + "member_permission_admin": "", # Missing field + } + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")