Merge branch 'main' of github.com:cisagov/manage.get.gov into es/476-add-cors-headers

This commit is contained in:
Erin 2023-11-06 09:58:10 -08:00
commit adfcecacce
No known key found for this signature in database
GPG key ID: 1CAD275313C62460
20 changed files with 743 additions and 124 deletions

View file

@ -9,6 +9,29 @@ our `user_group` model and run in a migration.
For more details, refer to the [user group model](../../src/registrar/models/user_group.py).
## Adding a user as analyst or granting full access via django-admin
If a new team member has joined, then they will need to be granted analyst (`cisa_analysts_group`) or full access (`full_access_group`) permissions in order to view the admin pages. These admin pages are the ones found at manage.get.gov/admin.
To do this, do the following:
1. The user in question will need to have a login.gov account and login into our system, this will create a `Users` table entry with their email address and name.
2. On that `Users` table note that the `GROUP` column should be blank for them as they have no special permissions yet.
3. Click on their username, then scroll down to the `User Permissions` section.
4. Under `User Permissions`, see the `Groups` table which has a column for `Available groups` and `Chosen groups`. Select the permission you want from the `Available groups` column and click the right arrow to move it to the `Chosen groups`. Note, if you want this user to be an analyst select `cisa_analysts_group`, otherwise select the `full_access_group`.
5. (Optional) If the user needs access to django admin (such as an analyst), then you will also need to make sure "Staff Status" is checked. This can be found in the same `User Permissions` section right below the checkbox for `Active`.
6. Click `Save` to apply all changes
## Removing a user group permission via django-admin
If an employee was given the wrong permissions or has had a change in roles that subsequently requires a permission change, then their permissions should be updated in django-admin. Much like in the previous section you can accomplish this by doing the following:
1. Go to the `Users` table an select the username for the user in question
2. Scroll down to the `User Permissions` section and find the `Groups` table which has a column for `Available groups` and `Chosen groups`.
3. In this table, select the permission you want to remove from the `Chosen groups` and then click the left facing arrow to move the permission to `Available groups`.
4. Depending on the scenario you may now need to add the opposite permission group to the `Chosen groups` section, please see the section above for instructions on how to do that.
5. If the user should no longer see the admin page, you must ensure that under `User Permissions`, `Staff status` is NOT checked.
6. Click `Save` to apply all changes
## Editing group permissions through code
We can edit and deploy new group permissions by:

View file

