Merge pull request #1200 from cisagov/dk/1016-nameservers-ui

Issue 1016 - Nameserver UI (includes issues 919 and 1104)
This commit is contained in:
dave-kennedy-ecs 2023-10-30 15:34:57 -04:00 committed by GitHub
commit 228da2aafa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 549 additions and 143 deletions

View file

@ -45,7 +45,7 @@ except NameError:
# Attn: these imports should NOT be at the top of the file
try:
from .client import CLIENT, commands
from .errors import RegistryError, ErrorCode, CANNOT_CONTACT_REGISTRY, GENERIC_ERROR
from .errors import RegistryError, ErrorCode
from epplib.models import common, info
from epplib.responses import extensions
from epplib import responses
@ -61,6 +61,4 @@ __all__ = [
"info",
"ErrorCode",
"RegistryError",
"CANNOT_CONTACT_REGISTRY",
"GENERIC_ERROR",
]

View file

@ -1,8 +1,5 @@
from enum import IntEnum
CANNOT_CONTACT_REGISTRY = "Update failed. Cannot contact the registry."
GENERIC_ERROR = "Value entered was wrong."
class ErrorCode(IntEnum):
"""

View file

@ -231,41 +231,15 @@ function handleValidationClick(e) {
/**
* An IIFE that attaches a click handler for our dynamic nameservers form
*
* Only does something on a single page, but it should be fast enough to run
* it everywhere.
* Prepare the namerservers and DS data forms delete buttons
* We will call this on the forms init, and also every time we add a form
*
*/
(function prepareNameserverForms() {
let serverForm = document.querySelectorAll(".server-form");
let container = document.querySelector("#form-container");
let addButton = document.querySelector("#add-nameserver-form");
let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
let formNum = serverForm.length-1;
if (addButton)
addButton.addEventListener('click', addForm);
function addForm(e){
let newForm = serverForm[2].cloneNode(true);
let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g');
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`);
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`);
container.insertBefore(newForm, addButton);
newForm.querySelector("input").value = "";
totalForms.setAttribute('value', `${formNum+1}`);
}
})();
function prepareDeleteButtons() {
function prepareDeleteButtons(formLabel) {
let deleteButtons = document.querySelectorAll(".delete-record");
let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
let isNameserversForm = document.title.includes("DNS name servers |");
let addButton = document.querySelector("#add-form");
// Loop through each delete button and attach the click event listener
deleteButtons.forEach((deleteButton) => {
@ -273,13 +247,15 @@ function prepareDeleteButtons() {
});
function removeForm(e){
let formToRemove = e.target.closest(".ds-record");
let formToRemove = e.target.closest(".repeatable-form");
formToRemove.remove();
let forms = document.querySelectorAll(".ds-record");
let forms = document.querySelectorAll(".repeatable-form");
totalForms.setAttribute('value', `${forms.length}`);
let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g');
let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g');
let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g');
// For the example on Nameservers
let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g');
forms.forEach((form, index) => {
// Iterate over child nodes of the current element
@ -294,48 +270,88 @@ function prepareDeleteButtons() {
});
});
Array.from(form.querySelectorAll('h2, legend')).forEach((node) => {
node.textContent = node.textContent.replace(formLabelRegex, `DS Data record ${index + 1}`);
// h2 and legend for DS form, label for nameservers
Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => {
// Ticket: 1192
// if (isNameserversForm && index <= 1 && !node.innerHTML.includes('*')) {
// // Create a new element
// const newElement = document.createElement('abbr');
// newElement.textContent = '*';
// // TODO: finish building abbr
// // Append the new element to the parent
// node.appendChild(newElement);
// // Find the next sibling that is an input element
// let nextInputElement = node.nextElementSibling;
// while (nextInputElement) {
// if (nextInputElement.tagName === 'INPUT') {
// // Found the next input element
// console.log(nextInputElement);
// break;
// }
// nextInputElement = nextInputElement.nextElementSibling;
// }
// nextInputElement.required = true;
// }
// Ticket: 1192 - remove if
if (!(isNameserversForm && index <= 1)) {
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
}
});
// Display the add more button if we have less than 13 forms
if (isNameserversForm && forms.length <= 13) {
addButton.classList.remove("display-none")
}
});
}
}
/**
* An IIFE that attaches a click handler for our dynamic DNSSEC forms
* An IIFE that attaches a click handler for our dynamic formsets
*
* Only does something on a few pages, but it should be fast enough to run
* it everywhere.
*/
(function prepareDNSSECForms() {
let serverForm = document.querySelectorAll(".ds-record");
(function prepareFormsetsForms() {
let repeatableForm = document.querySelectorAll(".repeatable-form");
let container = document.querySelector("#form-container");
let addButton = document.querySelector("#add-ds-form");
let addButton = document.querySelector("#add-form");
let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
let cloneIndex = 0;
let formLabel = '';
let isNameserversForm = document.title.includes("DNS name servers |");
if (isNameserversForm) {
cloneIndex = 2;
formLabel = "Name server";
} else if ((document.title.includes("DS Data |")) || (document.title.includes("Key Data |"))) {
formLabel = "DS Data record";
}
// Attach click event listener on the delete buttons of the existing forms
prepareDeleteButtons();
prepareDeleteButtons(formLabel);
// Attack click event listener on the add button
if (addButton)
addButton.addEventListener('click', addForm);
/*
* Add a formset to the end of the form.
* For each element in the added formset, name the elements with the prefix,
* form-{#}-{element_name} where # is the index of the formset and element_name
* is the element's name.
* Additionally, update the form element's metadata, including totalForms' value.
*/
function addForm(e){
let forms = document.querySelectorAll(".ds-record");
let forms = document.querySelectorAll(".repeatable-form");
let formNum = forms.length;
let newForm = serverForm[0].cloneNode(true);
let newForm = repeatableForm[cloneIndex].cloneNode(true);
let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g');
let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g');
// For the eample on Nameservers
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `DS Data record ${formNum}`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`);
container.insertBefore(newForm, addButton);
let inputs = newForm.querySelectorAll("input");
@ -379,9 +395,13 @@ function prepareDeleteButtons() {
totalForms.setAttribute('value', `${formNum}`);
// Attach click event listener on the delete buttons of the new form
prepareDeleteButtons();
}
prepareDeleteButtons(formLabel);
// Hide the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) {
addButton.classList.add("display-none")
}
}
})();
/**

View file

@ -4,6 +4,10 @@
margin-top: units(3);
}
.usa-form .usa-button.margin-bottom-075 {
margin-bottom: units(1.5);
}
.usa-form .usa-button.margin-top-1 {
margin-top: units(1);
}

View file

@ -5,8 +5,12 @@ from django.core.validators import MinValueValidator, MaxValueValidator, RegexVa
from django.forms import formset_factory
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.utility.errors import (
NameserverError,
NameserverErrorCodes as nsErrorCodes,
)
from ..models import Contact, DomainInformation
from ..models import Contact, DomainInformation, Domain
from .common import (
ALGORITHM_CHOICES,
DIGEST_TYPE_CHOICES,
@ -19,16 +23,78 @@ class DomainAddUserForm(forms.Form):
email = forms.EmailField(label="Email")
class IPAddressField(forms.CharField):
def validate(self, value):
super().validate(value) # Run the default CharField validation
class DomainNameserverForm(forms.Form):
"""Form for changing nameservers."""
domain = forms.CharField(widget=forms.HiddenInput, required=False)
server = forms.CharField(label="Name server", strip=True)
# when adding IPs to this form ensure they are stripped as well
ip = forms.CharField(label="IP Address (IPv4 or IPv6)", strip=True, required=False)
def clean(self):
# clean is called from clean_forms, which is called from is_valid
# after clean_fields. it is used to determine form level errors.
# is_valid is typically called from view during a post
cleaned_data = super().clean()
self.clean_empty_strings(cleaned_data)
server = cleaned_data.get("server", "")
ip = cleaned_data.get("ip", None)
# remove ANY spaces in the ip field
ip = ip.replace(" ", "")
domain = cleaned_data.get("domain", "")
ip_list = self.extract_ip_list(ip)
if ip and not server and ip_list:
self.add_error("server", NameserverError(code=nsErrorCodes.MISSING_HOST))
elif server:
self.validate_nameserver_ip_combo(domain, server, ip_list)
return cleaned_data
def clean_empty_strings(self, cleaned_data):
ip = cleaned_data.get("ip", "")
if ip and len(ip.strip()) == 0:
cleaned_data["ip"] = None
def extract_ip_list(self, ip):
return [ip.strip() for ip in ip.split(",")] if ip else []
def validate_nameserver_ip_combo(self, domain, server, ip_list):
try:
Domain.checkHostIPCombo(domain, server, ip_list)
except NameserverError as e:
if e.code == nsErrorCodes.GLUE_RECORD_NOT_ALLOWED:
self.add_error(
"server",
NameserverError(
code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED,
nameserver=domain,
ip=ip_list,
),
)
elif e.code == nsErrorCodes.MISSING_IP:
self.add_error(
"ip",
NameserverError(
code=nsErrorCodes.MISSING_IP, nameserver=domain, ip=ip_list
),
)
else:
self.add_error("ip", str(e))
NameserverFormset = formset_factory(
DomainNameserverForm,
extra=1,
max_num=13,
validate_max=True,
)

View file

@ -262,8 +262,11 @@ class Domain(TimeStampedModel, DomainHelper):
"""Creates the host object in the registry
doesn't add the created host to the domain
returns ErrorCode (int)"""
if addrs is not None:
addresses = [epp.Ip(addr=addr) for addr in addrs]
if addrs is not None and addrs != []:
addresses = [
epp.Ip(addr=addr, ip="v6" if self.is_ipv6(addr) else None)
for addr in addrs
]
request = commands.CreateHost(name=host, addrs=addresses)
else:
request = commands.CreateHost(name=host)
@ -274,7 +277,7 @@ class Domain(TimeStampedModel, DomainHelper):
return response.code
except RegistryError as e:
logger.error("Error _create_host, code was %s error was %s" % (e.code, e))
return e.code
raise e
def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]):
"""converts a list of hosts into a dictionary
@ -293,14 +296,16 @@ class Domain(TimeStampedModel, DomainHelper):
newDict[tup[0]] = tup[1]
return newDict
def isSubdomain(self, nameserver: str):
@classmethod
def isSubdomain(cls, name: str, nameserver: str):
"""Returns boolean if the domain name is found in the argument passed"""
subdomain_pattern = r"([\w-]+\.)*"
full_pattern = subdomain_pattern + self.name
full_pattern = subdomain_pattern + name
regex = re.compile(full_pattern)
return bool(regex.match(nameserver))
def checkHostIPCombo(self, nameserver: str, ip: list[str]):
@classmethod
def checkHostIPCombo(cls, name: str, nameserver: str, ip: list[str]):
"""Checks the parameters past for a valid combination
raises error if:
- nameserver is a subdomain but is missing ip
@ -314,22 +319,23 @@ class Domain(TimeStampedModel, DomainHelper):
NameserverError (if exception hit)
Returns:
None"""
if self.isSubdomain(nameserver) and (ip is None or ip == []):
if cls.isSubdomain(name, nameserver) and (ip is None or ip == []):
raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
elif not self.isSubdomain(nameserver) and (ip is not None and ip != []):
elif not cls.isSubdomain(name, nameserver) and (ip is not None and ip != []):
raise NameserverError(
code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip
)
elif ip is not None and ip != []:
for addr in ip:
if not self._valid_ip_addr(addr):
if not cls._valid_ip_addr(addr):
raise NameserverError(
code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
)
return None
def _valid_ip_addr(self, ipToTest: str):
@classmethod
def _valid_ip_addr(cls, ipToTest: str):
"""returns boolean if valid ip address string
We currently only accept v4 or v6 ips
returns:
@ -382,7 +388,9 @@ class Domain(TimeStampedModel, DomainHelper):
if newHostDict[prevHost] is not None and set(
newHostDict[prevHost]
) != set(addrs):
self.checkHostIPCombo(nameserver=prevHost, ip=newHostDict[prevHost])
self.__class__.checkHostIPCombo(
name=self.name, nameserver=prevHost, ip=newHostDict[prevHost]
)
updated_values.append((prevHost, newHostDict[prevHost]))
new_values = {
@ -392,7 +400,9 @@ class Domain(TimeStampedModel, DomainHelper):
}
for nameserver, ip in new_values.items():
self.checkHostIPCombo(nameserver=nameserver, ip=ip)
self.__class__.checkHostIPCombo(
name=self.name, nameserver=nameserver, ip=ip
)
return (deleted_values, updated_values, new_values, previousHostDict)
@ -567,7 +577,11 @@ class Domain(TimeStampedModel, DomainHelper):
if len(hosts) > 13:
raise NameserverError(code=nsErrorCodes.TOO_MANY_HOSTS)
if self.state not in [self.State.DNS_NEEDED, self.State.READY]:
if self.state not in [
self.State.DNS_NEEDED,
self.State.READY,
self.State.UNKNOWN,
]:
raise ActionNotAllowed("Nameservers can not be " "set in the current state")
logger.info("Setting nameservers")
@ -1351,7 +1365,7 @@ class Domain(TimeStampedModel, DomainHelper):
@transition(
field="state",
source=[State.DNS_NEEDED],
source=[State.DNS_NEEDED, State.READY],
target=State.READY,
# conditions=[dns_not_needed]
)
@ -1514,7 +1528,7 @@ class Domain(TimeStampedModel, DomainHelper):
data = registry.send(req, cleaned=True).res_data[0]
host = {
"name": name,
"addrs": getattr(data, "addrs", ...),
"addrs": [item.addr for item in getattr(data, "addrs", [])],
"cr_date": getattr(data, "cr_date", ...),
"statuses": getattr(data, "statuses", ...),
"tr_date": getattr(data, "tr_date", ...),
@ -1539,10 +1553,9 @@ class Domain(TimeStampedModel, DomainHelper):
return []
for ip_addr in ip_list:
if self.is_ipv6(ip_addr):
edited_ip_list.append(epp.Ip(addr=ip_addr, ip="v6"))
else: # default ip addr is v4
edited_ip_list.append(epp.Ip(addr=ip_addr))
edited_ip_list.append(
epp.Ip(addr=ip_addr, ip="v6" if self.is_ipv6(ip_addr) else None)
)
return edited_ip_list
@ -1580,7 +1593,7 @@ class Domain(TimeStampedModel, DomainHelper):
return response.code
except RegistryError as e:
logger.error("Error _update_host, code was %s error was %s" % (e.code, e))
return e.code
raise e
def addAndRemoveHostsFromDomain(
self, hostsToAdd: list[str], hostsToDelete: list[str]

View file

@ -29,7 +29,7 @@
{{ formset.management_form }}
{% for form in formset %}
<fieldset class="ds-record">
<fieldset class="repeatable-form">
<legend class="sr-only">DS Data record {{forloop.counter}}</legend>
@ -74,7 +74,7 @@
</fieldset>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-ds-form">
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span>

View file

@ -11,40 +11,79 @@
<h1>DNS name servers</h1>
<p>Before your domain can be used we'll need information about your domain
name servers.</p>
<p>Before your domain can be used well need information about your domain name servers. Name server records indicate which DNS server is authoritative for your domain.</p>
<p>Add a name server record by entering the address (e.g., ns1.nameserver.com) in the name server fields below. You must add at least two name servers (13 max).</p>
<div class="usa-alert usa-alert--slim usa-alert--info">
<div class="usa-alert__body">
<p class="margin-top-0">Add an IP address only when your name server's address includes your domain name (e.g., if your domain name is "example.gov" and your name server is "ns1.example.gov,” then an IP address is required.) To add multiple IP addresses, separate them with commas.</p>
<p class="margin-bottom-0">This step is uncommon unless you self-host your DNS or use custom addresses for your nameserver.</p>
</div>
</div>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
<form class="usa-form usa-form--extra-large" method="post" novalidate id="form-container">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<div class="server-form">
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% if forloop.counter <= 2 %}
{% with attr_required=True %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
{% input_with_errors form.server %}
{% endif %}
{% endwith %}
<div class="repeatable-form">
<div class="grid-row grid-gap-2 flex-end">
<div class="tablet:grid-col-5">
{{ form.domain }}
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% if forloop.counter <= 2 %}
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
{% input_with_errors form.server %}
{% endif %}
{% endwith %}
</div>
<div class="tablet:grid-col-5">
{% with sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.ip %}
{% endwith %}
</div>
<div class="tablet:grid-col-2">
{% comment %} TODO: remove this if for 1192 {% endcomment %}
{% if forloop.counter > 2 %}
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-nameserver-form">
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another name server</span>
</button>
<button
type="submit"
class="usa-button"
>Save
</button>
</form>
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
This solution still works when we remove the 'add more' at 13 forms {% endcomment %}
<div class="margin-top-2">
<button
type="submit"
class="usa-button"
>Save
</button>
<button
type="submit"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Reset the data in the Name Server form to the registry state (undo changes)"
>Cancel
</button>
</div>
</form>
{% endblock %} {# domain_content #}

View file

@ -756,7 +756,7 @@ class MockEppLib(TestCase):
mockDataInfoHosts = fakedEppObject(
"lastPw",
cr_date=datetime.datetime(2023, 8, 25, 19, 45, 35),
addrs=["1.2.3.4", "2.3.4.5"],
addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")],
)
mockDataHostChange = fakedEppObject(
@ -813,7 +813,7 @@ class MockEppLib(TestCase):
"ns2.nameserverwithip.gov",
"ns3.nameserverwithip.gov",
],
addrs=["1.2.3.4", "2.3.4.5"],
addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")],
)
justNameserver = fakedEppObject(

View file

@ -107,7 +107,7 @@ class TestDomainCache(MockEppLib):
}
expectedHostsDict = {
"name": self.mockDataInfoDomain.hosts[0],
"addrs": self.mockDataInfoHosts.addrs,
"addrs": [item.addr for item in self.mockDataInfoHosts.addrs],
"cr_date": self.mockDataInfoHosts.cr_date,
}

View file

@ -10,10 +10,7 @@ class TestNameserverError(TestCase):
def test_with_no_ip(self):
"""Test NameserverError when no ip address is passed"""
nameserver = "nameserver val"
expected = (
f"Nameserver {nameserver} needs to have an "
"IP address because it is a subdomain"
)
expected = "Using your domain for a name server requires an IP address"
nsException = NameserverError(
code=nsErrorCodes.MISSING_IP, nameserver=nameserver
@ -38,7 +35,7 @@ class TestNameserverError(TestCase):
ip = "ip val"
nameserver = "nameserver val"
expected = f"Nameserver {nameserver} has an invalid IP address: {ip}"
expected = f"{nameserver}: Enter an IP address in the required format."
nsException = NameserverError(
code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
)

View file

@ -10,6 +10,10 @@ from .common import MockEppLib, completed_application # type: ignore
from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
from registrar.utility.errors import (
NameserverError,
NameserverErrorCodes,
)
from registrar.models import (
DomainApplication,
@ -1442,20 +1446,165 @@ class TestDomainNameservers(TestDomainOverview):
)
self.assertContains(page, "DNS name servers")
@skip("Broken by adding registry connection fix in ticket 848")
def test_domain_nameservers_form(self):
"""Can change domain's nameservers.
def test_domain_nameservers_form_submit_one_nameserver(self):
"""Nameserver form submitted with one nameserver throws error.
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 only one nameserver, should error
# regarding required fields
with less_console_noise(): # swallow log warning message
result = nameservers_page.form.submit()
# form submission was a post, response should be a redirect
# 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. form requires a minimum of 2 name servers
self.assertContains(result, "This field is required.", count=2, status_code=200)
def test_domain_nameservers_form_submit_subdomain_missing_ip(self):
"""Nameserver form catches missing ip error on subdomain.
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 without two hosts, both subdomains,
# only one has ips
nameservers_page.form["form-1-server"] = "ns2.igorville.gov"
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. subdomain missing an ip
self.assertContains(
result,
str(NameserverError(code=NameserverErrorCodes.MISSING_IP)),
count=2,
status_code=200,
)
def test_domain_nameservers_form_submit_missing_host(self):
"""Nameserver form catches error when host is missing.
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 without two hosts, both subdomains,
# only one has ips
nameservers_page.form["form-1-ip"] = "127.0.0.1"
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. nameserver has ip but missing host
self.assertContains(
result,
str(NameserverError(code=NameserverErrorCodes.MISSING_HOST)),
count=2,
status_code=200,
)
def test_domain_nameservers_form_submit_glue_record_not_allowed(self):
"""Nameserver form catches error when IP is present
but host not subdomain.
Uses self.app WebTest because we need to interact with forms.
"""
nameserver1 = "ns1.igorville.gov"
nameserver2 = "ns2.igorville.com"
valid_ip = "127.0.0.1"
# 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 without two hosts, both subdomains,
# only one has ips
nameservers_page.form["form-0-server"] = nameserver1
nameservers_page.form["form-1-server"] = nameserver2
nameservers_page.form["form-1-ip"] = valid_ip
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. nameserver has ip but missing host
self.assertContains(
result,
str(NameserverError(code=NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED)),
count=2,
status_code=200,
)
def test_domain_nameservers_form_submit_invalid_ip(self):
"""Nameserver form catches invalid IP on submission.
Uses self.app WebTest because we need to interact with forms.
"""
nameserver = "ns2.igorville.gov"
invalid_ip = "123"
# 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 without two hosts, both subdomains,
# only one has ips
nameservers_page.form["form-1-server"] = nameserver
nameservers_page.form["form-1-ip"] = invalid_ip
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. nameserver has ip but missing host
self.assertContains(
result,
str(
NameserverError(
code=NameserverErrorCodes.INVALID_IP, nameserver=nameserver
)
),
count=2,
status_code=200,
)
def test_domain_nameservers_form_submits_successfully(self):
"""Nameserver form submits successfully with valid input.
Uses self.app WebTest because we need to interact with forms.
"""
nameserver1 = "ns1.igorville.gov"
nameserver2 = "ns2.igorville.gov"
invalid_ip = "127.0.0.1"
# 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 without two hosts, both subdomains,
# only one has ips
nameservers_page.form["form-0-server"] = nameserver1
nameservers_page.form["form-1-server"] = nameserver2
nameservers_page.form["form-1-ip"] = invalid_ip
with less_console_noise(): # swallow log warning message
result = nameservers_page.form.submit()
# form submission was a successful post, response should be a 302
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
@ -1465,9 +1614,8 @@ class TestDomainNameservers(TestDomainOverview):
page = result.follow()
self.assertContains(page, "The name servers for this domain have been updated")
@skip("Broken by adding registry connection fix in ticket 848")
def test_domain_nameservers_form_invalid(self):
"""Can change domain's nameservers.
"""Nameserver form does not submit with invalid data.
Uses self.app WebTest because we need to interact with forms.
"""
@ -1482,9 +1630,9 @@ class TestDomainNameservers(TestDomainOverview):
with less_console_noise(): # swallow logged 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 field.
self.assertContains(result, "This field is required", count=2, status_code=200)
# error text appears four times, twice at the top of the page,
# once around each required field.
self.assertContains(result, "This field is required", count=4, status_code=200)
class TestDomainAuthorizingOfficial(TestDomainOverview):

View file

@ -20,6 +20,41 @@ class ActionNotAllowed(Exception):
pass
class GenericErrorCodes(IntEnum):
"""Used across the registrar for
error mapping.
Overview of generic error codes:
- 1 GENERIC_ERROR a generic value error
- 2 CANNOT_CONTACT_REGISTRY a connection error w registry
"""
GENERIC_ERROR = 1
CANNOT_CONTACT_REGISTRY = 2
class GenericError(Exception):
"""
GenericError class used to raise exceptions across
the registrar
"""
_error_mapping = {
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: (
"Update failed. Cannot contact the registry."
),
GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
}
def __init__(self, *args, code=None, **kwargs):
super().__init__(*args, **kwargs)
self.code = code
if self.code in self._error_mapping:
self.message = self._error_mapping.get(self.code)
def __str__(self):
return f"{self.message}"
class NameserverErrorCodes(IntEnum):
"""Used in the NameserverError class for
error mapping.
@ -29,6 +64,8 @@ class NameserverErrorCodes(IntEnum):
value but is not a subdomain
- 3 INVALID_IP invalid ip address format or invalid version
- 4 TOO_MANY_HOSTS more than the max allowed host values
- 5 UNABLE_TO_UPDATE_DOMAIN unable to update the domain
- 6 MISSING_HOST host is missing for a nameserver
"""
MISSING_IP = 1
@ -36,6 +73,7 @@ class NameserverErrorCodes(IntEnum):
INVALID_IP = 3
TOO_MANY_HOSTS = 4
UNABLE_TO_UPDATE_DOMAIN = 5
MISSING_HOST = 6
class NameserverError(Exception):
@ -45,11 +83,15 @@ class NameserverError(Exception):
"""
_error_mapping = {
NameserverErrorCodes.MISSING_IP: "Nameserver {} needs to have an "
"IP address because it is a subdomain",
NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED: "Nameserver {} cannot be linked "
"because it is not a subdomain",
NameserverErrorCodes.INVALID_IP: "Nameserver {} has an invalid IP address: {}",
NameserverErrorCodes.MISSING_IP: (
"Using your domain for a name server requires an IP address"
),
NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED: (
"Name server address does not match domain name"
),
NameserverErrorCodes.INVALID_IP: (
"{}: Enter an IP address in the required format."
),
NameserverErrorCodes.TOO_MANY_HOSTS: (
"Too many hosts provided, you may not have more than 13 nameservers."
),
@ -57,6 +99,9 @@ class NameserverError(Exception):
"Unable to update domain, changes were not applied."
"Check logs as a Registry Error is the likely cause"
),
NameserverErrorCodes.MISSING_HOST: (
"Name server must be provided to enter IP address."
),
}
def __init__(self, *args, code=None, nameserver=None, ip=None, **kwargs):
@ -65,7 +110,7 @@ class NameserverError(Exception):
if self.code in self._error_mapping:
self.message = self._error_mapping.get(self.code)
if nameserver is not None and ip is not None:
self.message = self.message.format(str(nameserver), str(ip))
self.message = self.message.format(str(nameserver))
elif nameserver is not None:
self.message = self.message.format(str(nameserver))
elif ip is not None:

View file

@ -23,6 +23,12 @@ from registrar.models import (
UserDomainRole,
)
from registrar.models.public_contact import PublicContact
from registrar.utility.errors import (
GenericError,
GenericErrorCodes,
NameserverError,
NameserverErrorCodes as nsErrorCodes,
)
from registrar.models.utility.contact_error import ContactError
from ..forms import (
@ -40,8 +46,6 @@ from epplibwrapper import (
common,
extensions,
RegistryError,
CANNOT_CONTACT_REGISTRY,
GENERIC_ERROR,
)
from ..utility.email import send_templated_email, EmailSendingError
@ -215,6 +219,7 @@ class DomainNameserversView(DomainFormBaseView):
template_name = "domain_nameservers.html"
form_class = NameserverFormset
model = Domain
def get_initial(self):
"""The initial value for the form (which is a formset here)."""
@ -223,7 +228,9 @@ class DomainNameserversView(DomainFormBaseView):
if nameservers is not None:
# Add existing nameservers as initial data
initial_data.extend({"server": name} for name, *ip in nameservers)
initial_data.extend(
{"server": name, "ip": ",".join(ip)} for name, ip in nameservers
)
# Ensure at least 3 fields, filled or empty
while len(initial_data) < 2:
@ -252,25 +259,82 @@ class DomainNameserversView(DomainFormBaseView):
form.fields["server"].required = True
else:
form.fields["server"].required = False
form.fields["domain"].initial = self.object.name
return formset
def post(self, request, *args, **kwargs):
"""Form submission posts to this view.
This post method harmonizes using DomainBaseView and FormMixin
"""
self._get_domain(request)
formset = self.get_form()
if "btn-cancel-click" in request.POST:
url = self.get_success_url()
return HttpResponseRedirect(url)
if formset.is_valid():
return self.form_valid(formset)
else:
return self.form_invalid(formset)
def form_valid(self, formset):
"""The formset is valid, perform something with it."""
self.request.session["nameservers_form_domain"] = self.object
# Set the nameservers from the formset
nameservers = []
for form in formset:
try:
as_tuple = (form.cleaned_data["server"],)
ip_string = form.cleaned_data["ip"]
# ip_string will be None or a string of IP addresses
# comma-separated
ip_list = []
if ip_string:
# Split the string into a list using a comma as the delimiter
ip_list = ip_string.split(",")
as_tuple = (
form.cleaned_data["server"],
ip_list,
)
nameservers.append(as_tuple)
except KeyError:
# no server information in this field, skip it
pass
self.object.nameservers = nameservers
messages.success(
self.request, "The name servers for this domain have been updated."
)
try:
self.object.nameservers = nameservers
except NameserverError as Err:
# NamserverErrors *should* be caught in form; if reached here,
# there was an uncaught error in submission (through EPP)
messages.error(
self.request, NameserverError(code=nsErrorCodes.UNABLE_TO_UPDATE_DOMAIN)
)
logger.error(f"Nameservers error: {Err}")
# TODO: registry is not throwing an error when no connection
except RegistryError as Err:
if Err.is_connection_error():
messages.error(
self.request,
GenericError(code=GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
)
logger.error(f"Registry connection error: {Err}")
else:
messages.error(
self.request, GenericError(code=GenericErrorCodes.GENERIC_ERROR)
)
logger.error(f"Registry error: {Err}")
else:
messages.success(
self.request,
"The name servers for this domain have been updated. "
"Keep in mind that DNS changes may take some time to "
"propagate across the internet. It can take anywhere "
"from a few minutes to 48 hours for your changes to take place.",
)
# superclass has the redirect
return super().form_valid(formset)
@ -431,10 +495,15 @@ class DomainDsDataView(DomainFormBaseView):
self.object.dnssecdata = dnssecdata
except RegistryError as err:
if err.is_connection_error():
messages.error(self.request, CANNOT_CONTACT_REGISTRY)
messages.error(
self.request,
GenericError(code=GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
)
logger.error(f"Registry connection error: {err}")
else:
messages.error(self.request, GENERIC_ERROR)
messages.error(
self.request, GenericError(code=GenericErrorCodes.GENERIC_ERROR)
)
logger.error(f"Registry error: {err}")
return self.form_invalid(formset)
else:
@ -510,7 +579,10 @@ class DomainSecurityEmailView(DomainFormBaseView):
# If no default is created for security_contact,
# then we cannot connect to the registry.
if contact is None:
messages.error(self.request, CANNOT_CONTACT_REGISTRY)
messages.error(
self.request,
GenericError(code=GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
)
return redirect(self.get_success_url())
contact.email = new_email
@ -519,13 +591,20 @@ class DomainSecurityEmailView(DomainFormBaseView):
contact.save()
except RegistryError as Err:
if Err.is_connection_error():
messages.error(self.request, CANNOT_CONTACT_REGISTRY)
messages.error(
self.request,
GenericError(code=GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
)
logger.error(f"Registry connection error: {Err}")
else:
messages.error(self.request, GENERIC_ERROR)
messages.error(
self.request, GenericError(code=GenericErrorCodes.GENERIC_ERROR)
)
logger.error(f"Registry error: {Err}")
except ContactError as Err:
messages.error(self.request, GENERIC_ERROR)
messages.error(
self.request, GenericError(code=GenericErrorCodes.GENERIC_ERROR)
)
logger.error(f"Generic registry error: {Err}")
else:
messages.success(