Repository: cisagov/manage.get.gov

Issue:
Create, delete, and update nameserver settings at registry
### Issue Description

Implement, retrieve and set Name Servers within the domain.py file. When items are deleted, ensure delete is performed before the add command.
Implement tests as per #493 
NOTE: there are stubbed _update_or_create_host() and stubbed _delete_hosts functions that can be used. Delete if you choose not to use them

### Acceptance Criteria

1. Setting nameservers should change the domain to READY if there are between 2 and 13 (inclusive) nameservers. 
* Just need to add tests for this (add test for less than 2, more than 14 nameservers, and one that in between 2 and 13)

2. DELETE: Implement removing nameservers + _change the state_ (READY -> DNS NEEDED) if user has less then 2 nameservers
* Fix tests for these
* (Compare to list? And send delete command?)

3. UPDATE: If a nameserver has an IP address -- we need to update nameserver according (others _can_ has an IP address, dotgov _has_ to have a IP address)
* Can use an update command  to change the IP address
* If already has IP address and updates the IP address --> check if already have nameserver, and then go down update route

4.  Only allow changes if we are in READY or DNS NEEDED state
* Q: If state of nameserver is in Clienthold, should we even allow the user to edit the nameservers? (Assuming no)

### Additional Context (optional)
This is partially implemented but not tested
_No response_

### Issue Links

Relates to: #493 
Related to #562 


Discussion:

Resolving patch:
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -20,7 +20,8 @@ class DomainNameserverForm(forms.Form):
 
     """Form for changing nameservers."""
 