@ -76,16 +76,24 @@ These are the client certificate and its private key used to identify the regist
The private key is protected by a passphrase for safer transport and storage.
These were generated with:
These were generated with the following steps:
### Step 1: Generate an unencrypted private key with a named curve
```bash
openssl genpkey -out client.key \
-algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
-aes-256-cbc
openssl req -new -x509 -days 365 \
-key client.key -out client.crt \
-subj "/C=US/ST=DC/L=Washington/O=GSA/OU=18F/CN=GOV Prototype Registrar"
openssl ecparam -name prime256v1 -genkey -out client_unencrypted.key
```
### Step 2: Create an encrypted private key with a passphrase
```bash
openssl pkcs8 -topk8 -v2 aes-256-cbc -in client_unencrypted.key -out client.key
```
### Generate the certificate
```bash
openssl req -new -x509 -days 365 -key client.key -out client.crt -subj "/C=US/ST=DC/L=Washington/O=GSA/OU=18F/CN=GOV Prototype Registrar"
```
(If you can't use openssl on your computer directly, you can access it using Docker as `docker run --platform=linux/amd64 -it --rm -v $(pwd):/apps -w /apps alpine/openssl`.)
@ -97,7 +105,14 @@ base64 client.key
base64 client.crt
```
You'll need to give the new certificate to the registry vendor _before_ rotating it in production.
Note depending on your system you may need to instead run:
```bash
base64 -i client.key
base64 -i client.crt
```
You'll need to give the new certificate to the registry vendor _before_ rotating it in production. Once it has been accepted by the vender, make sure to update the kdbx file on Google Drive.
## REGISTRY_HOSTNAME

View file

@ -4,7 +4,7 @@ applications:
buildpacks:
- python_buildpack
path: ../../src
instances: 1
instances: 2
memory: 512M
stack: cflinuxfs4
timeout: 180
@ -23,6 +23,8 @@ applications:
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
# Which OIDC provider to use
OIDC_ACTIVE_PROVIDER: login.gov production
routes:
- route: getgov-stable.app.cloud.gov
services:

View file

@ -4,7 +4,7 @@ applications:
buildpacks:
- python_buildpack
path: ../../src
instances: 1
instances: 2
memory: 512M
stack: cflinuxfs4
timeout: 180

View file

@ -1,5 +1,6 @@
import logging
from django import forms
from django.http import HttpResponse
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@ -10,6 +11,7 @@ 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 registrar.utility import csv_export
from . import models
from auditlog.models import LogEntry # type: ignore
from auditlog.admin import LogEntryAdmin # type: ignore
@ -747,8 +749,59 @@ class DomainAdmin(ListHeaderAdmin):
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
change_list_template = "django/admin/domain_change_list.html"
readonly_fields = ["state"]
def export_data_type(self, request):
# match the CSV example with all the fields
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"'
csv_export.export_data_type_to_csv(response)
return response
def export_data_full(self, request):
# Smaller export based on 1
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="current-full.csv"'
csv_export.export_data_full_to_csv(response)
return response
def export_data_federal(self, request):
# Federal only
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="current-federal.csv"'
csv_export.export_data_federal_to_csv(response)
return response
def get_urls(self):
from django.urls import path
urlpatterns = super().get_urls()
# Used to extrapolate a path name, for instance
# name="{app_label}_{model_name}_export_data_type"
info = self.model._meta.app_label, self.model._meta.model_name
my_url = [
path(
"export_data_type/",
self.export_data_type,
name="%s_%s_export_data_type" % info,
),
path(
"export_data_full/",
self.export_data_full,
name="%s_%s_export_data_full" % info,
),
path(
"export_data_federal/",
self.export_data_federal,
name="%s_%s_export_data_federal" % info,
),
]
return my_url + urlpatterns
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {

View file

@ -273,31 +273,35 @@ function prepareDeleteButtons(formLabel) {
// 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
// If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required)
// inject the USWDS required markup and make sure the INPUT is required
if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
// Create a new element
const newElement = document.createElement('abbr');
newElement.textContent = '*';
newElement.setAttribute("title", "required");
newElement.classList.add("usa-hint", "usa-hint--required");
// // Append the new element to the parent
// node.appendChild(newElement);
// // Find the next sibling that is an input element
// let nextInputElement = node.nextElementSibling;
// Append the new element to the label
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;
// }
while (nextInputElement) {
if (nextInputElement.tagName === 'INPUT') {
// Found the next input element
nextInputElement.setAttribute("required", "")
break;
}
nextInputElement = nextInputElement.nextElementSibling;
}
nextInputElement.required = true;
}
// Ticket: 1192 - remove if
if (!(isNameserversForm && index <= 1)) {
let innerSpan = node.querySelector('span')
if (innerSpan) {
innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
} else {
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
}
@ -305,7 +309,15 @@ function prepareDeleteButtons(formLabel) {
// Display the add more button if we have less than 13 forms
if (isNameserversForm && forms.length <= 13) {
addButton.classList.remove("display-none")
console.log('remove disabled');
addButton.removeAttribute("disabled");
}
if (isNameserversForm && forms.length < 3) {
// Hide the delete buttons on the remaining nameservers
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.setAttribute("disabled", "true");
});
}
});
@ -333,6 +345,11 @@ function prepareDeleteButtons(formLabel) {
formLabel = "DS Data record";
}
// On load: Disable the add more button if we have 13 forms
if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) {
addButton.setAttribute("disabled", "true");
}
// Attach click event listener on the delete buttons of the existing forms
prepareDeleteButtons(formLabel);
@ -348,6 +365,33 @@ function prepareDeleteButtons(formLabel) {
// For the eample on Nameservers
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
// Some Nameserver form checks since the delete can mess up the source object we're copying
// in regards to required fields and hidden delete buttons
if (isNameserversForm) {
// If the source element we're copying has required on an input,
// reset that input
let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*');
if (formRequiredNeedsCleanUp) {
newForm.querySelector('label abbr').remove();
// Get all input elements within the container
const inputElements = newForm.querySelectorAll("input");
// Loop through each input element and remove the 'required' attribute
inputElements.forEach((input) => {
if (input.hasAttribute("required")) {
input.removeAttribute("required");
}
});
}
// If the source element we're copying has an disabled delete button,
// enable that button
let deleteButton= newForm.querySelector('.delete-record');
if (deleteButton.hasAttribute("disabled")) {
deleteButton.removeAttribute("disabled");
}
}
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
@ -397,9 +441,18 @@ function prepareDeleteButtons(formLabel) {
// Attach click event listener on the delete buttons of the new form
prepareDeleteButtons(formLabel);
// Hide the add more button if we have 13 forms
// Disable the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) {
addButton.classList.add("display-none")
addButton.setAttribute("disabled", "true");
}
if (isNameserversForm && forms.length >= 2) {
// Enable the delete buttons on the nameservers
forms.forEach((form, index) => {
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.removeAttribute("disabled");
});
});
}
}
})();

View file

@ -180,3 +180,48 @@ h1, h2, h3 {
background: var(--primary);
color: var(--header-link-color);
}
// Font mismatch issue due to conflicts between django and uswds,
// rough overrides for consistency and readability. May want to revise
// in the future
.object-tools li a,
.object-tools p a {
font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
text-transform: capitalize!important;
font-size: 14px!important;
}
// For consistency, make the overrided p a
// object tool buttons the same size as the ul li a
.object-tools p {
line-height: 1.25rem;
}
// Fix margins in mobile view
@media (max-width: 767px) {
.object-tools li {
// our CSS is read before django's, so need !important
// to override
margin-left: 0!important;
margin-right: 15px;
}
}
// Fix height of buttons
.object-tools li {
height: auto;
}
// Fixing height of buttons breaks layout because
// object-tools and changelist are siblings with
// flexbox positioning
#changelist {
clear: both;
}
// Account for the h2, roughly 90px
@include at-media(tablet) {
.object-tools {
padding-left: 90px;
}
}

View file

@ -49,6 +49,7 @@ env_debug = env.bool("DJANGO_DEBUG", default=False)
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
env_base_url = env.str("DJANGO_BASE_URL")
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
secret_login_key = b64decode(secret("DJANGO_SECRET_LOGIN_KEY", ""))
secret_key = secret("DJANGO_SECRET_KEY")
@ -382,8 +383,7 @@ LOGGING = {
# each handler has its choice of format
"formatters": {
"verbose": {
"format": "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] "
"%(message)s",
"format": "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
"datefmt": "%d/%b/%Y %H:%M:%S",
},
"simple": {
@ -494,11 +494,12 @@ OIDC_ALLOW_DYNAMIC_OP = False
# which provider to use if multiple are available
# (code does not currently support user selection)
OIDC_ACTIVE_PROVIDER = "login.gov"
# See above for the default value if the env variable is missing
OIDC_ACTIVE_PROVIDER = env_oidc_active_provider
OIDC_PROVIDERS = {
"login.gov": {
"identity sandbox": {
"srv_discovery_url": "https://idp.int.identitysandbox.gov",
"behaviour": {
# the 'code' workflow requires direct connectivity from us to Login.gov
@ -514,7 +515,26 @@ OIDC_PROVIDERS = {
"token_endpoint_auth_method": ["private_key_jwt"],
"sp_private_key": secret_login_key,
},
}
},
"login.gov production": {
"srv_discovery_url": "https://secure.login.gov",
"behaviour": {
# the 'code' workflow requires direct connectivity from us to Login.gov
"response_type": "code",
"scope": ["email", "profile:name", "phone"],
"user_info_request": ["email", "first_name", "last_name", "phone"],
"acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
},
"client_registration": {
"client_id": (
"urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"
),
"redirect_uris": [f"{env_base_url}/openid/callback/login/"],
"post_logout_redirect_uris": [f"{env_base_url}/openid/callback/logout/"],
"token_endpoint_auth_method": ["private_key_jwt"],
"sp_private_key": secret_login_key,
},
},
}
# endregion

View file

@ -23,11 +23,6 @@ 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."""
@ -35,7 +30,21 @@ class DomainNameserverForm(forms.Form):
server = forms.CharField(label="Name server", strip=True)
ip = forms.CharField(label="IP Address (IPv4 or IPv6)", strip=True, required=False)
ip = forms.CharField(
label="IP address (IPv4 or IPv6)",
strip=True,
required=False,
)
def __init__(self, *args, **kwargs):
super(DomainNameserverForm, self).__init__(*args, **kwargs)
# add custom error messages
self.fields["server"].error_messages.update(
{
"required": "A minimum of 2 name servers are required.",
}
)
def clean(self):
# clean is called from clean_forms, which is called from is_valid
@ -44,6 +53,11 @@ class DomainNameserverForm(forms.Form):
cleaned_data = super().clean()
self.clean_empty_strings(cleaned_data)
server = cleaned_data.get("server", "")
# remove ANY spaces in the server field
server = server.replace(" ", "")
# lowercase the server
server = server.lower()
cleaned_data["server"] = server
ip = cleaned_data.get("ip", None)
# remove ANY spaces in the ip field
ip = ip.replace(" ", "")
@ -52,7 +66,7 @@ class DomainNameserverForm(forms.Form):
ip_list = self.extract_ip_list(ip)
# validate if the form has a server or an ip
if ip and ip_list or server:
if (ip and ip_list) or server:
self.validate_nameserver_ip_combo(domain, server, ip_list)
return cleaned_data

View file

@ -45,8 +45,10 @@ class Command(BaseCommand):
self.transition_domains = TransitionDomain.objects.filter(
email_sent=False,
).order_by("username")
logger.info("Found %d transition domains", len(self.transition_domains))
self.build_emails_to_send_array()
logger.info("Prepared %d emails to send", len(self.emails_to_send))
if options["send_emails"]:
logger.info("about to send emails")
@ -58,6 +60,12 @@ class Command(BaseCommand):
logger.info("done sending emails and updating transition_domains")
else:
logger.info("not sending emails")
for email_context in self.emails_to_send:
logger.info(
"would send email to %s for %s",
email_context["email"],
email_context["domains"],
)
def build_emails_to_send_array(self):
"""this method sends emails to distinct usernames"""

View file

@ -3,7 +3,6 @@ import logging
import ipaddress
import re
from datetime import date
from string import digits
from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
@ -277,7 +276,12 @@ 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))
raise e
# OBJECT_EXISTS is an expected error code that should not raise
# an exception, rather return the code to be handled separately
if e.code == ErrorCode.OBJECT_EXISTS:
return e.code
else:
raise e
def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]):
"""converts a list of hosts into a dictionary
@ -305,30 +309,29 @@ class Domain(TimeStampedModel, DomainHelper):
return bool(regex.match(nameserver))
@classmethod
def isValidDomain(cls, nameserver: str):
def isValidHost(cls, nameserver: str):
"""Checks for validity of nameserver string based on these rules:
- first character is alpha
- last character is not - or .
- all characters alpha, 0-9, -, or .
- 2 character min, 24 character max (not including dashes/periods)
- first character is alpha or digit
- first and last character in each label is alpha or digit
- all characters alpha (lowercase), digit, -, or .
- each label has a min length of 1 and a max length of 63
- total host name has a max length of 253
"""
# pattern to test for valid domain
# pattern = r'^[a-zA-Z][a-zA-Z0-9-.]{0,22}[a-zA-Z0-9]$'
pattern = r"^[a-zA-Z][a-zA-Z0-9-.]*(\.[a-zA-Z0-9-]+){2}[a-zA-Z0-9]$"
# label pattern for each section of the host name, separated by .
labelpattern = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
# lookahead pattern ensures first character not - and total length < 254
lookaheadpatterns = r"^((?!-))(?=.{1,253}\.?$)"
# pattern assembles lookaheadpatterns and ensures there are at least
# 3 labels in the host name
pattern = lookaheadpatterns + labelpattern + r"(\." + labelpattern + r"){2,}$"
# attempt to match the pattern
match = re.match(pattern, nameserver)
# length of nameserver, not including - or .
characters_to_exclude = "-."
filtered_nameserver = "".join(
char for char in nameserver if char not in characters_to_exclude
)
nameserverLength = len(filtered_nameserver)
# return true if nameserver matches, and length less than 25;
# return true if nameserver matches
# otherwise false
return bool(match) and nameserverLength < 25
return bool(match)
@classmethod
def checkHostIPCombo(cls, name: str, nameserver: str, ip: list[str]):
@ -349,7 +352,7 @@ class Domain(TimeStampedModel, DomainHelper):
None"""
if ip and not nameserver:
raise NameserverError(code=nsErrorCodes.MISSING_HOST)
elif nameserver and not cls.isValidDomain(nameserver):
elif nameserver and not cls.isValidHost(nameserver):
raise NameserverError(code=nsErrorCodes.INVALID_HOST, nameserver=nameserver)
elif cls.isSubdomain(name, nameserver) and (ip is None or ip == []):
raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
@ -362,7 +365,7 @@ class Domain(TimeStampedModel, DomainHelper):
for addr in ip:
if not cls._valid_ip_addr(addr):
raise NameserverError(
code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
code=nsErrorCodes.INVALID_IP, nameserver=nameserver[:40], ip=ip
)
return None
@ -1228,34 +1231,6 @@ class Domain(TimeStampedModel, DomainHelper):
# ForeignKey on DomainInvitation creates an "invitations" member for
# all of the invitations that have been sent for this domain
def _validate_host_tuples(self, hosts: list[tuple[str]]):
"""
Helper function. Validate hostnames and IP addresses.
Raises:
ValueError if hostname or IP address appears invalid or mismatched.
"""
for host in hosts:
hostname = host[0].lower()
addresses: tuple[str] = host[1:] # type: ignore
if not bool(Domain.HOST_REGEX.match(hostname)):
raise ValueError("Invalid hostname: %s." % hostname)
if len(hostname) > Domain.MAX_LENGTH:
raise ValueError("Too long hostname: %s" % hostname)
is_subordinate = hostname.split(".", 1)[-1] == self.name
if is_subordinate and len(addresses) == 0:
raise ValueError(
"Must supply IP addresses for subordinate host %s" % hostname
)
if not is_subordinate and len(addresses) > 0:
raise ValueError("Must not supply IP addresses for %s" % hostname)
for address in addresses:
allow = set(":." + digits)
if any(c not in allow for c in address):
raise ValueError("Invalid IP address: %s." % address)
def _get_or_create_domain(self):
"""Try to fetch info about this domain. Create it if it does not exist."""
already_tried_to_create = False
@ -1625,7 +1600,12 @@ 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))
raise e
# OBJECT_EXISTS is an expected error code that should not raise
# an exception, rather return the code to be handled separately
if e.code == ErrorCode.OBJECT_EXISTS:
return e.code
else:
raise e
def addAndRemoveHostsFromDomain(
self, hostsToAdd: list[str], hostsToDelete: list[str]

View file

@ -11,10 +11,6 @@ class DomainHelper:
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}$")
# a domain name is alphanumeric or hyphen, has at least 2 dots, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
HOST_REGEX = re.compile(r"^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.){2,}([A-Za-z]){2,6}$")
# a domain can be no longer than 253 characters in total
MAX_LENGTH = 253

View file

@ -0,0 +1,23 @@
{% extends "admin/change_list.html" %}
{% block object-tools %}
<ul class="object-tools">
<li>
<a href="{% url 'admin:registrar_domain_export_data_type' %}" class="button">Export all domain metadata</a>
</li>
<li>
<a href="{% url 'admin:registrar_domain_export_data_full' %}" class="button">Export current-full.csv</a>
</li>
<li>
<a href="{% url 'admin:registrar_domain_export_data_federal' %}" class="button">Export current-federal.csv</a>
</li>
{% if has_add_permission %}
<li>
<a href="{% url 'admin:registrar_domain_add' %}" class="addlink">
Add Domain
</a>
</li>
{% endif %}
</ul>
{% endblock %}

View file

@ -2,8 +2,12 @@
class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}"
{% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %}
>
{{ field.label }}
{% if widget.attrs.required %}
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
{% endif %}
{% if span_for_text %}
<span>{{ field.label }}</span>
{% else %}
{{ field.label }}
{% endif %}
{% if widget.attrs.required %}
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
{% endif %}
</{{ label_tag }}>

View file

@ -17,7 +17,7 @@
<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-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>
@ -35,11 +35,14 @@
{{ 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" %}
{# span_for_text will wrap the copy in s <span>, which we'll use in the JS for this component #}
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" span_for_text=True %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
{% input_with_errors form.server %}
{% with span_for_text=True %}
{% input_with_errors form.server %}
{% endwith %}
{% endif %}
{% endwith %}
</div>
@ -49,14 +52,11 @@
{% 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 %}
<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>
</div>
</div>
</div>
@ -81,7 +81,7 @@
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)"
aria-label="Reset the data in the name server form to the registry state (undo changes)"
>Cancel
</button>
</div>

View file

@ -1630,6 +1630,56 @@ class TestRegistrantNameservers(MockEppLib):
return super().tearDown()
class TestNameserverValidation(TestCase):
"""Test the isValidDomain method which validates nameservers"""
def test_255_chars_is_too_long(self):
"""Test that domain of 255 chars or longer is invalid"""
domain_too_long = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.gov"
)
self.assertFalse(Domain.isValidHost(domain_too_long))
def test_64_char_label_too_long(self):
"""Test that label of 64 characters or longer is invalid"""
label_too_long = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
domain_label_too_long = "www." + label_too_long + ".gov"
self.assertFalse(Domain.isValidHost(domain_label_too_long))
def test_only_tld_and_sld(self):
"""Test that host with only a tld and sld is invalid"""
tld = "gov"
sld = "example"
domain_with_sld_and_tld = sld + "." + tld
self.assertFalse(Domain.isValidHost(domain_with_sld_and_tld))
def test_improper_chars_in_nameserver(self):
"""Test that host with improper chars is invalid"""
invalid_chars = "*&^"
domain_with_invalid_chars = "www.bad--" + invalid_chars + ".gov"
self.assertFalse(Domain.isValidHost(domain_with_invalid_chars))
def test_misplaced_dashes(self):
"""Test that misplaced dashes are invalid"""
self.assertFalse(Domain.isValidHost("-www.example.gov"))
self.assertFalse(Domain.isValidHost("www.example-.gov"))
self.assertTrue(Domain.isValidHost("www.ex-ample.gov"))
def test_valid_hostname(self):
"""Test that valid hostnames are valid"""
self.assertTrue(Domain.isValidHost("www.tld.sld.gov"))
self.assertTrue(Domain.isValidHost("www.valid.c"))
self.assertTrue(Domain.isValidHost("2ww.valid.gov"))
self.assertTrue(Domain.isValidHost("w.t.g"))
class TestRegistrantDNSSEC(MockEppLib):
"""Rule: Registrants may modify their secure DNS data"""

View file

@ -0,0 +1,195 @@
from django.test import TestCase
from io import StringIO
import csv
from registrar.models.domain_information import DomainInformation
from registrar.models.domain import Domain
from registrar.models.user import User
from django.contrib.auth import get_user_model
from registrar.utility.csv_export import export_domains_to_writer
class ExportDataTest(TestCase):
def setUp(self):
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
self.domain_1, _ = Domain.objects.get_or_create(
name="cdomain1.gov", state=Domain.State.READY
)
self.domain_2, _ = Domain.objects.get_or_create(
name="adomain2.gov", state=Domain.State.DNS_NEEDED
)
self.domain_3, _ = Domain.objects.get_or_create(
name="ddomain3.gov", state=Domain.State.ON_HOLD
)
self.domain_4, _ = Domain.objects.get_or_create(
name="bdomain4.gov", state=Domain.State.UNKNOWN
)
self.domain_4, _ = Domain.objects.get_or_create(
name="bdomain4.gov", state=Domain.State.UNKNOWN
)
self.domain_information_1, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_1,
organization_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_2,
organization_type="interstate",
)
self.domain_information_3, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_3,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
def tearDown(self):
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
User.objects.all().delete()
super().tearDown()
def test_export_domains_to_writer(self):
"""Test that export_domains_to_writer returns the
existing domain, test that sort by domain name works,
test that filter works"""
# Create a CSV file in memory
csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"AO",
"AO email",
"Submitter",
"Submitter title",
"Submitter email",
"Submitter phone",
"Security Contact Email",
"Status",
]
sort_fields = ["domain__name"]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
# Call the export function
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
# We expect READY domains,
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,AO,"
"AO email,Submitter,Submitter title,Submitter email,Submitter phone,"
"Security Contact Email,Status\n"
"adomain2.gov,Interstate,dnsneeded\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,ready\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,onhold\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = (
csv_content.replace(",,", "")
.replace(",", "")
.replace(" ", "")
.replace("\r\n", "\n")
.strip()
)
expected_content = (
expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
)
self.assertEqual(csv_content, expected_content)
def test_export_domains_to_writer_additional(self):
"""An additional test for filters and multi-column sort"""
# Create a CSV file in memory
csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"Security Contact Email",
]
sort_fields = ["domain__name", "federal_agency", "organization_type"]
filter_condition = {
"organization_type__icontains": "federal",
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
# Call the export function
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
# We expect READY domains,
# federal only
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,"
"State,Security Contact Email\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = (
csv_content.replace(",,", "")
.replace(",", "")
.replace(" ", "")
.replace("\r\n", "\n")
.strip()
)
expected_content = (
expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
)
self.assertEqual(csv_content, expected_content)

View file

@ -1170,6 +1170,7 @@ class TestWithDomainPermissions(TestWithUser):
if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete()
DomainApplication.objects.all().delete()
DomainInformation.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
UserDomainRole.objects.all().delete()
@ -1464,7 +1465,12 @@ class TestDomainNameservers(TestDomainOverview):
# 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)
self.assertContains(
result,
"A minimum of 2 name servers are required.",
count=2,
status_code=200,
)
def test_domain_nameservers_form_submit_subdomain_missing_ip(self):
"""Nameserver form catches missing ip error on subdomain.
@ -1665,7 +1671,12 @@ class TestDomainNameservers(TestDomainOverview):
# form submission was a post with an error, response should be a 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)
self.assertContains(
result,
"A minimum of 2 name servers are required.",
count=4,
status_code=200,
)
class TestDomainAuthorizingOfficial(TestDomainOverview):
@ -1833,7 +1844,11 @@ class TestDomainSecurityEmail(TestDomainOverview):
(
"RegistryError",
form_data_registry_error,
"Update failed. Cannot contact the registry.",
"""
Were experiencing a system connection error. Please wait a few minutes
and try again. If you continue to receive this error after a few tries,
contact help@get.gov
""",
),
("ContactError", form_data_contact_error, "Value entered was wrong."),
(
@ -1868,7 +1883,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
self.assertEqual(len(messages), 1)
message = messages[0]
self.assertEqual(message.tags, message_tag)
self.assertEqual(message.message, expected_message)
self.assertEqual(message.message.strip(), expected_message.strip())
def test_domain_overview_blocked_for_ineligible_user(self):
"""We could easily duplicate this test for all domain management

View file

@ -0,0 +1,119 @@
import csv
from registrar.models.domain import Domain
from registrar.models.domain_information import DomainInformation
from registrar.models.public_contact import PublicContact
def export_domains_to_writer(writer, columns, sort_fields, filter_condition):
# write columns headers to writer
writer.writerow(columns)
domainInfos = DomainInformation.objects.filter(**filter_condition).order_by(
*sort_fields
)
for domainInfo in domainInfos:
security_contacts = domainInfo.domain.contacts.filter(
contact_type=PublicContact.ContactTypeChoices.SECURITY
)
# create a dictionary of fields which can be included in output
FIELDS = {
"Domain name": domainInfo.domain.name,
"Domain type": domainInfo.get_organization_type_display()
+ " - "
+ domainInfo.get_federal_type_display()
if domainInfo.federal_type
else domainInfo.get_organization_type_display(),
"Agency": domainInfo.federal_agency,
"Organization name": domainInfo.organization_name,
"City": domainInfo.city,
"State": domainInfo.state_territory,
"AO": domainInfo.authorizing_official.first_name
+ " "
+ domainInfo.authorizing_official.last_name
if domainInfo.authorizing_official
else " ",
"AO email": domainInfo.authorizing_official.email
if domainInfo.authorizing_official
else " ",
"Security Contact Email": security_contacts[0].email
if security_contacts
else " ",
"Status": domainInfo.domain.state,
"Expiration Date": domainInfo.domain.expiration_date,
}
writer.writerow([FIELDS.get(column, "") for column in columns])
def export_data_type_to_csv(csv_file):
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"AO",
"AO email",
"Security Contact Email",
"Status",
"Expiration Date",
]
sort_fields = ["domain__name"]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
def export_data_full_to_csv(csv_file):
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"Security Contact Email",
]
sort_fields = ["domain__name", "federal_agency", "organization_type"]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
def export_data_federal_to_csv(csv_file):
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"Security Contact Email",
]
sort_fields = ["domain__name", "federal_agency", "organization_type"]
filter_condition = {
"organization_type__icontains": "federal",
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
export_domains_to_writer(writer, columns, sort_fields, filter_condition)

View file

@ -39,9 +39,11 @@ class GenericError(Exception):
"""
_error_mapping = {
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: (
"Update failed. Cannot contact the registry."
),
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """
Were experiencing a system connection error. Please wait a few minutes
and try again. If you continue to receive this error after a few tries,
contact help@get.gov
""",
GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
}
@ -104,7 +106,9 @@ class NameserverError(Exception):
NameserverErrorCodes.MISSING_HOST: (
"Name server must be provided to enter IP address."
),
NameserverErrorCodes.INVALID_HOST: ("Name server, {}, is not valid."),
NameserverErrorCodes.INVALID_HOST: (
"Enter a name server in the required format, like ns1.example.com"
),
}
def __init__(self, *args, code=None, nameserver=None, ip=None, **kwargs):