Merge branch 'main' into nmb/federal-combobox

This commit is contained in:
Neil Martinsen-Burrell 2022-12-06 14:57:06 -06:00
commit ad81c13f95
No known key found for this signature in database
GPG key ID: 6A3C818CC10D0184
35 changed files with 1281 additions and 94 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View file

@ -0,0 +1,65 @@
@startuml get.gov registrar deployment
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Deployment.puml
LAYOUT_WITH_LEGEND()
title get.gov registrar deployment
skinparam linetype polyline
Person(team, "get.gov developer", "Code Writer")
Deployment_Node(aws, "AWS GovCloud", "Amazon Web Services Region") {
Deployment_Node(cloudgov, "cloud.gov", "Cloud Foundry PaaS") {
System_Ext(cloudgov_router, "cloud.gov router", "Cloud Foundry service")
System_Ext(cloudgov_uaa, "cloud.gov authentication", "Cloud Foundry service")
System_Ext(cloudgov_controller, "cloud.gov controller", "Cloud Foundry orchestration")
System_Ext(cloudgov_dashboard, "cloud.gov dashboard", "Cloud Foundry web UI")
System_Ext(cloudgov_logdrain, "logs.fr.cloud.gov", "ELK")
Boundary(atob, "ATO boundary") {
Deployment_Node(organization, "get.gov organization") {
Deployment_Node(unstable, "unstable space") {
System_Boundary(dashboard_unstable, "get.gov registrar") {
Container(getgov_app_unstable, "Registrar Application", "Python, Django", "Delivers static HTML/CSS and forms")
ContainerDb(dashboard_db_unstable, "Unstable PostgreSQL Database", "AWS RDS", "Stores agency information and reports")
}
}
Deployment_Node(staging, "staging space") {
System_Boundary(dashboard_staging, "get.gov registrar") {
Container(getgov_app_staging, "Registrar Application", "Python, Django", "Delivers static HTML/CSS and forms")
ContainerDb(dashboard_db_staging, "Staging PostgreSQL Database", "AWS RDS", "Stores agency information and reports")
}
}
}
}
}
}
' Logs flow
Rel(staging, cloudgov_logdrain, "logs to", "stdout/stderr")
Rel(team, cloudgov_logdrain, "reviews logs", "https (443)")
Rel(team, cloudgov_uaa, "authenticates with", "https (443)")
Rel(team, cloudgov_dashboard, "inspects", "https (443)")
Rel(cloudgov_dashboard, cloudgov_controller, "inspects and manipulates state", "https (443)")
' Deployment
Boundary(deploymentservices, "Deployment services") {
Deployment_Node(github, "CI/CD Pipeline", "open source"){
System(github_repo, "cisagov/getgov", "Code repository")
System_Ext(github_actions_deploy, "github actions", "deploy")
System_Ext(github_actions_test, "github actions", "test, security check")
}
}
Rel(github_repo, github_actions_test, "pushes to all branches trigger test suite")
Rel(github_repo, github_actions_deploy, "pushes to main trigger deployment")
Rel(team, github_repo, "commits code, makes pull-request, approves PRs", "https (443)")
Rel(github_actions_deploy, cloudgov_controller, "pushes code, invokes tasks", "https (443)")
Rel(github_actions_deploy, cloudgov_router, "runs smoke tests on URLs", "https (443)")
Rel(cloudgov_controller, staging, "provisions/operates apps and services", "admin access limited")
Rel(cloudgov_controller, unstable, "provisions/operates apps and services")
Rel(getgov_app_staging, dashboard_db_staging, "reads agency info, reads/writes reports, ", "postgres (5432)")
Rel(getgov_app_unstable, dashboard_db_unstable, "reads agency info, reads/writes reports, ", "postgres (5432)")
Rel(cloudgov_router, getgov_app_staging, "proxies to", "https GET/POST (443)")
@enduml

View file

@ -78,6 +78,21 @@ To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Za
to MIDDLEWARE in settings.py. **Remove it when you are finished testing.**
### Reducing console noise in tests
Some tests, particularly when using Django's test client, will print errors.
These errors do not indicate test failure, but can make the output hard to read.
To silence them, we have a helper function `less_console_noise`:
```python
from .common import less_console_noise
...
with less_console_noise():
# <test code goes here>
```
### Accessibility Scanning
The tool `pa11y-ci` is used to scan pages for compliance with a set of

49
src/api/tests/common.py Normal file
View file

@ -0,0 +1,49 @@
import os
import logging
from contextlib import contextmanager
def get_handlers():
"""Obtain pointers to all StreamHandlers."""
handlers = {}
rootlogger = logging.getLogger()
for h in rootlogger.handlers:
if isinstance(h, logging.StreamHandler):
handlers[h.name] = h
for logger in logging.Logger.manager.loggerDict.values():
if not isinstance(logger, logging.PlaceHolder):
for h in logger.handlers:
if isinstance(h, logging.StreamHandler):
handlers[h.name] = h
return handlers
@contextmanager
def less_console_noise():
"""
Context manager to use in tests to silence console logging.
This is helpful on tests which trigger console messages
(such as errors) which are normal and expected.
It can easily be removed to debug a failing test.
"""
restore = {}
handlers = get_handlers()
devnull = open(os.devnull, "w")
# redirect all the streams
for handler in handlers.values():
prior = handler.setStream(devnull)
restore[handler.name] = prior
try:
# run the test
yield
finally:
# restore the streams
for handler in handlers.values():
handler.setStream(restore[handler.name])

View file

