mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-27 21:16:28 +02:00
Merge branch 'main' into za/3662-uat-testing-issues
This commit is contained in:
commit
2e1e1e1c20
10 changed files with 443 additions and 3 deletions
|
@ -72,6 +72,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3144,9 +3145,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||||
|
|
||||||
Returns a tuple: (obj: DomainRequest, should_proceed: bool)
|
Returns a tuple: (obj: DomainRequest, should_proceed: bool)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
should_proceed = True
|
should_proceed = True
|
||||||
error_message = None
|
error_message = None
|
||||||
|
domain_name = original_obj.requested_domain.name
|
||||||
|
|
||||||
# Get the method that should be run given the status
|
# Get the method that should be run given the status
|
||||||
selected_method = self.get_status_method_mapping(obj)
|
selected_method = self.get_status_method_mapping(obj)
|
||||||
|
@ -3168,6 +3169,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||||
# duplicated in the model and the error is raised from the model.
|
# duplicated in the model and the error is raised from the model.
|
||||||
# This avoids an ugly Django error screen.
|
# This avoids an ugly Django error screen.
|
||||||
error_message = "This action is not permitted. The domain is already active."
|
error_message = "This action is not permitted. The domain is already active."
|
||||||
|
elif (
|
||||||
|
original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
and original_obj.requested_domain is not None
|
||||||
|
and Domain.is_pending_delete(domain_name)
|
||||||
|
):
|
||||||
|
# 1. If the domain request is not approved in previous state (original status)
|
||||||
|
# 2. If the new status that's supposed to be triggered IS approved
|
||||||
|
# 3. That it's a valid domain
|
||||||
|
# 4. AND that the domain is currently in pendingDelete state
|
||||||
|
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.DOMAIN_IS_PENDING_DELETE)
|
||||||
elif (
|
elif (
|
||||||
original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
|
and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
|
|
@ -171,6 +171,7 @@ urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"admin/logout/",
|
"admin/logout/",
|
||||||
RedirectView.as_view(pattern_name="logout", permanent=False),
|
RedirectView.as_view(pattern_name="logout", permanent=False),
|
||||||
|
name="logout",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"admin/analytics/export_data_type/",
|
"admin/analytics/export_data_type/",
|
||||||
|
|
|
@ -256,6 +256,29 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
req = commands.CheckDomain([domain_name])
|
req = commands.CheckDomain([domain_name])
|
||||||
return registry.send(req, cleaned=True).res_data[0].avail
|
return registry.send(req, cleaned=True).res_data[0].avail
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_pending_delete(cls, domain: str) -> bool:
|
||||||
|
"""Check if domain is pendingDelete state via response from registry."""
|
||||||
|
domain_name = domain.lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
info_req = commands.InfoDomain(domain_name)
|
||||||
|
info_response = registry.send(info_req, cleaned=True)
|
||||||
|
# Ensure res_data exists and is not empty
|
||||||
|
if info_response and info_response.res_data:
|
||||||
|
# Use _extract_data_from_response bc it's same thing but jsonified
|
||||||
|
domain_response = cls._extract_data_from_response(cls, info_response) # type: ignore
|
||||||
|
domain_status_state = domain_response.get("statuses")
|
||||||
|
if "pendingDelete" in str(domain_status_state):
|
||||||
|
return True
|
||||||
|
except RegistryError as err:
|
||||||
|
if not err.is_connection_error():
|
||||||
|
logger.info(f"Domain does not exist yet so it won't be in pending delete -- {err}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise err
|
||||||
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def registered(cls, domain: str) -> bool:
|
def registered(cls, domain: str) -> bool:
|
||||||
"""Check if a domain is _not_ available."""
|
"""Check if a domain is _not_ available."""
|
||||||
|
@ -2026,7 +2049,9 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
|
|
||||||
def _extract_data_from_response(self, data_response):
|
def _extract_data_from_response(self, data_response):
|
||||||
"""extract data from response from registry"""
|
"""extract data from response from registry"""
|
||||||
|
|
||||||
data = data_response.res_data[0]
|
data = data_response.res_data[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"auth_info": getattr(data, "auth_info", ...),
|
"auth_info": getattr(data, "auth_info", ...),
|
||||||
"_contacts": getattr(data, "contacts", ...),
|
"_contacts": getattr(data, "contacts", ...),
|
||||||
|
|
229
src/registrar/permissions.py
Normal file
229
src/registrar/permissions.py
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
"""
|
||||||
|
Centralized permissions management for the registrar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import URLResolver, get_resolver, URLPattern
|
||||||
|
from registrar.decorators import (
|
||||||
|
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM,
|
||||||
|
IS_STAFF,
|
||||||
|
IS_DOMAIN_MANAGER,
|
||||||
|
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
|
||||||
|
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
|
||||||
|
IS_CISA_ANALYST,
|
||||||
|
IS_OMB_ANALYST,
|
||||||
|
IS_FULL_ACCESS,
|
||||||
|
IS_DOMAIN_REQUEST_CREATOR,
|
||||||
|
IS_STAFF_MANAGING_DOMAIN,
|
||||||
|
IS_PORTFOLIO_MEMBER,
|
||||||
|
HAS_PORTFOLIO_DOMAINS_ANY_PERM,
|
||||||
|
HAS_PORTFOLIO_DOMAINS_VIEW_ALL,
|
||||||
|
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
|
||||||
|
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
|
||||||
|
HAS_PORTFOLIO_MEMBERS_EDIT,
|
||||||
|
HAS_PORTFOLIO_MEMBERS_ANY_PERM,
|
||||||
|
HAS_PORTFOLIO_MEMBERS_VIEW,
|
||||||
|
ALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define permissions for each URL pattern by name
|
||||||
|
URL_PERMISSIONS = {
|
||||||
|
# Home & general pages
|
||||||
|
"home": [ALL],
|
||||||
|
"health": [ALL], # Intentionally no decorator
|
||||||
|
# Domain management
|
||||||
|
"domain": [HAS_PORTFOLIO_DOMAINS_VIEW_ALL, IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-dns": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-dns-nameservers": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-dns-dnssec": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-dns-dnssec-dsdata": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-org-name-address": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-suborganization": [IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-senior-official": [IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-security-email": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-renewal": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-users": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-users-add": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
"domain-user-delete": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
# Portfolio management
|
||||||
|
"domains": [HAS_PORTFOLIO_DOMAINS_ANY_PERM],
|
||||||
|
"no-portfolio-domains": [IS_PORTFOLIO_MEMBER],
|
||||||
|
"no-organization-domains": [IS_PORTFOLIO_MEMBER],
|
||||||
|
"members": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
"member": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
"member-delete": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"member-permissions": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"member-domains": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
"member-domains-edit": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"invitedmember": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
"invitedmember-delete": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"invitedmember-permissions": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"invitedmember-domains": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
"invitedmember-domains-edit": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"new-member": [HAS_PORTFOLIO_MEMBERS_EDIT],
|
||||||
|
"domain-requests": [HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM],
|
||||||
|
"no-portfolio-requests": [IS_PORTFOLIO_MEMBER],
|
||||||
|
"organization": [IS_PORTFOLIO_MEMBER],
|
||||||
|
"senior-official": [IS_PORTFOLIO_MEMBER],
|
||||||
|
# Domain requests
|
||||||
|
"domain-request-status": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"domain-request-status-viewonly": [HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL],
|
||||||
|
"domain-request-withdraw-confirmation": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"domain-request-withdrawn": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"domain-request-delete": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"edit-domain-request": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
# Admin functions
|
||||||
|
"analytics": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_data_type": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_data_full": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_data_domain_requests_full": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_data_federal": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_domains_growth": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_requests_growth": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_managed_domains": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"export_unmanaged_domains": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
"transfer_user": [IS_CISA_ANALYST, IS_FULL_ACCESS],
|
||||||
|
# Analytics
|
||||||
|
"all-domain-metadata": [IS_STAFF],
|
||||||
|
"current-full": [IS_STAFF],
|
||||||
|
"all-domain-requests-metadata": [IS_STAFF],
|
||||||
|
"domain-growth": [IS_STAFF],
|
||||||
|
"request-growth": [IS_STAFF],
|
||||||
|
"managed-domains": [IS_STAFF],
|
||||||
|
"unmanaged-domains": [IS_STAFF],
|
||||||
|
# Reports
|
||||||
|
"export-user-domains-as-csv": [IS_STAFF],
|
||||||
|
"export-portfolio-members-as-csv": [IS_STAFF],
|
||||||
|
"export_members_portfolio": [HAS_PORTFOLIO_MEMBERS_VIEW],
|
||||||
|
"export_data_type_user": [ALL],
|
||||||
|
# API endpoints
|
||||||
|
"get-senior-official-from-federal-agency-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST],
|
||||||
|
"get-portfolio-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST],
|
||||||
|
"get-suborganization-list-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST],
|
||||||
|
"get-federal-and-portfolio-types-from-federal-agency-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST],
|
||||||
|
"get-action-needed-email-for-user-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST],
|
||||||
|
"get-rejection-email-for-user-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST],
|
||||||
|
"get_domains_json": [ALL],
|
||||||
|
"get_domain_requests_json": [ALL],
|
||||||
|
"get_portfolio_members_json": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
"get_member_domains_json": [HAS_PORTFOLIO_MEMBERS_ANY_PERM],
|
||||||
|
# User profile
|
||||||
|
"finish-user-profile-setup": [ALL],
|
||||||
|
"user-profile": [ALL],
|
||||||
|
# Invitation
|
||||||
|
"invitation-cancel": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN],
|
||||||
|
# DNS Hosting
|
||||||
|
"prototype-domain-dns": [IS_STAFF],
|
||||||
|
# Domain request wizard
|
||||||
|
"start": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"finished": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"generic_org_type": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"tribal_government": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"organization_federal": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"organization_election": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"organization_contact": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"about_your_organization": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"senior_official": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"current_sites": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"dotgov_domain": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"purpose": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"other_contacts": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"additional_details": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"requirements": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"review": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"portfolio_requesting_entity": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
"portfolio_additional_details": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR],
|
||||||
|
}
|
||||||
|
|
||||||
|
UNCHECKED_URLS = [
|
||||||
|
"health",
|
||||||
|
"openid/",
|
||||||
|
"get-current-federal",
|
||||||
|
"get-current-full",
|
||||||
|
"available",
|
||||||
|
"rdap",
|
||||||
|
"todo",
|
||||||
|
"logout",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def verify_all_urls_have_permissions():
|
||||||
|
"""
|
||||||
|
Utility function to verify that all URLs in the application have defined permissions
|
||||||
|
in the permissions mapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolver = get_resolver()
|
||||||
|
missing_permissions = []
|
||||||
|
missing_names = []
|
||||||
|
|
||||||
|
# Collect all URL pattern names
|
||||||
|
for pattern in resolver.url_patterns:
|
||||||
|
# Skip URLResolver objects (like admin.site.urls)
|
||||||
|
if isinstance(pattern, URLResolver):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(pattern, "name") and pattern.name:
|
||||||
|
if pattern.name not in URL_PERMISSIONS and pattern.name not in UNCHECKED_URLS:
|
||||||
|
missing_permissions.append(pattern.name)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"URL pattern {pattern} has no name")
|
||||||
|
|
||||||
|
if missing_names:
|
||||||
|
raise ValueError(f"The following URL patterns have no name: {missing_names}")
|
||||||
|
|
||||||
|
return missing_permissions
|
||||||
|
|
||||||
|
|
||||||
|
def validate_permissions(): # noqa: C901
|
||||||
|
"""
|
||||||
|
Validates that all URL patterns have consistent permission rules between
|
||||||
|
the centralized mapping and view decorators.
|
||||||
|
|
||||||
|
Returns a dictionary of issues found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolver = get_resolver()
|
||||||
|
issues = {
|
||||||
|
"missing_in_mapping": [], # URLs with decorators but not in mapping
|
||||||
|
"missing_decorator": [], # URLs in mapping but missing decorators
|
||||||
|
"permission_mismatch": [], # URLs with different permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_url_pattern(pattern, parent_path=""):
|
||||||
|
if isinstance(pattern, URLPattern):
|
||||||
|
view_func = pattern.callback
|
||||||
|
path = f"{parent_path}/{pattern.pattern}"
|
||||||
|
url_name = pattern.name
|
||||||
|
|
||||||
|
if url_name:
|
||||||
|
# Skip check for endpoints that intentionally have no decorator
|
||||||
|
if url_name in UNCHECKED_URLS:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if view has decorator but missing from mapping
|
||||||
|
if getattr(view_func, "has_explicit_access", False) and url_name not in URL_PERMISSIONS:
|
||||||
|
issues["missing_in_mapping"].append((url_name, path))
|
||||||
|
|
||||||
|
# Check if view is in mapping but missing decorator
|
||||||
|
elif url_name in URL_PERMISSIONS and not getattr(view_func, "has_explicit_access", False):
|
||||||
|
issues["missing_decorator"].append((url_name, path))
|
||||||
|
|
||||||
|
# Check if permissions match (more complex, may need refinement)
|
||||||
|
elif getattr(view_func, "has_explicit_access", False) and url_name in URL_PERMISSIONS:
|
||||||
|
view_permissions = getattr(view_func, "_access_rules", set())
|
||||||
|
mapping_permissions = set(URL_PERMISSIONS[url_name])
|
||||||
|
|
||||||
|
if view_permissions != mapping_permissions:
|
||||||
|
issues["permission_mismatch"].append((url_name, path, view_permissions, mapping_permissions))
|
||||||
|
|
||||||
|
elif isinstance(pattern, URLResolver):
|
||||||
|
# Handle included URL patterns (nested)
|
||||||
|
new_parent = f"{parent_path}/{pattern.pattern}"
|
||||||
|
for p in pattern.url_patterns:
|
||||||
|
check_url_pattern(p, new_parent)
|
||||||
|
|
||||||
|
# Check all URL patterns
|
||||||
|
for pattern in resolver.url_patterns:
|
||||||
|
check_url_pattern(pattern)
|
||||||
|
|
||||||
|
return issues
|
|
@ -1092,7 +1092,7 @@ def completed_domain_request( # noqa
|
||||||
email="testy@town.com",
|
email="testy@town.com",
|
||||||
phone="(555) 555 5555",
|
phone="(555) 555 5555",
|
||||||
)
|
)
|
||||||
domain, _ = DraftDomain.objects.get_or_create(name=name)
|
domain = DraftDomain.objects.create(name=name)
|
||||||
other, _ = Contact.objects.get_or_create(
|
other, _ = Contact.objects.get_or_create(
|
||||||
first_name="Testy",
|
first_name="Testy",
|
||||||
last_name="Tester",
|
last_name="Tester",
|
||||||
|
|
|
@ -50,6 +50,7 @@ from unittest.mock import ANY, patch
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import boto3_mocking # type: ignore
|
import boto3_mocking # type: ignore
|
||||||
import logging
|
import logging
|
||||||
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -2315,7 +2316,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
|
|
||||||
Used to test errors when saving a change with an active domain, also used to test side effects
|
Used to test errors when saving a change with an active domain, also used to test side effects
|
||||||
when saving a change goes through."""
|
when saving a change goes through."""
|
||||||
|
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
# Create an instance of the model
|
# Create an instance of the model
|
||||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED)
|
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED)
|
||||||
|
@ -2428,6 +2428,44 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"Cannot approve. Requested domain is already in use.",
|
"Cannot approve. Requested domain is already in use.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_error_when_approving_domain_in_pending_delete_state(self):
|
||||||
|
"""If in pendingDelete state from in review -> approve not allowed."""
|
||||||
|
|
||||||
|
# 1. Create domain
|
||||||
|
to_be_in_pending_deleted = completed_domain_request(
|
||||||
|
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="meoward1.gov"
|
||||||
|
)
|
||||||
|
to_be_in_pending_deleted.creator = self.superuser
|
||||||
|
|
||||||
|
# 2. Put domain into in-review state
|
||||||
|
to_be_in_pending_deleted.status = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||||
|
to_be_in_pending_deleted.save()
|
||||||
|
|
||||||
|
# 3. Update request as a superuser
|
||||||
|
request = self.factory.post(f"/admin/registrar/domainrequest/{to_be_in_pending_deleted.pk}/change/")
|
||||||
|
request.user = self.superuser
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
|
setattr(request, "_messages", FallbackStorage(request))
|
||||||
|
|
||||||
|
# 4. Use ExitStack for combine patching like above
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client), ExitStack() as stack:
|
||||||
|
# Patch django.contrib.messages.error
|
||||||
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
|
||||||
|
with patch("registrar.models.domain.Domain.is_pending_delete", return_value=True):
|
||||||
|
# Attempt to approve to_be_in_pending_deleted
|
||||||
|
# (which should fail due is_pending_delete == True so nothing can get approved)
|
||||||
|
to_be_in_pending_deleted.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
|
||||||
|
# Save the model with the patched request
|
||||||
|
self.admin.save_model(request, to_be_in_pending_deleted, None, True)
|
||||||
|
|
||||||
|
messages.error.assert_called_once_with(
|
||||||
|
request,
|
||||||
|
"Domain of same name is currently in pending delete state.",
|
||||||
|
)
|
||||||
|
|
||||||
@less_console_noise
|
@less_console_noise
|
||||||
def test_no_error_when_saving_to_approved_and_domain_exists(self):
|
def test_no_error_when_saving_to_approved_and_domain_exists(self):
|
||||||
"""The negative of the redundant admin check on model transition not allowed."""
|
"""The negative of the redundant admin check on model transition not allowed."""
|
||||||
|
|
|
@ -666,6 +666,66 @@ class TestDomainAvailable(MockEppLib):
|
||||||
self.assertFalse(available)
|
self.assertFalse(available)
|
||||||
patcher.stop()
|
patcher.stop()
|
||||||
|
|
||||||
|
def test_is_pending_delete(self):
|
||||||
|
"""
|
||||||
|
Scenario: Testing if a domain is in pendingDelete status from the registry
|
||||||
|
Should return True
|
||||||
|
|
||||||
|
* Mock EPP response with pendingDelete status
|
||||||
|
* Validate InfoDomain command is called
|
||||||
|
* Validate response given mock
|
||||||
|
"""
|
||||||
|
|
||||||
|
with patch("registrar.models.domain.registry.send") as mocked_send, patch(
|
||||||
|
"registrar.models.domain.Domain._extract_data_from_response"
|
||||||
|
) as mocked_extract:
|
||||||
|
|
||||||
|
# Mock the registry response
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.res_data = [MagicMock(statuses=[MagicMock(state="pendingDelete")])]
|
||||||
|
mocked_send.return_value = mock_response
|
||||||
|
|
||||||
|
# Mock JSONified response
|
||||||
|
mocked_extract.return_value = {"statuses": ["pendingDelete"]}
|
||||||
|
|
||||||
|
result = Domain.is_pending_delete("is-pending-delete.gov")
|
||||||
|
|
||||||
|
mocked_send.assert_called_once_with(commands.InfoDomain("is-pending-delete.gov"), cleaned=True)
|
||||||
|
mocked_extract.assert_called_once()
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
def test_is_not_pending_delete(self):
|
||||||
|
"""
|
||||||
|
Scenario: Testing if a domain is NOT in pendingDelete status.
|
||||||
|
Should return False.
|
||||||
|
|
||||||
|
* Mock EPP response without pendingDelete status (isserverTransferProhibited)
|
||||||
|
* Validate response given mock
|
||||||
|
"""
|
||||||
|
|
||||||
|
with patch("registrar.models.domain.registry.send") as mocked_send, patch(
|
||||||
|
"registrar.models.domain.Domain._extract_data_from_response"
|
||||||
|
) as mocked_extract:
|
||||||
|
|
||||||
|
# Mock the registry response
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.res_data = [
|
||||||
|
MagicMock(statuses=[MagicMock(state="serverTransferProhibited"), MagicMock(state="ok")])
|
||||||
|
]
|
||||||
|
mocked_send.return_value = mock_response
|
||||||
|
|
||||||
|
# Mock JSONified response
|
||||||
|
mocked_extract.return_value = {"statuses": ["serverTransferProhibited", "ok"]}
|
||||||
|
|
||||||
|
result = Domain.is_pending_delete("is-not-pending.gov")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
mocked_send.assert_called_once_with(commands.InfoDomain("is-not-pending.gov"), cleaned=True)
|
||||||
|
mocked_extract.assert_called_once()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
def test_domain_available_with_invalid_error(self):
|
def test_domain_available_with_invalid_error(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Testing whether an invalid domain is available
|
Scenario: Testing whether an invalid domain is available
|
||||||
|
|
70
src/registrar/tests/test_permissions.py
Normal file
70
src/registrar/tests/test_permissions.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
"""
|
||||||
|
Tests for validating the permissions system consistency.
|
||||||
|
|
||||||
|
These tests ensure that:
|
||||||
|
1. All URLs have permissions defined in the centralized mapping
|
||||||
|
2. All views with permission decorators are in the mapping
|
||||||
|
3. The permissions in the decorators match those in the mapping
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from registrar.permissions import verify_all_urls_have_permissions, validate_permissions
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionsMapping(TestCase):
|
||||||
|
"""Test the centralized permissions mapping for completeness and consistency."""
|
||||||
|
|
||||||
|
def test_all_urls_have_permissions(self):
|
||||||
|
"""Verify that all URL patterns in the application have permissions defined in the mapping."""
|
||||||
|
missing_urls = verify_all_urls_have_permissions()
|
||||||
|
|
||||||
|
# Format URLs for better readability in case of failure
|
||||||
|
if missing_urls:
|
||||||
|
formatted_urls = "\n".join([f" - {url}" for url in missing_urls])
|
||||||
|
self.fail(
|
||||||
|
f"The following URL patterns are missing from URL_PERMISSIONS mapping:\n{formatted_urls}\n"
|
||||||
|
f"Please add them to the URL_PERMISSIONS dictionary in registrar/permissions.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_permission_decorator_consistency(self):
|
||||||
|
"""
|
||||||
|
Test that all views have consistent permission rules between
|
||||||
|
the centralized mapping and view decorators.
|
||||||
|
"""
|
||||||
|
issues = validate_permissions()
|
||||||
|
|
||||||
|
error_messages = []
|
||||||
|
|
||||||
|
if issues["missing_in_mapping"]:
|
||||||
|
urls = "\n".join([f" - {name} (at {path})" for name, path in issues["missing_in_mapping"]])
|
||||||
|
error_messages.append(
|
||||||
|
f"The following URLs have permission decorators but are missing from the mapping:\n{urls}\n"
|
||||||
|
"Add these URLs to the URL_PERMISSIONS dictionary in registrar/permissions.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
if issues["missing_decorator"]:
|
||||||
|
urls = "\n".join([f" - {name} (at {path})" for name, path in issues["missing_decorator"]])
|
||||||
|
error_messages.append(
|
||||||
|
f"The following URLs are in the mapping but missing @grant_access decorators:\n{urls}\n"
|
||||||
|
"Add appropriate @grant_access decorators to these views"
|
||||||
|
)
|
||||||
|
|
||||||
|
if issues["permission_mismatch"]:
|
||||||
|
mismatches = []
|
||||||
|
for name, path, view_perms, mapping_perms in issues["permission_mismatch"]:
|
||||||
|
view_perms_str = ", ".join(sorted(str(p) for p in view_perms))
|
||||||
|
mapping_perms_str = ", ".join(sorted(str(p) for p in mapping_perms))
|
||||||
|
mismatches.append(
|
||||||
|
f" - {name} (at {path}):\n"
|
||||||
|
f" Decorator: [{view_perms_str}]\n"
|
||||||
|
f" Mapping: [{mapping_perms_str}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
error_messages.append(
|
||||||
|
f"The following URLs have mismatched permissions between decorators and mapping:\n"
|
||||||
|
f"{chr(10).join(mismatches)}\n"
|
||||||
|
"Update either the decorator or the mapping to ensure consistency"
|
||||||
|
)
|
||||||
|
|
||||||
|
if error_messages:
|
||||||
|
self.fail("\n\n".join(error_messages))
|
|
@ -129,6 +129,7 @@ class FSMErrorCodes(IntEnum):
|
||||||
- 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user
|
- 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user
|
||||||
- 4 NO_REJECTION_REASON No rejection reason is specified
|
- 4 NO_REJECTION_REASON No rejection reason is specified
|
||||||
- 5 NO_ACTION_NEEDED_REASON No action needed reason is specified
|
- 5 NO_ACTION_NEEDED_REASON No action needed reason is specified
|
||||||
|
- 6 DOMAIN_IS_PENDING_DELETE Domain is in pending delete state
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APPROVE_DOMAIN_IN_USE = 1
|
APPROVE_DOMAIN_IN_USE = 1
|
||||||
|
@ -136,6 +137,7 @@ class FSMErrorCodes(IntEnum):
|
||||||
INVESTIGATOR_NOT_STAFF = 3
|
INVESTIGATOR_NOT_STAFF = 3
|
||||||
NO_REJECTION_REASON = 4
|
NO_REJECTION_REASON = 4
|
||||||
NO_ACTION_NEEDED_REASON = 5
|
NO_ACTION_NEEDED_REASON = 5
|
||||||
|
DOMAIN_IS_PENDING_DELETE = 6
|
||||||
|
|
||||||
|
|
||||||
class FSMDomainRequestError(Exception):
|
class FSMDomainRequestError(Exception):
|
||||||
|
@ -150,6 +152,7 @@ class FSMDomainRequestError(Exception):
|
||||||
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
|
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
|
||||||
FSMErrorCodes.NO_REJECTION_REASON: ("A reason is required for this status."),
|
FSMErrorCodes.NO_REJECTION_REASON: ("A reason is required for this status."),
|
||||||
FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
|
FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
|
||||||
|
FSMErrorCodes.DOMAIN_IS_PENDING_DELETE: ("Domain of same name is currently in pending delete state."),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, code=None, **kwargs):
|
def __init__(self, *args, code=None, **kwargs):
|
||||||
|
|
|
@ -15,6 +15,7 @@ from registrar.decorators import (
|
||||||
IS_DOMAIN_MANAGER,
|
IS_DOMAIN_MANAGER,
|
||||||
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
|
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
|
||||||
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
|
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
|
||||||
|
IS_STAFF,
|
||||||
IS_STAFF_MANAGING_DOMAIN,
|
IS_STAFF_MANAGING_DOMAIN,
|
||||||
grant_access,
|
grant_access,
|
||||||
)
|
)
|
||||||
|
@ -710,6 +711,7 @@ class PrototypeDomainDNSRecordForm(forms.Form):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@grant_access(IS_STAFF)
|
||||||
class PrototypeDomainDNSRecordView(DomainFormBaseView):
|
class PrototypeDomainDNSRecordView(DomainFormBaseView):
|
||||||
template_name = "prototype_domain_dns.html"
|
template_name = "prototype_domain_dns.html"
|
||||||
form_class = PrototypeDomainDNSRecordForm
|
form_class = PrototypeDomainDNSRecordForm
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue