Repository: cisagov/manage.get.gov

Issue:
Unable to edit any nameservers when using a subordinate host for another .gov
### Current Behavior

Currently, you can **add**  a nameserver that is a subordinate host to another .gov. For example, you can add ns.example.gov as a nameserver to mydomain.gov. After adding this host, on save, you will receive the hostname and corresponding ip addresses. Then if you try to edit ANY nameserver an error is thrown on that subordinate host saying "Name server address does not match domain name"

### Expected Behavior

It shouldn't throw an error on the nameserver in question and the user should be able to make changes to other nameservers

### Steps to Reproduce

1. add a nameserver that already exists in our zone and is a subordinate host to another domain. 
2. click save and notice IP addresses appear
3. edit/remove or add another nameserver and click save
4. see the error "Name server address does not match domain name"


### Environment

_No response_

### Additional Context

Note this error is occuring purely because the nameserver in question has IP addresses and is not a subdomain of the current domain (aka it has IPs and it belongs to another .gov). 


Implementation:
Do not edit the UX or allow IP addresses all the time. Instead, just make sure the user never sees the IP addresses to begin with. Also on createHost you don't want to send the ip addresses for a hostname that belongs to another .gov. By removing it on the front end, then when it is sent to the backend there is no worry of a create being called with ips for another .gov
### Issue Links

relates to: #1662


Discussion:
[Slack convo](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1707418410439089) for reference


Resolving patch:
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -427,7 +427,6 @@ def checkHostIPCombo(cls, name: str, nameserver: str, ip: list[str]):
             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 != []:
@@ -1778,6 +1777,10 @@ def _update_hosts_and_ips_in_db(self, cleaned):
         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:


Test patch:
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -691,6 +691,56 @@ def dummyInfoContactResultData(
         ],
         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)),
@@ -828,6 +878,24 @@ def dummyInfoContactResultData(
         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,
@@ -989,6 +1057,8 @@ def mockSend(self, _request, cleaned):
                 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])
 
@@ -1003,6 +1073,25 @@ def mockCreateHostCommands(self, _request, cleaned):
                 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", [])
@@ -1083,6 +1172,10 @@ def mockInfoDomainCommands(self, _request, cleaned):
             "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_models_domain.py b/src/registrar/tests/test_models_domain.py
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -96,7 +96,7 @@ def test_cache_used_when_avail(self):
 
             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 @@ def test_cache_nested_elements(self):
             }
             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 @@ def test_cache_nested_elements(self):
             # 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])
@@ -1572,31 +1625,100 @@ def side_effect(_request, cleaned):
             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_domain.py b/src/registrar/tests/test_views_domain.py
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -821,14 +821,15 @@ def test_domain_nameservers_form_submit_whitespace(self):
         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 @@ def test_domain_nameservers_form_submits_successfully(self):
         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