@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, RequestFactory
from ..views import available, _domains, in_domains
from .common import less_console_noise
API_BASE_PATH = "/api/v1/available/"
@ -104,10 +105,12 @@ class AvailableAPITest(TestCase):
def test_available_post(self):
"""Cannot post to the /available/ API endpoint."""
response = self.client.post(API_BASE_PATH + "nonsense")
with less_console_noise():
response = self.client.post(API_BASE_PATH + "nonsense")
self.assertEqual(response.status_code, 405)
def test_available_bad_input(self):
self.client.force_login(self.user)
response = self.client.get(API_BASE_PATH + "blah!;")
with less_console_noise():
response = self.client.get(API_BASE_PATH + "blah!;")
self.assertEqual(response.status_code, 400)

View file

@ -11,7 +11,7 @@ import requests
from cachetools.func import ttl_cache
from registrar.models import Website
from registrar.models import Domain
DOMAIN_FILE_URL = (
"https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv"
@ -35,7 +35,7 @@ def _domains():
# get the domain before the first comma
domain = line.split(",", 1)[0]
# sanity-check the string we got from the file here
if Website.string_could_be_domain(domain):
if Domain.string_could_be_domain(domain):
# lowercase everything when we put it in domains
domains.add(domain.lower())
return domains
@ -68,8 +68,8 @@ def available(request, domain=""):
# validate that the given domain could be a domain name and fail early if
# not.
if not (
Website.string_could_be_domain(domain)
or Website.string_could_be_domain(domain + ".gov")
Domain.string_could_be_domain(domain)
or Domain.string_could_be_domain(domain + ".gov")
):
raise BadRequest("Invalid request.")
# a domain is available if it is NOT in the list of current domains

0
src/epp/__init__.py Normal file
View file

40
src/epp/mock_epp.py Normal file
View file

@ -0,0 +1,40 @@
"""
This file defines a number of mock functions which can be used to simulate
communication with the registry until that integration is implemented.
"""
from datetime import datetime
def domain_check(_):
"""Is domain available for registration?"""
return True
def domain_info(domain):
"""What does the registry know about this domain?"""
return {
"name": domain,
"roid": "EXAMPLE1-REP",
"status": ["ok"],
"registrant": "jd1234",
"contact": {
"admin": "sh8013",
"tech": None,
},
"ns": {
f"ns1.{domain}",
f"ns2.{domain}",
},
"host": [
f"ns1.{domain}",
f"ns2.{domain}",
],
"sponsor": "ClientX",
"creator": "ClientY",
# TODO: think about timezones
"creation_date": datetime.today(),
"updator": "ClientX",
"last_update_date": datetime.today(),
"expiration_date": datetime.today(),
"last_transfer_date": datetime.today(),
}

View file

@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse
from .models import User, UserProfile, DomainApplication, Website
from . import models
class AuditedAdmin(admin.ModelAdmin):
@ -26,7 +26,7 @@ class UserProfileInline(admin.StackedInline):
"""Edit a user's profile on the user page."""
model = UserProfile
model = models.UserProfile
class MyUserAdmin(UserAdmin):
@ -36,6 +36,24 @@ class MyUserAdmin(UserAdmin):
inlines = [UserProfileInline]
admin.site.register(User, MyUserAdmin)
admin.site.register(DomainApplication, AuditedAdmin)
admin.site.register(Website, AuditedAdmin)
class HostIPInline(admin.StackedInline):
"""Edit an ip address on the host page."""
model = models.HostIP
class MyHostAdmin(AuditedAdmin):
"""Custom host admin class to use our inlines."""
inlines = [HostIPInline]
admin.site.register(models.User, MyUserAdmin)
admin.site.register(models.Contact, AuditedAdmin)
admin.site.register(models.DomainApplication, AuditedAdmin)
admin.site.register(models.Domain, AuditedAdmin)
admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Nameserver, MyHostAdmin)
admin.site.register(models.Website, AuditedAdmin)

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<rect x="-0.14" y="0.382" rx="15" ry="15" width="501.007" height="500.135" style="fill: rgb(0, 82, 136);"/>
<text x="-1.813" y="338.582" style="fill: rgb(0, 190, 200); font-family: Helvetica; font-size: 240px; font-weight: bold; white-space: pre;">.gov</text>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View file

@ -26,3 +26,77 @@ i.e.
.sr-only {
@include sr-only;
}
h1 {
@include typeset('sans', '2xl', 2);
margin: 0 0 units(1);
}
h2 {
font-weight: font-weight('semibold');
line-height: line-height('heading', 3);
margin: units(4) 0 units(1);
&:first-of-type {
margin-top: units(2);
}
}
.register-form-step p {
@include typeset('sans', 'sm', 5);
max-width: measure(5);
&:last-of-type {
margin-bottom: 0;
}
}
.register-form-step a {
color: color('primary');
&:visited {
color: color('violet-70v'); //USWDS default
}
}
a.breadcrumb__back {
display:flex;
align-items: center;
margin-bottom: units(2.5);
&:visited {
color: color('primary');
}
@include at-media('desktop') {
//align to top of sidebar
margin-top: units(-0.5);
}
}
.sidenav__step--locked {
color: color('base-darker');
span {
display: flex;
align-items: flex-start;
padding: units(1);
.usa-icon {
flex-shrink: 0;
//align lock body to x-height
margin: units('2px') units(1) 0 0;
}
}
}
.stepnav {
margin-top: units(2);
}
.review__step__name {
font-weight: font-weight('semibold');
}
footer {
//Workaround because USWDS units jump from 10 to 15
margin-top: units(10) + units(2);
}

View file

