diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index 5d9000401..01079a670 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -19,6 +19,7 @@ jobs: || startsWith(github.head_ref, 'rh/') || startsWith(github.head_ref, 'nl/') || startsWith(github.head_ref, 'dk/') + || startsWith(github.head_ref, 'es/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 705014af1..3b1035657 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -15,6 +15,7 @@ on: options: - stable - staging + - es - nl - rh - za diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index 0bf1af2d9..654fa27b5 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - stable - staging + - es - nl - rh - za diff --git a/ops/manifests/manifest-es.yaml b/ops/manifests/manifest-es.yaml new file mode 100644 index 000000000..c4847553f --- /dev/null +++ b/ops/manifests/manifest-es.yaml @@ -0,0 +1,30 @@ +--- +applications: +- name: getgov-es + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-es.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov + routes: + - route: getgov-es.app.cloud.gov + services: + - getgov-credentials + - getgov-es-database diff --git a/src/api/tests/test_available.py b/src/api/tests/test_available.py index 39ddba071..0bbe01f03 100644 --- a/src/api/tests/test_available.py +++ b/src/api/tests/test_available.py @@ -39,7 +39,7 @@ class AvailableViewTest(TestCase): self.assertIn("gsa.gov", domains) # entries are all lowercase so GSA.GOV is not in the set self.assertNotIn("GSA.GOV", domains) - self.assertNotIn("igorville.gov", domains) + self.assertNotIn("igorvilleremixed.gov", domains) # all the entries have dots self.assertNotIn("gsa", domains) @@ -48,7 +48,7 @@ class AvailableViewTest(TestCase): # input is lowercased so GSA.GOV should be found self.assertTrue(in_domains("GSA.GOV")) # This domain should not have been registered - self.assertFalse(in_domains("igorville.gov")) + self.assertFalse(in_domains("igorvilleremixed.gov")) def test_in_domains_dotgov(self): """Domain searches work without trailing .gov""" @@ -56,7 +56,7 @@ class AvailableViewTest(TestCase): # input is lowercased so GSA.GOV should be found self.assertTrue(in_domains("GSA")) # This domain should not have been registered - self.assertFalse(in_domains("igorville")) + self.assertFalse(in_domains("igorvilleremixed")) def test_not_available_domain(self): """gsa.gov is not available""" @@ -66,17 +66,17 @@ class AvailableViewTest(TestCase): self.assertFalse(json.loads(response.content)["available"]) def test_available_domain(self): - """igorville.gov is still available""" - request = self.factory.get(API_BASE_PATH + "igorville.gov") + """igorvilleremixed.gov is still available""" + request = self.factory.get(API_BASE_PATH + "igorvilleremixed.gov") request.user = self.user - response = available(request, domain="igorville.gov") + response = available(request, domain="igorvilleremixed.gov") self.assertTrue(json.loads(response.content)["available"]) def test_available_domain_dotgov(self): - """igorville.gov is still available even without the .gov suffix""" - request = self.factory.get(API_BASE_PATH + "igorville") + """igorvilleremixed.gov is still available even without the .gov suffix""" + request = self.factory.get(API_BASE_PATH + "igorvilleremixed") request.user = self.user - response = available(request, domain="igorville") + response = available(request, domain="igorvilleremixed") self.assertTrue(json.loads(response.content)["available"]) def test_error_handling(self): diff --git a/src/epplibwrapper/__init__.py b/src/epplibwrapper/__init__.py index 996e840ce..8873476d4 100644 --- a/src/epplibwrapper/__init__.py +++ b/src/epplibwrapper/__init__.py @@ -48,6 +48,7 @@ try: from .errors import RegistryError, ErrorCode from epplib.models import common from epplib.responses import extensions + from epplib import responses except ImportError: pass @@ -56,6 +57,7 @@ __all__ = [ "commands", "common", "extensions", + "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/config/settings.py b/src/registrar/config/settings.py index e272e6622..ceb215a4d 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -570,6 +570,7 @@ SECURE_SSL_REDIRECT = True ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", + "getgov-es.app.cloud.gov", "getgov-nl.app.cloud.gov", "getgov-rh.app.cloud.gov", "getgov-za.app.cloud.gov", diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index a4e75dd2e..e1db054b1 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -82,6 +82,16 @@ class UserFixture: "first_name": "Nicolle", "last_name": "LeClair", }, + { + "username": "24840450-bf47-4d89-8aa9-c612fe68f9da", + "first_name": "Erin", + "last_name": "Song", + }, + { + "username": "e0ea8b94-6e53-4430-814a-849a7ca45f21", + "first_name": "Kristina", + "last_name": "Yin", + }, ] STAFF = [ @@ -134,6 +144,18 @@ 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", + }, + { + "username": "9a98e4c9-9409-479d-964e-4aec7799107f", + "first_name": "Kristina-Analyst", + "last_name": "Yin-Analyst", + "email": "kristina.yin+1@gsa.gov", + }, ] STAFF_PERMISSIONS = [ diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 0f2436bf7..34f23261d 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 @@ -637,11 +637,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() @@ -686,7 +681,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 @@ -847,16 +842,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", 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 b9e1ce16f..16dd30017 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -17,11 +17,12 @@ 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, extensions, + responses, RegistryError, ErrorCode, ) @@ -270,6 +271,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""" @@ -743,6 +852,9 @@ class TestRegistrantDNSSEC(MockEppLib): """ + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + def side_effect(_request, cleaned): return MagicMock( res_data=[self.mockDataInfoDomain], @@ -811,6 +923,9 @@ class TestRegistrantDNSSEC(MockEppLib): """ + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + def side_effect(_request, cleaned): return MagicMock( res_data=[self.mockDataInfoDomain], @@ -879,6 +994,9 @@ class TestRegistrantDNSSEC(MockEppLib): """ + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + def side_effect(_request, cleaned): return MagicMock( res_data=[self.mockDataInfoDomain], @@ -945,6 +1063,9 @@ class TestRegistrantDNSSEC(MockEppLib): """ + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + def side_effect(_request, cleaned): return MagicMock( res_data=[self.mockDataInfoDomain], @@ -1005,6 +1126,9 @@ class TestRegistrantDNSSEC(MockEppLib): Then a user-friendly error message is returned for displaying on the web """ + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + def side_effect(_request, cleaned): raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR) @@ -1260,7 +1384,7 @@ class TestAnalystLock(TestCase): raise -class TestAnalystDelete(TestCase): +class TestAnalystDelete(MockEppLib): """Rule: Analysts may delete a domain""" def setUp(self): @@ -1269,35 +1393,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)