-    server = forms.CharField(label="Name server")
+    server = forms.CharField(label="Name server", strip=True)
+    # when adding IPs to this form ensure they are stripped as well
 
 
 NameserverFormset = formset_factory(
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
@@ -1,11 +1,13 @@
 from itertools import zip_longest
 import logging
+import ipaddress
+import re
 from datetime import date
 from string import digits
 from django_fsm import FSMField, transition, TransitionNotAllowed  # type: ignore
 
 from django.db import models
-
+from typing import Any
 from epplibwrapper import (
     CLIENT as registry,
     commands,
@@ -15,8 +17,16 @@
     RegistryError,
     ErrorCode,
 )
+
+from registrar.utility.errors import (
+    ActionNotAllowed,
+    NameserverError,
+    NameserverErrorCodes as nsErrorCodes,
+)
+
 from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
 
+
 from .utility.domain_field import DomainField
 from .utility.domain_helper import DomainHelper
 from .utility.time_stamped_model import TimeStampedModel
@@ -218,13 +228,13 @@ def password(self) -> str:
         raise NotImplementedError()
 
     @Cache
-    def nameservers(self) -> list[tuple[str]]:
+    def nameservers(self) -> list[tuple[str, list]]:
         """
         Get or set a complete list of nameservers for this domain.
 
         Hosts are provided as a list of tuples, e.g.
 
-            [("ns1.example.com",), ("ns1.example.gov", "0.0.0.0")]
+            [("ns1.example.com",), ("ns1.example.gov", ["0.0.0.0"])]
 
         Subordinate hosts (something.your-domain.gov) MUST have IP addresses,
         while non-subordinate hosts MUST NOT.
@@ -232,39 +242,21 @@ def nameservers(self) -> list[tuple[str]]:
         try:
             hosts = self._get_property("hosts")
         except Exception as err:
-            # Don't throw error as this is normal for a new domain
-            # TODO - 433 error handling ticket should address this
+            # Do not raise error when missing nameservers
+            # this is a standard occurence when a domain
+            # is first created
             logger.info("Domain is missing nameservers %s" % err)
             return []
 
+        # TODO-687 fix this return value
         hostList = []
         for host in hosts:
-            # TODO - this should actually have a second tuple value with the ip address
-            # ignored because uncertain if we will even have a way to display mult.
-            #  and adresses can be a list of mult address
-            hostList.append((host["name"],))
+            hostList.append((host["name"], host["addrs"]))
 
         return hostList
 
-    def _check_host(self, hostnames: list[str]):
-        """check if host is available, True if available
-        returns boolean"""
-        checkCommand = commands.CheckHost(hostnames)
-        try:
-            response = registry.send(checkCommand, cleaned=True)
-            return response.res_data[0].avail
-        except RegistryError as err:
-            logger.warning(
-                "Couldn't check hosts %s. Errorcode was %s, error was %s",
-                hostnames,
-                err.code,
-                err,
-            )
-            return False
-
     def _create_host(self, host, addrs):
-        """Call _check_host first before using this function,
-        This creates the host object in the registry
+        """Creates the host object in the registry
         doesn't add the created host to the domain
         returns ErrorCode (int)"""
         logger.info("Creating host")
@@ -282,6 +274,187 @@ def _create_host(self, host, addrs):
             logger.error("Error _create_host, code was %s error was %s" % (e.code, e))
             return e.code
 
+    def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]):
+        """converts a list of hosts into a dictionary
+        Args:
+            list[tuple[str, list]]: such as [("123",["1","2","3"])]
+            This is the list of hosts to convert
+
+        returns:
+            convertDict (dict(str,list))- such as{"123":["1","2","3"]}"""
+        newDict: dict[str, Any] = {}
+
+        for tup in listToConvert:
+            if len(tup) == 1:
+                newDict[tup[0]] = None
+            elif len(tup) == 2:
+                newDict[tup[0]] = tup[1]
+        return newDict
+
+    def isSubdomain(self, nameserver: str):
+        """Returns boolean if the domain name is found in the argument passed"""
+        subdomain_pattern = r"([\w-]+\.)*"
+        full_pattern = subdomain_pattern + self.name
+        regex = re.compile(full_pattern)
+        return bool(regex.match(nameserver))
+
+    def checkHostIPCombo(self, nameserver: str, ip: list[str]):
+        """Checks the parameters past for a valid combination
+        raises error if:
+            - nameserver is a subdomain but is missing ip
+            - nameserver is not a subdomain but has ip
+            - nameserver is a subdomain but an ip passed is invalid
+
+        Args:
+            hostname (str)- nameserver or subdomain
+            ip (list[str])-list of ip strings
+        Throws:
+            NameserverError (if exception hit)
+        Returns:
+            None"""
+        if self.isSubdomain(nameserver) and (ip is None or ip == []):
+            raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
+
+        elif not self.isSubdomain(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 != []:
+            for addr in ip:
+                if not self._valid_ip_addr(addr):
+                    raise NameserverError(
+                        code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
+                    )
+        return None
+
+    def _valid_ip_addr(self, ipToTest: str):
+        """returns boolean if valid ip address string
+        We currently only accept v4 or v6 ips
+        returns:
+            isValid (boolean)-True for valid ip address"""
+        try:
+            ip = ipaddress.ip_address(ipToTest)
+            return ip.version == 6 or ip.version == 4
+
+        except ValueError:
+            return False
+
+    def getNameserverChanges(
+        self, hosts: list[tuple[str, list]]
+    ) -> tuple[list, list, dict, dict]:
+        """
+        calls self.nameserver, it should pull from cache but may result
+        in an epp call
+        Args:
+            hosts: list[tuple[str, list]] such as [("123",["1","2","3"])]
+        Throws:
+            NameserverError (if exception hit)
+        Returns:
+            tuple[list, list, dict, dict]
+                These four tuple values as follows:
+                deleted_values: list[str]
+                updated_values: list[str]
+                new_values: dict(str,list)
+                prevHostDict: dict(str,list)"""
+
+        oldNameservers = self.nameservers
+
+        previousHostDict = self._convert_list_to_dict(oldNameservers)
+
+        newHostDict = self._convert_list_to_dict(hosts)
+        deleted_values = []
+        # TODO-currently a list of tuples, why not dict? for consistency
+        updated_values = []
+        new_values = {}
+
+        for prevHost in previousHostDict:
+            addrs = previousHostDict[prevHost]
+            # get deleted values-which are values in previous nameserver list
+            # but are not in the list of new host values
+            if prevHost not in newHostDict:
+                deleted_values.append(prevHost)
+            # if the host exists in both, check if the addresses changed
+            else:
+                # TODO - host is being updated when previous was None+new is empty list
+                # add check here
+                if newHostDict[prevHost] is not None and set(
+                    newHostDict[prevHost]
+                ) != set(addrs):
+                    self.checkHostIPCombo(nameserver=prevHost, ip=newHostDict[prevHost])
+                    updated_values.append((prevHost, newHostDict[prevHost]))
+
+        new_values = {
+            key: newHostDict.get(key)
+            for key in newHostDict
+            if key not in previousHostDict and key.strip() != ""
+        }
+
+        for nameserver, ip in new_values.items():
+            self.checkHostIPCombo(nameserver=nameserver, ip=ip)
+
+        return (deleted_values, updated_values, new_values, previousHostDict)
+
+    def _update_host_values(self, updated_values, oldNameservers):
+        for hostTuple in updated_values:
+            updated_response_code = self._update_host(
+                hostTuple[0], hostTuple[1], oldNameservers.get(hostTuple[0])
+            )
+            if updated_response_code not in [
+                ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
+                ErrorCode.OBJECT_EXISTS,
+            ]:
+                logger.warning(
+                    "Could not update host %s. Error code was: %s "
+                    % (hostTuple[0], updated_response_code)
+                )
+
+    def createNewHostList(self, new_values: dict):
+        """convert the dictionary of new values to a list of HostObjSet
+        for use in the UpdateDomain epp message
+        Args:
+            new_values: dict(str,list)- dict of {nameserver:ips} to add to domain
+        Returns:
+            tuple [list[epp.HostObjSet], int]
+            list[epp.HostObjSet]-epp object  for use in the UpdateDomain epp message
+                defaults to empty list
+            int-number of items being created default 0
+        """
+
+        hostStringList = []
+        for key, value in new_values.items():
+            createdCode = self._create_host(
+                host=key, addrs=value
+            )  # creates in registry
+            if (
+                createdCode == ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
+                or createdCode == ErrorCode.OBJECT_EXISTS
+            ):
+                hostStringList.append(key)
+        if hostStringList == []:
+            return [], 0
+
+        addToDomainObject = epp.HostObjSet(hosts=hostStringList)
+        return [addToDomainObject], len(hostStringList)
+
+    def createDeleteHostList(self, hostsToDelete: list[str]):
+        """
+        Args:
+            hostsToDelete (list[str])- list of nameserver/host names to remove
+        Returns:
+            tuple [list[epp.HostObjSet], int]
+            list[epp.HostObjSet]-epp object  for use in the UpdateDomain epp message
+                defaults to empty list
+            int-number of items being created default 0
+        """
+        deleteStrList = []
+        for nameserver in hostsToDelete:
+            deleteStrList.append(nameserver)
+        if deleteStrList == []:
+            return [], 0
+        deleteObj = epp.HostObjSet(hosts=hostsToDelete)
+
+        return [deleteObj], len(deleteStrList)
+
     @Cache
     def dnssecdata(self) -> extensions.DNSSECExtension:
         return self._get_property("dnssecdata")
@@ -304,54 +477,62 @@ def dnssecdata(self, _dnssecdata: extensions.DNSSECExtension):
             raise e
 
     @nameservers.setter  # type: ignore
-    def nameservers(self, hosts: list[tuple[str]]):
-        """host should be a tuple of type str, str,... where the elements are
+    def nameservers(self, hosts: list[tuple[str, list]]):
+        """Host should be a tuple of type str, str,... where the elements are
         Fully qualified host name, addresses associated with the host
-        example: [(ns1.okay.gov, 127.0.0.1, others ips)]"""
-        # TODO: ticket #848 finish this implementation
-        # must delete nameservers as well or update
-        # ip version checking may need to be added in a different ticket
+        example: [(ns1.okay.gov, [127.0.0.1, others ips])]"""
 
         if len(hosts) > 13:
-            raise ValueError(
-                "Too many hosts provided, you may not have more than 13 nameservers."
-            )
+            raise NameserverError(code=nsErrorCodes.TOO_MANY_HOSTS)
+
+        if self.state not in [self.State.DNS_NEEDED, self.State.READY]:
+            raise ActionNotAllowed("Nameservers can not be " "set in the current state")
+
         logger.info("Setting nameservers")
         logger.info(hosts)
-        for hostTuple in hosts:
-            host = hostTuple[0]
-            addrs = None
-            if len(hostTuple) > 1:
-                addrs = hostTuple[1:]
-            avail = self._check_host([host])
-            if avail:
-                createdCode = self._create_host(host=host, addrs=addrs)
-
-                # update the domain obj
-                if createdCode == ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
-                    # add host to domain
-                    request = commands.UpdateDomain(
-                        name=self.name, add=[epp.HostObjSet([host])]
-                    )
 
-                    try:
-                        registry.send(request, cleaned=True)
-                    except RegistryError as e:
-                        logger.error(
-                            "Error adding nameserver, code was %s error was %s"
-                            % (e.code, e)
-                        )
+        # get the changes made by user and old nameserver values
+        (
+            deleted_values,
+            updated_values,
+            new_values,
+            oldNameservers,
+        ) = self.getNameserverChanges(hosts=hosts)
+
+        _ = self._update_host_values(
+            updated_values, oldNameservers
+        )  # returns nothing, just need to be run and errors
+        addToDomainList, addToDomainCount = self.createNewHostList(new_values)
+        deleteHostList, deleteCount = self.createDeleteHostList(deleted_values)
+        responseCode = self.addAndRemoveHostsFromDomain(
+            hostsToAdd=addToDomainList, hostsToDelete=deleteHostList
+        )
 
-        try:
-            self.ready()
-            self.save()
-        except Exception as err:
-            logger.info(
-                "nameserver setter checked for create state "
-                "and it did not succeed. Error: %s" % err
-            )
-        # TODO - handle removed nameservers here will need to change the state
-        #   then go back to DNS_NEEDED
+        # if unable to update domain raise error and stop
+        if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
+            raise NameserverError(code=nsErrorCodes.UNABLE_TO_UPDATE_DOMAIN)
+
+        successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
+
+        self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
+        if successTotalNameservers < 2:
+            try:
+                self.dns_needed()
+                self.save()
+            except Exception as err:
+                logger.info(
+                    "nameserver setter checked for dns_needed state "
+                    "and it did not succeed. Warning: %s" % err
+                )
+        elif successTotalNameservers >= 2 and successTotalNameservers <= 13:
+            try:
+                self.ready()
+                self.save()
+            except Exception as err:
+                logger.info(
+                    "nameserver setter checked for create state "
+                    "and it did not succeed. Warning: %s" % err
+                )
 
     @Cache
     def statuses(self) -> list[str]:
@@ -963,7 +1144,7 @@ def _get_or_create_domain(self):
                 if e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
                     # avoid infinite loop
                     already_tried_to_create = True
-                    self.pendingCreate()
+                    self.dns_needed_from_unknown()
                     self.save()
                 else:
                     logger.error(e)
@@ -977,7 +1158,7 @@ def addRegistrant(self):
         return registrant.registry_id
 
     @transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED)
