diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index 583791fc1..ebbe4a376 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -21,6 +21,7 @@ jobs: || startsWith(github.head_ref, 'dk/') || startsWith(github.head_ref, 'es/') || startsWith(github.head_ref, 'ky/') + || startsWith(github.head_ref, 'backup/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index b1880c830..96078f932 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - backup - ky - es - nl diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index a28270a22..943175a87 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - backup - ky - es - nl diff --git a/ops/manifests/manifest-backup.yaml b/ops/manifests/manifest-backup.yaml new file mode 100644 index 000000000..c4615d1d5 --- /dev/null +++ b/ops/manifests/manifest-backup.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-backup + 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-backup.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 + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-backup.app.cloud.gov + services: + - getgov-credentials + - getgov-backup-database diff --git a/src/api/views.py b/src/api/views.py index 8cc2eda0c..56a2b786a 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -2,6 +2,9 @@ from django.apps import apps from django.views.decorators.http import require_http_methods from django.http import JsonResponse +from django.utils.safestring import mark_safe + +from registrar.templatetags.url_helpers import public_site_url import requests @@ -18,8 +21,13 @@ DOMAIN_API_MESSAGES = { " For example, if you want www.city.gov, you would enter “city”" " (without the quotes).", "extra_dots": "Enter the .gov domain you want without any periods.", - "unavailable": "That domain isn’t available. Try entering another one." - " Contact us if you need help coming up with a domain.", + # message below is considered safe; no user input can be inserted into the message + # body; public_site_url() function reads from local app settings and therefore safe + "unavailable": mark_safe( # nosec + "That domain isn’t available. " + "" + "Read more about choosing your .gov domain.".format(public_site_url("domains/choosing")) + ), "invalid": "Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens).", "success": "That domain is available!", "error": "Error finding domain availability. Please wait a few minutes and try again. If you continue \ diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 6585d602a..2f9bc97c5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -322,7 +322,7 @@ class WebsiteAdmin(ListHeaderAdmin): class UserDomainRoleAdmin(ListHeaderAdmin): - """Custom domain role admin class.""" + """Custom user domain role admin class.""" # Columns list_display = [ @@ -340,6 +340,8 @@ class UserDomainRoleAdmin(ListHeaderAdmin): ] search_help_text = "Search by user, domain, or role." + autocomplete_fields = ["user", "domain"] + class DomainInvitationAdmin(ListHeaderAdmin): """Custom domain invitation admin class.""" diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index b659b117e..d069e8dc4 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -115,14 +115,14 @@ function inlineToast(el, id, style, msg) { toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; toastBody.classList.add("usa-alert__body"); p.classList.add("usa-alert__text"); - p.innerText = msg; + p.innerHTML = msg; toastBody.appendChild(p); toast.appendChild(toastBody); el.parentNode.insertBefore(toast, el.nextSibling); } else { // update and show the existing message div toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; - toast.querySelector("div p").innerText = msg; + toast.querySelector("div p").innerHTML = msg; makeVisible(toast); } } else { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 56218c377..3afc81a35 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -245,3 +245,9 @@ h1, h2, h3 { padding-left: 90px; } } + +// Combo box +#select2-id_domain-results, +#select2-id_user-results { + width: 100%; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 3487b1b66..7f20c8129 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -627,6 +627,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-backup.app.cloud.gov", "getgov-ky.app.cloud.gov", "getgov-es.app.cloud.gov", "getgov-nl.app.cloud.gov", diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 9c09467cd..71c86ef41 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -118,8 +118,34 @@ class DomainNameserverForm(forms.Form): self.add_error("ip", str(e)) +class BaseNameserverFormset(forms.BaseFormSet): + def clean(self): + """ + Check for duplicate entries in the formset. + """ + if any(self.errors): + # Don't bother validating the formset unless each form is valid on its own + return + + data = [] + duplicates = [] + + for form in self.forms: + if form.cleaned_data: + value = form.cleaned_data["server"] + if value in data: + form.add_error( + "server", + NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value), + ) + duplicates.append(value) + else: + data.append(value) + + NameserverFormset = formset_factory( DomainNameserverForm, + formset=BaseNameserverFormset, extra=1, max_num=13, validate_max=True, diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 0070d4119..6599a2436 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1219,6 +1219,8 @@ class TestDomainOverview(TestWithDomainPermissions, WebTest): self.app.set_user(self.user.username) self.client.force_login(self.user) + +class TestDomainDetail(TestDomainOverview): def test_domain_detail_link_works(self): home_page = self.app.get("/") self.assertContains(home_page, "igorville.gov") @@ -1227,7 +1229,7 @@ class TestDomainOverview(TestWithDomainPermissions, WebTest): self.assertContains(detail_page, "igorville.gov") self.assertContains(detail_page, "Status") - def test_domain_overview_blocked_for_ineligible_user(self): + def test_domain_detail_blocked_for_ineligible_user(self): """We could easily duplicate this test for all domain management views, but a single url test should be solid enough since all domain management pages share the same permissions class""" @@ -1239,7 +1241,7 @@ class TestDomainOverview(TestWithDomainPermissions, WebTest): response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) self.assertEqual(response.status_code, 403) - def test_domain_overview_allowed_for_on_hold(self): + def test_domain_detail_allowed_for_on_hold(self): """Test that the domain overview page displays for on hold domain""" home_page = self.app.get("/") self.assertContains(home_page, "on-hold.gov") @@ -1248,7 +1250,7 @@ class TestDomainOverview(TestWithDomainPermissions, WebTest): detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain_on_hold.id})) self.assertNotContains(detail_page, "Edit") - def test_domain_see_just_nameserver(self): + def test_domain_detail_see_just_nameserver(self): home_page = self.app.get("/") self.assertContains(home_page, "justnameserver.com") @@ -1259,7 +1261,7 @@ class TestDomainOverview(TestWithDomainPermissions, WebTest): self.assertContains(detail_page, "ns1.justnameserver.com") self.assertContains(detail_page, "ns2.justnameserver.com") - def test_domain_see_nameserver_and_ip(self): + def test_domain_detail_see_nameserver_and_ip(self): home_page = self.app.get("/") self.assertContains(home_page, "nameserverwithip.gov") @@ -1275,7 +1277,7 @@ class TestDomainOverview(TestWithDomainPermissions, WebTest): self.assertContains(detail_page, "(1.2.3.4,") self.assertContains(detail_page, "2.3.4.5)") - def test_domain_with_no_information_or_application(self): + def test_domain_detail_with_no_information_or_application(self): """Test that domain management page returns 200 and displays error when no domain information or domain application exist""" # have to use staff user for this test @@ -1506,6 +1508,30 @@ class TestDomainNameservers(TestDomainOverview): status_code=200, ) + def test_domain_nameservers_form_submit_duplicate_host(self): + """Nameserver form catches error when host is duplicated. + + Uses self.app WebTest because we need to interact with forms. + """ + # initial nameservers page has one server with two ips + nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # attempt to submit the form with duplicate host names of fake.host.com + nameservers_page.form["form-0-ip"] = "" + nameservers_page.form["form-1-server"] = "fake.host.com" + with less_console_noise(): # swallow log warning message + result = nameservers_page.form.submit() + # form submission was a post with an error, response should be a 200 + # error text appears twice, once at the top of the page, once around + # the required field. remove duplicate entry + self.assertContains( + result, + str(NameserverError(code=NameserverErrorCodes.DUPLICATE_HOST)), + count=2, + status_code=200, + ) + def test_domain_nameservers_form_submit_whitespace(self): """Nameserver form removes whitespace from ip. diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 420c616cb..52b1ea1d3 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -68,7 +68,8 @@ class NameserverErrorCodes(IntEnum): - 4 TOO_MANY_HOSTS more than the max allowed host values - 5 MISSING_HOST host is missing for a nameserver - 6 INVALID_HOST host is invalid for a nameserver - - 7 BAD_DATA bad data input for nameserver + - 7 DUPLICATE_HOST host is a duplicate + - 8 BAD_DATA bad data input for nameserver """ MISSING_IP = 1 @@ -77,7 +78,8 @@ class NameserverErrorCodes(IntEnum): TOO_MANY_HOSTS = 4 MISSING_HOST = 5 INVALID_HOST = 6 - BAD_DATA = 7 + DUPLICATE_HOST = 7 + BAD_DATA = 8 class NameserverError(Exception): @@ -93,6 +95,7 @@ class NameserverError(Exception): NameserverErrorCodes.TOO_MANY_HOSTS: ("Too many hosts provided, you may not have more than 13 nameservers."), NameserverErrorCodes.MISSING_HOST: ("Name server must be provided to enter IP address."), NameserverErrorCodes.INVALID_HOST: ("Enter a name server in the required format, like ns1.example.com"), + NameserverErrorCodes.DUPLICATE_HOST: ("Remove duplicate entry"), NameserverErrorCodes.BAD_DATA: ( "There’s something wrong with the name server information you provided. " "If you need help email us at help@get.gov."