diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 76fad8975..7003affe4 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -758,6 +758,14 @@ class DomainInformationAdmin(ListHeaderAdmin): # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) + autocomplete_fields = [ + "creator", + "domain_application", + "authorizing_official", + "domain", + "submitter", + ] + # Table ordering ordering = ["domain__name"] @@ -1097,6 +1105,14 @@ class DomainInformationInline(admin.StackedInline): # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) + autocomplete_fields = [ + "creator", + "domain_application", + "authorizing_official", + "domain", + "submitter", + ] + def formfield_for_manytomany(self, db_field, request, **kwargs): """customize the behavior of formfields with manytomany relationships. the customized behavior includes sorting of objects in lists as well as customizing helper text""" @@ -1176,7 +1192,14 @@ class DomainAdmin(ListHeaderAdmin): if object_id is not None: domain = Domain.objects.get(pk=object_id) years_to_extend_by = self._get_calculated_years_for_exp_date(domain) - curr_exp_date = domain.registry_expiration_date + + try: + curr_exp_date = domain.registry_expiration_date + except KeyError: + # No expiration date was found. Return none. + extra_context["extended_expiration_date"] = None + return super().changeform_view(request, object_id, form_url, extra_context) + if curr_exp_date < date.today(): extra_context["extended_expiration_date"] = date.today() + relativedelta(years=years_to_extend_by) else: diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 009baa1c6..bb8e22ad7 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -286,6 +286,7 @@ AWS_MAX_ATTEMPTS = 3 BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS}) # email address to use for various automated correspondence +# also used as a default to and bcc email DEFAULT_FROM_EMAIL = "help@get.gov " # connect to an (external) SMTP server for sending email diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 1ee7e0036..df5b195c6 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -284,6 +284,7 @@ class OrganizationContactForm(RegistrarForm): message="Enter a zip code in the form of 12345 or 12345-6789.", ) ], + error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")}, ) urbanization = forms.CharField( required=False, diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 020ba43c0..449c4c4bb 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -13,6 +13,7 @@ from typing import Any from registrar.models.host import Host from registrar.models.host_ip import HostIP from registrar.utility.enums import DefaultEmail +from registrar.utility import errors from registrar.utility.errors import ( ActionNotAllowed, @@ -192,9 +193,17 @@ class Domain(TimeStampedModel, DomainHelper): @classmethod def available(cls, domain: str) -> bool: - """Check if a domain is available.""" + """Check if a domain is available. + This is called by the availablility api and + is called in the validate function on the request/domain page + + throws- RegistryError or InvalidDomainError""" if not cls.string_could_be_domain(domain): - raise ValueError("Not a valid domain: %s" % str(domain)) + logger.warning("Not a valid domain: %s" % str(domain)) + # throw invalid domain error so that it can be caught in + # validate_and_handle_errors in domain_helper + raise errors.InvalidDomainError() + domain_name = domain.lower() req = commands.CheckDomain([domain_name]) return registry.send(req, cleaned=True).res_data[0].avail @@ -429,7 +438,6 @@ class Domain(TimeStampedModel, DomainHelper): raise NameserverError(code=nsErrorCodes.INVALID_HOST, nameserver=nameserver) elif cls.isSubdomain(name, nameserver) and (ip is None or ip == []): raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver) - elif not cls.isSubdomain(name, nameserver) and (ip is not None and ip != []): raise NameserverError(code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip) elif ip is not None and ip != []: @@ -1780,6 +1788,10 @@ class Domain(TimeStampedModel, DomainHelper): for cleaned_host in cleaned_hosts: # Check if the cleaned_host already exists host_in_db, host_created = Host.objects.get_or_create(domain=self, name=cleaned_host["name"]) + # Check if the nameserver is a subdomain of the current domain + # If it is NOT a subdomain, we remove the IP address + if not Domain.isSubdomain(self.name, cleaned_host["name"]): + cleaned_host["addrs"] = [] # Get cleaned list of ips for update cleaned_ips = cleaned_host["addrs"] if not host_created: diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 609b9df33..11b456337 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -4,6 +4,7 @@ from typing import Union import logging from django.apps import apps +from django.conf import settings from django.db import models from django_fsm import FSMField, transition # type: ignore from django.utils import timezone @@ -611,7 +612,9 @@ class DomainApplication(TimeStampedModel): logger.error(err) logger.error(f"Can't query an approved domain while attempting {called_from}") - def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True): + def _send_status_update_email( + self, new_status, email_template, email_template_subject, send_email=True, bcc_address="" + ): """Send a status update email to the submitter. The email goes to the email address that the submitter gave as their @@ -636,6 +639,7 @@ class DomainApplication(TimeStampedModel): email_template_subject, self.submitter.email, context={"application": self}, + bcc_address=bcc_address, ) logger.info(f"The {new_status} email sent to: {self.submitter.email}") except EmailSendingError: @@ -677,11 +681,17 @@ class DomainApplication(TimeStampedModel): # Limit email notifications to transitions from Started and Withdrawn limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN] + bcc_address = "" + if settings.IS_PRODUCTION: + bcc_address = settings.DEFAULT_FROM_EMAIL + if self.status in limited_statuses: self._send_status_update_email( "submission confirmation", "emails/submission_confirmation.txt", "emails/submission_confirmation_subject.txt", + True, + bcc_address, ) @transition( diff --git a/src/registrar/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py index 9e3559676..8b9391add 100644 --- a/src/registrar/models/utility/domain_helper.py +++ b/src/registrar/models/utility/domain_helper.py @@ -33,11 +33,12 @@ class DomainHelper: # Split into pieces for the linter domain = cls._validate_domain_string(domain, blank_ok) - try: - if not check_domain_available(domain): - raise errors.DomainUnavailableError() - except RegistryError as err: - raise errors.RegistrySystemError() from err + if domain != "": + try: + if not check_domain_available(domain): + raise errors.DomainUnavailableError() + except RegistryError as err: + raise errors.RegistrySystemError() from err return domain @staticmethod diff --git a/src/registrar/templates/emails/status_change_rejected.txt b/src/registrar/templates/emails/status_change_rejected.txt index 3dae38c0d..b9c89be07 100644 --- a/src/registrar/templates/emails/status_change_rejected.txt +++ b/src/registrar/templates/emails/status_change_rejected.txt @@ -9,8 +9,7 @@ STATUS: Rejected ---------------------------------------------------------------- {% if application.rejection_reason != 'other' %} -REJECTION REASON{% endif %} -{% if application.rejection_reason == 'domain_purpose' %} +REJECTION REASON{% endif %}{% if application.rejection_reason == 'domain_purpose' %} Your domain request was rejected because the purpose you provided did not meet our requirements. You didn’t provide enough information about how you intend to use the domain. @@ -19,8 +18,7 @@ Learn more about: - Eligibility for a .gov domain - What you can and can’t do with .gov domains -If you have questions or comments, reply to this email. -{% elif application.rejection_reason == 'requestor' %} +If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'requestor' %} Your domain request was rejected because we don’t believe you’re eligible to request a .gov domain on behalf of {{ application.organization_name }}. You must be a government employee, or be working on behalf of a government organization, to request a .gov domain. @@ -28,8 +26,7 @@ working on behalf of a government organization, to request a .gov domain. DEMONSTRATE ELIGIBILITY If you can provide more information that demonstrates your eligibility, or you want to -discuss further, reply to this email. -{% elif application.rejection_reason == 'second_domain_reasoning' %} +discuss further, reply to this email.{% elif application.rejection_reason == 'second_domain_reasoning' %} Your domain request was rejected because {{ application.organization_name }} has a .gov domain. Our practice is to approve one domain per online service per government organization. We evaluate additional requests on a case-by-case basis. You did not provide sufficient @@ -38,11 +35,9 @@ justification for an additional domain. Read more about our practice of approving one domain per online service . -If you have questions or comments, reply to this email. -{% elif application.rejection_reason == 'contacts_or_organization_legitimacy' %} +If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'contacts_or_organization_legitimacy' %} Your domain request was rejected because we could not verify the organizational -contacts you provided. If you have questions or comments, reply to this email. -{% elif application.rejection_reason == 'organization_eligibility' %} +contacts you provided. If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'organization_eligibility' %} Your domain request was rejected because we determined that {{ application.organization_name }} is not eligible for a .gov domain. .Gov domains are only available to official U.S.-based government organizations. @@ -53,8 +48,7 @@ If you can provide documentation that demonstrates your eligibility, reply to th This can include links to (or copies of) your authorizing legislation, your founding charter or bylaws, or other similar documentation. Without this, we can’t approve a .gov domain for your organization. Learn more about eligibility for .gov domains -. -{% elif application.rejection_reason == 'naming_requirements' %} +.{% elif application.rejection_reason == 'naming_requirements' %} Your domain request was rejected because it does not meet our naming requirements. Domains should uniquely identify a government organization and be clear to the general public. Learn more about naming requirements for your type of organization @@ -63,8 +57,7 @@ general public. Learn more about naming requirements for your type of organizati YOU CAN SUBMIT A NEW REQUEST We encourage you to request a domain that meets our requirements. If you have -questions or want to discuss potential domain names, reply to this email. -{% elif application.rejection_reason == 'other' %} +questions or want to discuss potential domain names, reply to this email.{% elif application.rejection_reason == 'other' %} YOU CAN SUBMIT A NEW REQUEST If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request. diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 17833d689..ee1ab8b68 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -692,6 +692,56 @@ class MockEppLib(TestCase): ], ex_date=datetime.date(2023, 5, 25), ) + + mockDataInfoDomainSubdomain = fakedEppObject( + "fakePw", + cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], + hosts=["fake.meoward.gov"], + statuses=[ + common.Status(state="serverTransferProhibited", description="", lang="en"), + common.Status(state="inactive", description="", lang="en"), + ], + ex_date=datetime.date(2023, 5, 25), + ) + + mockDataInfoDomainSubdomainAndIPAddress = fakedEppObject( + "fakePw", + cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], + hosts=["fake.meow.gov"], + statuses=[ + common.Status(state="serverTransferProhibited", description="", lang="en"), + common.Status(state="inactive", description="", lang="en"), + ], + ex_date=datetime.date(2023, 5, 25), + addrs=[common.Ip(addr="2.0.0.8")], + ) + + mockDataInfoDomainNotSubdomainNoIP = fakedEppObject( + "fakePw", + cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], + hosts=["fake.meow.com"], + statuses=[ + common.Status(state="serverTransferProhibited", description="", lang="en"), + common.Status(state="inactive", description="", lang="en"), + ], + ex_date=datetime.date(2023, 5, 25), + ) + + mockDataInfoDomainSubdomainNoIP = fakedEppObject( + "fakePw", + cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], + hosts=["fake.subdomainwoip.gov"], + statuses=[ + common.Status(state="serverTransferProhibited", description="", lang="en"), + common.Status(state="inactive", description="", lang="en"), + ], + ex_date=datetime.date(2023, 5, 25), + ) + mockDataExtensionDomain = fakedEppObject( "fakePw", cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), @@ -829,6 +879,24 @@ class MockEppLib(TestCase): addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")], ) + mockDataInfoHosts1IP = fakedEppObject( + "lastPw", + cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)), + addrs=[common.Ip(addr="2.0.0.8")], + ) + + mockDataInfoHostsNotSubdomainNoIP = fakedEppObject( + "lastPw", + cr_date=make_aware(datetime.datetime(2023, 8, 26, 19, 45, 35)), + addrs=[], + ) + + mockDataInfoHostsSubdomainNoIP = fakedEppObject( + "lastPw", + cr_date=make_aware(datetime.datetime(2023, 8, 27, 19, 45, 35)), + addrs=[], + ) + mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35))) addDsData1 = { "keyTag": 1234, @@ -995,6 +1063,8 @@ class MockEppLib(TestCase): return self.mockDeleteDomainCommands(_request, cleaned) case commands.RenewDomain: return self.mockRenewDomainCommand(_request, cleaned) + case commands.InfoHost: + return self.mockInfoHostCommmands(_request, cleaned) case _: return MagicMock(res_data=[self.mockDataInfoHosts]) @@ -1009,6 +1079,25 @@ class MockEppLib(TestCase): code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, ) + def mockInfoHostCommmands(self, _request, cleaned): + request_name = getattr(_request, "name", None) + + # Define a dictionary to map request names to data and extension values + request_mappings = { + "fake.meow.gov": (self.mockDataInfoHosts1IP, None), # is subdomain and has ip + "fake.meow.com": (self.mockDataInfoHostsNotSubdomainNoIP, None), # not subdomain w no ip + "fake.subdomainwoip.gov": (self.mockDataInfoHostsSubdomainNoIP, None), # subdomain w no ip + } + + # Retrieve the corresponding values from the dictionary + default_mapping = (self.mockDataInfoHosts, None) + res_data, extensions = request_mappings.get(request_name, default_mapping) + + return MagicMock( + res_data=[res_data], + extensions=[extensions] if extensions is not None else [], + ) + def mockUpdateHostCommands(self, _request, cleaned): test_ws_ip = common.Ip(addr="1.1. 1.1") addrs_submitted = getattr(_request, "addrs", []) @@ -1097,6 +1186,10 @@ class MockEppLib(TestCase): "adomain2.gov": (self.InfoDomainWithVerisignSecurityContact, None), "defaulttechnical.gov": (self.InfoDomainWithDefaultTechnicalContact, None), "justnameserver.com": (self.justNameserver, None), + "meoward.gov": (self.mockDataInfoDomainSubdomain, None), + "meow.gov": (self.mockDataInfoDomainSubdomainAndIPAddress, None), + "fakemeow.gov": (self.mockDataInfoDomainNotSubdomainNoIP, None), + "subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None), } # Retrieve the corresponding values from the dictionary diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 341a8fac9..f88721649 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1,5 +1,5 @@ from datetime import date -from django.test import TestCase, RequestFactory, Client +from django.test import TestCase, RequestFactory, Client, override_settings from django.contrib.admin.sites import AdminSite from contextlib import ExitStack from django_webtest import WebTest # type: ignore @@ -609,7 +609,9 @@ class TestDomainApplicationAdmin(MockEppLib): # Use the model admin's save_model method self.admin.save_model(request, application, form=None, change=True) - def assert_email_is_accurate(self, expected_string, email_index, email_address): + def assert_email_is_accurate( + self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address="" + ): """Helper method for the email test cases. email_index is the index of the email in mock_client.""" @@ -629,12 +631,26 @@ class TestDomainApplicationAdmin(MockEppLib): self.assertEqual(to_email, email_address) self.assertIn(expected_string, email_body) + if test_that_no_bcc: + _ = "" + with self.assertRaises(KeyError): + with less_console_noise(): + _ = kwargs["Destination"]["BccAddresses"][0] + self.assertEqual(_, "") + + if bcc_email_address: + bcc_email = kwargs["Destination"]["BccAddresses"][0] + self.assertEqual(bcc_email, bcc_email_address) + def test_save_model_sends_submitted_email(self): """When transitioning to submitted from started or withdrawn on a domain request, an email is sent out. When transitioning to submitted from dns needed or in review on a domain request, - no email is sent out.""" + no email is sent out. + + Also test that the default email set in settings is NOT BCCd on non-prod whenever + an email does go out.""" with less_console_noise(): # Ensure there is no user with this email @@ -646,7 +662,64 @@ class TestDomainApplicationAdmin(MockEppLib): # Test Submitted Status from started self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL) + self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, True) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Test Withdrawn Status + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.WITHDRAWN) + self.assert_email_is_accurate( + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL, True + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # Test Submitted Status Again (from withdrawn) + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to IN_REVIEW + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.IN_REVIEW) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test Submitted Status Again from in IN_REVIEW, no new email should be sent + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to IN_REVIEW + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.IN_REVIEW) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to ACTION_NEEDED + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.ACTION_NEEDED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + @override_settings(IS_PRODUCTION=True) + def test_save_model_sends_submitted_email_with_bcc_on_prod(self): + """When transitioning to submitted from started or withdrawn on a domain request, + an email is sent out. + + When transitioning to submitted from dns needed or in review on a domain request, + no email is sent out. + + Also test that the default email set in settings IS BCCd on prod whenever + an email does go out.""" + + with less_console_noise(): + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + BCC_EMAIL = settings.DEFAULT_FROM_EMAIL + + # Create a sample application + application = completed_application() + + # Test Submitted Status from started + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) + self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Test Withdrawn Status @@ -658,6 +731,7 @@ class TestDomainApplicationAdmin(MockEppLib): # Test Submitted Status Again (from withdrawn) self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) + self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) # Move it to IN_REVIEW diff --git a/src/registrar/tests/test_environment_variables_effects.py b/src/registrar/tests/test_environment_variables_effects.py deleted file mode 100644 index 3a838c2a2..000000000 --- a/src/registrar/tests/test_environment_variables_effects.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.test import Client, TestCase, override_settings -from django.contrib.auth import get_user_model - - -class MyTestCase(TestCase): - def setUp(self): - self.client = Client() - username = "test_user" - first_name = "First" - last_name = "Last" - email = "info@example.com" - self.user = get_user_model().objects.create( - username=username, first_name=first_name, last_name=last_name, email=email - ) - self.client.force_login(self.user) - - def tearDown(self): - super().tearDown() - self.user.delete() - - @override_settings(IS_PRODUCTION=True) - def test_production_environment(self): - """No banner on prod.""" - home_page = self.client.get("/") - self.assertNotContains(home_page, "You are on a test site.") - - @override_settings(IS_PRODUCTION=False) - def test_non_production_environment(self): - """Banner on non-prod.""" - home_page = self.client.get("/") - self.assertContains(home_page, "You are on a test site.") diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 1c4d2521e..647d0ff47 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -20,7 +20,7 @@ from registrar.models.user import User from registrar.utility.errors import ActionNotAllowed, NameserverError from registrar.models.utility.contact_error import ContactError, ContactErrorCodes - +from registrar.utility import errors from django_fsm import TransitionNotAllowed # type: ignore from epplibwrapper import ( @@ -96,7 +96,7 @@ class TestDomainCache(MockEppLib): self.mockedSendFunction.assert_has_calls(expectedCalls) - def test_cache_nested_elements(self): + def test_cache_nested_elements_not_subdomain(self): """Cache works correctly with the nested objects cache and hosts""" with less_console_noise(): domain, _ = Domain.objects.get_or_create(name="igorville.gov") @@ -113,7 +113,7 @@ class TestDomainCache(MockEppLib): } expectedHostsDict = { "name": self.mockDataInfoDomain.hosts[0], - "addrs": [item.addr for item in self.mockDataInfoHosts.addrs], + "addrs": [], # should return empty bc fake.host.com is not a subdomain of igorville.gov "cr_date": self.mockDataInfoHosts.cr_date, } @@ -138,6 +138,59 @@ class TestDomainCache(MockEppLib): # invalidate cache domain._cache = {} + # get host + domain._get_property("hosts") + # Should return empty bc fake.host.com is not a subdomain of igorville.gov + self.assertEqual(domain._cache["hosts"], [expectedHostsDict]) + + # get contacts + domain._get_property("contacts") + self.assertEqual(domain._cache["hosts"], [expectedHostsDict]) + self.assertEqual(domain._cache["contacts"], expectedContactsDict) + + def test_cache_nested_elements_is_subdomain(self): + """Cache works correctly with the nested objects cache and hosts""" + with less_console_noise(): + domain, _ = Domain.objects.get_or_create(name="meoward.gov") + + # The contact list will initially contain objects of type 'DomainContact' + # this is then transformed into PublicContact, and cache should NOT + # hold onto the DomainContact object + expectedUnfurledContactsList = [ + common.DomainContact(contact="123", type="security"), + ] + expectedContactsDict = { + PublicContact.ContactTypeChoices.ADMINISTRATIVE: None, + PublicContact.ContactTypeChoices.SECURITY: "123", + PublicContact.ContactTypeChoices.TECHNICAL: None, + } + expectedHostsDict = { + "name": self.mockDataInfoDomainSubdomain.hosts[0], + "addrs": [item.addr for item in self.mockDataInfoHosts.addrs], + "cr_date": self.mockDataInfoHosts.cr_date, + } + + # this can be changed when the getter for contacts is implemented + domain._get_property("contacts") + + # check domain info is still correct and not overridden + self.assertEqual(domain._cache["auth_info"], self.mockDataInfoDomainSubdomain.auth_info) + self.assertEqual(domain._cache["cr_date"], self.mockDataInfoDomainSubdomain.cr_date) + + # check contacts + self.assertEqual(domain._cache["_contacts"], self.mockDataInfoDomainSubdomain.contacts) + # The contact list should not contain what is sent by the registry by default, + # as _fetch_cache will transform the type to PublicContact + self.assertNotEqual(domain._cache["contacts"], expectedUnfurledContactsList) + self.assertEqual(domain._cache["contacts"], expectedContactsDict) + + # get and check hosts is set correctly + domain._get_property("hosts") + self.assertEqual(domain._cache["hosts"], [expectedHostsDict]) + self.assertEqual(domain._cache["contacts"], expectedContactsDict) + # invalidate cache + domain._cache = {} + # get host domain._get_property("hosts") self.assertEqual(domain._cache["hosts"], [expectedHostsDict]) @@ -502,16 +555,26 @@ class TestDomainAvailable(MockEppLib): self.assertFalse(available) patcher.stop() - def test_domain_available_with_value_error(self): + def test_domain_available_with_invalid_error(self): """ Scenario: Testing whether an invalid domain is available - Should throw ValueError + Should throw InvalidDomainError - Validate ValueError is raised + Validate InvalidDomainError is raised """ - with self.assertRaises(ValueError): + with self.assertRaises(errors.InvalidDomainError): Domain.available("invalid-string") + def test_domain_available_with_empty_string(self): + """ + Scenario: Testing whether an empty string domain name is available + Should throw InvalidDomainError + + Validate InvalidDomainError is raised + """ + with self.assertRaises(errors.InvalidDomainError): + Domain.available("") + def test_domain_available_unsuccessful(self): """ Scenario: Testing behavior when registry raises a RegistryError @@ -1572,31 +1635,100 @@ class TestRegistrantNameservers(MockEppLib): self.assertEqual(nameservers[0][1], ["1.1.1.1"]) patcher.stop() - def test_nameservers_stored_on_fetch_cache(self): + def test_nameservers_stored_on_fetch_cache_a_subdomain_with_ip(self): + """ + #1: Nameserver is a subdomain, and has an IP address + referenced by mockDataInfoDomainSubdomainAndIPAddress + """ + with less_console_noise(): + # make the domain + domain, _ = Domain.objects.get_or_create(name="meow.gov", state=Domain.State.READY) + + # mock the get_or_create methods for Host and HostIP + with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object( + HostIP.objects, "get_or_create" + ) as mock_host_ip_get_or_create: + mock_host_get_or_create.return_value = (Host(domain=domain), True) + mock_host_ip_get_or_create.return_value = (HostIP(), True) + + # force fetch_cache to be called, which will return above documented mocked hosts + domain.nameservers + + mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.meow.gov") + # Retrieve the mocked_host from the return value of the mock + actual_mocked_host, _ = mock_host_get_or_create.return_value + mock_host_ip_get_or_create.assert_called_with(address="2.0.0.8", host=actual_mocked_host) + self.assertEqual(mock_host_ip_get_or_create.call_count, 1) + + def test_nameservers_stored_on_fetch_cache_a_subdomain_without_ip(self): + """ + #2: Nameserver is a subdomain, but doesn't have an IP address associated + referenced by mockDataInfoDomainSubdomainNoIP + """ + with less_console_noise(): + # make the domain + domain, _ = Domain.objects.get_or_create(name="subdomainwoip.gov", state=Domain.State.READY) + + # mock the get_or_create methods for Host and HostIP + with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object( + HostIP.objects, "get_or_create" + ) as mock_host_ip_get_or_create: + mock_host_get_or_create.return_value = (Host(domain=domain), True) + mock_host_ip_get_or_create.return_value = (HostIP(), True) + + # force fetch_cache to be called, which will return above documented mocked hosts + domain.nameservers + + mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.subdomainwoip.gov") + mock_host_ip_get_or_create.assert_not_called() + self.assertEqual(mock_host_ip_get_or_create.call_count, 0) + + def test_nameservers_stored_on_fetch_cache_not_subdomain_with_ip(self): """ Scenario: Nameservers are stored in db when they are retrieved from fetch_cache. Verify the success of this by asserting get_or_create calls to db. The mocked data for the EPP calls returns a host name of 'fake.host.com' from InfoDomain and an array of 2 IPs: 1.2.3.4 and 2.3.4.5 from InfoHost + + #3: Nameserver is not a subdomain, but it does have an IP address returned + due to how we set up our defaults """ with less_console_noise(): domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) - # mock the get_or_create methods for Host and HostIP + with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object( HostIP.objects, "get_or_create" ) as mock_host_ip_get_or_create: - # Set the return value for the mocks - mock_host_get_or_create.return_value = (Host(), True) + mock_host_get_or_create.return_value = (Host(domain=domain), True) mock_host_ip_get_or_create.return_value = (HostIP(), True) + # force fetch_cache to be called, which will return above documented mocked hosts domain.nameservers - # assert that the mocks are called + mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.host.com") - # Retrieve the mocked_host from the return value of the mock - actual_mocked_host, _ = mock_host_get_or_create.return_value - mock_host_ip_get_or_create.assert_called_with(address="2.3.4.5", host=actual_mocked_host) - self.assertEqual(mock_host_ip_get_or_create.call_count, 2) + mock_host_ip_get_or_create.assert_not_called() + self.assertEqual(mock_host_ip_get_or_create.call_count, 0) + + def test_nameservers_stored_on_fetch_cache_not_subdomain_without_ip(self): + """ + #4: Nameserver is not a subdomain and doesn't have an associated IP address + referenced by self.mockDataInfoDomainNotSubdomainNoIP + """ + with less_console_noise(): + domain, _ = Domain.objects.get_or_create(name="fakemeow.gov", state=Domain.State.READY) + + with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object( + HostIP.objects, "get_or_create" + ) as mock_host_ip_get_or_create: + mock_host_get_or_create.return_value = (Host(domain=domain), True) + mock_host_ip_get_or_create.return_value = (HostIP(), True) + + # force fetch_cache to be called, which will return above documented mocked hosts + domain.nameservers + mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.meow.com") + mock_host_ip_get_or_create.assert_not_called() + self.assertEqual(mock_host_ip_get_or_create.call_count, 0) @skip("not implemented yet") def test_update_is_unsuccessful(self): diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 891c254c5..8f9a8e4fc 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1,4 +1,4 @@ -from django.test import Client, TestCase +from django.test import Client, TestCase, override_settings from django.contrib.auth import get_user_model from .common import MockEppLib # type: ignore @@ -50,3 +50,32 @@ class TestWithUser(MockEppLib): DomainApplication.objects.all().delete() DomainInformation.objects.all().delete() self.user.delete() + + +class TestEnvironmentVariablesEffects(TestCase): + def setUp(self): + self.client = Client() + username = "test_user" + first_name = "First" + last_name = "Last" + email = "info@example.com" + self.user = get_user_model().objects.create( + username=username, first_name=first_name, last_name=last_name, email=email + ) + self.client.force_login(self.user) + + def tearDown(self): + super().tearDown() + self.user.delete() + + @override_settings(IS_PRODUCTION=True) + def test_production_environment(self): + """No banner on prod.""" + home_page = self.client.get("/") + self.assertNotContains(home_page, "You are on a test site.") + + @override_settings(IS_PRODUCTION=False) + def test_non_production_environment(self): + """Banner on non-prod.""" + home_page = self.client.get("/") + self.assertContains(home_page, "You are on a test site.") diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 2c8e796ac..59b5faaa9 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -821,14 +821,15 @@ class TestDomainNameservers(TestDomainOverview): nameserver1 = "ns1.igorville.gov" nameserver2 = "ns2.igorville.gov" valid_ip = "1.1. 1.1" - # initial nameservers page has one server with two ips + valid_ip_2 = "2.2. 2.2" # have to throw an error in order to test that the whitespace has been stripped from ip nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) # attempt to submit the form without one host and an ip with whitespace nameservers_page.form["form-0-server"] = nameserver1 - nameservers_page.form["form-1-ip"] = valid_ip + nameservers_page.form["form-0-ip"] = valid_ip + nameservers_page.form["form-1-ip"] = valid_ip_2 nameservers_page.form["form-1-server"] = nameserver2 with less_console_noise(): # swallow log warning message result = nameservers_page.form.submit() @@ -937,15 +938,14 @@ class TestDomainNameservers(TestDomainOverview): nameserver1 = "ns1.igorville.gov" nameserver2 = "ns2.igorville.gov" valid_ip = "127.0.0.1" - # initial nameservers page has one server with two ips + valid_ip_2 = "128.0.0.2" nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - # attempt to submit the form without two hosts, both subdomains, - # only one has ips nameservers_page.form["form-0-server"] = nameserver1 + nameservers_page.form["form-0-ip"] = valid_ip nameservers_page.form["form-1-server"] = nameserver2 - nameservers_page.form["form-1-ip"] = valid_ip + nameservers_page.form["form-1-ip"] = valid_ip_2 with less_console_noise(): # swallow log warning message result = nameservers_page.form.submit() # form submission was a successful post, response should be a 302 diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 461637f23..232453ad5 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -15,7 +15,7 @@ class EmailSendingError(RuntimeError): pass -def send_templated_email(template_name: str, subject_template_name: str, to_address: str, context={}): +def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}): """Send an email built from a template to one email address. template_name and subject_template_name are relative to the same template @@ -40,10 +40,14 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr except Exception as exc: raise EmailSendingError("Could not access the SES client.") from exc + destination = {"ToAddresses": [to_address]} + if bcc_address: + destination["BccAddresses"] = [bcc_address] + try: ses_client.send_email( FromEmailAddress=settings.DEFAULT_FROM_EMAIL, - Destination={"ToAddresses": [to_address]}, + Destination=destination, Content={ "Simple": { "Subject": {"Data": subject}, diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index f5517da25..72eb65f1e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -14,6 +14,7 @@ from django.http import HttpResponseRedirect from django.shortcuts import redirect from django.urls import reverse from django.views.generic.edit import FormMixin +from django.conf import settings from registrar.models import ( Domain, @@ -707,7 +708,7 @@ class DomainAddUserView(DomainFormBaseView): adding a success message to the view if the email sending succeeds""" # Set a default email address to send to for staff - requestor_email = "help@get.gov" + requestor_email = settings.DEFAULT_FROM_EMAIL # Check if the email requestor has a valid email address if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":