-    def pendingCreate(self):
+    def dns_needed_from_unknown(self):
         logger.info("Changing to dns_needed")
 
         registrantID = self.addRegistrant()
@@ -1053,26 +1234,54 @@ def deletedInEpp(self):
         else:
             self._invalidate_cache()
 
+    # def is_dns_needed(self):
+    #     """Commented out and kept in the codebase
+    #     as this call should be made, but adds
+    #     a lot of processing time
+    #     when EPP calling is made more efficient
+    #     this should be added back in
+
+    #     The goal is to double check that
+    #     the nameservers we set are in fact
+    #     on the registry
+    #     """
+    #     self._invalidate_cache()
+    #     nameserverList = self.nameservers
+    #     return len(nameserverList) < 2
+
+    # def dns_not_needed(self):
+    #     return not self.is_dns_needed()
+
     @transition(
         field="state",
         source=[State.DNS_NEEDED],
         target=State.READY,
+        # conditions=[dns_not_needed]
     )
     def ready(self):
         """Transition to the ready state
         domain should have nameservers and all contacts
         and now should be considered live on a domain
         """
-        # TODO - in nameservers tickets 848 and 562
-        #   check here if updates need to be made
-        # consider adding these checks as constraints
-        #  within the transistion itself
-        nameserverList = self.nameservers
         logger.info("Changing to ready state")
-        if len(nameserverList) < 2 or len(nameserverList) > 13:
-            raise ValueError("Not ready to become created, cannot transition yet")
         logger.info("able to transition to ready state")
 
+    @transition(
+        field="state",
+        source=[State.READY],
+        target=State.DNS_NEEDED,
+        # conditions=[is_dns_needed]
+    )
+    def dns_needed(self):
+        """Transition to the DNS_NEEDED state
+        domain should NOT have nameservers but
+        SHOULD have all contacts
+        Going to check nameservers and will
+        result in an EPP call
+        """
+        logger.info("Changing to DNS_NEEDED state")
+        logger.info("able to transition to DNS_NEEDED state")
+
     def _disclose_fields(self, contact: PublicContact):
         """creates a disclose object that can be added to a contact Create using
         .disclose= <this function> on the command before sending.
@@ -1198,6 +1407,10 @@ def _get_or_create_contact(self, contact: PublicContact):
 
                 raise e
 
+    def is_ipv6(self, ip: str):
+        ip_addr = ipaddress.ip_address(ip)
+        return ip_addr.version == 6
+
     def _fetch_hosts(self, host_data):
         """Fetch host info."""
         hosts = []
@@ -1215,11 +1428,131 @@ def _fetch_hosts(self, host_data):
             hosts.append({k: v for k, v in host.items() if v is not ...})
         return hosts
 
-    def _update_or_create_host(self, host):
-        raise NotImplementedError()
+    def _convert_ips(self, ip_list: list[str]):
+        """Convert Ips to a list of epp.Ip objects
+        use when sending update host command.
+        if there are no ips an empty list will be returned
 
