diff --git a/docs/architecture/diagrams/get.gov registrar deployment.png b/docs/architecture/diagrams/get.gov registrar deployment.png new file mode 100644 index 000000000..a48b4aba7 Binary files /dev/null and b/docs/architecture/diagrams/get.gov registrar deployment.png differ diff --git a/docs/architecture/diagrams/get.gov registrar deployment.puml b/docs/architecture/diagrams/get.gov registrar deployment.puml new file mode 100644 index 000000000..80ddc2726 --- /dev/null +++ b/docs/architecture/diagrams/get.gov registrar deployment.puml @@ -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 \ No newline at end of file diff --git a/docs/developer/README.md b/docs/developer/README.md index b9b79032a..0d814250c 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -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(): + # +``` + ### Accessibility Scanning The tool `pa11y-ci` is used to scan pages for compliance with a set of diff --git a/src/api/tests/common.py b/src/api/tests/common.py new file mode 100644 index 000000000..cdf2f8f30 --- /dev/null +++ b/src/api/tests/common.py @@ -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]) diff --git a/src/api/tests/test_available.py b/src/api/tests/test_available.py index 6f6e3775c..0529882f2 100644 --- a/src/api/tests/test_available.py +++ b/src/api/tests/test_available.py @@ -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) diff --git a/src/api/views.py b/src/api/views.py index 042a447e3..ab9a151d6 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -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 diff --git a/src/epp/__init__.py b/src/epp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/epp/mock_epp.py b/src/epp/mock_epp.py new file mode 100644 index 000000000..7d1d1d84e --- /dev/null +++ b/src/epp/mock_epp.py @@ -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(), + } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0ffcaaedc..19cf60729 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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) diff --git a/src/registrar/assets/img/CISA_logo.png b/src/registrar/assets/img/CISA_logo.png new file mode 100644 index 000000000..ee16d16a8 Binary files /dev/null and b/src/registrar/assets/img/CISA_logo.png differ diff --git a/src/registrar/assets/img/dottedgov-round.svg b/src/registrar/assets/img/dottedgov-round.svg new file mode 100644 index 000000000..4315188a4 --- /dev/null +++ b/src/registrar/assets/img/dottedgov-round.svg @@ -0,0 +1,5 @@ + + + + .gov + diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss index 98f5a0cf0..7d80d5f76 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss @@ -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); +} diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/sass/_theme/_uswds-theme.scss index f5d0724a6..715963528 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme.scss @@ -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, + +); diff --git a/src/registrar/assets/sass/_theme/cisa_colors.scss b/src/registrar/assets/sass/_theme/cisa_colors.scss new file mode 100644 index 000000000..7466a3490 --- /dev/null +++ b/src/registrar/assets/sass/_theme/cisa_colors.scss @@ -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; diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 23033780d..2e16c35e2 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -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 diff --git a/src/registrar/migrations/0002_domain_host_nameserver_hostip_and_more.py b/src/registrar/migrations/0002_domain_host_nameserver_hostip_and_more.py new file mode 100644 index 000000000..738deabe5 --- /dev/null +++ b/src/registrar/migrations/0002_domain_host_nameserver_hostip_and_more.py @@ -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", + ), + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index ef2f2e587..1bb9dde84 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -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) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py new file mode 100644 index 000000000..e834df5d7 --- /dev/null +++ b/src/registrar/models/domain.py @@ -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}(? 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="", + ) diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 008b4d43e..681d7edc8 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -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: diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py new file mode 100644 index 000000000..23f6c7659 --- /dev/null +++ b/src/registrar/models/host.py @@ -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", + ) diff --git a/src/registrar/models/host_ip.py b/src/registrar/models/host_ip.py new file mode 100644 index 000000000..22847b77c --- /dev/null +++ b/src/registrar/models/host_ip.py @@ -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", + ) diff --git a/src/registrar/models/nameserver.py b/src/registrar/models/nameserver.py new file mode 100644 index 000000000..13295f5b5 --- /dev/null +++ b/src/registrar/models/nameserver.py @@ -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 diff --git a/src/registrar/models/website.py b/src/registrar/models/website.py index 83c4b8222..a0db7a2a2 100644 --- a/src/registrar/models/website.py +++ b/src/registrar/models/website.py @@ -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}(? 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) diff --git a/src/registrar/templates/application_form.html b/src/registrar/templates/application_form.html index 2977eaac2..4c28048af 100644 --- a/src/registrar/templates/application_form.html +++ b/src/registrar/templates/application_form.html @@ -9,9 +9,9 @@ {% include 'application_sidebar.html' %}
-
+
{% if wizard.steps.prev %} - + Previous step @@ -19,12 +19,14 @@ {% endif %}