@ -13,10 +13,103 @@ in the form $setting: value,
// $setting: value
// );
//
@use "cisa_colors" as *;
@use "uswds-core" with (
$theme-banner-background-color: "ink",
$theme-banner-link-color: "primary-light",
$theme-banner-max-width: "none",
/*----------------------------
# USWDS Compile Settings
-----------------------------*/
$theme-show-notifications: false,
$theme-hero-image: "../img/registrar/dotgov_banner.png"
)
/*----------------------------
# Banner Settings
-----------------------------*/
$theme-banner-background-color: "primary-darker",
$theme-banner-link-color: "primary-lighter",
/*----------------------------
# Hero Image
-----------------------------*/
$theme-hero-image: "../img/registrar/dotgov_banner.png",
/*----------------------------
# Typography Settings
-----------------------------
## Type scales
----------------------------*/
$theme-type-scale-2xl: 12,
$theme-type-scale-xl: 10,
$theme-type-scale-lg: 8,
$theme-type-scale-md: 7,
$theme-type-scale-sm: 5,
$theme-type-scale-xs: 3,
/*---------------------------
## Heading sizing
----------------------------*/
$theme-h1-font-size: "2xl",
$theme-h2-font-size: "xl",
$theme-h4-font-size: "md",
$theme-h5-font-size: "sm",
$theme-h6-font-size: "xs",
$theme-body-font-size: "sm",
/*---------------------------
## Font weights
----------------------------*/
$theme-font-weight-semibold: 600,
/*---------------------------
## Font roles
----------------------------*/
$theme-font-role-heading: 'sans',
/*----------------------------
# Color Settings
------------------------------
## Primary color
----------------------------*/
$theme-color-primary-darker: $dhs-blue-70,
$theme-color-primary-dark: $dhs-blue-60,
$theme-color-primary: $dhs-blue,
$theme-color-primary-light: $dhs-blue-30,
$theme-color-primary-lighter: $dhs-blue-15,
$theme-color-primary-lightest: $dhs-blue-10,
/*---------------------------
## Accent color
----------------------------*/
$theme-color-accent-cool: $dhs-light-blue-60,
$theme-color-accent-cool-dark: $dhs-light-blue-70,
$theme-color-accent-cool-light: $dhs-light-blue-40,
/*---------------------------
## Error state
----------------------------*/
$theme-color-error-darker: $dhs-red-70,
$theme-color-error-dark: $dhs-red-60,
$theme-color-error: $dhs-red,
$theme-color-error-light: $dhs-red-30,
$theme-color-error-lighter: $dhs-red-15,
/*---------------------------
## Success state
----------------------------*/
$theme-color-success-darker: $dhs-green-70,
$theme-color-success-dark: $dhs-green-60,
$theme-color-success: $dhs-green,
$theme-color-success-light: $dhs-green-30,
$theme-color-success-lighter: $dhs-green-15,
/*---------------------------
# Input settings
----------------------------*/
$theme-input-line-height: 5,
);

View file

@ -0,0 +1,83 @@
/*
================================================================================
DHS color palette
Taken from: https://www.dhs.gov/xlibrary/dhsweb/_site/color-palette.html
================================================================================
*/
/*--- Blue --*/
$dhs-blue-90: #000305;
$dhs-blue-80: #001726;
$dhs-blue-70: #002b47;
$dhs-blue-60: #003e67;
$dhs-blue: #005288;
$dhs-blue-40: #3d7ca5;
$dhs-blue-30: #7aa5c1;
$dhs-blue-20: #b8cfde;
$dhs-blue-15: #d6e3ec;
$dhs-blue-10: #f5f8fa;
/*--- Light Blue ---*/
$dhs-light-blue-90: #000507;
$dhs-light-blue-80: #002231;
$dhs-light-blue-70: #003e5a;
$dhs-light-blue-60: #005b84;
$dhs-light-blue: #0078ae;
$dhs-light-blue-40: #3d98c1;
$dhs-light-blue-30: #7ab9d5;
$dhs-light-blue-20: #b8d9e8;
$dhs-light-blue-15: #d6e9f2;
$dhs-light-blue-10: #f5fafc;
/*--- Gray ---*/
$dhs-gray-90: #080808;
$dhs-gray-80: #363637;
$dhs-gray-70: #646566;
$dhs-gray-60: #929395;
$dhs-gray: #c0c2c4;
$dhs-gray-40: #cfd1d2;
$dhs-gray-30: #dedfe0;
$dhs-gray-20: #edeeee;
$dhs-gray-15: #f5f5f6;
$dhs-gray-10: #fcfdfd;
/*--- Dark Gray ---*/
$dhs-dark-gray-90: #040404;
$dhs-dark-gray-80: #19191a;
$dhs-dark-gray-70: #2f2f30;
$dhs-dark-gray-60: #444547;
$dhs-dark-gray: #5a5b5d;
$dhs-dark-gray-40: #828284;
$dhs-dark-gray-30: #a9aaab;
$dhs-dark-gray-20: #d1d1d2;
$dhs-dark-gray-15: #e5e5e5;
$dhs-dark-gray-10: #f8f8f9;
/*--- Red ---*/
$dhs-red-90: #080102;
$dhs-red-80: #37050d;
$dhs-red-70: #660919;
$dhs-red-60: #950e24;
$dhs-red: #c41230;
$dhs-red-40: #d24b62;
$dhs-red-30: #e08493;
$dhs-red-20: #eebdc5;
$dhs-red-15: #f6d9de;
$dhs-red-10: #fdf6f7;
/*--- Green ---*/
$dhs-green-90: #040602;
$dhs-green-80: #1a2a0e;
$dhs-green-70: #314f1a;
$dhs-green-60: #477326;
$dhs-green: #5e9732;
$dhs-green-40: #85b063;
$dhs-green-30: #abc994;
$dhs-green-20: #d2e2c6;
$dhs-green-15: #e5eede;
$dhs-green-10: #f9fbf7;

View file

@ -13,7 +13,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from formtools.wizard.views import NamedUrlSessionWizardView # type: ignore
from registrar.models import DomainApplication, Website
from registrar.models import DomainApplication, Domain
logger = logging.getLogger(__name__)
@ -523,8 +523,8 @@ class ApplicationWizard(LoginRequiredMixin, NamedUrlSessionWizardView):
# This isn't really the requested_domain field
# but we need something in this field to make the form submittable
requested_site, _ = Website.objects.get_or_create(
website=contact_data["organization_name"] + ".gov"
requested_site, _ = Domain.objects.get_or_create(
name=contact_data["organization_name"] + ".gov"
)
application.requested_domain = requested_site
return application

View file

@ -0,0 +1,165 @@
# Generated by Django 4.1.3 on 2022-11-28 19:07
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django_fsm # type: ignore
class Migration(migrations.Migration):
dependencies = [
("registrar", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Domain",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(
default=None,
help_text="Fully qualified domain name",
max_length=253,
),
),
(
"is_active",
django_fsm.FSMField(
choices=[(True, "Yes"), (False, "No")],
default=False,
help_text="Domain is live in the registry",
max_length=50,
),
),
("owners", models.ManyToManyField(to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name="Host",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(
default=None,
help_text="Fully qualified domain name",
max_length=253,
unique=True,
),
),
(
"domain",
models.ForeignKey(
help_text="Domain to which this host belongs",
on_delete=django.db.models.deletion.PROTECT,
related_name="host",
to="registrar.domain",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Nameserver",
fields=[
(
"host_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="registrar.host",
),
),
],
options={
"abstract": False,
},
bases=("registrar.host",),
),
migrations.CreateModel(
name="HostIP",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"address",
models.CharField(
default=None,
help_text="IP address",
max_length=46,
validators=[django.core.validators.validate_ipv46_address],
),
),
(
"host",
models.ForeignKey(
help_text="Host to which this IP address belongs",
on_delete=django.db.models.deletion.PROTECT,
related_name="ip",
to="registrar.host",
),
),
],
options={
"abstract": False,
},
),
migrations.AlterField(
model_name="domainapplication",
name="requested_domain",
field=models.OneToOneField(
blank=True,
help_text="The requested domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_application",
to="registrar.domain",
),
),
migrations.AddConstraint(
model_name="domain",
constraint=models.UniqueConstraint(
condition=models.Q(("is_active", True)),
fields=("name",),
name="unique_domain_name_in_registry",
),
),
]

View file