-    def _delete_host(self, host):
-        raise NotImplementedError()
+        Args:
+            ip_list (list[str]): the new list of ips, may be empty
+        Returns:
+            edited_ip_list (list[epp.Ip]): list of epp.ip objects ready to
+            be sent to the registry
+        """
+        edited_ip_list = []
+        if ip_list is None:
+            return []
+
+        for ip_addr in ip_list:
+            if self.is_ipv6(ip_addr):
+                edited_ip_list.append(epp.Ip(addr=ip_addr, ip="v6"))
+            else:  # default ip addr is v4
+                edited_ip_list.append(epp.Ip(addr=ip_addr))
+
+        return edited_ip_list
+
+    def _update_host(self, nameserver: str, ip_list: list[str], old_ip_list: list[str]):
+        """Update an existing host object in EPP. Sends the update host command
+        can result in a RegistryError
+        Args:
+            nameserver (str): nameserver or subdomain
+            ip_list (list[str]): the new list of ips, may be empty
+            old_ip_list  (list[str]): the old ip list, may also be empty
+
+        Returns:
+            errorCode (int): one of ErrorCode enum type values
+
+        """
+        try:
+            if (
+                ip_list is None
+                or len(ip_list) == 0
+                and isinstance(old_ip_list, list)
+                and len(old_ip_list) != 0
+            ):
+                return ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
+
+            added_ip_list = set(ip_list).difference(old_ip_list)
+            removed_ip_list = set(old_ip_list).difference(ip_list)
+
+            request = commands.UpdateHost(
+                name=nameserver,
+                add=self._convert_ips(list(added_ip_list)),
+                rem=self._convert_ips(list(removed_ip_list)),
+            )
+            response = registry.send(request, cleaned=True)
+            logger.info("_update_host()-> sending req as %s" % request)
+            return response.code
+        except RegistryError as e:
+            logger.error("Error _update_host, code was %s error was %s" % (e.code, e))
+            return e.code
+
+    def addAndRemoveHostsFromDomain(
+        self, hostsToAdd: list[str], hostsToDelete: list[str]
+    ):
+        """sends an UpdateDomain message to the registry with the hosts provided
+        Args:
+            hostsToDelete (list[epp.HostObjSet])- list of host objects to delete
+            hostsToAdd (list[epp.HostObjSet])- list of host objects to add
+        Returns:
+            response code (int)- RegistryErrorCode integer value
+            defaults to return COMMAND_COMPLETED_SUCCESSFULLY
+            if there is nothing to add or delete
+        """
+
+        if hostsToAdd == [] and hostsToDelete == []:
+            return ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
+
+        try:
+            updateReq = commands.UpdateDomain(
+                name=self.name, rem=hostsToDelete, add=hostsToAdd
+            )
+
+            logger.info(
+                "addAndRemoveHostsFromDomain()-> sending update domain req as %s"
+                % updateReq
+            )
+            response = registry.send(updateReq, cleaned=True)
+
+            return response.code
+        except RegistryError as e:
+            logger.error(
+                "Error addAndRemoveHostsFromDomain, code was %s error was %s"
+                % (e.code, e)
+            )
+            return e.code
+
+    def _delete_hosts_if_not_used(self, hostsToDelete: list[str]):
+        """delete the host object in registry,
+        will only delete the host object, if it's not being used by another domain
+        Performs just the DeleteHost epp call
+        Supresses regstry error, as registry can disallow delete for various reasons
+        Args:
+            hostsToDelete (list[str])- list of nameserver/host names to remove
+        Returns:
+            None
+
+        """
+        try:
+            for nameserver in hostsToDelete:
+                deleteHostReq = commands.DeleteHost(name=nameserver)
+                registry.send(deleteHostReq, cleaned=True)
+                logger.info(
+                    "_delete_hosts_if_not_used()-> sending delete host req as %s"
+                    % deleteHostReq
+                )
+
+        except RegistryError as e:
+            if e.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION:
+                logger.info(
+                    "Did not remove host %s because it is in use on another domain."
+                    % nameserver
+                )
+            else:
+                logger.error(
+                    "Error _delete_hosts_if_not_used, code was %s error was %s"
+                    % (e.code, e)
+                )
 
     def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
         """Contact registry for info about a domain."""
diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py
--- a/src/registrar/utility/errors.py
+++ b/src/registrar/utility/errors.py
@@ -1,3 +1,6 @@
+from enum import IntEnum
+
+
 class BlankValueError(ValueError):
     pass
 
@@ -8,3 +11,65 @@ class ExtraDotsError(ValueError):
 
 class DomainUnavailableError(ValueError):
     pass
+
+
+class ActionNotAllowed(Exception):
+    """User accessed an action that is not
+    allowed by the current state"""
+
+    pass
+
+
+class NameserverErrorCodes(IntEnum):
+    """Used in the NameserverError class for
+    error mapping.
+    Overview of nameserver error codes:
+        - 1 MISSING_IP  ip address is missing for a nameserver
+        - 2 GLUE_RECORD_NOT_ALLOWED a host has a nameserver
+                                    value but is not a subdomain
+        - 3 INVALID_IP  invalid ip address format or invalid version
+        - 4 TOO_MANY_HOSTS  more than the max allowed host values
+    """
+
+    MISSING_IP = 1
+    GLUE_RECORD_NOT_ALLOWED = 2
+    INVALID_IP = 3
+    TOO_MANY_HOSTS = 4
+    UNABLE_TO_UPDATE_DOMAIN = 5
+
+
+class NameserverError(Exception):
+    """
+    NameserverError class used to raise exceptions on
+    the nameserver getter
+    """
+
+    _error_mapping = {
+        NameserverErrorCodes.MISSING_IP: "Nameserver {} needs to have an "
+        "IP address because it is a subdomain",
+        NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED: "Nameserver {} cannot be linked "
+        "because it is not a subdomain",
+        NameserverErrorCodes.INVALID_IP: "Nameserver {} has an invalid IP address: {}",
+        NameserverErrorCodes.TOO_MANY_HOSTS: (
+            "Too many hosts provided, you may not have more than 13 nameservers."
+        ),
+        NameserverErrorCodes.UNABLE_TO_UPDATE_DOMAIN: (
+            "Unable to update domain, changes were not applied."
+            "Check logs as a Registry Error is the likely cause"
+        ),
+    }
+
+    def __init__(self, *args, code=None, nameserver=None, ip=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.code = code
+        if self.code in self._error_mapping:
+            self.message = self._error_mapping.get(self.code)
+            if nameserver is not None and ip is not None:
+                self.message = self.message.format(str(nameserver), str(ip))
+            elif nameserver is not None:
+                self.message = self.message.format(str(nameserver))
+            elif ip is not None:
+                self.message = self.message.format(str(ip))
+
+    def __str__(self):
+        return f"{self.message}"


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
@@ -571,6 +571,8 @@ def __init__(
             contacts=...,
             hosts=...,
             statuses=...,
+            avail=...,
+            addrs=...,
             registrant=...,
         ):
             self.auth_info = auth_info
@@ -578,6 +580,8 @@ def __init__(
             self.contacts = contacts
             self.hosts = hosts
             self.statuses = statuses
+            self.avail = avail  # use for CheckDomain
+            self.addrs = addrs
             self.registrant = registrant
 
         def dummyInfoContactResultData(
@@ -682,22 +686,88 @@ def dummyInfoContactResultData(
         hosts=["fake.host.com"],
     )
 
+    infoDomainThreeHosts = fakedEppObject(
+        "my-nameserver.gov",
+        cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
+        contacts=[],
+        hosts=[
+            "ns1.my-nameserver-1.com",
+            "ns1.my-nameserver-2.com",
+            "ns1.cats-are-superior3.com",
+        ],
+    )
+    infoDomainNoHost = fakedEppObject(
+        "my-nameserver.gov",
+        cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
+        contacts=[],
+        hosts=[],
+    )
+
+    infoDomainTwoHosts = fakedEppObject(
+        "my-nameserver.gov",
+        cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
+        contacts=[],
+        hosts=["ns1.my-nameserver-1.com", "ns1.my-nameserver-2.com"],
+    )
+
     mockDataInfoHosts = fakedEppObject(
+        "lastPw",
+        cr_date=datetime.datetime(2023, 8, 25, 19, 45, 35),
+        addrs=["1.2.3.4", "2.3.4.5"],
+    )
+
+    mockDataHostChange = fakedEppObject(
         "lastPw", cr_date=datetime.datetime(2023, 8, 25, 19, 45, 35)
     )
 
+    infoDomainHasIP = fakedEppObject(
+        "nameserverwithip.gov",
+        cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
+        contacts=[],
+        hosts=[
+            "ns1.nameserverwithip.gov",
+            "ns2.nameserverwithip.gov",
+            "ns3.nameserverwithip.gov",
+        ],
+        addrs=["1.2.3.4", "2.3.4.5"],
+    )
+
+    infoDomainCheckHostIPCombo = fakedEppObject(
+        "nameserversubdomain.gov",
+        cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
+        contacts=[],
+        hosts=[
+            "ns1.nameserversubdomain.gov",
+            "ns2.nameserversubdomain.gov",
+        ],
+    )
+
+    def _getattrInfoDomain(self, _request):
+        if getattr(_request, "name", None) == "security.gov":
+            return MagicMock(res_data=[self.infoDomainNoContact])
+        elif getattr(_request, "name", None) == "my-nameserver.gov":
+            if self.mockedSendFunction.call_count == 5:
+                return MagicMock(res_data=[self.infoDomainTwoHosts])
+            else:
+                return MagicMock(res_data=[self.infoDomainNoHost])
+        elif getattr(_request, "name", None) == "nameserverwithip.gov":
+            return MagicMock(res_data=[self.infoDomainHasIP])
+        elif getattr(_request, "name", None) == "namerserversubdomain.gov":
+            return MagicMock(res_data=[self.infoDomainCheckHostIPCombo])
+        elif getattr(_request, "name", None) == "freeman.gov":
+            return MagicMock(res_data=[self.InfoDomainWithContacts])
+        elif getattr(_request, "name", None) == "threenameserversDomain.gov":
+            return MagicMock(res_data=[self.infoDomainThreeHosts])
+        return MagicMock(res_data=[self.mockDataInfoDomain])
+
     def mockSend(self, _request, cleaned):
         """Mocks the registry.send function used inside of domain.py
         registry is imported from epplibwrapper
         returns objects that simulate what would be in a epp response
         but only relevant pieces for tests"""
         if isinstance(_request, commands.InfoDomain):
-            if getattr(_request, "name", None) == "security.gov":
-                return MagicMock(res_data=[self.infoDomainNoContact])
-            elif getattr(_request, "name", None) == "freeman.gov":
-                return MagicMock(res_data=[self.InfoDomainWithContacts])
-            else:
-                return MagicMock(res_data=[self.mockDataInfoDomain])
+            return self._getattrInfoDomain(_request)
+
         elif isinstance(_request, commands.InfoContact):
             mocked_result: info.InfoContactResultData
 
@@ -724,6 +794,26 @@ def mockSend(self, _request, cleaned):
             # use this for when a contact is being updated
             # sets the second send() to fail
             raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
+        elif isinstance(_request, commands.CreateHost):
+            return MagicMock(
+                res_data=[self.mockDataHostChange],
+                code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
+            )
+        elif isinstance(_request, commands.UpdateHost):
+            return MagicMock(
+                res_data=[self.mockDataHostChange],
+                code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
+            )
+        elif isinstance(_request, commands.UpdateDomain):
+            return MagicMock(
+                res_data=[self.mockDataHostChange],
+                code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
+            )
+        elif isinstance(_request, commands.DeleteHost):
+            return MagicMock(
+                res_data=[self.mockDataHostChange],
+                code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
+            )
         elif (
             isinstance(_request, commands.DeleteDomain)
             and getattr(_request, "name", None) == "failDelete.gov"
@@ -734,6 +824,7 @@ def mockSend(self, _request, cleaned):
                 raise RegistryError(
                     code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION
                 )
+
         return MagicMock(res_data=[self.mockDataInfoHosts])
 
     def setUp(self):
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
@@ -16,7 +16,10 @@
 from registrar.models.draft_domain import DraftDomain
 from registrar.models.public_contact import PublicContact
 from registrar.models.user import User
+from registrar.utility.errors import ActionNotAllowed, NameserverError
+
 from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
+
 from .common import MockEppLib
 from django_fsm import TransitionNotAllowed  # type: ignore
 from epplibwrapper import (
@@ -104,6 +107,7 @@ def test_cache_nested_elements(self):
         }
         expectedHostsDict = {
             "name": self.mockDataInfoDomain.hosts[0],
+            "addrs": self.mockDataInfoHosts.addrs,
             "cr_date": self.mockDataInfoHosts.cr_date,
         }
 
@@ -264,7 +268,7 @@ def test_approved_application_creates_domain_locally(self):
         application.status = DomainApplication.SUBMITTED
         # transition to approve state
         application.approve()
-        # should hav information present for this domain
+        # should have information present for this domain
         domain = Domain.objects.get(name="igorville.gov")
         self.assertTrue(domain)
         self.mockedSendFunction.assert_not_called()
@@ -534,7 +538,7 @@ def test_no_security_email(self):
         expectedSecContact = PublicContact.get_default_security()
         expectedSecContact.domain = self.domain
 
-        self.domain.pendingCreate()
+        self.domain.dns_needed_from_unknown()
 
         self.assertEqual(self.mockedSendFunction.call_count, 8)
         self.assertEqual(PublicContact.objects.filter(domain=self.domain).count(), 4)
@@ -577,7 +581,8 @@ def test_user_adds_security_email(self):
                 created contact of type 'security'
         """
         # make a security contact that is a PublicContact
