Merge pull request #3185 from cisagov/ms/2823-update-delete-domain-process

#2823: update delete domain process - [MS]
This commit is contained in:
Matt-Spence 2024-12-16 14:04:47 -05:00 committed by GitHub
commit c02b036301
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 335 additions and 75 deletions

View file

@ -20,7 +20,7 @@ applications:
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
DJANGO_LOG_LEVEL: DEBUG
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -62,9 +62,11 @@ class RegistryError(Exception):
- 2501 - 2502 Something malicious or abusive may have occurred
"""
def __init__(self, *args, code=None, **kwargs):
def __init__(self, *args, code=None, note="", **kwargs):
super().__init__(*args, **kwargs)
self.code = code
# note is a string that can be used to provide additional context
self.note = note
def should_retry(self):
return self.code == ErrorCode.COMMAND_FAILED

View file

@ -3334,7 +3334,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
except RegistryError as err:
# Using variables to get past the linter
message1 = f"Cannot delete Domain when in state {obj.state}"
message2 = "This subdomain is being used as a hostname on another domain"
message2 = f"This subdomain is being used as a hostname on another domain: {err.note}"
# Human-readable mappings of ErrorCodes. Can be expanded.
error_messages = {
# noqa on these items as black wants to reformat to an invalid length

View file

@ -230,6 +230,12 @@ class Domain(TimeStampedModel, DomainHelper):
"""Called during delete. Example: `del domain.registrant`."""
super().__delete__(obj)
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
# If the domain is deleted we don't want the expiration date to be set
if self.state == self.State.DELETED and self.expiration_date:
self.expiration_date = None
super().save(force_insert, force_update, using, update_fields)
@classmethod
def available(cls, domain: str) -> bool:
"""Check if a domain is available.
@ -253,7 +259,7 @@ class Domain(TimeStampedModel, DomainHelper):
return not cls.available(domain)
@Cache
def contacts(self) -> dict[str, str]:
def registry_contacts(self) -> dict[str, str]:
"""
Get a dictionary of registry IDs for the contacts for this domain.
@ -706,7 +712,7 @@ class Domain(TimeStampedModel, DomainHelper):
raise e
@nameservers.setter # type: ignore
def nameservers(self, hosts: list[tuple[str, list]]):
def nameservers(self, hosts: list[tuple[str, list]]): # noqa
"""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])]"""
@ -743,7 +749,12 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
try:
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
except Exception as e:
# we don't need this part to succeed in order to continue.
logger.error("Failed to delete nameserver hosts: %s", e)
if successTotalNameservers < 2:
try:
self.dns_needed()
@ -1029,6 +1040,47 @@ class Domain(TimeStampedModel, DomainHelper):
def _delete_domain(self):
"""This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller"""
logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain
hosts = Host.objects.filter(name__regex=r".+{}".format(self.name))
for host in hosts:
if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
raise RegistryError(
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}",
)
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=[])
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
addToDomainList, _ = self.createNewHostList(new_values)
deleteHostList, _ = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
for contact in contacts:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
self._update_domain_with_contact(contact, rem=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info("Deleting domain %s", self.name)
request = commands.DeleteDomain(name=self.name)
registry.send(request, cleaned=True)
@ -1096,7 +1148,7 @@ class Domain(TimeStampedModel, DomainHelper):
Returns True if expired, False otherwise.
"""
if self.expiration_date is None:
return True
return self.state != self.State.DELETED
now = timezone.now().date()
return self.expiration_date < now
@ -1430,6 +1482,8 @@ class Domain(TimeStampedModel, DomainHelper):
@transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED)
def deletedInEpp(self):
"""Domain is deleted in epp but is saved in our database.
Subdomains will be deleted first if not in use by another domain.
Contacts for this domain will also be deleted.
Error handling should be provided by the caller."""
# While we want to log errors, we want to preserve
# that information when this function is called.
@ -1439,8 +1493,9 @@ class Domain(TimeStampedModel, DomainHelper):
logger.info("deletedInEpp()-> inside _delete_domain")
self._delete_domain()
self.deleted = timezone.now()
self.expiration_date = None
except RegistryError as err:
logger.error(f"Could not delete domain. Registry returned error: {err}")
logger.error(f"Could not delete domain. Registry returned error: {err}. {err.note}")
raise err
except TransitionNotAllowed as err:
logger.error("Could not delete domain. FSM failure: {err}")
@ -1745,7 +1800,6 @@ class Domain(TimeStampedModel, DomainHelper):
"""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:
@ -1764,6 +1818,8 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
raise e
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown

View file

@ -1232,6 +1232,7 @@ class MockEppLib(TestCase):
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
registrant="regContact",
ex_date=date(2023, 5, 25),
)
@ -1394,6 +1395,15 @@ class MockEppLib(TestCase):
hosts=["fake.host.com"],
)
infoDomainSharedHost = fakedEppObject(
"sharedHost.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
"ns1.sharedhost.com",
],
)
infoDomainThreeHosts = fakedEppObject(
"my-nameserver.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
@ -1604,6 +1614,8 @@ class MockEppLib(TestCase):
return self.mockInfoContactCommands(_request, cleaned)
case commands.CreateContact:
return self.mockCreateContactCommands(_request, cleaned)
case commands.DeleteContact:
return self.mockDeleteContactCommands(_request, cleaned)
case commands.UpdateDomain:
return self.mockUpdateDomainCommands(_request, cleaned)
case commands.CreateHost:
@ -1611,10 +1623,7 @@ class MockEppLib(TestCase):
case commands.UpdateHost:
return self.mockUpdateHostCommands(_request, cleaned)
case commands.DeleteHost:
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
return self.mockDeleteHostCommands(_request, cleaned)
case commands.CheckDomain:
return self.mockCheckDomainCommand(_request, cleaned)
case commands.DeleteDomain:
@ -1667,6 +1676,15 @@ class MockEppLib(TestCase):
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockDeleteHostCommands(self, _request, cleaned):
host = getattr(_request, "name", None)
if "sharedhost.com" in host:
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note="ns1.sharedhost.com")
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockUpdateDomainCommands(self, _request, cleaned):
if getattr(_request, "name", None) == "dnssec-invalid.gov":
raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR)
@ -1678,10 +1696,7 @@ class MockEppLib(TestCase):
def mockDeleteDomainCommands(self, _request, cleaned):
if getattr(_request, "name", None) == "failDelete.gov":
name = getattr(_request, "name", None)
fake_nameserver = "ns1.failDelete.gov"
if name in fake_nameserver:
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
return None
def mockRenewDomainCommand(self, _request, cleaned):
@ -1721,6 +1736,7 @@ class MockEppLib(TestCase):
# Define a dictionary to map request names to data and extension values
request_mappings = {
"fake.gov": (self.mockDataInfoDomain, None),
"security.gov": (self.infoDomainNoContact, None),
"dnssec-dsdata.gov": (
self.mockDataInfoDomain,
@ -1751,6 +1767,7 @@ class MockEppLib(TestCase):
"subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None),
"ddomain3.gov": (self.InfoDomainWithContacts, None),
"igorville.gov": (self.InfoDomainWithContacts, None),
"sharingiscaring.gov": (self.infoDomainSharedHost, None),
}
# Retrieve the corresponding values from the dictionary
@ -1801,6 +1818,15 @@ class MockEppLib(TestCase):
raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
return MagicMock(res_data=[self.mockDataInfoHosts])
def mockDeleteContactCommands(self, _request, cleaned):
if getattr(_request, "id", None) == "fail":
raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
else:
return MagicMock(
res_data=[self.mockDataInfoContact],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def setUp(self):
"""mock epp send function as this will fail locally"""
self.mockSendPatch = patch("registrar.models.domain.registry.send")

View file

@ -16,6 +16,7 @@ from registrar.models import (
Host,
Portfolio,
)
from registrar.models.public_contact import PublicContact
from registrar.models.user_domain_role import UserDomainRole
from .common import (
MockSESClient,
@ -59,6 +60,7 @@ class TestDomainAdminAsStaff(MockEppLib):
def tearDown(self):
super().tearDown()
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
@ -170,7 +172,7 @@ class TestDomainAdminAsStaff(MockEppLib):
@less_console_noise_decorator
def test_deletion_is_successful(self):
"""
Scenario: Domain deletion is unsuccessful
Scenario: Domain deletion is successful
When the domain is deleted
Then a user-friendly success message is returned for displaying on the web
And `state` is set to `DELETED`
@ -221,6 +223,55 @@ class TestDomainAdminAsStaff(MockEppLib):
self.assertEqual(domain.state, Domain.State.DELETED)
# @less_console_noise_decorator
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
When the domain is deleted and has shared subdomains
Then a user-friendly success message is returned for displaying on the web
And `state` is not set to `DELETED`
"""
domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD)
# Put in client hold
domain.place_client_hold()
# Ensure everything is displaying correctly
response = self.client.get(
"/admin/registrar/domain/{}/change/".format(domain.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Remove from registry")
# The contents of the modal should exist before and after the post.
# Check for the header
self.assertContains(response, "Are you sure you want to remove this domain from the registry?")
# Check for some of its body
self.assertContains(response, "When a domain is removed from the registry:")
# Check for some of the button content
self.assertContains(response, "Yes, remove from registry")
# Test the info dialog
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_delete_domain": "Remove from registry", "name": domain.name},
follow=True,
)
request.user = self.client
with patch("django.contrib.messages.add_message") as mock_add_message:
self.admin.do_delete_domain(request, domain)
mock_add_message.assert_called_once_with(
request,
messages.ERROR,
"Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", # noqa
extra_tags="",
fail_silently=False,
)
self.assertEqual(domain.state, Domain.State.ON_HOLD)
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""

View file

@ -9,6 +9,7 @@ from django.db.utils import IntegrityError
from unittest.mock import MagicMock, patch, call
import datetime
from django.utils.timezone import make_aware
from api.tests.common import less_console_noise_decorator
from registrar.models import Domain, Host, HostIP
from unittest import skip
@ -1454,6 +1455,7 @@ class TestRegistrantNameservers(MockEppLib):
),
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())
@ -2582,12 +2584,32 @@ class TestAnalystDelete(MockEppLib):
"""
super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
self.domain_with_contacts, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.READY)
self.domain_on_hold, _ = Domain.objects.get_or_create(name="fake-on-hold.gov", state=Domain.State.ON_HOLD)
Host.objects.create(name="ns1.sharingiscaring.gov", domain=self.domain_on_hold)
PublicContact.objects.create(
registry_id="regContact",
contact_type=PublicContact.ContactTypeChoices.REGISTRANT,
domain=self.domain_with_contacts,
)
PublicContact.objects.create(
registry_id="adminContact",
contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE,
domain=self.domain_with_contacts,
)
PublicContact.objects.create(
registry_id="techContact",
contact_type=PublicContact.ContactTypeChoices.TECHNICAL,
domain=self.domain_with_contacts,
)
def tearDown(self):
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
def test_analyst_deletes_domain(self):
"""
Scenario: Analyst permanently deletes a domain
@ -2597,59 +2619,163 @@ class TestAnalystDelete(MockEppLib):
The deleted date is set.
"""
with less_console_noise():
# Put the domain in client hold
self.domain.place_client_hold()
# Delete it...
self.domain.deletedInEpp()
self.domain.save()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="fake.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.DELETED)
# Domain should have a deleted
self.assertNotEqual(self.domain.deleted, None)
# Cache should be invalidated
self.assertEqual(self.domain._cache, {})
# Put the domain in client hold
self.domain.place_client_hold()
# Delete it...
self.domain.deletedInEpp()
self.domain.save()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="fake.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.DELETED)
# Domain should have a deleted
self.assertNotEqual(self.domain.deleted, None)
# Cache should be invalidated
self.assertEqual(self.domain._cache, {})
@less_console_noise_decorator
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
When a subdomain exists
When a subdomain exists that is in use by another domain
Then a client error is returned of code 2305
And `state` is not set to `DELETED`
"""
with less_console_noise():
# Desired domain
domain, _ = Domain.objects.get_or_create(name="failDelete.gov", state=Domain.State.ON_HOLD)
# Put the domain in client hold
domain.place_client_hold()
# Delete it
with self.assertRaises(RegistryError) as err:
domain.deletedInEpp()
domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="failDelete.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(domain, None)
# State should not have changed
self.assertEqual(domain.state, Domain.State.ON_HOLD)
# Desired domain
domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD)
# Put the domain in client hold
domain.place_client_hold()
# Delete it
with self.assertRaises(RegistryError) as err:
domain.deletedInEpp()
domain.save()
self.assertTrue(err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
self.assertEqual(err.msg, "Host ns1.sharingiscaring.gov is in use by: fake-on-hold.gov")
# Domain itself should not be deleted
self.assertNotEqual(domain, None)
# State should not have changed
self.assertEqual(domain.state, Domain.State.ON_HOLD)
@less_console_noise_decorator
def test_deletion_with_host_and_contacts(self):
"""
Scenario: Domain with related Host and Contacts is Deleted
When a contact and host exists that is tied to this domain
Then all the needed commands are sent to the registry
And `state` is set to `DELETED`
"""
# Put the domain in client hold
self.domain_with_contacts.place_client_hold()
# Delete it
self.domain_with_contacts.deletedInEpp()
self.domain_with_contacts.save()
# Check that the host and contacts are deleted
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="freeman.gov",
add=[common.Status(state=Domain.Status.CLIENT_HOLD, description="", lang="en")],
rem=[],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
]
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.InfoDomain(name="freeman.gov", auth_info=None),
cleaned=True,
),
call(
commands.InfoHost(name="fake.host.com"),
cleaned=True,
),
call(
commands.UpdateDomain(
name="freeman.gov",
add=[],
rem=[common.HostObjSet(hosts=["fake.host.com"])],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
]
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteHost(name="fake.host.com"),
cleaned=True,
),
call(
commands.UpdateDomain(
name="freeman.gov",
add=[],
rem=[common.DomainContact(contact="adminContact", type="admin")],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
call(
commands.DeleteContact(id="adminContact"),
cleaned=True,
),
call(
commands.UpdateDomain(
name="freeman.gov",
add=[],
rem=[common.DomainContact(contact="techContact", type="tech")],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
call(
commands.DeleteContact(id="techContact"),
cleaned=True,
),
],
any_order=True,
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="freeman.gov"),
cleaned=True,
),
],
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain_with_contacts, None)
# State should have changed
self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED)
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""
Scenario: Domain deletion is unsuccessful due to FSM rules
@ -2661,15 +2787,14 @@ class TestAnalystDelete(MockEppLib):
The deleted date is still null.
"""
with less_console_noise():
self.assertEqual(self.domain.state, Domain.State.READY)
with self.assertRaises(TransitionNotAllowed) as err:
self.domain.deletedInEpp()
self.domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
# Domain should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.READY)
# deleted should be null
self.assertEqual(self.domain.deleted, None)
self.assertEqual(self.domain.state, Domain.State.READY)
with self.assertRaises(TransitionNotAllowed) as err:
self.domain.deletedInEpp()
self.domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
# Domain should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.READY)
# deleted should be null
self.assertEqual(self.domain.deleted, None)