{{form_titles|get_item:wizard.steps.current}}

{% block form_content %} - {% if wizard.steps.next %} - - {% else %} - - {% endif %} - +
+ {% if wizard.steps.next %} + + {% else %} + + {% endif %} + +
{% endblock %} diff --git a/src/registrar/templates/application_org_election.html b/src/registrar/templates/application_org_election.html index 1499c1e0b..ed30d3efe 100644 --- a/src/registrar/templates/application_org_election.html +++ b/src/registrar/templates/application_org_election.html @@ -10,7 +10,7 @@ {% csrf_token %}
-

Is your organization an election office?

+

Is your organization an election office?

{% radio_buttons_by_value wizard.form.is_election_board as choices %} {% include "includes/radio_button.html" with choice=choices|get_item:True %} diff --git a/src/registrar/templates/application_org_federal.html b/src/registrar/templates/application_org_federal.html index d10b2c442..59daf5d7c 100644 --- a/src/registrar/templates/application_org_federal.html +++ b/src/registrar/templates/application_org_federal.html @@ -10,7 +10,7 @@ {% csrf_token %}
-

Which federal branch is your organization in?

+

Which federal branch is your organization in?

{% radio_buttons_by_value wizard.form.federal_type as federal_choices %} {% include "includes/radio_button.html" with choice=federal_choices.Executive%} diff --git a/src/registrar/templates/application_other_contacts.html b/src/registrar/templates/application_other_contacts.html index d40d4e1fd..c2f63d7ae 100644 --- a/src/registrar/templates/application_other_contacts.html +++ b/src/registrar/templates/application_other_contacts.html @@ -14,7 +14,7 @@
-

Contact 2

+

Contact 2

{{ wizard.form.first_name|add_label_class:"usa-label" }} {{ wizard.form.first_name|add_class:"usa-input"|attr:"aria-describedby:instructions" }} diff --git a/src/registrar/templates/application_sidebar.html b/src/registrar/templates/application_sidebar.html index 33e06812f..60b619e8b 100644 --- a/src/registrar/templates/application_sidebar.html +++ b/src/registrar/templates/application_sidebar.html @@ -10,11 +10,15 @@ {{ form_titles|get_item:this_step }}
{% else %} -
  • - {{ form_titles|get_item:this_step }} - - locked until previous steps have been completed - +
  • + + + {{ form_titles|get_item:this_step }} + locked until previous steps have been completed + {% endif %}
  • {% endfor %} diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index 7d2091703..1c544c352 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -48,6 +48,21 @@ Skip to main content + {% if IS_DEMO_SITE %} +
    +
    +
    +

    + TEST SITE - Do not use real personal information. Demo purposes only. +

    +
    +
    +
    + {% endif %} +
    @@ -104,20 +119,6 @@
    - {% if IS_DEMO_SITE %} -
    -
    -
    -

    - TEST SITE - Do not use real personal information. Demo purposes only. -

    -
    -
    -
    - {% endif %} {% block usa_overlay %}
    {% endblock %} @@ -179,15 +180,7 @@ {% block content_bottom %}{% endblock %} -
    - {% block footer_nav %} - {% endblock %} - {% block footer %} -
    - -
    - {% endblock %} -
    + {% include "includes/footer.html" %} {% block init_js %}{% endblock %}{# useful for vars and other initializations #} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index a4780c1c5..2010d87a2 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -32,7 +32,7 @@ {% for application in domain_applications %} - {{ application.requested_domain.website }} + {{ application.requested_domain.name }} {{ application.status }} {% endfor %} diff --git a/src/registrar/templates/includes/footer.html b/src/registrar/templates/includes/footer.html new file mode 100644 index 000000000..6e3a1d4ae --- /dev/null +++ b/src/registrar/templates/includes/footer.html @@ -0,0 +1,124 @@ +{% load static %} + + +
    +
    +
    +
    + +
    +
    +

    get.gov

    +

    + An official website of the Cybersecurity and Infrastructure Security Agency +

    +
    +
    +
    + +
    +
    +
    + Looking for U.S. government information and services? +
    + Visit USA.gov +
    +
    diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 3c885b948..b6c7c01d8 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -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 diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 761f25a22..535ab9360 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -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 diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 201f70233..dc08086a4 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -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): diff --git a/src/zap.conf b/src/zap.conf index e384e3518..04a5d8eac 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -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)