-        self.domain.pendingCreate()  # make sure a security email already exists
+        # make sure a security email already exists
+        self.domain.dns_needed_from_unknown()
         expectedSecContact = PublicContact.get_default_security()
         expectedSecContact.domain = self.domain
         expectedSecContact.email = "newEmail@fake.com"
@@ -893,7 +898,7 @@ def test_contact_getter_registrant(self):
         self.assertEqual(self.domain_contact._cache["registrant"], expected_contact_db)
 
 
-class TestRegistrantNameservers(TestCase):
+class TestRegistrantNameservers(MockEppLib):
     """Rule: Registrants may modify their nameservers"""
 
     def setUp(self):
@@ -902,9 +907,91 @@ def setUp(self):
             Given the registrant is logged in
             And the registrant is the admin on a domain
         """
-        pass
+        super().setUp()
+        self.nameserver1 = "ns1.my-nameserver-1.com"
+        self.nameserver2 = "ns1.my-nameserver-2.com"
+        self.nameserver3 = "ns1.cats-are-superior3.com"
+
+        self.domain, _ = Domain.objects.get_or_create(
+            name="my-nameserver.gov", state=Domain.State.DNS_NEEDED
+        )
+        self.domainWithThreeNS, _ = Domain.objects.get_or_create(
+            name="threenameserversDomain.gov", state=Domain.State.READY
+        )
+
+    def test_get_nameserver_changes_success_deleted_vals(self):
+        """Testing only deleting and no other changes"""
+        self.domain._cache["hosts"] = [
+            {"name": "ns1.example.com", "addrs": None},
+            {"name": "ns2.example.com", "addrs": ["1.2.3.4"]},
+        ]
+        newChanges = [
+            ("ns1.example.com",),
+        ]
+        (
+            deleted_values,
+            updated_values,
+            new_values,
+            oldNameservers,
+        ) = self.domain.getNameserverChanges(newChanges)
+
+        self.assertEqual(deleted_values, ["ns2.example.com"])
+        self.assertEqual(updated_values, [])
+        self.assertEqual(new_values, {})
+        self.assertEqual(
+            oldNameservers,
+            {"ns1.example.com": None, "ns2.example.com": ["1.2.3.4"]},
+        )
+
+    def test_get_nameserver_changes_success_updated_vals(self):
+        """Testing only updating no other changes"""
+        self.domain._cache["hosts"] = [
+            {"name": "ns3.my-nameserver.gov", "addrs": ["1.2.3.4"]},
+        ]
+        newChanges = [
+            ("ns3.my-nameserver.gov", ["1.2.4.5"]),
+        ]
+        (
+            deleted_values,
+            updated_values,
+            new_values,
+            oldNameservers,
+        ) = self.domain.getNameserverChanges(newChanges)
+
+        self.assertEqual(deleted_values, [])
+        self.assertEqual(updated_values, [("ns3.my-nameserver.gov", ["1.2.4.5"])])
+        self.assertEqual(new_values, {})
+        self.assertEqual(
+            oldNameservers,
+            {"ns3.my-nameserver.gov": ["1.2.3.4"]},
+        )
+
+    def test_get_nameserver_changes_success_new_vals(self):
+        # Testing only creating no other changes
+        self.domain._cache["hosts"] = [
+            {"name": "ns1.example.com", "addrs": None},
+        ]
+        newChanges = [
+            ("ns1.example.com",),
+            ("ns4.example.com",),
+        ]
+        (
+            deleted_values,
+            updated_values,
+            new_values,
+            oldNameservers,
+        ) = self.domain.getNameserverChanges(newChanges)
+
+        self.assertEqual(deleted_values, [])
+        self.assertEqual(updated_values, [])
+        self.assertEqual(new_values, {"ns4.example.com": None})
+        self.assertEqual(
+            oldNameservers,
+            {
+                "ns1.example.com": None,
+            },
+        )
 
-    @skip("not implemented yet")
     def test_user_adds_one_nameserver(self):
         """
         Scenario: Registrant adds a single nameserver
