diff --git a/src/epplibwrapper/__init__.py b/src/epplibwrapper/__init__.py
index b306dbd0e..65de3ec05 100644
--- a/src/epplibwrapper/__init__.py
+++ b/src/epplibwrapper/__init__.py
@@ -45,6 +45,7 @@ try:
from .client import CLIENT, commands
from .errors import RegistryError, ErrorCode
from epplib.models import common
+ from epplib import responses
except ImportError:
pass
@@ -52,6 +53,7 @@ __all__ = [
"CLIENT",
"commands",
"common",
+ "responses",
"ErrorCode",
"RegistryError",
]
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index e99e767bd..275f67bb3 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -6,10 +6,13 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse
+from epplibwrapper.errors import ErrorCode, RegistryError
+from registrar.models.domain import Domain
from registrar.models.utility.admin_sort_fields import AdminSortFields
from . import models
from auditlog.models import LogEntry # type: ignore
from auditlog.admin import LogEntryAdmin # type: ignore
+from django_fsm import TransitionNotAllowed # type: ignore
logger = logging.getLogger(__name__)
@@ -716,16 +719,61 @@ class DomainAdmin(ListHeaderAdmin):
return super().response_change(request, obj)
def do_delete_domain(self, request, obj):
+ if not isinstance(obj, Domain):
+ # Could be problematic if the type is similar,
+ # but not the same (same field/func names).
+ # We do not want to accidentally delete records.
+ self.message_user(request, "Object is not of type Domain", messages.ERROR)
+ return
+
try:
- obj.deleted()
+ obj.deletedInEpp()
obj.save()
- except Exception as err:
- self.message_user(request, err, messages.ERROR)
+ 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"
+ # Human-readable mappings of ErrorCodes. Can be expanded.
+ error_messages = {
+ # noqa on these items as black wants to reformat to an invalid length
+ ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1,
+ ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2,
+ }
+
+ message = "Cannot connect to the registry"
+ if not err.is_connection_error():
+ # If nothing is found, will default to returned err
+ message = error_messages.get(err.code, err)
+ self.message_user(
+ request, f"Error deleting this Domain: {message}", messages.ERROR
+ )
+ except TransitionNotAllowed:
+ if obj.state == Domain.State.DELETED:
+ self.message_user(
+ request,
+ "This domain is already deleted",
+ messages.INFO,
+ )
+ else:
+ self.message_user(
+ request,
+ "Error deleting this Domain: "
+ f"Can't switch from state '{obj.state}' to 'deleted'"
+ ", must be either 'dns_needed' or 'on_hold'",
+ messages.ERROR,
+ )
+ except Exception:
+ self.message_user(
+ request,
+ "Could not delete: An unspecified error occured",
+ messages.ERROR,
+ )
else:
self.message_user(
request,
- ("Domain %s Should now be deleted " ". Thanks!") % obj.name,
+ ("Domain %s has been deleted. Thanks!") % obj.name,
)
+
return HttpResponseRedirect(".")
def do_get_status(self, request, obj):
diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py
index a4e75dd2e..521d632d6 100644
--- a/src/registrar/fixtures.py
+++ b/src/registrar/fixtures.py
@@ -82,6 +82,11 @@ class UserFixture:
"first_name": "Nicolle",
"last_name": "LeClair",
},
+ {
+ "username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
+ "first_name": "Erin",
+ "last_name": "Song",
+ },
]
STAFF = [
@@ -134,6 +139,12 @@ class UserFixture:
"last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com",
},
+ {
+ "username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
+ "first_name": "Erin-Analyst",
+ "last_name": "Song-Analyst",
+ "email": "erin.song+1@gsa.gov",
+ },
]
STAFF_PERMISSIONS = [
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 2c7f8703c..e45724a9b 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -2,7 +2,7 @@ import logging
from datetime import date
from string import digits
-from django_fsm import FSMField, transition # type: ignore
+from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models
@@ -609,11 +609,6 @@ class Domain(TimeStampedModel, DomainHelper):
"""
return self.state == self.State.READY
- def delete_request(self):
- """Delete from host. Possibly a duplicate of _delete_host?"""
- # TODO fix in ticket #901
- pass
-
def transfer(self):
"""Going somewhere. Not implemented."""
raise NotImplementedError()
@@ -658,7 +653,7 @@ class Domain(TimeStampedModel, DomainHelper):
"""This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller"""
request = commands.DeleteDomain(name=self.name)
- registry.send(request)
+ registry.send(request, cleaned=True)
def __str__(self) -> str:
return self.name
@@ -804,16 +799,32 @@ class Domain(TimeStampedModel, DomainHelper):
self._remove_client_hold()
# TODO -on the client hold ticket any additional error handling here
- @transition(field="state", source=State.ON_HOLD, target=State.DELETED)
- def deleted(self):
- """domain is deleted in epp but is saved in our database"""
- # TODO Domains may not be deleted if:
- # a child host is being used by
- # another .gov domains. The host must be first removed
- # and/or renamed before the parent domain may be deleted.
- logger.info("pendingCreate()-> inside pending create")
- self._delete_domain()
- # TODO - delete ticket any additional error handling here
+ @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.
+ 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.
+ # Human-readable errors are introduced at the admin.py level,
+ # as doing everything here would reduce reliablity.
+ try:
+ logger.info("deletedInEpp()-> inside _delete_domain")
+ self._delete_domain()
+ except RegistryError as err:
+ logger.error(f"Could not delete domain. Registry returned error: {err}")
+ raise err
+ except TransitionNotAllowed as err:
+ logger.error("Could not delete domain. FSM failure: {err}")
+ raise err
+ except Exception as err:
+ logger.error(
+ f"Could not delete domain. An unspecified error occured: {err}"
+ )
+ raise err
+ else:
+ self._invalidate_cache()
@transition(
field="state",
@@ -974,75 +985,38 @@ class Domain(TimeStampedModel, DomainHelper):
# statuses can just be a list no need to keep the epp object
if "statuses" in cleaned.keys():
cleaned["statuses"] = [status.state for status in cleaned["statuses"]]
+
+ # Capture and store old hosts and contacts from cache if they exist
+ old_cache_hosts = self._cache.get("hosts")
+ old_cache_contacts = self._cache.get("contacts")
+
# get contact info, if there are any
if (
- # fetch_contacts and
- "_contacts" in cleaned
+ fetch_contacts
+ and "_contacts" in cleaned
and isinstance(cleaned["_contacts"], list)
and len(cleaned["_contacts"])
):
- cleaned["contacts"] = []
- for domainContact in cleaned["_contacts"]:
- # we do not use _get_or_create_* because we expect the object we
- # just asked the registry for still exists --
- # if not, that's a problem
-
- # TODO- discuss-should we check if contact is in public contacts
- # and add it if not- this is really to keep in mine the transisiton
- req = commands.InfoContact(id=domainContact.contact)
- data = registry.send(req, cleaned=True).res_data[0]
-
- # extract properties from response
- # (Ellipsis is used to mean "null")
- # convert this to use PublicContactInstead
- contact = {
- "id": domainContact.contact,
- "type": domainContact.type,
- "auth_info": getattr(data, "auth_info", ...),
- "cr_date": getattr(data, "cr_date", ...),
- "disclose": getattr(data, "disclose", ...),
- "email": getattr(data, "email", ...),
- "fax": getattr(data, "fax", ...),
- "postal_info": getattr(data, "postal_info", ...),
- "statuses": getattr(data, "statuses", ...),
- "tr_date": getattr(data, "tr_date", ...),
- "up_date": getattr(data, "up_date", ...),
- "voice": getattr(data, "voice", ...),
- }
-
- cleaned["contacts"].append(
- {k: v for k, v in contact.items() if v is not ...}
- )
+ cleaned["contacts"] = self._fetch_contacts(cleaned["_contacts"])
+ # We're only getting contacts, so retain the old
+ # hosts that existed in cache (if they existed)
+ # and pass them along.
+ if old_cache_hosts is not None:
+ cleaned["hosts"] = old_cache_hosts
# get nameserver info, if there are any
if (
- # fetch_hosts and
- "_hosts" in cleaned
+ fetch_hosts
+ and "_hosts" in cleaned
and isinstance(cleaned["_hosts"], list)
and len(cleaned["_hosts"])
):
- # TODO- add elif in cache set it to be the old cache value
- # no point in removing
- cleaned["hosts"] = []
- for name in cleaned["_hosts"]:
- # we do not use _get_or_create_* because we expect the object we
- # just asked the registry for still exists --
- # if not, that's a problem
- req = commands.InfoHost(name=name)
- data = registry.send(req, cleaned=True).res_data[0]
- # extract properties from response
- # (Ellipsis is used to mean "null")
- host = {
- "name": name,
- "addrs": getattr(data, "addrs", ...),
- "cr_date": getattr(data, "cr_date", ...),
- "statuses": getattr(data, "statuses", ...),
- "tr_date": getattr(data, "tr_date", ...),
- "up_date": getattr(data, "up_date", ...),
- }
- cleaned["hosts"].append(
- {k: v for k, v in host.items() if v is not ...}
- )
+ cleaned["hosts"] = self._fetch_hosts(cleaned["_hosts"])
+ # We're only getting hosts, so retain the old
+ # contacts that existed in cache (if they existed)
+ # and pass them along.
+ if old_cache_contacts is not None:
+ cleaned["contacts"] = old_cache_contacts
# replace the prior cache with new data
self._cache = cleaned
@@ -1050,6 +1024,46 @@ class Domain(TimeStampedModel, DomainHelper):
except RegistryError as e:
logger.error(e)
+ def _fetch_contacts(self, contact_data):
+ """Fetch contact info."""
+ contacts = []
+ for domainContact in contact_data:
+ req = commands.InfoContact(id=domainContact.contact)
+ data = registry.send(req, cleaned=True).res_data[0]
+ contact = {
+ "id": domainContact.contact,
+ "type": domainContact.type,
+ "auth_info": getattr(data, "auth_info", ...),
+ "cr_date": getattr(data, "cr_date", ...),
+ "disclose": getattr(data, "disclose", ...),
+ "email": getattr(data, "email", ...),
+ "fax": getattr(data, "fax", ...),
+ "postal_info": getattr(data, "postal_info", ...),
+ "statuses": getattr(data, "statuses", ...),
+ "tr_date": getattr(data, "tr_date", ...),
+ "up_date": getattr(data, "up_date", ...),
+ "voice": getattr(data, "voice", ...),
+ }
+ contacts.append({k: v for k, v in contact.items() if v is not ...})
+ return contacts
+
+ def _fetch_hosts(self, host_data):
+ """Fetch host info."""
+ hosts = []
+ for name in host_data:
+ req = commands.InfoHost(name=name)
+ data = registry.send(req, cleaned=True).res_data[0]
+ host = {
+ "name": name,
+ "addrs": getattr(data, "addrs", ...),
+ "cr_date": getattr(data, "cr_date", ...),
+ "statuses": getattr(data, "statuses", ...),
+ "tr_date": getattr(data, "tr_date", ...),
+ "up_date": getattr(data, "up_date", ...),
+ }
+ hosts.append({k: v for k, v in host.items() if v is not ...})
+ return hosts
+
def _invalidate_cache(self):
"""Remove cache data when updates are made."""
self._cache = {}
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
index 7df51baf4..68429d381 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -6,6 +6,7 @@ import logging
from django.apps import apps
from django.db import models
from django_fsm import FSMField, transition # type: ignore
+from registrar.models.domain import Domain
from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError
@@ -610,9 +611,11 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information
(will cascade), and send an email notification."""
-
if self.status == self.APPROVED:
- self.approved_domain.delete_request()
+ domain_state = self.approved_domain.state
+ # Only reject if it exists on EPP
+ if domain_state != Domain.State.UNKNOWN:
+ self.approved_domain.deletedInEpp()
self.approved_domain.delete()
self.approved_domain = None
@@ -638,7 +641,10 @@ class DomainApplication(TimeStampedModel):
and domain_information (will cascade) when they exist."""
if self.status == self.APPROVED:
- self.approved_domain.delete_request()
+ domain_state = self.approved_domain.state
+ # Only reject if it exists on EPP
+ if domain_state != Domain.State.UNKNOWN:
+ self.approved_domain.deletedInEpp()
self.approved_domain.delete()
self.approved_domain = None
diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html
index 1b8b90930..ac26fc922 100644
--- a/src/registrar/templates/django/admin/domain_change_form.html
+++ b/src/registrar/templates/django/admin/domain_change_form.html
@@ -15,7 +15,9 @@
{% endif %}
-
+ {% if original.state != original.State.DELETED %}
+
+ {% endif %}
{{ block.super }}
{% endblock %}
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
index fe41647f9..10c387099 100644
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -604,6 +604,16 @@ class MockEppLib(TestCase):
# 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.DeleteDomain)
+ and 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
+ )
return MagicMock(res_data=[self.mockDataInfoHosts])
def setUp(self):
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 9ff9ce451..def475536 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -49,6 +49,7 @@ class TestDomainAdmin(MockEppLib):
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
self.staffuser = create_user()
+ self.factory = RequestFactory()
super().setUp()
def test_place_and_remove_hold(self):
@@ -87,6 +88,155 @@ class TestDomainAdmin(MockEppLib):
self.assertContains(response, "Place hold")
self.assertNotContains(response, "Remove hold")
+ def test_deletion_is_successful(self):
+ """
+ Scenario: Domain deletion is unsuccessful
+ When the domain is deleted
+ Then a user-friendly success message is returned for displaying on the web
+ And `state` is et to `DELETED`
+ """
+ domain = create_ready_domain()
+ # Put in client hold
+ domain.place_client_hold()
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+
+ # 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, "Delete Domain in Registry")
+
+ # Test the info dialog
+ request = self.factory.post(
+ "/admin/registrar/domain/{}/change/".format(domain.pk),
+ {"_delete_domain": "Delete Domain in 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.INFO,
+ "Domain city.gov has been deleted. Thanks!",
+ extra_tags="",
+ fail_silently=False,
+ )
+
+ self.assertEqual(domain.state, Domain.State.DELETED)
+
+ def test_deletion_ready_fsm_failure(self):
+ """
+ Scenario: Domain deletion is unsuccessful
+ When an error is returned from epplibwrapper
+ Then a user-friendly error message is returned for displaying on the web
+ And `state` is not set to `DELETED`
+ """
+ domain = create_ready_domain()
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+
+ # 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, "Delete Domain in Registry")
+
+ # Test the error
+ request = self.factory.post(
+ "/admin/registrar/domain/{}/change/".format(domain.pk),
+ {"_delete_domain": "Delete Domain in 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: "
+ "Can't switch from state 'ready' to 'deleted'"
+ ", must be either 'dns_needed' or 'on_hold'",
+ extra_tags="",
+ fail_silently=False,
+ )
+
+ self.assertEqual(domain.state, Domain.State.READY)
+
+ def test_analyst_deletes_domain_idempotent(self):
+ """
+ Scenario: Analyst tries to delete an already deleted domain
+ Given `state` is already `DELETED`
+ When `domain.deletedInEpp()` is called
+ Then `commands.DeleteDomain` is sent to the registry
+ And Domain returns normally without an error dialog
+ """
+ domain = create_ready_domain()
+ # Put in client hold
+ domain.place_client_hold()
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+
+ # 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, "Delete Domain in Registry")
+
+ # Test the info dialog
+ request = self.factory.post(
+ "/admin/registrar/domain/{}/change/".format(domain.pk),
+ {"_delete_domain": "Delete Domain in Registry", "name": domain.name},
+ follow=True,
+ )
+ request.user = self.client
+
+ # Delete it once
+ 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.INFO,
+ "Domain city.gov has been deleted. Thanks!",
+ extra_tags="",
+ fail_silently=False,
+ )
+
+ self.assertEqual(domain.state, Domain.State.DELETED)
+
+ # Try to delete it again
+ # Test the info dialog
+ request = self.factory.post(
+ "/admin/registrar/domain/{}/change/".format(domain.pk),
+ {"_delete_domain": "Delete Domain in 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.INFO,
+ "This domain is already deleted",
+ extra_tags="",
+ fail_silently=False,
+ )
+
+ self.assertEqual(domain.state, Domain.State.DELETED)
+
@skip("Waiting on epp lib to implement")
def test_place_and_remove_hold_epp(self):
raise
@@ -138,8 +288,9 @@ class TestDomainApplicationAdminForm(TestCase):
)
-class TestDomainApplicationAdmin(TestCase):
+class TestDomainApplicationAdmin(MockEppLib):
def setUp(self):
+ super().setUp()
self.site = AdminSite()
self.factory = RequestFactory()
self.admin = DomainApplicationAdmin(
@@ -690,6 +841,7 @@ class TestDomainApplicationAdmin(TestCase):
domain_information.refresh_from_db()
def tearDown(self):
+ super().tearDown()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py
index 54045bb32..bf258db31 100644
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -5,7 +5,7 @@ This file tests the various ways in which the registrar interacts with the regis
"""
from django.test import TestCase
from django.db.utils import IntegrityError
-from unittest.mock import patch, call
+from unittest.mock import MagicMock, patch, call
import datetime
from registrar.models import Domain
@@ -16,10 +16,11 @@ from registrar.models.draft_domain import DraftDomain
from registrar.models.public_contact import PublicContact
from registrar.models.user import User
from .common import MockEppLib
-
+from django_fsm import TransitionNotAllowed # type: ignore
from epplibwrapper import (
commands,
common,
+ responses,
RegistryError,
ErrorCode,
)
@@ -51,8 +52,6 @@ class TestDomainCache(MockEppLib):
commands.InfoDomain(name="igorville.gov", auth_info=None),
cleaned=True,
),
- call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
- call(commands.InfoHost(name="fake.host.com"), cleaned=True),
],
any_order=False, # Ensure calls are in the specified order
)
@@ -74,8 +73,6 @@ class TestDomainCache(MockEppLib):
call(
commands.InfoDomain(name="igorville.gov", auth_info=None), cleaned=True
),
- call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
- call(commands.InfoHost(name="fake.host.com"), cleaned=True),
]
self.mockedSendFunction.assert_has_calls(expectedCalls)
@@ -110,6 +107,19 @@ class TestDomainCache(MockEppLib):
# 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])
+
+ # get contacts
+ domain._get_property("contacts")
+ self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
+ self.assertEqual(domain._cache["contacts"], [expectedContactsDict])
def tearDown(self) -> None:
Domain.objects.all().delete()
@@ -170,8 +180,6 @@ class TestDomainCreation(MockEppLib):
commands.InfoDomain(name="beef-tongue.gov", auth_info=None),
cleaned=True,
),
- call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
- call(commands.InfoHost(name="fake.host.com"), cleaned=True),
],
any_order=False, # Ensure calls are in the specified order
)
@@ -221,8 +229,6 @@ class TestDomainStatuses(MockEppLib):
commands.InfoDomain(name="chicken-liver.gov", auth_info=None),
cleaned=True,
),
- call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
- call(commands.InfoHost(name="fake.host.com"), cleaned=True),
],
any_order=False, # Ensure calls are in the specified order
)
@@ -263,6 +269,114 @@ class TestDomainStatuses(MockEppLib):
super().tearDown()
+class TestDomainAvailable(MockEppLib):
+ """Test Domain.available"""
+
+ # No SetUp or tearDown necessary for these tests
+
+ def test_domain_available(self):
+ """
+ Scenario: Testing whether an available domain is available
+ Should return True
+
+ Mock response to mimic EPP Response
+ Validate CheckDomain command is called
+ Validate response given mock
+ """
+
+ def side_effect(_request, cleaned):
+ return MagicMock(
+ res_data=[
+ responses.check.CheckDomainResultData(
+ name="available.gov", avail=True, reason=None
+ )
+ ],
+ )
+
+ patcher = patch("registrar.models.domain.registry.send")
+ mocked_send = patcher.start()
+ mocked_send.side_effect = side_effect
+
+ available = Domain.available("available.gov")
+ mocked_send.assert_has_calls(
+ [
+ call(
+ commands.CheckDomain(
+ ["available.gov"],
+ ),
+ cleaned=True,
+ )
+ ]
+ )
+ self.assertTrue(available)
+ patcher.stop()
+
+ def test_domain_unavailable(self):
+ """
+ Scenario: Testing whether an unavailable domain is available
+ Should return False
+
+ Mock response to mimic EPP Response
+ Validate CheckDomain command is called
+ Validate response given mock
+ """
+
+ def side_effect(_request, cleaned):
+ return MagicMock(
+ res_data=[
+ responses.check.CheckDomainResultData(
+ name="unavailable.gov", avail=False, reason="In Use"
+ )
+ ],
+ )
+
+ patcher = patch("registrar.models.domain.registry.send")
+ mocked_send = patcher.start()
+ mocked_send.side_effect = side_effect
+
+ available = Domain.available("unavailable.gov")
+ mocked_send.assert_has_calls(
+ [
+ call(
+ commands.CheckDomain(
+ ["unavailable.gov"],
+ ),
+ cleaned=True,
+ )
+ ]
+ )
+ self.assertFalse(available)
+ patcher.stop()
+
+ def test_domain_available_with_value_error(self):
+ """
+ Scenario: Testing whether an invalid domain is available
+ Should throw ValueError
+
+ Validate ValueError is raised
+ """
+ with self.assertRaises(ValueError):
+ Domain.available("invalid-string")
+
+ def test_domain_available_unsuccessful(self):
+ """
+ Scenario: Testing behavior when registry raises a RegistryError
+
+ Validate RegistryError is raised
+ """
+
+ def side_effect(_request, cleaned):
+ raise RegistryError(code=ErrorCode.COMMAND_SYNTAX_ERROR)
+
+ patcher = patch("registrar.models.domain.registry.send")
+ mocked_send = patcher.start()
+ mocked_send.side_effect = side_effect
+
+ with self.assertRaises(RegistryError):
+ Domain.available("raises-error.gov")
+ patcher.stop()
+
+
class TestRegistrantContacts(MockEppLib):
"""Rule: Registrants may modify their WHOIS data"""
@@ -940,7 +1054,7 @@ class TestAnalystLock(TestCase):
raise
-class TestAnalystDelete(TestCase):
+class TestAnalystDelete(MockEppLib):
"""Rule: Analysts may delete a domain"""
def setUp(self):
@@ -949,35 +1063,99 @@ class TestAnalystDelete(TestCase):
Given the analyst is logged in
And a domain exists in the registry
"""
- pass
+ super().setUp()
+ self.domain, _ = Domain.objects.get_or_create(
+ name="fake.gov", state=Domain.State.READY
+ )
+ self.domain_on_hold, _ = Domain.objects.get_or_create(
+ name="fake-on-hold.gov", state=Domain.State.ON_HOLD
+ )
+
+ def tearDown(self):
+ Domain.objects.all().delete()
+ super().tearDown()
- @skip("not implemented yet")
def test_analyst_deletes_domain(self):
"""
Scenario: Analyst permanently deletes a domain
- When `domain.delete()` is called
+ When `domain.deletedInEpp()` is called
Then `commands.DeleteDomain` is sent to the registry
And `state` is set to `DELETED`
"""
- raise
+ # Put the domain in client hold
+ self.domain.place_client_hold()
+ # Delete it...
+ self.domain.deletedInEpp()
+ self.mockedSendFunction.assert_has_calls(
+ [
+ call(
+ commands.DeleteDomain(name="fake.gov"),
+ cleaned=True,
+ )
+ ]
+ )
- @skip("not implemented yet")
- def test_analyst_deletes_domain_idempotent(self):
- """
- Scenario: Analyst tries to delete an already deleted domain
- Given `state` is already `DELETED`
- When `domain.delete()` is called
- Then `commands.DeleteDomain` is sent to the registry
- And Domain returns normally (without error)
- """
- raise
+ # 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)
+
+ # Cache should be invalidated
+ self.assertEqual(self.domain._cache, {})
- @skip("not implemented yet")
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
- When an error is returned from epplibwrapper
- Then a user-friendly error message is returned for displaying on the web
+ When a subdomain exists
+ Then a client error is returned of code 2305
And `state` is not set to `DELETED`
"""
- raise
+ # 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()
+ 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)
+
+ def test_deletion_ready_fsm_failure(self):
+ """
+ Scenario: Domain deletion is unsuccessful due to FSM rules
+ Given state is 'ready'
+ When `domain.deletedInEpp()` is called
+ and domain is of `state` is `READY`
+ Then an FSM error is returned
+ And `state` is not set to `DELETED`
+ """
+ self.assertEqual(self.domain.state, Domain.State.READY)
+ with self.assertRaises(TransitionNotAllowed) as err:
+ self.domain.deletedInEpp()
+ 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)
diff --git a/src/requirements.txt b/src/requirements.txt
index 52ded59fc..a5972c4dc 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -7,7 +7,7 @@ certifi==2023.7.22 ; python_version >= '3.6'
cfenv==0.5.3
cffi==1.15.1
charset-normalizer==3.1.0 ; python_full_version >= '3.7.0'
-cryptography==41.0.3 ; python_version >= '3.7'
+cryptography==41.0.4 ; python_version >= '3.7'
defusedxml==0.7.1 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
dj-database-url==2.0.0
dj-email-url==1.0.6