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