@@ -914,9 +1001,31 @@ def test_user_adds_one_nameserver(self):
                 to the registry
             And `domain.is_active` returns False
         """
-        raise
 
-    @skip("not implemented yet")
+        # set 1 nameserver
+        nameserver = "ns1.my-nameserver.com"
+        self.domain.nameservers = [(nameserver,)]
+
+        # when we create a host, we should've updated at the same time
+        created_host = commands.CreateHost(nameserver)
+        update_domain_with_created = commands.UpdateDomain(
+            name=self.domain.name,
+            add=[common.HostObjSet([created_host.name])],
+            rem=[],
+        )
+
+        # checking if commands were sent (commands have to be sent in order)
+        expectedCalls = [
+            call(created_host, cleaned=True),
+            call(update_domain_with_created, cleaned=True),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls)
+
+        # check that status is still NOT READY
+        # as you have less than 2 nameservers
+        self.assertFalse(self.domain.is_active())
+
     def test_user_adds_two_nameservers(self):
         """
         Scenario: Registrant adds 2 or more nameservers, thereby activating the domain
@@ -926,9 +1035,36 @@ def test_user_adds_two_nameservers(self):
                 to the registry
             And `domain.is_active` returns True
         """
-        raise
 
-    @skip("not implemented yet")
+        # set 2 nameservers
+        self.domain.nameservers = [(self.nameserver1,), (self.nameserver2,)]
+
+        # when you create a host, you also have to update at same time
+        created_host1 = commands.CreateHost(self.nameserver1)
+        created_host2 = commands.CreateHost(self.nameserver2)
+
+        update_domain_with_created = commands.UpdateDomain(
+            name=self.domain.name,
+            add=[
+                common.HostObjSet([created_host1.name, created_host2.name]),
+            ],
+            rem=[],
+        )
+
+        infoDomain = commands.InfoDomain(name="my-nameserver.gov", auth_info=None)
+        # checking if commands were sent (commands have to be sent in order)
+        expectedCalls = [
+            call(infoDomain, cleaned=True),
+            call(created_host1, cleaned=True),
+            call(created_host2, cleaned=True),
+            call(update_domain_with_created, cleaned=True),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
+        self.assertEqual(4, self.mockedSendFunction.call_count)
+        # check that status is READY
+        self.assertTrue(self.domain.is_active())
+
     def test_user_adds_too_many_nameservers(self):
         """
         Scenario: Registrant adds 14 or more nameservers
@@ -936,9 +1072,44 @@ def test_user_adds_too_many_nameservers(self):
             When `domain.nameservers` is set to an array of length 14
             Then Domain raises a user-friendly error
         """
-        raise
 
-    @skip("not implemented yet")
+        # set 13+ nameservers
+        nameserver1 = "ns1.cats-are-superior1.com"
+        nameserver2 = "ns1.cats-are-superior2.com"
+        nameserver3 = "ns1.cats-are-superior3.com"
+        nameserver4 = "ns1.cats-are-superior4.com"
+        nameserver5 = "ns1.cats-are-superior5.com"
+        nameserver6 = "ns1.cats-are-superior6.com"
+        nameserver7 = "ns1.cats-are-superior7.com"
+        nameserver8 = "ns1.cats-are-superior8.com"
+        nameserver9 = "ns1.cats-are-superior9.com"
+        nameserver10 = "ns1.cats-are-superior10.com"
+        nameserver11 = "ns1.cats-are-superior11.com"
+        nameserver12 = "ns1.cats-are-superior12.com"
+        nameserver13 = "ns1.cats-are-superior13.com"
+        nameserver14 = "ns1.cats-are-superior14.com"
+
+        def _get_14_nameservers():
+            self.domain.nameservers = [
+                (nameserver1,),
+                (nameserver2,),
+                (nameserver3,),
+                (nameserver4,),
+                (nameserver5,),
+                (nameserver6,),
+                (nameserver7,),
+                (nameserver8,),
+                (nameserver9),
+                (nameserver10,),
+                (nameserver11,),
+                (nameserver12,),
+                (nameserver13,),
+                (nameserver14,),
+            ]
+
+        self.assertRaises(NameserverError, _get_14_nameservers)
+        self.assertEqual(self.mockedSendFunction.call_count, 0)
+
     def test_user_removes_some_nameservers(self):
         """
         Scenario: Registrant removes some nameservers, while keeping at least 2