@ -2,6 +2,10 @@ from auditlog.registry import auditlog # type: ignore
from .contact import Contact
from .domain_application import DomainApplication
from .domain import Domain
from .host_ip import HostIP
from .host import Host
from .nameserver import Nameserver
from .user_profile import UserProfile
from .user import User
from .website import Website
@ -9,6 +13,10 @@ from .website import Website
__all__ = [
"Contact",
"DomainApplication",
"Domain",
"HostIP",
"Host",
"Nameserver",
"UserProfile",
"User",
"Website",
@ -16,6 +24,10 @@ __all__ = [
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(HostIP)
auditlog.register(Host)
auditlog.register(Nameserver)
auditlog.register(UserProfile)
auditlog.register(User)
auditlog.register(Website)

View file

@ -0,0 +1,237 @@
import logging
import re
from django.db import models
from django_fsm import FSMField, transition # type: ignore
from epp.mock_epp import domain_info, domain_check
from .utility.time_stamped_model import TimeStampedModel
from .domain_application import DomainApplication
from .user import User
logger = logging.getLogger(__name__)
class Domain(TimeStampedModel):
"""
Manage the lifecycle of domain names.
The registry is the source of truth for this data and this model exists:
1. To tie ownership information in the registrar to
DNS entries in the registry; and
2. To allow a new registrant to draft DNS entries before their
application is approved
"""
class Meta:
constraints = [
# draft domains may share the same name, but
# once approved, they must be globally unique
models.UniqueConstraint(
fields=["name"],
condition=models.Q(is_active=True),
name="unique_domain_name_in_registry",
),
]
class Status(models.TextChoices):
"""
The status codes we can receive from the registry.
These are detailed in RFC 5731 in section 2.3.
https://www.rfc-editor.org/std/std69.txt
"""
# Requests to delete the object MUST be rejected.
CLIENT_DELETE_PROHIBITED = "clientDeleteProhibited"
SERVER_DELETE_PROHIBITED = "serverDeleteProhibited"
# DNS delegation information MUST NOT be published for the object.
CLIENT_HOLD = "clientHold"
SERVER_HOLD = "serverHold"
# Requests to renew the object MUST be rejected.
CLIENT_RENEW_PROHIBITED = "clientRenewProhibited"
SERVER_RENEW_PROHIBITED = "serverRenewProhibited"
# Requests to transfer the object MUST be rejected.
CLIENT_TRANSFER_PROHIBITED = "clientTransferProhibited"
SERVER_TRANSFER_PROHIBITED = "serverTransferProhibited"
# Requests to update the object (other than to remove this status)
# MUST be rejected.
CLIENT_UPDATE_PROHIBITED = "clientUpdateProhibited"
SERVER_UPDATE_PROHIBITED = "serverUpdateProhibited"
# Delegation information has not been associated with the object.
# This is the default status when a domain object is first created
# and there are no associated host objects for the DNS delegation.
# This status can also be set by the server when all host-object
# associations are removed.
INACTIVE = "inactive"
# This is the normal status value for an object that has no pending
# operations or prohibitions. This value is set and removed by the
# server as other status values are added or removed.
OK = "ok"
# A transform command has been processed for the object, but the
# action has not been completed by the server. Server operators can
# delay action completion for a variety of reasons, such as to allow
# for human review or third-party action. A transform command that
# is processed, but whose requested action is pending, is noted with
# response code 1001.
PENDING_CREATE = "pendingCreate"
PENDING_DELETE = "pendingDelete"
PENDING_RENEW = "pendingRenew"
PENDING_TRANSFER = "pendingTransfer"
PENDING_UPDATE = "pendingUpdate"
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
# 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}")
@classmethod
def string_could_be_domain(cls, domain: str) -> bool:
"""Return True if the string could be a domain name, otherwise False."""
if cls.DOMAIN_REGEX.match(domain):
return True
return False
@classmethod
def available(cls, domain: str) -> bool:
"""Check if a domain is available.
Not implemented. Returns a dummy value for testing."""
return domain_check(domain)
def transfer(self):
"""Going somewhere. Not implemented."""
pass
def renew(self):
"""Time to renew. Not implemented."""
pass
def _get_property(self, property):
"""Get some info about a domain."""
if not self.is_active:
return None
if not hasattr(self, "info"):
try:
# get info from registry
self.info = domain_info(self.name)
except Exception as e:
logger.error(e)
# TODO: back off error handling
return None
if hasattr(self, "info"):
if property in self.info:
return self.info[property]
else:
raise KeyError(
"Requested key %s was not found in registry data." % str(property)
)
else:
# TODO: return an error if registry cannot be contacted
return None
def could_be_domain(self) -> bool:
"""Could this instance be a domain?"""
# short-circuit if self.website is null/None
if not self.name:
return False
return self.string_could_be_domain(str(self.name))
@transition(field="is_active", source="*", target=True)
def activate(self):
"""This domain should be made live."""
if hasattr(self, "domain_application"):
if self.domain_application.status != DomainApplication.APPROVED:
raise ValueError("Cannot activate. Application must be approved.")
if Domain.objects.filter(name=self.name, is_active=True).exists():
raise ValueError("Cannot activate. Domain name is already in use.")
# TODO: depending on the details of our registry integration
# we will either contact the registry and deploy the domain
# in this function OR we will verify that it has already been
# activated and reject this state transition if it has not
pass
@transition(field="is_active", source="*", target=False)
def deactivate(self):
"""This domain should not be live."""
# there are security concerns to having this function exist
# within the codebase; discuss these with the project lead
# if there is a feature request to implement this
raise Exception("Cannot revoke, contact registry.")
def __str__(self) -> str:
return self.name
@property
def roid(self):
return self._get_property("roid")
@property
def status(self):
return self._get_property("status")
@property
def registrant(self):
return self._get_property("registrant")
@property
def sponsor(self):
return self._get_property("sponsor")
@property
def creator(self):
return self._get_property("creator")
@property
def creation_date(self):
return self._get_property("creation_date")
@property
def updator(self):
return self._get_property("updator")
@property
def last_update_date(self):
return self._get_property("last_update_date")
@property
def expiration_date(self):
return self._get_property("expiration_date")
@property
def last_transfer_date(self):
return self._get_property("last_transfer_date")
name = models.CharField(
max_length=253,
blank=False,
default=None, # prevent saving without a value
help_text="Fully qualified domain name",
)
# we use `is_active` rather than `domain_application.status`
# because domains may exist without associated applications
is_active = FSMField(
choices=[
(True, "Yes"),
(False, "No"),
],
default=False,
# TODO: how to edit models in Django admin if protected = True
protected=False,
help_text="Domain is live in the registry",
)
# TODO: determine the relationship between this field
# and the domain application's `creator` and `submitter`
owners = models.ManyToManyField(
User,
help_text="",
)

View file

@ -154,12 +154,12 @@ class DomainApplication(TimeStampedModel):
related_name="current+",
)
requested_domain = models.ForeignKey(
Website,
requested_domain = models.OneToOneField(
"Domain",
null=True,
blank=True,
help_text="The requested domain",
related_name="requested+",
related_name="domain_application",
on_delete=models.PROTECT,
)
alternative_domains = models.ManyToManyField(
@ -211,8 +211,8 @@ class DomainApplication(TimeStampedModel):
def __str__(self):
try:
if self.requested_domain and self.requested_domain.website:
return self.requested_domain.website
if self.requested_domain and self.requested_domain.name:
return self.requested_domain.name
else:
return f"{self.status} application created by {self.creator}"
except Exception:

View file

@ -0,0 +1,33 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
from .domain import Domain
class Host(TimeStampedModel):
"""
Hosts are internet-connected computers.
They may handle email, serve websites, or perform other tasks.
The registry is the source of truth for this data.
This model exists ONLY to allow a new registrant to draft DNS entries
before their application is approved.
"""
name = models.CharField(
max_length=253,
null=False,
blank=False,
default=None, # prevent saving without a value
unique=True,
help_text="Fully qualified domain name",
)
domain = models.ForeignKey(
Domain,
on_delete=models.PROTECT,
related_name="host", # access this Host via the Domain as `domain.host`
help_text="Domain to which this host belongs",
)

View file

@ -0,0 +1,32 @@
from django.db import models
from django.core.validators import validate_ipv46_address
from .utility.time_stamped_model import TimeStampedModel
from .host import Host
class HostIP(TimeStampedModel):
"""
Hosts may have one or more IP addresses.
The registry is the source of truth for this data.
This model exists ONLY to allow a new registrant to draft DNS entries
before their application is approved.
"""
address = models.CharField(
max_length=46,
null=False,
blank=False,
default=None, # prevent saving without a value
validators=[validate_ipv46_address],
help_text="IP address",
)
host = models.ForeignKey(
Host,
on_delete=models.PROTECT,
related_name="ip", # access this HostIP via the Host as `host.ip`
help_text="Host to which this IP address belongs",
)

View file

@ -0,0 +1,16 @@
from .host import Host
class Nameserver(Host):
"""
A nameserver is a host which has been delegated to respond to DNS queries.
The registry is the source of truth for this data.
This model exists ONLY to allow a new registrant to draft DNS entries
before their application is approved.
"""
# there is nothing here because all of the fields are
# defined over there on the Host class
pass

View file

@ -1,5 +1,3 @@
import re
from django.db import models
@ -16,26 +14,5 @@ class Website(models.Model):
help_text="",
)
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
# 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}")
@classmethod
def string_could_be_domain(cls, domain: str) -> bool:
"""Return True if the string could be a domain name, otherwise False.
TODO: when we have a Domain class, this could be a classmethod there.
"""
if cls.DOMAIN_REGEX.match(domain):
return True
return False
def could_be_domain(self) -> bool:
"""Could this instance be a domain?"""
# short-circuit if self.website is null/None
if not self.website:
return False
return self.string_could_be_domain(str(self.website))
def __str__(self) -> str:
return str(self.website)

View file

@ -9,9 +9,9 @@
{% include 'application_sidebar.html' %}
</div>
<div class="grid-col-9">
<main id="main-content" class="grid-container">
<main id="main-content" class="grid-container register-form-step">
{% if wizard.steps.prev %}
<a href="{% url wizard.url_name step=wizard.steps.prev %}">
<a href="{% url wizard.url_name step=wizard.steps.prev %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
</svg><span class="margin-left-05">Previous step </span>
@ -19,12 +19,14 @@
{% endif %}
<h1> {{form_titles|get_item:wizard.steps.current}} </h1>
{% block form_content %}
{% if wizard.steps.next %}
<button type="submit" class="usa-button">Next</button>
{% else %}
<button type="submit" class="usa-button">Submit your domain request</button>
{% endif %}
<button type="button" class="usa-button usa-button--outline">Save</button>
<div class="stepnav">
{% if wizard.steps.next %}
<button type="submit" class="usa-button">Next</button>
{% else %}
<button type="submit" class="usa-button">Submit your domain request</button>
{% endif %}
<button type="button" class="usa-button usa-button--outline">Save</button>
</div>
</main>
</div>
{% endblock %}

View file

@ -10,7 +10,7 @@
{% csrf_token %}
<fieldset id="election_board__fieldset" class="usa-fieldset">
<legend>
<h2>Is your organization an election office?</h2>
<h2 class="margin-bottom-05">Is your organization an election office?</h2>
</legend>
{% radio_buttons_by_value wizard.form.is_election_board as choices %}
{% include "includes/radio_button.html" with choice=choices|get_item:True %}

View file

@ -10,7 +10,7 @@
{% csrf_token %}
<fieldset id="federal_type__fieldset" class="usa-fieldset">
<legend>
<h2>Which federal branch is your organization in?</h2>
<h2 class="margin-bottom-5">Which federal branch is your organization in?</h2>
</legend>
{% radio_buttons_by_value wizard.form.federal_type as federal_choices %}
{% include "includes/radio_button.html" with choice=federal_choices.Executive%}

View file

@ -14,7 +14,7 @@
<fieldset class="usa-fieldset">
<legend>
<h2> Contact 2 </h2>
<h2 class="margin-bottom-05"> Contact 2 </h2>
</legend>
{{ wizard.form.first_name|add_label_class:"usa-label" }}
{{ wizard.form.first_name|add_class:"usa-input"|attr:"aria-describedby:instructions" }}

View file

@ -10,11 +10,15 @@
{{ form_titles|get_item:this_step }}
</a>
{% else %}
<li class="usa-sidenav__item step--locked">
{{ form_titles|get_item:this_step }}
<svg class="usa-icon" aria-labelledby="locked-step" role="img">
<title id="locked-step__{{forloop.counter}}">locked until previous steps have been completed </title>
<use xlink:href="{%static 'img/sprite.svg'%}#lock"></use>
<li class="usa-sidenav__item sidenav__step--locked">
<span>
<svg class="usa-icon" aria-hidden="true" focsuable="false" role="img" width="24"height="24" >
<title id="locked-step__{{forloop.counter}}">lock icon</title>
<use xlink:href="{%static 'img/sprite.svg'%}#lock"></use>
</svg>
{{ form_titles|get_item:this_step }}
<span class="usa-sr-only">locked until previous steps have been completed</span>
</span>
{% endif %}
</li>
{% endfor %}

View file

@ -48,6 +48,21 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<a class="usa-skipnav" href="#main-content">Skip to main content</a>
{% if IS_DEMO_SITE %}
<section
class="usa-site-alert usa-site-alert--emergency usa-site-alert--no-icon"
aria-label="Site alert"
>
<div class="usa-alert">
<div class="usa-alert__body">
<p class="usa-alert__text">
<strong>TEST SITE</strong> - Do not use real personal information. Demo purposes only.
</p>
</div>
</div>
</section>
{% endif %}
<section class="usa-banner" aria-label="Official website of the United States government">
<div class="usa-accordion">
<header class="usa-banner__header">
@ -104,20 +119,6 @@
</div>
</div>
</section>
{% if IS_DEMO_SITE %}
<section
class="usa-site-alert usa-site-alert--emergency usa-site-alert--no-icon"
aria-label="Site alert"
>
<div class="usa-alert">
<div class="usa-alert__body">
<p class="usa-alert__text">
<strong>TEST SITE</strong> - Do not use real personal information. Demo purposes only.
</p>
</div>
</div>
</section>
{% endif %}
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %}
@ -179,15 +180,7 @@
{% block content_bottom %}{% endblock %}
</div>
<footer id="footer" role="contentinfo">
{% block footer_nav %}
{% endblock %}
{% block footer %}
<div>
<p class="copyright">&copy; {% now "Y" %} CISA .gov Registrar</p>
</div>
{% endblock %}
</footer>
{% include "includes/footer.html" %}
</div> <!-- /#wrapper -->
{% block init_js %}{% endblock %}{# useful for vars and other initializations #}

View file

@ -32,7 +32,7 @@
<tbody>
{% for application in domain_applications %}
<tr>
<th>{{ application.requested_domain.website }}</th>
<th>{{ application.requested_domain.name }}</th>
<td>{{ application.status }}</td>
</tr>
{% endfor %}

View file

@ -0,0 +1,124 @@
{% load static %}
<footer class="usa-footer">
<div class="usa-footer__secondary-section">
<div class="grid-container">
<div class="grid-row grid-gap">
<div
class="
usa-footer__logo
grid-row
mobile-lg:grid-col-6 mobile-lg:grid-gap-2
"
>
<div class="mobile-lg:grid-col-auto">
<img
class="usa-footer__logo-img"
src="{% static 'img/dottedgov-round.svg' %}"
alt="dot gov registrar logo"
width="50px"
/>
</div>
</div>
<div class="usa-footer__contact-links mobile-lg:grid-col-6">
<p class="usa-footer__contact-heading">
Contact us
</p>
<address class="usa-footer__address">
<div class="usa-footer__contact-info grid-row grid-gap">
<div class="grid-col-auto">
<a href="mailto:registrar@dotgov.gov">registrar@dotgov.gov</a>
</div>
</div>
</address>
</div>
</div>
</div>
</div>
</footer>
<div class="usa-identifier">
<section
class="usa-identifier__section usa-identifier__section--masthead"
aria-label="Agency identifier"
>
<div class="usa-identifier__container">
<div class="usa-identifier__logos">
<a href="https://www.cisa.gov" class="usa-identifier__logo"
><img
class="usa-identifier__logo-img"
src="{% static 'img/CISA_logo.png' %}"
alt="CISA logo"
role="img"
width="200px"
/></a>
</div>
<section
class="usa-identifier__identity"
aria-label="Agency description"
>
<p class="usa-identifier__identity-domain">get.gov</p>
<p class="usa-identifier__identity-disclaimer">
An official website of the <a href="https://www.cisa.gov" class="usa-link usa-link--external">Cybersecurity and Infrastructure Security Agency</a>
</p>
</section>
</div>
</section>
<nav
class="usa-identifier__section usa-identifier__section--required-links"
aria-label="Important links"
>
<div class="usa-identifier__container">
<ul class="usa-identifier__required-links-list">
<li class="usa-identifier__required-links-item">
<a
href="https://home.dotgov.gov/about/"
class="usa-identifier__required-link usa-link"
>About .gov</a
>
</li>
<li class="usa-identifier__required-links-item">
<a
href="https://github.com/cisagov/getgov"
class="usa-identifier__required-link usa-link usa-link--external"
>.gov on Github</a
>
</li>
<li class="usa-identifier__required-links-item">
<a href="https://home.dotgov.gov/privacy/" class="usa-identifier__required-link usa-link"
>Privacy policy</a
>
</li>
<li class="usa-identifier__required-links-item">
<a href="https://www.dhs.gov/accessibility" class="usa-identifier__required-link usa-link"
>Accessibility</a
>
</li>
<li class="usa-identifier__required-links-item">
<a href="TODO" class="usa-identifier__required-link usa-link"
>Vulnerability disclosure policy</a
>
</li>
<li class="usa-identifier__required-links-item">
<a href="https://www.cisa.gov/cisa-no-fear-act-reporting" class="usa-identifier__required-link usa-link"
>No FEAR Act data</a
>
</li>
<li class="usa-identifier__required-links-item">
<a href="https://www.dhs.gov/freedom-information-act-foia" class="usa-identifier__required-link usa-link"
>FOIA requests</a
>
</li>
</ul>
</div>
</nav>
<section
class="usa-identifier__section usa-identifier__section--usagov"
aria-label="U.S. government information and services"
>
<div class="usa-identifier__container">
<div class="usa-identifier__usagov-description">
Looking for U.S. government information and services?
</div>
<a href="https://www.usa.gov/" class="usa-link">Visit USA.gov</a>
</div>
</section>

View file

@ -1,7 +1,57 @@
import os
import logging
from contextlib import contextmanager
from django.conf import settings
from django.contrib.auth import get_user_model, login
def get_handlers():
"""Obtain pointers to all StreamHandlers."""
handlers = {}
rootlogger = logging.getLogger()
for h in rootlogger.handlers:
if isinstance(h, logging.StreamHandler):
handlers[h.name] = h
for logger in logging.Logger.manager.loggerDict.values():
if not isinstance(logger, logging.PlaceHolder):
for h in logger.handlers:
if isinstance(h, logging.StreamHandler):
handlers[h.name] = h
return handlers
@contextmanager
def less_console_noise():
"""
Context manager to use in tests to silence console logging.
This is helpful on tests which trigger console messages
(such as errors) which are normal and expected.
It can easily be removed to debug a failing test.
"""
restore = {}
handlers = get_handlers()
devnull = open(os.devnull, "w")
# redirect all the streams
for handler in handlers.values():
prior = handler.setStream(devnull)
restore[handler.name] = prior
try:
# run the test
yield
finally:
# restore the streams
for handler in handlers.values():
handler.setStream(restore[handler.name])
class MockUserLogin:
def __init__(self, get_response):
self.get_response = get_response

View file

@ -1,7 +1,8 @@
from django.test import TestCase
from django.db.utils import IntegrityError
from registrar.models import Contact, DomainApplication, User, Website
from registrar.models import Contact, DomainApplication, User, Website, Domain
from unittest import skip
class TestDomainApplication(TestCase):
@ -22,6 +23,7 @@ class TestDomainApplication(TestCase):
contact = Contact.objects.create()
com_website, _ = Website.objects.get_or_create(website="igorville.com")
gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
investigator=user,
@ -35,7 +37,7 @@ class TestDomainApplication(TestCase):
state_territory="CA",
zip_code="12345-6789",
authorizing_official=contact,
requested_domain=gov_website,
requested_domain=domain,
submitter=contact,
purpose="Igorville rules!",
security_email="security@igorville.gov",
@ -56,9 +58,101 @@ class TestDomainApplication(TestCase):
def test_status_fsm_submit_succeed(self):
user, _ = User.objects.get_or_create()
site = Website.objects.create(website="igorville.gov")
site = Domain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user, requested_domain=site
)
application.submit()
self.assertEqual(application.status, application.SUBMITTED)
class TestDomain(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain."""
with self.assertRaisesRegex(IntegrityError, "name"):
Domain.objects.create()
def test_minimal_create(self):
"""Can create with just a name."""
domain = Domain.objects.create(name="igorville.gov")
self.assertEquals(domain.is_active, False)
def test_get_status(self):
"""Returns proper status based on `is_active`."""
domain = Domain.objects.create(name="igorville.gov")
domain.save()
self.assertEquals(None, domain.status)
domain.activate()
domain.save()
self.assertIn("ok", domain.status)
def test_fsm_activate_fail_unique(self):
"""Can't activate domain if name is not unique."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
d2, _ = Domain.objects.get_or_create(name="igorville.gov")
d1.activate()
d1.save()
with self.assertRaises(ValueError):
d2.activate()
def test_fsm_activate_fail_unapproved(self):
"""Can't activate domain if application isn't approved."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
d1.domain_application = application
d1.save()
with self.assertRaises(ValueError):
d1.activate()
@skip("Not implemented yet.")
class TestDomainApplicationLifeCycle(TestCase):
def test_application_approval(self):
# DomainApplication is created
# test: Domain is created and is inactive
# analyst approves DomainApplication
# test: Domain is activated
pass
def test_application_rejection(self):
# DomainApplication is created
# test: Domain is created and is inactive
# analyst rejects DomainApplication
# test: Domain remains inactive
pass
def test_application_deleted_before_approval(self):
# DomainApplication is created
# test: Domain is created and is inactive
# admin deletes DomainApplication
# test: Domain is deleted; Hosts, HostIps and Nameservers are deleted
pass
def test_application_deleted_following_approval(self):
# DomainApplication is created
# test: Domain is created and is inactive
# analyst approves DomainApplication
# admin deletes DomainApplication
# test: DomainApplication foreign key field on Domain is set to null
pass
def test_application_approval_with_conflicting_name(self):
# DomainApplication #1 is created
# test: Domain #1 is created and is inactive
# analyst approves DomainApplication #1
# test: Domain #1 is activated
# DomainApplication #2 is created, with the same domain name string
# test: Domain #2 is created and is inactive
# analyst approves DomainApplication #2
# test: error is raised
# test: DomainApplication #1 remains approved
# test: Domain #1 remains active
# test: DomainApplication #2 remains in investigating
# test: Domain #2 remains inactive
pass
def test_application_approval_with_network_errors(self):
# TODO: scenario wherein application is approved,
# but attempts to contact the registry to activate the domain fail
pass

View file

@ -5,9 +5,11 @@ from django.contrib.auth import get_user_model
from django_webtest import WebTest # type: ignore
from registrar.models import DomainApplication, Website
from registrar.models import DomainApplication, Domain
from registrar.forms.application_wizard import TITLES
from .common import less_console_noise
class TestViews(TestCase):
def setUp(self):
@ -58,7 +60,7 @@ class LoggedInTests(TestWithUser):
def test_home_lists_domain_applications(self):
response = self.client.get("/")
self.assertNotContains(response, "igorville.gov")
site = Website.objects.create(website="igorville.gov")
site = Domain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site
)
@ -307,7 +309,8 @@ class FormTests(TestWithUser, WebTest):
# following this redirect is a GET request, so include the cookie
# here too.
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
final_result = review_result.follow()
with less_console_noise():
final_result = review_result.follow()
self.assertContains(final_result, "Thank you for your domain request")
def test_application_form_conditional_federal(self):

View file

@ -48,7 +48,7 @@
10038 OUTOFSCOPE http://app:8080/public/img/.*
10038 OUTOFSCOPE http://app:8080/public/css/.*
10038 OUTOFSCOPE http://app:8080/public/js/.*
10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml)
10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO)
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers
10038 OUTOFSCOPE http://app:8080/openid/login/
10039 FAIL (X-Backend-Server Header Information Leak - Passive/beta)