@@ -948,21 +1119,84 @@ def test_user_removes_some_nameservers(self):
                 to the registry
             And `domain.is_active` returns True
         """
-        raise
 
-    @skip("not implemented yet")
+        # Mock is set to return 3 nameservers on infodomain
+        self.domainWithThreeNS.nameservers = [(self.nameserver1,), (self.nameserver2,)]
+        expectedCalls = [
+            # calls info domain, and info on all hosts
+            # to get past values
+            # then removes the single host and updates domain
+            call(
+                commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None),
+                cleaned=True,
+            ),
+            call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True),
+            call(
+                commands.UpdateDomain(
+                    name=self.domainWithThreeNS.name,
+                    add=[],
+                    rem=[common.HostObjSet(hosts=["ns1.cats-are-superior3.com"])],
+                    nsset=None,
+                    keyset=None,
+                    registrant=None,
+                    auth_info=None,
+                ),
+                cleaned=True,
+            ),
+            call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
+        self.assertTrue(self.domainWithThreeNS.is_active())
+
     def test_user_removes_too_many_nameservers(self):
         """
         Scenario: Registrant removes some nameservers, bringing the total to less than 2
-            Given the domain has 3 nameservers
+            Given the domain has 2 nameservers
             When `domain.nameservers` is set to an array containing nameserver #1
             Then `commands.UpdateDomain` and `commands.DeleteHost` is sent
                 to the registry
             And `domain.is_active` returns False
+
         """
-        raise
 
-    @skip("not implemented yet")
+        self.domainWithThreeNS.nameservers = [(self.nameserver1,)]
+        expectedCalls = [
+            call(
+                commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None),
+                cleaned=True,
+            ),
+            call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True),
+            call(commands.DeleteHost(name="ns1.my-nameserver-2.com"), cleaned=True),
+            call(
+                commands.UpdateDomain(
+                    name=self.domainWithThreeNS.name,
+                    add=[],
+                    rem=[
+                        common.HostObjSet(
+                            hosts=[
+                                "ns1.my-nameserver-2.com",
+                                "ns1.cats-are-superior3.com",
+                            ]
+                        ),
+                    ],
+                    nsset=None,
+                    keyset=None,
+                    registrant=None,
+                    auth_info=None,
+                ),
+                cleaned=True,
+            ),
+            call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
+        self.assertFalse(self.domainWithThreeNS.is_active())
+
     def test_user_replaces_nameservers(self):
         """
         Scenario: Registrant simultaneously adds and removes some nameservers
@@ -973,9 +1207,60 @@ def test_user_replaces_nameservers(self):
             And `commands.UpdateDomain` is sent to add #4 and #5 plus remove #2 and #3
             And `commands.DeleteHost` is sent to delete #2 and #3
         """
-        raise
+        self.domainWithThreeNS.nameservers = [
+            (self.nameserver1,),
+            ("ns1.cats-are-superior1.com",),
+            ("ns1.cats-are-superior2.com",),
+        ]
+
+        expectedCalls = [
+            call(
+                commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None),
+                cleaned=True,
+            ),
+            call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True),
+            call(commands.DeleteHost(name="ns1.my-nameserver-2.com"), cleaned=True),
+            call(
+                commands.CreateHost(name="ns1.cats-are-superior1.com", addrs=[]),
+                cleaned=True,
+            ),
+            call(
+                commands.CreateHost(name="ns1.cats-are-superior2.com", addrs=[]),
+                cleaned=True,
+            ),
+            call(
+                commands.UpdateDomain(
+                    name=self.domainWithThreeNS.name,
+                    add=[
+                        common.HostObjSet(
+                            hosts=[
+                                "ns1.cats-are-superior1.com",
+                                "ns1.cats-are-superior2.com",
+                            ]
+                        ),
+                    ],
+                    rem=[
+                        common.HostObjSet(
+                            hosts=[
+                                "ns1.my-nameserver-2.com",
+                                "ns1.cats-are-superior3.com",
+                            ]
+                        ),
+                    ],
+                    nsset=None,
+                    keyset=None,
+                    registrant=None,
+                    auth_info=None,
+                ),
+                cleaned=True,
+            ),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
+        self.assertTrue(self.domainWithThreeNS.is_active())
 
-    @skip("not implemented yet")
     def test_user_cannot_add_subordinate_without_ip(self):
         """
         Scenario: Registrant adds a nameserver which is a subdomain of their .gov
@@ -984,9 +1269,12 @@ def test_user_cannot_add_subordinate_without_ip(self):
                 with a subdomain of the domain and no IP addresses
             Then Domain raises a user-friendly error
         """
-        raise
 
-    @skip("not implemented yet")
+        dotgovnameserver = "my-nameserver.gov"
+
+        with self.assertRaises(NameserverError):
+            self.domain.nameservers = [(dotgovnameserver,)]
+
     def test_user_updates_ips(self):
         """
         Scenario: Registrant changes IP addresses for a nameserver
@@ -996,9 +1284,53 @@ def test_user_updates_ips(self):
                 with a different IP address(es)
             Then `commands.UpdateHost` is sent to the registry
         """
-        raise
+        domain, _ = Domain.objects.get_or_create(
+            name="nameserverwithip.gov", state=Domain.State.READY
+        )
+        domain.nameservers = [
+            ("ns1.nameserverwithip.gov", ["2.3.4.5", "1.2.3.4"]),
+            (
+                "ns2.nameserverwithip.gov",
+                ["1.2.3.4", "2.3.4.5", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"],
+            ),
+            ("ns3.nameserverwithip.gov", ["2.3.4.5"]),
+        ]
+
+        expectedCalls = [
+            call(
+                commands.InfoDomain(name="nameserverwithip.gov", auth_info=None),
+                cleaned=True,
+            ),
+            call(commands.InfoHost(name="ns1.nameserverwithip.gov"), cleaned=True),
+            call(commands.InfoHost(name="ns2.nameserverwithip.gov"), cleaned=True),
+            call(commands.InfoHost(name="ns3.nameserverwithip.gov"), cleaned=True),
+            call(
+                commands.UpdateHost(
+                    name="ns2.nameserverwithip.gov",
+                    add=[
+                        common.Ip(
+                            addr="2001:0db8:85a3:0000:0000:8a2e:0370:7334", ip="v6"
+                        )
+                    ],
+                    rem=[],
+                    chg=None,
+                ),
+                cleaned=True,
+            ),
+            call(
+                commands.UpdateHost(
+                    name="ns3.nameserverwithip.gov",
+                    add=[],
+                    rem=[common.Ip(addr="1.2.3.4", ip=None)],
+                    chg=None,
+                ),
+                cleaned=True,
+            ),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
+        self.assertTrue(domain.is_active())
 
-    @skip("not implemented yet")
     def test_user_cannot_add_non_subordinate_with_ip(self):
         """
         Scenario: Registrant adds a nameserver which is NOT a subdomain of their .gov
@@ -1007,9 +1339,11 @@ def test_user_cannot_add_non_subordinate_with_ip(self):
                 which is not a subdomain of the domain and has IP addresses
             Then Domain raises a user-friendly error
         """
-        raise
+        dotgovnameserver = "mynameserverdotgov.gov"
+
+        with self.assertRaises(NameserverError):
+            self.domain.nameservers = [(dotgovnameserver, ["1.2.3"])]
 
-    @skip("not implemented yet")
     def test_nameservers_are_idempotent(self):
         """
         Scenario: Registrant adds a set of nameservers twice, due to a UI glitch
@@ -1017,10 +1351,68 @@ def test_nameservers_are_idempotent(self):
                 to the registry twice with identical data
             Then no errors are raised in Domain
         """
-        # implementation note: this requires seeing what happens when these are actually
-        # sent like this, and then implementing appropriate mocks for any errors the
-        # registry normally sends in this case
-        raise
+
+        # Checking that it doesn't create or update even if out of order
+        self.domainWithThreeNS.nameservers = [
+            (self.nameserver3,),
+            (self.nameserver1,),
+            (self.nameserver2,),
+        ]
+
+        expectedCalls = [
+            call(
+                commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None),
+                cleaned=True,
+            ),
+            call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True),
+            call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True),
+        ]
+
+        self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
+        self.assertEqual(self.mockedSendFunction.call_count, 4)
+
+    def test_is_subdomain_with_no_ip(self):
+        domain, _ = Domain.objects.get_or_create(
+            name="nameserversubdomain.gov", state=Domain.State.READY
+        )
+
+        with self.assertRaises(NameserverError):
+            domain.nameservers = [
+                ("ns1.nameserversubdomain.gov",),
+                ("ns2.nameserversubdomain.gov",),
+            ]
+
+    def test_not_subdomain_but_has_ip(self):
+        domain, _ = Domain.objects.get_or_create(
+            name="nameserversubdomain.gov", state=Domain.State.READY
+        )
+
+        with self.assertRaises(NameserverError):
+            domain.nameservers = [
+                ("ns1.cats-da-best.gov", ["1.2.3.4"]),
+                ("ns2.cats-da-best.gov", ["2.3.4.5"]),
+            ]
+
+    def test_is_subdomain_but_ip_addr_not_valid(self):
+        domain, _ = Domain.objects.get_or_create(
+            name="nameserversubdomain.gov", state=Domain.State.READY
+        )
+
+        with self.assertRaises(NameserverError):
+            domain.nameservers = [
+                ("ns1.nameserversubdomain.gov", ["1.2.3"]),
+                ("ns2.nameserversubdomain.gov", ["2.3.4"]),
+            ]
+
+    def test_setting_not_allowed(self):
+        """Scenario: A domain state is not Ready or DNS Needed
+        then setting nameservers is not allowed"""
+        domain, _ = Domain.objects.get_or_create(
+            name="onholdDomain.gov", state=Domain.State.ON_HOLD
+        )
+        with self.assertRaises(ActionNotAllowed):
+            domain.nameservers = [self.nameserver1, self.nameserver2]
 
     @skip("not implemented yet")
     def test_update_is_unsuccessful(self):
@@ -1028,8 +1420,22 @@ def test_update_is_unsuccessful(self):
         Scenario: An update to the nameservers is unsuccessful
             When an error is returned from epplibwrapper
             Then a user-friendly error message is returned for displaying on the web
+
+        Note: TODO 433 -- we will perform correct error handling and complete
+        this ticket. We want to raise an error for update/create/delete, but
+        don't want to lose user info (and exit out too early)
         """
-        raise
+
+        domain, _ = Domain.objects.get_or_create(
+            name="failednameserver.gov", state=Domain.State.READY
+        )
+
+        with self.assertRaises(RegistryError):
+            domain.nameservers = [("ns1.failednameserver.gov", ["4.5.6"])]
+
+    def tearDown(self):
+        Domain.objects.all().delete()
+        return super().tearDown()
 
 
 class TestRegistrantDNSSEC(MockEppLib):
diff --git a/src/registrar/tests/test_nameserver_error.py b/src/registrar/tests/test_nameserver_error.py
new file mode 100644
--- /dev/null
+++ b/src/registrar/tests/test_nameserver_error.py
@@ -0,0 +1,46 @@
+from django.test import TestCase
+
+from registrar.utility.errors import (
+    NameserverError,
+    NameserverErrorCodes as nsErrorCodes,
+)
+
+
+class TestNameserverError(TestCase):
+    def test_with_no_ip(self):
+        """Test NameserverError when no ip address is passed"""
+        nameserver = "nameserver val"
+        expected = (
+            f"Nameserver {nameserver} needs to have an "
+            "IP address because it is a subdomain"
+        )
+
+        nsException = NameserverError(
+            code=nsErrorCodes.MISSING_IP, nameserver=nameserver
+        )
+        self.assertEqual(nsException.message, expected)
+        self.assertEqual(nsException.code, nsErrorCodes.MISSING_IP)
+
+    def test_with_only_code(self):
+        """Test NameserverError when no ip address
+        and no nameserver is passed"""
+        nameserver = "nameserver val"
+        expected = "Too many hosts provided, you may not have more than 13 nameservers."
+
+        nsException = NameserverError(
+            code=nsErrorCodes.TOO_MANY_HOSTS, nameserver=nameserver
+        )
+        self.assertEqual(nsException.message, expected)
+        self.assertEqual(nsException.code, nsErrorCodes.TOO_MANY_HOSTS)
+
+    def test_with_ip_nameserver(self):
+        """Test NameserverError when ip and nameserver are passed"""
+        ip = "ip val"
+        nameserver = "nameserver val"
+
+        expected = f"Nameserver {nameserver} has an invalid IP address: {ip}"
+        nsException = NameserverError(
+            code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
+        )
+        self.assertEqual(nsException.message, expected)
+        self.assertEqual(nsException.code, nsErrorCodes.INVALID_IP)